Skip to main content

chio_api_protect/
proxy.rs

1//! Reverse proxy server that evaluates requests and forwards to upstream.
2
3use std::collections::{HashMap, HashSet};
4use std::net::SocketAddr;
5use std::sync::Arc;
6
7use axum::body::Body;
8use axum::extract::{ConnectInfo, Path, Query, State};
9use axum::http::{header::AUTHORIZATION, Request, StatusCode};
10use axum::middleware::{self, Next};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{any, get, post};
13use axum::Json;
14use axum::Router;
15use rusqlite::{params, Connection};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use tokio::sync::Mutex;
19use tracing::{info, warn};
20
21use chio_core_types::capability::{
22    CapabilityToken, CapabilityTokenBody, ChioScope, Operation, PromptGrant, ResourceGrant,
23    ToolGrant,
24};
25use chio_core_types::crypto::{Keypair, PublicKey};
26use chio_http_core::{
27    handle_batch_respond, handle_get_approval, handle_list_pending, handle_respond,
28    http_status_metadata_decision, http_status_metadata_final, ApprovalAdmin, ApprovalHandlerError,
29    AuthMethod, BatchRespondRequest, CallerIdentity, ChioHttpRequest, EvaluateResponse,
30    HealthResponse, HttpMethod, HttpReceipt, HttpReceiptBody, PendingQuery, RespondRequest,
31    SidecarStatus, Verdict, VerifyReceiptResponse,
32};
33use chio_kernel::{ApprovalStore, InMemoryApprovalStore};
34use chio_openapi::{ChioExtensions, DefaultPolicy};
35use chio_store_sqlite::SqliteApprovalStore;
36
37use crate::error::ProtectError;
38use crate::evaluator::{RequestEvaluator, RouteEntry};
39use crate::spec_discovery::{discover_spec, load_spec_from_file};
40
41/// Configuration for the protect proxy.
42pub struct ProtectConfig {
43    /// Upstream URL to proxy to.
44    pub upstream: String,
45    /// Optional in-memory OpenAPI spec content (YAML or JSON).
46    pub spec_content: Option<String>,
47    /// Optional OpenAPI spec path. When omitted, the proxy auto-discovers the spec.
48    pub spec_path: Option<String>,
49    /// Address to listen on (e.g., "127.0.0.1:9090").
50    pub listen_addr: String,
51    /// Optional SQLite path for receipt persistence.
52    pub receipt_db: Option<String>,
53    /// Optional bearer token that authorizes remote sidecar control requests.
54    pub sidecar_control_token: Option<String>,
55    /// Optional seed used to keep the sidecar signer stable across restarts.
56    pub signer_seed_hex: Option<String>,
57    /// Explicit capability issuers trusted by the HTTP authority.
58    pub trusted_capability_issuers: Vec<PublicKey>,
59}
60
61impl std::fmt::Debug for ProtectConfig {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("ProtectConfig")
64            .field("upstream", &self.upstream)
65            .field(
66                "spec_content",
67                &self.spec_content.as_ref().map(|_| "<inline>"),
68            )
69            .field("spec_path", &self.spec_path)
70            .field("listen_addr", &self.listen_addr)
71            .field("receipt_db", &self.receipt_db)
72            .field(
73                "sidecar_control_token",
74                &self.sidecar_control_token.as_ref().map(|_| "<redacted>"),
75            )
76            .field(
77                "signer_seed_hex",
78                &self.signer_seed_hex.as_ref().map(|_| "<redacted>"),
79            )
80            .field(
81                "trusted_capability_issuers",
82                &self.trusted_capability_issuers,
83            )
84            .finish()
85    }
86}
87
88/// Stored receipts for inspection and querying.
89struct ReceiptLog {
90    receipts: Vec<HttpReceipt>,
91}
92
93struct SqliteReceiptStore {
94    connection: Connection,
95}
96
97impl SqliteReceiptStore {
98    fn open(path: &str) -> Result<Self, ProtectError> {
99        let connection = Connection::open(path)
100            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
101        connection
102            .execute_batch(
103                "
104                CREATE TABLE IF NOT EXISTS http_receipts (
105                    id TEXT PRIMARY KEY,
106                    receipt_json TEXT NOT NULL
107                );
108                CREATE TABLE IF NOT EXISTS revoked_capabilities (
109                    capability_id TEXT PRIMARY KEY
110                );
111                ",
112            )
113            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
114        Ok(Self { connection })
115    }
116
117    fn load_receipts(&self) -> Result<Vec<HttpReceipt>, ProtectError> {
118        let mut statement = self
119            .connection
120            .prepare("SELECT receipt_json FROM http_receipts ORDER BY rowid ASC")
121            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
122        let rows = statement
123            .query_map([], |row| row.get::<_, String>(0))
124            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
125
126        let mut receipts = Vec::new();
127        for row in rows {
128            let receipt_json =
129                row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
130            let receipt: HttpReceipt = serde_json::from_str(&receipt_json)
131                .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
132            receipts.push(receipt);
133        }
134        Ok(receipts)
135    }
136
137    fn append(&mut self, receipt: &HttpReceipt) -> Result<(), ProtectError> {
138        let receipt_json = serde_json::to_string(receipt)
139            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
140        self.connection
141            .execute(
142                "INSERT OR REPLACE INTO http_receipts (id, receipt_json) VALUES (?1, ?2)",
143                params![receipt.id, receipt_json],
144            )
145            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
146        Ok(())
147    }
148
149    fn load_revoked_capability_ids(&self) -> Result<HashSet<String>, ProtectError> {
150        let mut statement = self
151            .connection
152            .prepare("SELECT capability_id FROM revoked_capabilities ORDER BY rowid ASC")
153            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
154        let rows = statement
155            .query_map([], |row| row.get::<_, String>(0))
156            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
157
158        let mut capability_ids = HashSet::new();
159        for row in rows {
160            let capability_id =
161                row.map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
162            capability_ids.insert(capability_id);
163        }
164        Ok(capability_ids)
165    }
166
167    fn revoke_capability(&mut self, capability_id: &str) -> Result<(), ProtectError> {
168        self.connection
169            .execute(
170                "INSERT OR REPLACE INTO revoked_capabilities (capability_id) VALUES (?1)",
171                params![capability_id],
172            )
173            .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?;
174        Ok(())
175    }
176}
177
178/// Shared proxy state.
179struct ProxyState {
180    evaluator: RequestEvaluator,
181    signer_keypair: Keypair,
182    upstream: String,
183    http_client: reqwest::Client,
184    approval_admin: ApprovalAdmin,
185    receipt_log: Mutex<ReceiptLog>,
186    receipt_store: Option<Mutex<SqliteReceiptStore>>,
187    revoked_capability_ids: Mutex<HashSet<String>>,
188    sidecar_control_token: Option<String>,
189}
190
191/// The protect proxy.
192pub struct ProtectProxy {
193    config: ProtectConfig,
194}
195
196impl ProtectProxy {
197    pub fn new(config: ProtectConfig) -> Self {
198        Self { config }
199    }
200
201    async fn load_spec_content(&self) -> Result<String, ProtectError> {
202        if let Some(spec_content) = &self.config.spec_content {
203            return Ok(spec_content.clone());
204        }
205        if let Some(spec_path) = &self.config.spec_path {
206            return load_spec_from_file(spec_path);
207        }
208        discover_spec(&self.config.upstream).await
209    }
210
211    /// Build the route table from the OpenAPI spec.
212    /// Parses the spec directly to preserve path and method information.
213    fn build_routes(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
214        let spec = chio_openapi::OpenApiSpec::parse(spec_content)?;
215        let mut routes = Vec::new();
216
217        for (path, path_item) in &spec.paths {
218            for (method_str, operation) in &path_item.operations {
219                let method = match method_str.as_str() {
220                    "GET" => HttpMethod::Get,
221                    "POST" => HttpMethod::Post,
222                    "PUT" => HttpMethod::Put,
223                    "PATCH" => HttpMethod::Patch,
224                    "DELETE" => HttpMethod::Delete,
225                    "HEAD" => HttpMethod::Head,
226                    "OPTIONS" => HttpMethod::Options,
227                    _ => continue,
228                };
229
230                let extensions = ChioExtensions::from_operation(&operation.raw);
231                let policy = DefaultPolicy::for_method_with_extensions(method, &extensions);
232                routes.push(RouteEntry {
233                    pattern: path.clone(),
234                    method,
235                    operation_id: operation.operation_id.clone(),
236                    policy,
237                });
238            }
239        }
240
241        Ok(routes)
242    }
243
244    /// Start the proxy server. This blocks until the server shuts down.
245    pub async fn run(self) -> Result<(), ProtectError> {
246        let spec_content = self.load_spec_content().await?;
247        let routes = Self::build_routes(&spec_content)?;
248        let route_count = routes.len();
249
250        let keypair = match &self.config.signer_seed_hex {
251            Some(seed_hex) => Keypair::from_seed_hex(seed_hex)
252                .map_err(|error| ProtectError::Config(error.to_string()))?,
253            None => Keypair::generate(),
254        };
255        let policy_hash = chio_core_types::sha256_hex(spec_content.as_bytes());
256
257        let approval_store: Arc<dyn ApprovalStore> = if let Some(path) = &self.config.receipt_db {
258            Arc::new(
259                SqliteApprovalStore::open(path)
260                    .map_err(|error| ProtectError::ReceiptStore(error.to_string()))?,
261            )
262        } else {
263            Arc::new(InMemoryApprovalStore::new())
264        };
265
266        let evaluator = RequestEvaluator::new_with_approval_store_and_trusted_capability_issuers(
267            routes,
268            keypair.clone(),
269            policy_hash,
270            Arc::clone(&approval_store),
271            self.config.trusted_capability_issuers.clone(),
272        );
273
274        let (receipt_log, receipt_store, revoked_capability_ids) =
275            if let Some(path) = &self.config.receipt_db {
276                let store = SqliteReceiptStore::open(path)?;
277                let receipts = store.load_receipts()?;
278                let revoked_capability_ids = store.load_revoked_capability_ids()?;
279                (
280                    ReceiptLog { receipts },
281                    Some(Mutex::new(store)),
282                    revoked_capability_ids,
283                )
284            } else {
285                (
286                    ReceiptLog {
287                        receipts: Vec::new(),
288                    },
289                    None,
290                    HashSet::new(),
291                )
292            };
293
294        let state = Arc::new(ProxyState {
295            evaluator,
296            signer_keypair: keypair,
297            upstream: self.config.upstream.clone(),
298            http_client: reqwest::Client::new(),
299            approval_admin: ApprovalAdmin::new(approval_store),
300            receipt_log: Mutex::new(receipt_log),
301            receipt_store,
302            revoked_capability_ids: Mutex::new(revoked_capability_ids),
303            sidecar_control_token: self.config.sidecar_control_token.clone(),
304        });
305
306        let app = build_app(Arc::clone(&state));
307
308        let listener = tokio::net::TcpListener::bind(&self.config.listen_addr)
309            .await
310            .map_err(|e| {
311                ProtectError::Config(format!("cannot bind {}: {e}", self.config.listen_addr))
312            })?;
313
314        info!(
315            "chio api protect: proxying {} routes to {} on {}",
316            route_count, self.config.upstream, self.config.listen_addr
317        );
318
319        axum::serve(
320            listener,
321            app.into_make_service_with_connect_info::<SocketAddr>(),
322        )
323        .await
324        .map_err(ProtectError::Io)?;
325
326        Ok(())
327    }
328
329    /// Build routes from spec content for testing.
330    pub fn routes_from_spec(spec_content: &str) -> Result<Vec<RouteEntry>, ProtectError> {
331        Self::build_routes(spec_content)
332    }
333}
334
335fn build_app(state: Arc<ProxyState>) -> Router {
336    let approval_routes = Router::new()
337        .route("/approvals/pending", get(list_pending_approvals_handler))
338        .route(
339            "/approvals/batch/respond",
340            post(batch_respond_approvals_handler),
341        )
342        .route("/approvals/{id}/respond", post(respond_approval_handler))
343        .route("/approvals/{id}", get(get_approval_handler))
344        .route_layer(middleware::from_fn_with_state(
345            Arc::clone(&state),
346            require_sidecar_control_middleware,
347        ));
348
349    Router::new()
350        .route("/chio/evaluate", post(sidecar_evaluate_handler))
351        .route("/chio/verify", post(sidecar_verify_handler))
352        .route("/chio/health", get(sidecar_health_handler))
353        .merge(approval_routes)
354        .route("/v1/capabilities/mint", post(sidecar_mint_handler))
355        .route("/v1/capabilities/release", post(sidecar_release_handler))
356        .route("/v1/receipts", post(sidecar_submit_receipt_handler))
357        .route("/{*path}", any(proxy_handler))
358        .route("/", any(proxy_handler))
359        .with_state(state)
360}
361
362async fn list_pending_approvals_handler(
363    State(state): State<Arc<ProxyState>>,
364    Query(query): Query<PendingQuery>,
365) -> Response {
366    match handle_list_pending(&state.approval_admin, query) {
367        Ok(response) => approval_json(StatusCode::OK, response),
368        Err(error) => approval_error_response(error),
369    }
370}
371
372async fn get_approval_handler(
373    State(state): State<Arc<ProxyState>>,
374    Path(approval_id): Path<String>,
375) -> Response {
376    match handle_get_approval(&state.approval_admin, &approval_id) {
377        Ok(response) => approval_json(StatusCode::OK, response),
378        Err(error) => approval_error_response(error),
379    }
380}
381
382async fn respond_approval_handler(
383    State(state): State<Arc<ProxyState>>,
384    Path(approval_id): Path<String>,
385    body: Result<Json<RespondRequest>, axum::extract::rejection::JsonRejection>,
386) -> Response {
387    let Json(body) = match body {
388        Ok(body) => body,
389        Err(error) => {
390            return approval_error_response(ApprovalHandlerError::BadRequest(format!(
391                "invalid approval response payload: {error}"
392            )));
393        }
394    };
395
396    let now = chrono::Utc::now().timestamp() as u64;
397    match handle_respond(&state.approval_admin, &approval_id, body, now) {
398        Ok(response) => approval_json(StatusCode::OK, response),
399        Err(error) => approval_error_response(error),
400    }
401}
402
403async fn batch_respond_approvals_handler(
404    State(state): State<Arc<ProxyState>>,
405    body: Result<Json<BatchRespondRequest>, axum::extract::rejection::JsonRejection>,
406) -> Response {
407    let Json(body) = match body {
408        Ok(body) => body,
409        Err(error) => {
410            return approval_error_response(ApprovalHandlerError::BadRequest(format!(
411                "invalid batch approval payload: {error}"
412            )));
413        }
414    };
415
416    let now = chrono::Utc::now().timestamp() as u64;
417    match handle_batch_respond(&state.approval_admin, body, now) {
418        Ok(response) => approval_json(StatusCode::OK, response),
419        Err(error) => approval_error_response(error),
420    }
421}
422
423async fn require_sidecar_control_middleware(
424    State(state): State<Arc<ProxyState>>,
425    request: Request<Body>,
426    next: Next,
427) -> Response {
428    if let Err(response) =
429        require_sidecar_control_request(&request, state.sidecar_control_token.as_deref())
430    {
431        return response;
432    }
433
434    next.run(request).await
435}
436
437/// Axum handler that evaluates the request and proxies to upstream.
438async fn proxy_handler(State(state): State<Arc<ProxyState>>, request: Request<Body>) -> Response {
439    let uri = request.uri().clone();
440    let raw_headers = request.headers().clone();
441    let method = match request.method().as_str() {
442        "GET" => HttpMethod::Get,
443        "POST" => HttpMethod::Post,
444        "PUT" => HttpMethod::Put,
445        "PATCH" => HttpMethod::Patch,
446        "DELETE" => HttpMethod::Delete,
447        "HEAD" => HttpMethod::Head,
448        "OPTIONS" => HttpMethod::Options,
449        _ => {
450            return (StatusCode::METHOD_NOT_ALLOWED, "unsupported method").into_response();
451        }
452    };
453
454    let path = uri.path().to_string();
455    let query = parse_query_params(uri.query());
456    let forwarded_query = forwarded_query_string(uri.query());
457
458    // Extract relevant headers.
459    let mut headers = HashMap::new();
460    for (name, value) in &raw_headers {
461        if let Ok(v) = value.to_str() {
462            headers.insert(name.as_str().to_string(), v.to_string());
463        }
464    }
465
466    // Read body for hashing.
467    let body_bytes = match axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024).await {
468        Ok(b) => b,
469        Err(e) => {
470            warn!("failed to read request body: {e}");
471            return (StatusCode::BAD_REQUEST, "failed to read request body").into_response();
472        }
473    };
474    let body_length = body_bytes.len() as u64;
475    let body_hash = if body_bytes.is_empty() {
476        None
477    } else {
478        Some(chio_core_types::sha256_hex(&body_bytes))
479    };
480
481    if let Some(response) =
482        revoked_proxy_response(&state, method, &path, &query, &headers, body_hash.clone()).await
483    {
484        return response;
485    }
486
487    // Evaluate.
488    let result =
489        match state
490            .evaluator
491            .evaluate(method, &path, &query, &headers, body_hash, body_length)
492        {
493            Ok(r) => r,
494            Err(e) => {
495                warn!("evaluation error: {e}");
496                return evaluation_error_response(&e);
497            }
498        };
499
500    // If denied, return structured 403.
501    if result.verdict.is_denied() {
502        let denied_status = StatusCode::from_u16(verdict_http_status(&result.verdict))
503            .unwrap_or(StatusCode::FORBIDDEN);
504        let final_receipt = match finalize_and_record_receipt(
505            &state,
506            &result.receipt,
507            denied_status.as_u16(),
508        )
509        .await
510        {
511            Ok(receipt) => receipt,
512            Err(response) => return response,
513        };
514        let error_body = serde_json::json!({
515            "error": "chio_access_denied",
516            "message": match &result.verdict {
517                Verdict::Deny { reason, .. } => reason.clone(),
518                _ => "access denied".to_string(),
519            },
520            "receipt_id": final_receipt.id,
521            "suggestion": "provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter",
522        });
523        return Response::builder()
524            .status(denied_status)
525            .header("content-type", "application/json")
526            .header("X-Chio-Receipt-Id", &final_receipt.id)
527            .body(Body::from(
528                serde_json::to_string(&error_body).unwrap_or_default(),
529            ))
530            .unwrap_or_else(|_| denied_status.into_response());
531    }
532
533    // Proxy to upstream.
534    let mut upstream_url = format!("{}{}", state.upstream.trim_end_matches('/'), &path);
535    if let Some(raw_query) = forwarded_query {
536        upstream_url.push('?');
537        upstream_url.push_str(&raw_query);
538    }
539
540    let mut upstream_req = state.http_client.request(
541        match method {
542            HttpMethod::Get => reqwest::Method::GET,
543            HttpMethod::Post => reqwest::Method::POST,
544            HttpMethod::Put => reqwest::Method::PUT,
545            HttpMethod::Patch => reqwest::Method::PATCH,
546            HttpMethod::Delete => reqwest::Method::DELETE,
547            HttpMethod::Head => reqwest::Method::HEAD,
548            HttpMethod::Options => reqwest::Method::OPTIONS,
549        },
550        &upstream_url,
551    );
552
553    // Forward end-to-end request headers while keeping Chio transport and
554    // hop-by-hop connection details local to the proxy.
555    for (name, value) in &raw_headers {
556        if should_forward_request_header(name.as_str()) {
557            upstream_req = upstream_req.header(name, value);
558        }
559    }
560
561    if !body_bytes.is_empty() {
562        upstream_req = upstream_req.body(body_bytes.to_vec());
563    }
564
565    match upstream_req.send().await {
566        Ok(resp) => {
567            let status =
568                StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
569            let response_headers = resp.headers().clone();
570            let final_receipt =
571                match finalize_and_record_receipt(&state, &result.receipt, status.as_u16()).await {
572                    Ok(receipt) => receipt,
573                    Err(response) => return response,
574                };
575
576            let mut response_builder = Response::builder().status(status);
577
578            // Forward response headers.
579            for (name, value) in &response_headers {
580                response_builder = response_builder.header(name.as_str(), value.as_bytes());
581            }
582
583            // Add receipt ID header.
584            response_builder = response_builder.header("X-Chio-Receipt-Id", &final_receipt.id);
585
586            match resp.bytes().await {
587                Ok(body) => response_builder
588                    .body(Body::from(body))
589                    .unwrap_or_else(|_| (StatusCode::BAD_GATEWAY, "bad gateway").into_response()),
590                Err(error) => {
591                    finalize_bad_gateway(
592                        &state,
593                        &result.receipt,
594                        format!("failed to read upstream response: {error}"),
595                    )
596                    .await
597                }
598            }
599        }
600        Err(e) => {
601            warn!("upstream error: {e}");
602            finalize_bad_gateway(&state, &result.receipt, format!("upstream error: {e}")).await
603        }
604    }
605}
606
607async fn sidecar_evaluate_handler(
608    State(state): State<Arc<ProxyState>>,
609    request: Request<Body>,
610) -> Response {
611    let (parts, body) = request.into_parts();
612    let transport_query = parse_query_params(parts.uri.query());
613    let presented_capability = extract_transport_capability(&parts.headers, &transport_query);
614    let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
615        Ok(bytes) => bytes,
616        Err(error) => {
617            warn!("failed to read evaluation body: {error}");
618            return (
619                StatusCode::BAD_REQUEST,
620                axum::Json(serde_json::json!({
621                    "error": "chio_bad_request",
622                    "message": "failed to read evaluation body",
623                })),
624            )
625                .into_response();
626        }
627    };
628
629    let chio_request: ChioHttpRequest = match serde_json::from_slice(&body_bytes) {
630        Ok(request) => request,
631        Err(error) => {
632            warn!("failed to decode ChioHttpRequest: {error}");
633            return (
634                StatusCode::BAD_REQUEST,
635                axum::Json(serde_json::json!({
636                    "error": "chio_bad_request",
637                    "message": format!("invalid ChioHttpRequest payload: {error}"),
638                })),
639            )
640                .into_response();
641        }
642    };
643
644    if let Some(response) =
645        revoked_sidecar_evaluate_response(&state, &chio_request, presented_capability.as_deref())
646            .await
647    {
648        return response;
649    }
650
651    let result = match state
652        .evaluator
653        .evaluate_chio_request(chio_request, presented_capability.as_deref())
654    {
655        Ok(result) => result,
656        Err(error) => {
657            warn!("sidecar evaluation error: {error}");
658            return evaluation_error_response(&error);
659        }
660    };
661
662    if let Err(error) = record_receipt(&state, &result.receipt).await {
663        warn!("failed to persist receipt: {error}");
664        return (
665            StatusCode::INTERNAL_SERVER_ERROR,
666            axum::Json(serde_json::json!({
667                "error": "chio_receipt_persistence_failed",
668                "message": error.to_string(),
669            })),
670        )
671            .into_response();
672    }
673
674    (
675        StatusCode::OK,
676        axum::Json(EvaluateResponse {
677            verdict: result.verdict,
678            receipt: result.receipt,
679            evidence: result.evidence,
680            // This sidecar evaluation endpoint preserves the existing
681            // evaluate-only contract. Execution endpoints attach nonces
682            // when they invoke the kernel tool-call path.
683            execution_nonce: None,
684        }),
685    )
686        .into_response()
687}
688
689async fn sidecar_verify_handler(
690    State(_state): State<Arc<ProxyState>>,
691    request: Request<Body>,
692) -> Response {
693    let (_parts, body) = request.into_parts();
694    let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
695        Ok(bytes) => bytes,
696        Err(error) => {
697            warn!("failed to read verify body: {error}");
698            return (
699                StatusCode::BAD_REQUEST,
700                axum::Json(serde_json::json!({
701                    "error": "chio_bad_request",
702                    "message": "failed to read receipt verification body",
703                })),
704            )
705                .into_response();
706        }
707    };
708
709    let receipt: HttpReceipt = match serde_json::from_slice(&body_bytes) {
710        Ok(receipt) => receipt,
711        Err(error) => {
712            warn!("failed to decode HttpReceipt: {error}");
713            return (
714                StatusCode::BAD_REQUEST,
715                axum::Json(serde_json::json!({
716                    "error": "chio_bad_request",
717                    "message": format!("invalid HttpReceipt payload: {error}"),
718                })),
719            )
720                .into_response();
721        }
722    };
723
724    let valid = receipt.verify_signature().unwrap_or(false);
725    (StatusCode::OK, axum::Json(VerifyReceiptResponse { valid })).into_response()
726}
727
728async fn sidecar_health_handler(State(_state): State<Arc<ProxyState>>) -> Response {
729    (
730        StatusCode::OK,
731        axum::Json(HealthResponse {
732            status: SidecarStatus::Healthy,
733            version: env!("CARGO_PKG_VERSION").to_string(),
734        }),
735    )
736        .into_response()
737}
738
739#[derive(Debug, Deserialize)]
740struct SidecarMintRequest {
741    subject: String,
742    #[serde(default)]
743    scopes: Vec<String>,
744    #[serde(default)]
745    ttl: Option<u64>,
746    #[serde(default)]
747    ttl_seconds: Option<u64>,
748    #[serde(default)]
749    ttl_nanos: Option<u64>,
750    #[serde(default)]
751    job_uid: String,
752}
753
754#[derive(Debug, Serialize, Deserialize)]
755struct SidecarMintResponse {
756    capability: CapabilityToken,
757}
758
759#[derive(Debug, Deserialize)]
760struct SidecarReleaseRequest {
761    capability_id: String,
762    #[serde(default)]
763    job_uid: String,
764    #[serde(default)]
765    reason: String,
766}
767
768#[derive(Debug, Serialize, Deserialize)]
769struct SidecarReleaseResponse {
770    released: bool,
771}
772
773#[derive(Debug, Deserialize)]
774struct SidecarSubmitReceiptRequest {
775    job_name: String,
776    namespace: String,
777    job_uid: String,
778    #[serde(default)]
779    capability_id: Option<String>,
780    outcome: String,
781    #[serde(default)]
782    started_at: Option<String>,
783    #[serde(default)]
784    completed_at: Option<String>,
785    #[serde(default)]
786    steps: Vec<SidecarSubmitStepReceipt>,
787}
788
789#[derive(Debug, Deserialize)]
790struct SidecarSubmitStepReceipt {
791    pod_name: String,
792    phase: String,
793    payload: String,
794    observed_at: String,
795}
796
797#[derive(Debug, Serialize, Deserialize)]
798struct SidecarSubmitReceiptResponse {
799    receipt_id: String,
800    accepted: bool,
801}
802
803async fn sidecar_mint_handler(
804    State(state): State<Arc<ProxyState>>,
805    request: Request<Body>,
806) -> Response {
807    if let Err(response) =
808        require_sidecar_control_request(&request, state.sidecar_control_token.as_deref())
809    {
810        return response;
811    }
812    let (_parts, body) = request.into_parts();
813    let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
814        Ok(bytes) => bytes,
815        Err(error) => {
816            warn!("failed to read capability mint body: {error}");
817            return sidecar_bad_request("failed to read capability mint body").into_response();
818        }
819    };
820
821    let mint_request: SidecarMintRequest = match serde_json::from_slice(&body_bytes) {
822        Ok(request) => request,
823        Err(error) => {
824            warn!("failed to decode capability mint request: {error}");
825            return sidecar_bad_request(&format!("invalid capability mint payload: {error}"))
826                .into_response();
827        }
828    };
829
830    if mint_request.subject.trim().is_empty() {
831        return sidecar_bad_request("subject must not be empty").into_response();
832    }
833
834    let scope = match build_sidecar_scope(&mint_request.scopes) {
835        Ok(scope) => scope,
836        Err(error) => return sidecar_bad_request(&error).into_response(),
837    };
838
839    let issued_at = chrono::Utc::now().timestamp() as u64;
840    let ttl_seconds = ttl_seconds_from_wire(
841        mint_request.ttl_seconds,
842        mint_request.ttl_nanos,
843        mint_request.ttl,
844    );
845    let expires_at = issued_at.saturating_add(ttl_seconds);
846    let subject = derive_sidecar_subject_key(&mint_request.subject, &mint_request.job_uid);
847    let capability_id = match derive_sidecar_capability_id(
848        &mint_request.subject,
849        &mint_request.job_uid,
850        ttl_seconds,
851        &scope,
852    ) {
853        Ok(capability_id) => capability_id,
854        Err(error) => {
855            warn!("failed to derive deterministic capability id: {error}");
856            return internal_json_error_response(
857                "chio_capability_mint_failed",
858                "failed to derive deterministic capability id",
859            );
860        }
861    };
862
863    let capability = match CapabilityToken::sign(
864        CapabilityTokenBody {
865            id: capability_id,
866            issuer: state.signer_keypair.public_key(),
867            subject,
868            scope,
869            issued_at,
870            expires_at,
871            delegation_chain: Vec::new(),
872        },
873        &state.signer_keypair,
874    ) {
875        Ok(capability) => capability,
876        Err(error) => {
877            warn!("failed to sign compatibility capability token: {error}");
878            return (
879                StatusCode::INTERNAL_SERVER_ERROR,
880                axum::Json(serde_json::json!({
881                    "error": "chio_capability_mint_failed",
882                    "message": error.to_string(),
883                })),
884            )
885                .into_response();
886        }
887    };
888
889    (
890        StatusCode::OK,
891        axum::Json(SidecarMintResponse { capability }),
892    )
893        .into_response()
894}
895
896async fn sidecar_release_handler(
897    State(state): State<Arc<ProxyState>>,
898    request: Request<Body>,
899) -> Response {
900    if let Err(response) =
901        require_sidecar_control_request(&request, state.sidecar_control_token.as_deref())
902    {
903        return response;
904    }
905    let (_parts, body) = request.into_parts();
906    let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
907        Ok(bytes) => bytes,
908        Err(error) => {
909            warn!("failed to read capability release body: {error}");
910            return sidecar_bad_request("failed to read capability release body").into_response();
911        }
912    };
913
914    let release_request: SidecarReleaseRequest = match serde_json::from_slice(&body_bytes) {
915        Ok(request) => request,
916        Err(error) => {
917            warn!("failed to decode capability release request: {error}");
918            return sidecar_bad_request(&format!("invalid capability release payload: {error}"))
919                .into_response();
920        }
921    };
922
923    if release_request.capability_id.trim().is_empty() {
924        return sidecar_bad_request("capability_id must not be empty").into_response();
925    }
926
927    let capability_id = release_request.capability_id.trim().to_string();
928    let Some(store) = &state.receipt_store else {
929        return internal_json_error_response(
930            "chio_capability_release_failed",
931            "persistent receipt_db must be configured for capability release",
932        );
933    };
934    let mut store = store.lock().await;
935    if let Err(error) = store.revoke_capability(&capability_id) {
936        warn!("failed to persist capability revocation: {error}");
937        return internal_json_error_response("chio_capability_release_failed", &error.to_string());
938    }
939    state
940        .revoked_capability_ids
941        .lock()
942        .await
943        .insert(capability_id);
944    let _ = (release_request.job_uid, release_request.reason);
945
946    (
947        StatusCode::OK,
948        axum::Json(SidecarReleaseResponse { released: true }),
949    )
950        .into_response()
951}
952
953async fn sidecar_submit_receipt_handler(
954    State(state): State<Arc<ProxyState>>,
955    request: Request<Body>,
956) -> Response {
957    if let Err(response) =
958        require_sidecar_control_request(&request, state.sidecar_control_token.as_deref())
959    {
960        return response;
961    }
962    let (_parts, body) = request.into_parts();
963    let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
964        Ok(bytes) => bytes,
965        Err(error) => {
966            warn!("failed to read receipt submission body: {error}");
967            return sidecar_bad_request("failed to read receipt submission body").into_response();
968        }
969    };
970
971    let receipt_request: SidecarSubmitReceiptRequest = match serde_json::from_slice(&body_bytes) {
972        Ok(request) => request,
973        Err(error) => {
974            warn!("failed to decode receipt submission payload: {error}");
975            return sidecar_bad_request(&format!("invalid receipt submission payload: {error}"))
976                .into_response();
977        }
978    };
979
980    if receipt_request.job_name.trim().is_empty()
981        || receipt_request.namespace.trim().is_empty()
982        || receipt_request.job_uid.trim().is_empty()
983        || receipt_request.outcome.trim().is_empty()
984    {
985        return sidecar_bad_request("job_name, namespace, job_uid, and outcome are required")
986            .into_response();
987    }
988
989    for step in &receipt_request.steps {
990        if step.pod_name.trim().is_empty()
991            || step.phase.trim().is_empty()
992            || step.payload.trim().is_empty()
993            || step.observed_at.trim().is_empty()
994        {
995            return sidecar_bad_request(
996                "receipt steps must include pod_name, phase, payload, and observed_at",
997            )
998            .into_response();
999        }
1000    }
1001
1002    let caller_identity_hash = match CallerIdentity::anonymous().identity_hash() {
1003        Ok(hash) => hash,
1004        Err(error) => {
1005            warn!("failed to hash synthetic receipt caller identity: {error}");
1006            return internal_json_error_response("chio_receipt_sign_failed", &error.to_string());
1007        }
1008    };
1009
1010    let receipt_id = uuid::Uuid::now_v7().to_string();
1011    let capability_id = receipt_request
1012        .capability_id
1013        .clone()
1014        .filter(|value| !value.trim().is_empty());
1015    let receipt = match HttpReceipt::sign(
1016        HttpReceiptBody {
1017            id: receipt_id.clone(),
1018            request_id: format!("job-receipt-submission:{}", receipt_request.job_uid),
1019            route_pattern: "/v1/receipts".to_string(),
1020            method: HttpMethod::Post,
1021            caller_identity_hash,
1022            session_id: None,
1023            verdict: Verdict::Allow,
1024            evidence: Vec::new(),
1025            response_status: StatusCode::OK.as_u16(),
1026            timestamp: chrono::Utc::now().timestamp() as u64,
1027            content_hash: chio_core_types::sha256_hex(&body_bytes),
1028            policy_hash: manual_receipt_policy_hash("chio_api_protect_sidecar_receipt_submission"),
1029            capability_id,
1030            metadata: Some(sidecar_submit_receipt_metadata(&receipt_request)),
1031            kernel_key: state.signer_keypair.public_key(),
1032        },
1033        &state.signer_keypair,
1034    ) {
1035        Ok(receipt) => receipt,
1036        Err(error) => {
1037            warn!("failed to sign submitted sidecar receipt: {error}");
1038            return internal_json_error_response("chio_receipt_sign_failed", &error.to_string());
1039        }
1040    };
1041
1042    if let Err(error) = record_receipt(&state, &receipt).await {
1043        warn!("failed to persist submitted sidecar receipt: {error}");
1044        return internal_json_error_response("chio_receipt_persistence_failed", &error.to_string());
1045    }
1046
1047    (
1048        StatusCode::OK,
1049        axum::Json(SidecarSubmitReceiptResponse {
1050            receipt_id,
1051            accepted: true,
1052        }),
1053    )
1054        .into_response()
1055}
1056
1057fn parse_query_params(raw_query: Option<&str>) -> HashMap<String, String> {
1058    raw_query
1059        .map(|query| {
1060            url::form_urlencoded::parse(query.as_bytes())
1061                .map(|(key, value)| (key.into_owned(), value.into_owned()))
1062                .collect()
1063        })
1064        .unwrap_or_default()
1065}
1066
1067fn forwarded_query_string(raw_query: Option<&str>) -> Option<String> {
1068    let raw_query = raw_query?;
1069    let filtered = url::form_urlencoded::parse(raw_query.as_bytes())
1070        .filter(|(key, _)| key != "chio_capability")
1071        .map(|(key, value)| (key.into_owned(), value.into_owned()))
1072        .collect::<Vec<_>>();
1073
1074    if filtered.is_empty() {
1075        return None;
1076    }
1077
1078    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
1079    for (key, value) in filtered {
1080        serializer.append_pair(&key, &value);
1081    }
1082    let query = serializer.finish();
1083    (!query.is_empty()).then_some(query)
1084}
1085
1086fn sidecar_bad_request(message: &str) -> (StatusCode, axum::Json<serde_json::Value>) {
1087    (
1088        StatusCode::BAD_REQUEST,
1089        axum::Json(serde_json::json!({
1090            "error": "chio_bad_request",
1091            "message": message,
1092        })),
1093    )
1094}
1095
1096#[allow(clippy::result_large_err)]
1097fn require_sidecar_control_request(
1098    request: &Request<Body>,
1099    expected_bearer_token: Option<&str>,
1100) -> Result<(), Response> {
1101    if let Some(expected_bearer_token) = expected_bearer_token.map(str::trim) {
1102        if expected_bearer_token.is_empty() {
1103            warn!("rejecting sidecar control request with blank bearer token configuration");
1104            return Err(sidecar_control_forbidden_response(true));
1105        }
1106        if sidecar_control_bearer_token_matches(request, expected_bearer_token) {
1107            return Ok(());
1108        }
1109        if let Some(peer) = request.extensions().get::<ConnectInfo<SocketAddr>>() {
1110            warn!(
1111                peer = %peer.0,
1112                "rejecting sidecar control request without valid bearer token"
1113            );
1114        } else {
1115            warn!("rejecting sidecar control request without valid bearer token");
1116        }
1117        return Err(sidecar_control_forbidden_response(true));
1118    }
1119
1120    if let Some(peer) = request.extensions().get::<ConnectInfo<SocketAddr>>() {
1121        if peer.0.ip().is_loopback() {
1122            return Ok(());
1123        }
1124    }
1125
1126    if let Some(peer) = request.extensions().get::<ConnectInfo<SocketAddr>>() {
1127        warn!(
1128            peer = %peer.0,
1129            "rejecting non-loopback sidecar control request without configured bearer token"
1130        );
1131    } else {
1132        warn!("rejecting sidecar control request without peer address");
1133    }
1134    Err(sidecar_control_forbidden_response(false))
1135}
1136
1137fn sidecar_control_bearer_token_matches(
1138    request: &Request<Body>,
1139    expected_bearer_token: &str,
1140) -> bool {
1141    request
1142        .headers()
1143        .get(AUTHORIZATION)
1144        .and_then(|value| value.to_str().ok())
1145        .and_then(|value| {
1146            let (scheme, token) = value.split_once(' ')?;
1147            if scheme.eq_ignore_ascii_case("bearer") {
1148                Some(token)
1149            } else {
1150                None
1151            }
1152        })
1153        .is_some_and(|token| token == expected_bearer_token)
1154}
1155
1156fn sidecar_control_forbidden_response(remote_auth_configured: bool) -> Response {
1157    let message = if remote_auth_configured {
1158        "sidecar control endpoints require a loopback caller or valid bearer token"
1159    } else {
1160        "sidecar control endpoints require a loopback caller"
1161    };
1162    (
1163        StatusCode::FORBIDDEN,
1164        axum::Json(serde_json::json!({
1165            "error": "chio_control_forbidden",
1166            "message": message,
1167        })),
1168    )
1169        .into_response()
1170}
1171
1172fn ttl_seconds_from_wire(
1173    ttl_seconds_wire: Option<u64>,
1174    ttl_nanos_wire: Option<u64>,
1175    ttl_legacy_wire: Option<u64>,
1176) -> u64 {
1177    const DEFAULT_TTL_SECONDS: u64 = 3600;
1178    const NANOS_PER_SECOND: u64 = 1_000_000_000;
1179
1180    if let Some(ttl_seconds) = ttl_seconds_wire {
1181        return match ttl_seconds {
1182            0 => DEFAULT_TTL_SECONDS,
1183            ttl_seconds => ttl_seconds,
1184        };
1185    }
1186
1187    if let Some(ttl_nanos) = ttl_nanos_wire {
1188        return match ttl_nanos {
1189            0 => DEFAULT_TTL_SECONDS,
1190            ttl_nanos => std::cmp::max(
1191                1,
1192                ttl_nanos.saturating_add(NANOS_PER_SECOND - 1) / NANOS_PER_SECOND,
1193            ),
1194        };
1195    }
1196
1197    match ttl_legacy_wire {
1198        Some(0) | None => DEFAULT_TTL_SECONDS,
1199        Some(ttl) if ttl < NANOS_PER_SECOND => ttl,
1200        Some(ttl) => std::cmp::max(
1201            1,
1202            ttl.saturating_add(NANOS_PER_SECOND - 1) / NANOS_PER_SECOND,
1203        ),
1204    }
1205}
1206
1207fn derive_sidecar_subject_key(subject: &str, job_uid: &str) -> chio_core_types::crypto::PublicKey {
1208    let mut hasher = Sha256::new();
1209    hasher.update(subject.as_bytes());
1210    hasher.update([0]);
1211    hasher.update(job_uid.as_bytes());
1212    let seed: [u8; 32] = hasher.finalize().into();
1213    Keypair::from_seed(&seed).public_key()
1214}
1215
1216fn derive_sidecar_capability_id(
1217    subject: &str,
1218    job_uid: &str,
1219    ttl_seconds: u64,
1220    scope: &ChioScope,
1221) -> Result<String, serde_json::Error> {
1222    #[derive(Serialize)]
1223    struct SidecarCapabilityIdMaterial<'a> {
1224        subject: &'a str,
1225        job_uid: &'a str,
1226        ttl_seconds: u64,
1227        tool_grants: Vec<String>,
1228        resource_grants: Vec<String>,
1229        prompt_grants: Vec<String>,
1230    }
1231
1232    fn sorted_grant_encodings<T: Serialize>(
1233        grants: &[T],
1234    ) -> Result<Vec<String>, serde_json::Error> {
1235        let mut encodings = grants
1236            .iter()
1237            .map(serde_json::to_string)
1238            .collect::<Result<Vec<_>, _>>()?;
1239        encodings.sort_unstable();
1240        Ok(encodings)
1241    }
1242
1243    let id_material = SidecarCapabilityIdMaterial {
1244        subject,
1245        job_uid,
1246        ttl_seconds,
1247        tool_grants: sorted_grant_encodings(&scope.grants)?,
1248        resource_grants: sorted_grant_encodings(&scope.resource_grants)?,
1249        prompt_grants: sorted_grant_encodings(&scope.prompt_grants)?,
1250    };
1251    let encoded = serde_json::to_vec(&id_material)?;
1252    Ok(format!("sidecar-{}", chio_core_types::sha256_hex(&encoded)))
1253}
1254
1255fn build_sidecar_scope(scopes: &[String]) -> Result<ChioScope, String> {
1256    let mut tool_grants = Vec::new();
1257    let mut resource_grants = Vec::new();
1258    let mut prompt_grants = Vec::new();
1259
1260    for scope in scopes {
1261        match parse_sidecar_scope(scope)? {
1262            SidecarScopeGrant::Tool(grant) => tool_grants.push(grant),
1263            SidecarScopeGrant::Resource(grant) => resource_grants.push(grant),
1264            SidecarScopeGrant::Prompt(grant) => prompt_grants.push(grant),
1265        }
1266    }
1267
1268    Ok(ChioScope {
1269        grants: tool_grants,
1270        resource_grants,
1271        prompt_grants,
1272    })
1273}
1274
1275enum SidecarScopeGrant {
1276    Tool(ToolGrant),
1277    Resource(ResourceGrant),
1278    Prompt(PromptGrant),
1279}
1280
1281fn parse_sidecar_scope(raw: &str) -> Result<SidecarScopeGrant, String> {
1282    let parts: Vec<&str> = raw
1283        .split(':')
1284        .map(str::trim)
1285        .filter(|part| !part.is_empty())
1286        .collect();
1287
1288    if parts.first() == Some(&"tools") && parts.len() >= 2 {
1289        return Ok(SidecarScopeGrant::Tool(ToolGrant {
1290            server_id: "*".to_string(),
1291            tool_name: parts[1..].join(":"),
1292            operations: vec![Operation::Invoke],
1293            constraints: Vec::new(),
1294            max_invocations: None,
1295            max_cost_per_invocation: None,
1296            max_total_cost: None,
1297            dpop_required: None,
1298        }));
1299    }
1300
1301    match parts.as_slice() {
1302        [tool_name, operation] => Ok(SidecarScopeGrant::Tool(ToolGrant {
1303            server_id: "*".to_string(),
1304            tool_name: (*tool_name).to_string(),
1305            operations: vec![parse_sidecar_operation(operation, true)?],
1306            constraints: Vec::new(),
1307            max_invocations: None,
1308            max_cost_per_invocation: None,
1309            max_total_cost: None,
1310            dpop_required: None,
1311        })),
1312        ["tool", server_id, tool_name, operation] => Ok(SidecarScopeGrant::Tool(ToolGrant {
1313            server_id: (*server_id).to_string(),
1314            tool_name: (*tool_name).to_string(),
1315            operations: vec![parse_sidecar_operation(operation, false)?],
1316            constraints: Vec::new(),
1317            max_invocations: None,
1318            max_cost_per_invocation: None,
1319            max_total_cost: None,
1320            dpop_required: None,
1321        })),
1322        ["resource", uri_pattern, operation] => Ok(SidecarScopeGrant::Resource(ResourceGrant {
1323            uri_pattern: (*uri_pattern).to_string(),
1324            operations: vec![parse_sidecar_operation(operation, false)?],
1325        })),
1326        ["prompt", prompt_name, operation] => Ok(SidecarScopeGrant::Prompt(PromptGrant {
1327            prompt_name: (*prompt_name).to_string(),
1328            operations: vec![parse_sidecar_operation(operation, false)?],
1329        })),
1330        _ => Err(format!("unsupported controller scope syntax: {raw}")),
1331    }
1332}
1333
1334fn parse_sidecar_operation(raw: &str, shorthand: bool) -> Result<Operation, String> {
1335    match raw.to_ascii_lowercase().as_str() {
1336        "invoke" | "call" | "exec" | "execute" => Ok(Operation::Invoke),
1337        "write" if shorthand => Ok(Operation::Invoke),
1338        "read_result" | "result" => Ok(Operation::ReadResult),
1339        "read" if shorthand => Ok(Operation::Read),
1340        "read" => Ok(Operation::Read),
1341        "subscribe" | "watch" => Ok(Operation::Subscribe),
1342        "get" => Ok(Operation::Get),
1343        "delegate" => Ok(Operation::Delegate),
1344        _ => Err(format!("unsupported controller scope operation: {raw}")),
1345    }
1346}
1347
1348fn evaluation_error_response(error: &ProtectError) -> Response {
1349    match error {
1350        ProtectError::PendingApproval {
1351            approval_id,
1352            kernel_receipt_id,
1353        } => {
1354            let mut body = serde_json::json!({
1355                "error": "chio_approval_required",
1356                "message": "request requires human approval before it can proceed",
1357                "kernel_receipt_id": kernel_receipt_id,
1358            });
1359            if let Some(approval_id) = approval_id {
1360                body["approval_id"] = serde_json::Value::String(approval_id.clone());
1361                body["resume_path"] =
1362                    serde_json::Value::String(format!("/approvals/{approval_id}/respond"));
1363            }
1364            (StatusCode::CONFLICT, axum::Json(body)).into_response()
1365        }
1366        _ => (
1367            StatusCode::INTERNAL_SERVER_ERROR,
1368            axum::Json(serde_json::json!({
1369                "error": "chio_evaluation_failed",
1370                "message": error.to_string(),
1371            })),
1372        )
1373            .into_response(),
1374    }
1375}
1376
1377fn approval_json<T>(status: StatusCode, response: T) -> Response
1378where
1379    T: Serialize,
1380{
1381    (status, Json(response)).into_response()
1382}
1383
1384fn approval_error_response(error: ApprovalHandlerError) -> Response {
1385    let status = StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1386    (status, Json(error.body())).into_response()
1387}
1388
1389fn internal_json_error_response(error: &str, message: &str) -> Response {
1390    (
1391        StatusCode::INTERNAL_SERVER_ERROR,
1392        axum::Json(serde_json::json!({
1393            "error": error,
1394            "message": message,
1395        })),
1396    )
1397        .into_response()
1398}
1399
1400fn extract_presented_capability_from_maps<'a>(
1401    headers: &'a HashMap<String, String>,
1402    query: &'a HashMap<String, String>,
1403) -> Option<&'a str> {
1404    headers
1405        .get("x-chio-capability")
1406        .or_else(|| headers.get("X-Chio-Capability"))
1407        .map(String::as_str)
1408        .or_else(|| query.get("chio_capability").map(String::as_str))
1409}
1410
1411fn extract_caller_identity(headers: &HashMap<String, String>) -> CallerIdentity {
1412    if let Some(auth) = headers
1413        .get("authorization")
1414        .or_else(|| headers.get("Authorization"))
1415    {
1416        if let Some(token) = auth.strip_prefix("Bearer ") {
1417            let token_hash = chio_core_types::sha256_hex(token.as_bytes());
1418            return CallerIdentity {
1419                subject: format!("bearer:{}", &token_hash[..16]),
1420                auth_method: AuthMethod::Bearer { token_hash },
1421                verified: false,
1422                tenant: None,
1423                agent_id: None,
1424            };
1425        }
1426    }
1427
1428    for key_header in &["x-api-key", "X-Api-Key", "X-API-Key"] {
1429        if let Some(key_value) = headers.get(*key_header) {
1430            let key_hash = chio_core_types::sha256_hex(key_value.as_bytes());
1431            return CallerIdentity {
1432                subject: format!("apikey:{}", &key_hash[..16]),
1433                auth_method: AuthMethod::ApiKey {
1434                    key_name: key_header.to_string(),
1435                    key_hash,
1436                },
1437                verified: false,
1438                tenant: None,
1439                agent_id: None,
1440            };
1441        }
1442    }
1443
1444    CallerIdentity::anonymous()
1445}
1446
1447fn presented_capability_id(raw_capability: Option<&str>) -> Option<String> {
1448    serde_json::from_str::<CapabilityToken>(raw_capability?)
1449        .ok()
1450        .map(|token| token.id)
1451}
1452
1453async fn find_revoked_capability_id(
1454    state: &Arc<ProxyState>,
1455    raw_capability: Option<&str>,
1456    capability_id_hint: Option<&str>,
1457) -> Option<String> {
1458    let capability_id = presented_capability_id(raw_capability)
1459        .or_else(|| capability_id_hint.map(ToOwned::to_owned))?;
1460    let revoked_capability_ids = state.revoked_capability_ids.lock().await;
1461    revoked_capability_ids
1462        .contains(&capability_id)
1463        .then_some(capability_id)
1464}
1465
1466fn revoked_capability_verdict() -> Verdict {
1467    Verdict::deny_with_status(
1468        "capability token has been revoked",
1469        "CapabilityRevocation",
1470        403,
1471    )
1472}
1473
1474fn manual_receipt_policy_hash(label: &str) -> String {
1475    chio_core_types::sha256_hex(label.as_bytes())
1476}
1477
1478#[allow(clippy::too_many_arguments)]
1479fn build_manual_receipt(
1480    state: &Arc<ProxyState>,
1481    request_id: String,
1482    route_pattern: String,
1483    method: HttpMethod,
1484    caller_identity_hash: String,
1485    session_id: Option<String>,
1486    verdict: Verdict,
1487    response_status: u16,
1488    timestamp: u64,
1489    content_hash: String,
1490    capability_id: Option<String>,
1491    metadata: Option<serde_json::Value>,
1492    policy_label: &str,
1493) -> Result<HttpReceipt, ProtectError> {
1494    HttpReceipt::sign(
1495        HttpReceiptBody {
1496            id: uuid::Uuid::now_v7().to_string(),
1497            request_id,
1498            route_pattern,
1499            method,
1500            caller_identity_hash,
1501            session_id,
1502            verdict,
1503            evidence: Vec::new(),
1504            response_status,
1505            timestamp,
1506            content_hash,
1507            policy_hash: manual_receipt_policy_hash(policy_label),
1508            capability_id,
1509            metadata,
1510            kernel_key: state.signer_keypair.public_key(),
1511        },
1512        &state.signer_keypair,
1513    )
1514    .map_err(|error| ProtectError::ReceiptSign(error.to_string()))
1515}
1516
1517async fn revoked_proxy_response(
1518    state: &Arc<ProxyState>,
1519    method: HttpMethod,
1520    path: &str,
1521    query: &HashMap<String, String>,
1522    headers: &HashMap<String, String>,
1523    body_hash: Option<String>,
1524) -> Option<Response> {
1525    let capability_id = find_revoked_capability_id(
1526        state,
1527        extract_presented_capability_from_maps(headers, query),
1528        None,
1529    )
1530    .await?;
1531    let caller = extract_caller_identity(headers);
1532    let caller_identity_hash = match caller.identity_hash() {
1533        Ok(hash) => hash,
1534        Err(error) => {
1535            warn!("failed to hash caller identity for revocation receipt: {error}");
1536            return Some(internal_json_error_response(
1537                "chio_receipt_sign_failed",
1538                &error.to_string(),
1539            ));
1540        }
1541    };
1542
1543    let mut request = ChioHttpRequest::new(
1544        uuid::Uuid::now_v7().to_string(),
1545        method,
1546        path.to_string(),
1547        path.to_string(),
1548        caller,
1549    );
1550    request.query = query.clone();
1551    request.body_hash = body_hash;
1552
1553    let content_hash = match request.content_hash() {
1554        Ok(hash) => hash,
1555        Err(error) => {
1556            warn!("failed to compute revocation request content hash: {error}");
1557            return Some(internal_json_error_response(
1558                "chio_receipt_sign_failed",
1559                &error.to_string(),
1560            ));
1561        }
1562    };
1563
1564    let verdict = revoked_capability_verdict();
1565    let receipt = match build_manual_receipt(
1566        state,
1567        request.request_id.clone(),
1568        request.route_pattern.clone(),
1569        request.method,
1570        caller_identity_hash,
1571        None,
1572        verdict.clone(),
1573        StatusCode::FORBIDDEN.as_u16(),
1574        request.timestamp,
1575        content_hash,
1576        Some(capability_id),
1577        Some(http_status_metadata_final(None)),
1578        "chio_api_protect_revoked_capability",
1579    ) {
1580        Ok(receipt) => receipt,
1581        Err(error) => {
1582            warn!("failed to sign revocation receipt: {error}");
1583            return Some(internal_json_error_response(
1584                "chio_receipt_sign_failed",
1585                &error.to_string(),
1586            ));
1587        }
1588    };
1589
1590    if let Err(error) = record_receipt(state, &receipt).await {
1591        warn!("failed to persist revocation receipt: {error}");
1592        return Some(internal_json_error_response(
1593            "chio_receipt_persistence_failed",
1594            &error.to_string(),
1595        ));
1596    }
1597
1598    let denied_status =
1599        StatusCode::from_u16(verdict_http_status(&verdict)).unwrap_or(StatusCode::FORBIDDEN);
1600    let error_body = serde_json::json!({
1601        "error": "chio_access_denied",
1602        "message": "capability token has been revoked",
1603        "receipt_id": receipt.id,
1604        "suggestion": "request a fresh capability token before retrying",
1605    });
1606
1607    Some(
1608        Response::builder()
1609            .status(denied_status)
1610            .header("content-type", "application/json")
1611            .header("X-Chio-Receipt-Id", &receipt.id)
1612            .body(Body::from(
1613                serde_json::to_string(&error_body).unwrap_or_default(),
1614            ))
1615            .unwrap_or_else(|_| denied_status.into_response()),
1616    )
1617}
1618
1619async fn revoked_sidecar_evaluate_response(
1620    state: &Arc<ProxyState>,
1621    request: &ChioHttpRequest,
1622    presented_capability: Option<&str>,
1623) -> Option<Response> {
1624    let capability_id = find_revoked_capability_id(
1625        state,
1626        presented_capability,
1627        request.capability_id.as_deref(),
1628    )
1629    .await?;
1630    let caller_identity_hash = match request.caller.identity_hash() {
1631        Ok(hash) => hash,
1632        Err(error) => {
1633            warn!("failed to hash caller identity for sidecar revocation: {error}");
1634            return Some(internal_json_error_response(
1635                "chio_receipt_sign_failed",
1636                &error.to_string(),
1637            ));
1638        }
1639    };
1640    let content_hash = match request.content_hash() {
1641        Ok(hash) => hash,
1642        Err(error) => {
1643            warn!("failed to compute sidecar revocation content hash: {error}");
1644            return Some(internal_json_error_response(
1645                "chio_receipt_sign_failed",
1646                &error.to_string(),
1647            ));
1648        }
1649    };
1650    let route_pattern = if request.route_pattern.is_empty() {
1651        request.path.clone()
1652    } else {
1653        request.route_pattern.clone()
1654    };
1655    let verdict = revoked_capability_verdict();
1656    let receipt = match build_manual_receipt(
1657        state,
1658        request.request_id.clone(),
1659        route_pattern,
1660        request.method,
1661        caller_identity_hash,
1662        request.session_id.clone(),
1663        verdict.clone(),
1664        StatusCode::FORBIDDEN.as_u16(),
1665        request.timestamp,
1666        content_hash,
1667        Some(capability_id),
1668        Some(http_status_metadata_decision()),
1669        "chio_api_protect_revoked_capability",
1670    ) {
1671        Ok(receipt) => receipt,
1672        Err(error) => {
1673            warn!("failed to sign sidecar revocation receipt: {error}");
1674            return Some(internal_json_error_response(
1675                "chio_receipt_sign_failed",
1676                &error.to_string(),
1677            ));
1678        }
1679    };
1680
1681    if let Err(error) = record_receipt(state, &receipt).await {
1682        warn!("failed to persist sidecar revocation receipt: {error}");
1683        return Some(internal_json_error_response(
1684            "chio_receipt_persistence_failed",
1685            &error.to_string(),
1686        ));
1687    }
1688
1689    Some(
1690        (
1691            StatusCode::OK,
1692            axum::Json(EvaluateResponse {
1693                verdict,
1694                receipt,
1695                evidence: Vec::new(),
1696                execution_nonce: None,
1697            }),
1698        )
1699            .into_response(),
1700    )
1701}
1702
1703fn sidecar_submit_receipt_metadata(
1704    receipt_request: &SidecarSubmitReceiptRequest,
1705) -> serde_json::Value {
1706    let mut metadata = match http_status_metadata_final(None) {
1707        serde_json::Value::Object(object) => object,
1708        _ => serde_json::Map::new(),
1709    };
1710    metadata.insert(
1711        "submission_kind".to_string(),
1712        serde_json::Value::String("job_receipt".to_string()),
1713    );
1714    metadata.insert(
1715        "job_name".to_string(),
1716        serde_json::Value::String(receipt_request.job_name.clone()),
1717    );
1718    metadata.insert(
1719        "namespace".to_string(),
1720        serde_json::Value::String(receipt_request.namespace.clone()),
1721    );
1722    metadata.insert(
1723        "job_uid".to_string(),
1724        serde_json::Value::String(receipt_request.job_uid.clone()),
1725    );
1726    metadata.insert(
1727        "outcome".to_string(),
1728        serde_json::Value::String(receipt_request.outcome.clone()),
1729    );
1730    if let Some(started_at) = &receipt_request.started_at {
1731        metadata.insert(
1732            "started_at".to_string(),
1733            serde_json::Value::String(started_at.clone()),
1734        );
1735    }
1736    if let Some(completed_at) = &receipt_request.completed_at {
1737        metadata.insert(
1738            "completed_at".to_string(),
1739            serde_json::Value::String(completed_at.clone()),
1740        );
1741    }
1742    metadata.insert(
1743        "steps".to_string(),
1744        serde_json::Value::Array(
1745            receipt_request
1746                .steps
1747                .iter()
1748                .map(|step| {
1749                    serde_json::json!({
1750                        "pod_name": step.pod_name,
1751                        "phase": step.phase,
1752                        "payload": step.payload,
1753                        "observed_at": step.observed_at,
1754                    })
1755                })
1756                .collect(),
1757        ),
1758    );
1759    serde_json::Value::Object(metadata)
1760}
1761
1762fn should_forward_request_header(name: &str) -> bool {
1763    !matches!(
1764        name,
1765        "connection"
1766            | "proxy-connection"
1767            | "keep-alive"
1768            | "proxy-authenticate"
1769            | "proxy-authorization"
1770            | "te"
1771            | "trailer"
1772            | "transfer-encoding"
1773            | "upgrade"
1774            | "host"
1775            | "content-length"
1776            | "x-chio-capability"
1777    )
1778}
1779
1780fn verdict_http_status(verdict: &Verdict) -> u16 {
1781    match verdict {
1782        Verdict::Allow => 200,
1783        Verdict::Deny { http_status, .. } => *http_status,
1784        Verdict::Cancel { .. } | Verdict::Incomplete { .. } => 500,
1785    }
1786}
1787
1788fn extract_transport_capability(
1789    headers: &axum::http::HeaderMap,
1790    query: &HashMap<String, String>,
1791) -> Option<String> {
1792    headers
1793        .get("x-chio-capability")
1794        .and_then(|value| value.to_str().ok())
1795        .map(ToOwned::to_owned)
1796        .or_else(|| query.get("chio_capability").cloned())
1797}
1798
1799async fn record_receipt(
1800    state: &Arc<ProxyState>,
1801    receipt: &HttpReceipt,
1802) -> Result<(), ProtectError> {
1803    if let Some(store) = &state.receipt_store {
1804        let mut store = store.lock().await;
1805        store.append(receipt)?;
1806    }
1807
1808    let mut log = state.receipt_log.lock().await;
1809    log.receipts.push(receipt.clone());
1810    Ok(())
1811}
1812
1813async fn finalize_and_record_receipt(
1814    state: &Arc<ProxyState>,
1815    decision_receipt: &HttpReceipt,
1816    response_status: u16,
1817) -> Result<HttpReceipt, Response> {
1818    let receipt = state
1819        .evaluator
1820        .finalize_receipt(decision_receipt, response_status)
1821        .map_err(|error| {
1822            warn!("failed to finalize receipt: {error}");
1823            (
1824                StatusCode::INTERNAL_SERVER_ERROR,
1825                "failed to finalize receipt",
1826            )
1827                .into_response()
1828        })?;
1829
1830    record_receipt(state, &receipt).await.map_err(|error| {
1831        warn!("failed to persist receipt: {error}");
1832        (
1833            StatusCode::INTERNAL_SERVER_ERROR,
1834            "failed to persist receipt",
1835        )
1836            .into_response()
1837    })?;
1838
1839    Ok(receipt)
1840}
1841
1842async fn finalize_bad_gateway(
1843    state: &Arc<ProxyState>,
1844    decision_receipt: &HttpReceipt,
1845    message: String,
1846) -> Response {
1847    match finalize_and_record_receipt(state, decision_receipt, StatusCode::BAD_GATEWAY.as_u16())
1848        .await
1849    {
1850        Ok(receipt) => Response::builder()
1851            .status(StatusCode::BAD_GATEWAY)
1852            .header("X-Chio-Receipt-Id", &receipt.id)
1853            .body(Body::from(message))
1854            .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()),
1855        Err(response) => response,
1856    }
1857}
1858
1859#[cfg(test)]
1860mod tests {
1861    use super::*;
1862    use axum::body::to_bytes;
1863    use chio_core_types::capability::{
1864        CapabilityToken, CapabilityTokenBody, ChioScope, GovernedApprovalDecision,
1865        GovernedApprovalToken, GovernedApprovalTokenBody,
1866    };
1867    use chio_http_core::{
1868        http_status_scope, RespondResponse, CHIO_HTTP_STATUS_SCOPE_DECISION,
1869        CHIO_HTTP_STATUS_SCOPE_FINAL,
1870    };
1871    use chio_kernel::{ApprovalOutcome, ApprovalRequest};
1872    use chio_openapi::PolicyDecision;
1873    use std::io::{Read, Write};
1874    use std::net::TcpListener;
1875    use std::thread;
1876    use tower::ServiceExt;
1877
1878    const PETSTORE_YAML: &str = r#"
1879openapi: "3.0.0"
1880info:
1881  title: Petstore
1882  version: "1.0.0"
1883paths:
1884  /pets:
1885    get:
1886      operationId: listPets
1887      summary: List all pets
1888      responses:
1889        "200":
1890          description: A list of pets
1891    post:
1892      operationId: createPet
1893      summary: Create a pet
1894      requestBody:
1895        content:
1896          application/json:
1897            schema:
1898              type: object
1899              properties:
1900                name:
1901                  type: string
1902      responses:
1903        "201":
1904          description: Created
1905  /pets/{petId}:
1906    get:
1907      operationId: showPetById
1908      summary: Info for a specific pet
1909      parameters:
1910        - name: petId
1911          in: path
1912          required: true
1913          schema:
1914            type: string
1915      responses:
1916        "200":
1917          description: A pet
1918    delete:
1919      operationId: deletePet
1920      summary: Delete a pet
1921      parameters:
1922        - name: petId
1923          in: path
1924          required: true
1925          schema:
1926            type: string
1927      responses:
1928        "204":
1929          description: Deleted
1930"#;
1931
1932    fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
1933        let now = chrono::Utc::now().timestamp() as u64;
1934        let token = CapabilityToken::sign(
1935            CapabilityTokenBody {
1936                id: id.to_string(),
1937                issuer: issuer.public_key(),
1938                subject: issuer.public_key(),
1939                scope: ChioScope::default(),
1940                issued_at: now.saturating_sub(60),
1941                expires_at: now + 3600,
1942                delegation_chain: Vec::new(),
1943            },
1944            &issuer,
1945        )
1946        .expect("token should sign");
1947        serde_json::to_string(&token).expect("token should serialize")
1948    }
1949
1950    struct MockUpstreamServer {
1951        base_url: String,
1952        requests: Arc<std::sync::Mutex<Vec<String>>>,
1953        handle: thread::JoinHandle<()>,
1954    }
1955
1956    impl MockUpstreamServer {
1957        fn bind_mock_upstream_listener() -> Option<TcpListener> {
1958            match TcpListener::bind("127.0.0.1:0") {
1959                Ok(listener) => Some(listener),
1960                Err(error) => match error.kind() {
1961                    std::io::ErrorKind::PermissionDenied
1962                    | std::io::ErrorKind::AddrNotAvailable
1963                    | std::io::ErrorKind::Unsupported => {
1964                        eprintln!(
1965                            "skipping proxy mock-upstream test because loopback bind is unavailable: {error}"
1966                        );
1967                        None
1968                    }
1969                    _ => panic!("bind mock upstream listener: {error}"),
1970                },
1971            }
1972        }
1973
1974        fn spawn(status: u16, headers: Vec<(&str, &str)>, body: &str) -> Option<Self> {
1975            let listener = Self::bind_mock_upstream_listener()?;
1976            let address = listener.local_addr().expect("listener address");
1977            let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
1978            let request_log = Arc::clone(&requests);
1979            let headers = headers
1980                .into_iter()
1981                .map(|(name, value)| (name.to_string(), value.to_string()))
1982                .collect::<Vec<_>>();
1983            let body = body.to_string();
1984            let handle = thread::spawn(move || {
1985                let (mut stream, _) = listener.accept().expect("accept upstream connection");
1986                let request = read_http_request(&mut stream);
1987                request_log.lock().expect("request log lock").push(request);
1988                write_http_response(&mut stream, status, &headers, &body);
1989            });
1990            Some(Self {
1991                base_url: format!("http://{}", address),
1992                requests,
1993                handle,
1994            })
1995        }
1996
1997        fn base_url(&self) -> String {
1998            self.base_url.clone()
1999        }
2000
2001        fn requests(&self) -> Vec<String> {
2002            self.requests.lock().expect("request log lock").clone()
2003        }
2004
2005        fn join(self) {
2006            self.handle.join().expect("join mock upstream thread");
2007        }
2008    }
2009
2010    fn test_state(routes: Vec<RouteEntry>, upstream: String) -> Arc<ProxyState> {
2011        test_state_with_receipt_db(routes, upstream, None)
2012    }
2013
2014    fn test_state_with_receipt_db(
2015        routes: Vec<RouteEntry>,
2016        upstream: String,
2017        receipt_db: Option<&str>,
2018    ) -> Arc<ProxyState> {
2019        let keypair = Keypair::generate();
2020        let approval_store: Arc<dyn ApprovalStore> = if let Some(path) = receipt_db {
2021            Arc::new(SqliteApprovalStore::open(path).expect("open sqlite approval store"))
2022        } else {
2023            Arc::new(InMemoryApprovalStore::new())
2024        };
2025        let (receipt_store, receipts, revoked_capability_ids) = if let Some(path) = receipt_db {
2026            let store = SqliteReceiptStore::open(path).expect("open sqlite receipt store");
2027            let receipts = store.load_receipts().expect("load persisted receipts");
2028            let revoked_capability_ids = store
2029                .load_revoked_capability_ids()
2030                .expect("load revoked capability ids");
2031            (Some(Mutex::new(store)), receipts, revoked_capability_ids)
2032        } else {
2033            (None, Vec::new(), HashSet::new())
2034        };
2035        let evaluator = RequestEvaluator::new_with_approval_store(
2036            routes,
2037            keypair.clone(),
2038            "test-policy".to_string(),
2039            Arc::clone(&approval_store),
2040        );
2041        Arc::new(ProxyState {
2042            evaluator,
2043            signer_keypair: keypair,
2044            upstream,
2045            http_client: reqwest::Client::new(),
2046            approval_admin: ApprovalAdmin::new(approval_store),
2047            receipt_log: Mutex::new(ReceiptLog { receipts }),
2048            receipt_store,
2049            revoked_capability_ids: Mutex::new(revoked_capability_ids),
2050            sidecar_control_token: None,
2051        })
2052    }
2053
2054    fn pending_approval_request(approval_id: &str) -> (ApprovalRequest, Keypair, Keypair) {
2055        let request_subject = Keypair::generate();
2056        let approver = Keypair::generate();
2057        let approval = ApprovalRequest {
2058            approval_id: approval_id.to_string(),
2059            policy_id: "policy-hitl".to_string(),
2060            subject_id: "agent-1".to_string(),
2061            capability_id: "cap-1".to_string(),
2062            subject_public_key: Some(request_subject.public_key()),
2063            tool_server: "srv".to_string(),
2064            tool_name: "tool".to_string(),
2065            action: "invoke".to_string(),
2066            parameter_hash: "hash-1".to_string(),
2067            expires_at: 4_000_000_000,
2068            callback_hint: None,
2069            created_at: 123,
2070            summary: "pending approval".to_string(),
2071            governed_intent: None,
2072            trusted_approvers: vec![approver.public_key()],
2073            triggered_by: vec!["force_approval".to_string()],
2074        };
2075        (approval, request_subject, approver)
2076    }
2077
2078    fn signed_approval_response_token(
2079        approval_id: &str,
2080        subject: &Keypair,
2081        approver: &Keypair,
2082        decision: GovernedApprovalDecision,
2083    ) -> GovernedApprovalToken {
2084        let now = chrono::Utc::now().timestamp() as u64;
2085        GovernedApprovalToken::sign(
2086            GovernedApprovalTokenBody {
2087                id: format!("tok-{approval_id}"),
2088                approver: approver.public_key(),
2089                subject: subject.public_key(),
2090                governed_intent_hash: "hash-1".to_string(),
2091                request_id: approval_id.to_string(),
2092                issued_at: now.saturating_sub(10),
2093                expires_at: now + 600,
2094                decision,
2095            },
2096            approver,
2097        )
2098        .expect("approval token should sign")
2099    }
2100
2101    fn temp_receipt_db_path() -> String {
2102        let mut path = std::env::temp_dir();
2103        path.push(format!("chio-api-protect-test-{}.db", uuid::Uuid::now_v7()));
2104        path.to_string_lossy().to_string()
2105    }
2106
2107    fn with_peer_addr(mut request: Request<Body>, peer: SocketAddr) -> Request<Body> {
2108        request.extensions_mut().insert(ConnectInfo(peer));
2109        request
2110    }
2111
2112    fn with_loopback_peer(request: Request<Body>) -> Request<Body> {
2113        with_peer_addr(request, SocketAddr::from(([127, 0, 0, 1], 4100)))
2114    }
2115
2116    fn read_http_request<R: Read>(stream: &mut R) -> String {
2117        let mut request = Vec::new();
2118        let mut chunk = [0_u8; 1024];
2119        let mut header_end = None;
2120        let mut content_length = 0_usize;
2121
2122        loop {
2123            let read = stream.read(&mut chunk).expect("read request");
2124            if read == 0 {
2125                break;
2126            }
2127            request.extend_from_slice(&chunk[..read]);
2128            if header_end.is_none() {
2129                header_end = find_header_end(&request);
2130                if let Some(end) = header_end {
2131                    content_length = parse_content_length(&request[..end]);
2132                }
2133            }
2134            if let Some(end) = header_end {
2135                if request.len() >= end + content_length {
2136                    break;
2137                }
2138            }
2139        }
2140
2141        String::from_utf8(request).expect("request should be valid UTF-8")
2142    }
2143
2144    fn find_header_end(request: &[u8]) -> Option<usize> {
2145        request
2146            .windows(4)
2147            .position(|window| window == b"\r\n\r\n")
2148            .map(|position| position + 4)
2149    }
2150
2151    fn parse_content_length(headers: &[u8]) -> usize {
2152        String::from_utf8_lossy(headers)
2153            .lines()
2154            .find_map(|line| {
2155                let (name, value) = line.split_once(':')?;
2156                if name.eq_ignore_ascii_case("content-length") {
2157                    value.trim().parse::<usize>().ok()
2158                } else {
2159                    None
2160                }
2161            })
2162            .unwrap_or(0)
2163    }
2164
2165    fn write_http_response<W: Write>(
2166        stream: &mut W,
2167        status: u16,
2168        headers: &[(String, String)],
2169        body: &str,
2170    ) {
2171        let mut response = format!(
2172            "HTTP/1.1 {status} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
2173            http_status_text(status),
2174            body.len(),
2175        );
2176        for (name, value) in headers {
2177            response.push_str(&format!("{name}: {value}\r\n"));
2178        }
2179        response.push_str("\r\n");
2180        response.push_str(body);
2181        stream
2182            .write_all(response.as_bytes())
2183            .expect("write upstream response");
2184    }
2185
2186    fn http_status_text(status: u16) -> &'static str {
2187        match status {
2188            200 => "OK",
2189            201 => "Created",
2190            502 => "Bad Gateway",
2191            _ => "Unknown",
2192        }
2193    }
2194
2195    #[test]
2196    fn build_routes_from_petstore() {
2197        let routes = ProtectProxy::routes_from_spec(PETSTORE_YAML).unwrap();
2198        assert!(!routes.is_empty());
2199
2200        // Should have GET and POST for /pets, GET and DELETE for /pets/{petId}
2201        let get_pets = routes.iter().find(|r| {
2202            r.method == HttpMethod::Get
2203                && r.pattern.contains("/pets")
2204                && !r.pattern.contains("{petId}")
2205        });
2206        assert!(get_pets.is_some());
2207
2208        let post_pets = routes.iter().find(|r| r.method == HttpMethod::Post);
2209        assert!(post_pets.is_some());
2210        assert_eq!(
2211            post_pets.map(|r| r.policy.clone()),
2212            Some(PolicyDecision::DenyByDefault)
2213        );
2214
2215        let delete_pet = routes.iter().find(|r| r.method == HttpMethod::Delete);
2216        assert!(delete_pet.is_some());
2217    }
2218
2219    #[test]
2220    fn get_routes_allowed_by_default() {
2221        let routes = ProtectProxy::routes_from_spec(PETSTORE_YAML).unwrap();
2222        let get_routes: Vec<_> = routes
2223            .iter()
2224            .filter(|r| r.method == HttpMethod::Get)
2225            .collect();
2226        for route in get_routes {
2227            assert_eq!(route.policy, PolicyDecision::SessionAllow);
2228        }
2229    }
2230
2231    #[test]
2232    fn side_effect_routes_denied_by_default() {
2233        let routes = ProtectProxy::routes_from_spec(PETSTORE_YAML).unwrap();
2234        let mut_routes: Vec<_> = routes
2235            .iter()
2236            .filter(|r| r.method.requires_capability())
2237            .collect();
2238        for route in mut_routes {
2239            assert_eq!(route.policy, PolicyDecision::DenyByDefault);
2240        }
2241    }
2242
2243    #[test]
2244    fn x_chio_side_effects_true_overrides_safe_method() {
2245        let spec = r#"
2246openapi: 3.1.0
2247info:
2248  title: Override Test
2249  version: 1.0.0
2250paths:
2251  /dangerous-read:
2252    get:
2253      operationId: dangerousRead
2254      x-chio-side-effects: true
2255      responses:
2256        "200":
2257          description: ok
2258"#;
2259
2260        let routes = ProtectProxy::routes_from_spec(spec).unwrap();
2261        let route = routes
2262            .iter()
2263            .find(|route| route.pattern == "/dangerous-read" && route.method == HttpMethod::Get)
2264            .expect("route");
2265
2266        assert_eq!(route.policy, PolicyDecision::DenyByDefault);
2267    }
2268
2269    #[test]
2270    fn x_chio_side_effects_false_overrides_mutating_method() {
2271        let spec = r#"
2272openapi: 3.1.0
2273info:
2274  title: Override Test
2275  version: 1.0.0
2276paths:
2277  /safe-post:
2278    post:
2279      operationId: safePost
2280      x-chio-side-effects: false
2281      responses:
2282        "200":
2283          description: ok
2284"#;
2285
2286        let routes = ProtectProxy::routes_from_spec(spec).unwrap();
2287        let route = routes
2288            .iter()
2289            .find(|route| route.pattern == "/safe-post" && route.method == HttpMethod::Post)
2290            .expect("route");
2291
2292        assert_eq!(route.policy, PolicyDecision::SessionAllow);
2293    }
2294
2295    #[test]
2296    fn x_chio_approval_required_forces_deny() {
2297        let spec = r#"
2298openapi: 3.1.0
2299info:
2300  title: Override Test
2301  version: 1.0.0
2302paths:
2303  /approved-read:
2304    get:
2305      operationId: approvedRead
2306      x-chio-side-effects: false
2307      x-chio-approval-required: true
2308      responses:
2309        "200":
2310          description: ok
2311"#;
2312
2313        let routes = ProtectProxy::routes_from_spec(spec).unwrap();
2314        let route = routes
2315            .iter()
2316            .find(|route| route.pattern == "/approved-read" && route.method == HttpMethod::Get)
2317            .expect("route");
2318
2319        assert_eq!(route.policy, PolicyDecision::DenyByDefault);
2320    }
2321
2322    #[test]
2323    fn forwarded_query_string_strips_chio_capability() {
2324        let token = signed_capability_token_json(&Keypair::generate(), "cap-query");
2325        let query = url::form_urlencoded::Serializer::new(String::new())
2326            .append_pair("source", "test")
2327            .append_pair("chio_capability", &token)
2328            .append_pair("mode", "full")
2329            .finish();
2330
2331        assert_eq!(
2332            forwarded_query_string(Some(&query)).as_deref(),
2333            Some("source=test&mode=full")
2334        );
2335    }
2336
2337    #[tokio::test]
2338    async fn evaluation_error_response_surfaces_pending_approval_state() {
2339        let response = evaluation_error_response(&ProtectError::PendingApproval {
2340            approval_id: Some("ap-123".to_string()),
2341            kernel_receipt_id: "kr-456".to_string(),
2342        });
2343        assert_eq!(response.status(), StatusCode::CONFLICT);
2344
2345        let body = to_bytes(response.into_body(), 1024 * 1024)
2346            .await
2347            .expect("response body");
2348        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
2349        assert_eq!(json["error"], "chio_approval_required");
2350        assert_eq!(json["approval_id"], "ap-123");
2351        assert_eq!(json["kernel_receipt_id"], "kr-456");
2352        assert_eq!(json["resume_path"], "/approvals/ap-123/respond");
2353    }
2354
2355    #[tokio::test]
2356    async fn approval_routes_are_handled_before_proxy_catch_all() {
2357        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2358        let (approval, subject, approver) = pending_approval_request("ap-route-1");
2359        state
2360            .approval_admin
2361            .store()
2362            .store_pending(&approval)
2363            .expect("store pending approval");
2364
2365        let token = signed_approval_response_token(
2366            &approval.approval_id,
2367            &subject,
2368            &approver,
2369            GovernedApprovalDecision::Approved,
2370        );
2371        let request = with_loopback_peer(
2372            Request::builder()
2373                .method("POST")
2374                .uri(format!("/approvals/{}/respond", approval.approval_id))
2375                .header("content-type", "application/json")
2376                .body(Body::from(
2377                    serde_json::to_vec(&RespondRequest {
2378                        outcome: ApprovalOutcome::Approved,
2379                        reason: Some("approved".to_string()),
2380                        approver: approver.public_key(),
2381                        token,
2382                    })
2383                    .expect("serialize approval response"),
2384                ))
2385                .expect("request"),
2386        );
2387
2388        let response = build_app(Arc::clone(&state))
2389            .oneshot(request)
2390            .await
2391            .expect("approval response");
2392        assert_eq!(response.status(), StatusCode::OK);
2393
2394        let body = to_bytes(response.into_body(), 1024 * 1024)
2395            .await
2396            .expect("response body");
2397        let json: RespondResponse = serde_json::from_slice(&body).expect("json body");
2398        assert_eq!(json.approval_id, "ap-route-1");
2399        assert_eq!(json.outcome, ApprovalOutcome::Approved);
2400        assert!(state
2401            .approval_admin
2402            .store()
2403            .get_pending("ap-route-1")
2404            .expect("approval lookup")
2405            .is_none());
2406    }
2407
2408    #[tokio::test]
2409    async fn approval_routes_reject_remote_callers_without_control_access() {
2410        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2411        let remote = SocketAddr::from(([10, 1, 2, 3], 5200));
2412
2413        let request = with_peer_addr(
2414            Request::builder()
2415                .method("GET")
2416                .uri("/approvals/pending")
2417                .body(Body::empty())
2418                .expect("request"),
2419            remote,
2420        );
2421
2422        let response = build_app(Arc::clone(&state))
2423            .oneshot(request)
2424            .await
2425            .expect("approval response");
2426        assert_eq!(response.status(), StatusCode::FORBIDDEN);
2427
2428        let body = to_bytes(response.into_body(), 1024 * 1024)
2429            .await
2430            .expect("response body");
2431        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
2432        assert_eq!(json["error"], "chio_control_forbidden");
2433    }
2434
2435    #[test]
2436    fn evaluator_and_approval_routes_share_the_same_store() {
2437        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2438        let evaluator_store = state.evaluator.approval_store();
2439
2440        assert!(Arc::ptr_eq(&evaluator_store, state.approval_admin.store()));
2441    }
2442
2443    #[tokio::test]
2444    async fn proxy_handler_denies_without_capability_and_records_receipt() {
2445        let state = test_state(
2446            vec![RouteEntry {
2447                pattern: "/pets".to_string(),
2448                method: HttpMethod::Post,
2449                operation_id: Some("createPet".to_string()),
2450                policy: PolicyDecision::DenyByDefault,
2451            }],
2452            "http://127.0.0.1:1".to_string(),
2453        );
2454        let request = Request::builder()
2455            .method("POST")
2456            .uri("/pets")
2457            .header("content-type", "application/json")
2458            .body(Body::from(r#"{"name":"fido"}"#))
2459            .expect("request");
2460
2461        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2462        assert_eq!(response.status(), StatusCode::FORBIDDEN);
2463        let receipt_id = response
2464            .headers()
2465            .get("x-chio-receipt-id")
2466            .and_then(|value| value.to_str().ok())
2467            .expect("receipt id header")
2468            .to_string();
2469        assert_eq!(
2470            response
2471                .headers()
2472                .get("content-type")
2473                .and_then(|value| value.to_str().ok()),
2474            Some("application/json")
2475        );
2476
2477        let body = to_bytes(response.into_body(), 1024 * 1024)
2478            .await
2479            .expect("response body");
2480        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
2481        assert_eq!(json["error"], "chio_access_denied");
2482        assert_eq!(
2483            json["suggestion"],
2484            "provide a valid capability token in the X-Chio-Capability header or chio_capability query parameter"
2485        );
2486        assert!(json["receipt_id"].as_str().is_some());
2487
2488        let log = state.receipt_log.lock().await;
2489        assert_eq!(log.receipts.len(), 1);
2490        assert_eq!(log.receipts[0].id, receipt_id);
2491        assert_eq!(log.receipts[0].response_status, 403);
2492        assert_eq!(
2493            http_status_scope(log.receipts[0].metadata.as_ref()),
2494            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
2495        );
2496        assert!(log.receipts[0]
2497            .verify_signature()
2498            .expect("receipt signature"));
2499    }
2500
2501    #[tokio::test]
2502    async fn proxy_handler_forwards_allowed_requests_and_end_to_end_headers() {
2503        let Some(server) = MockUpstreamServer::spawn(
2504            201,
2505            vec![("content-type", "application/json"), ("x-upstream", "ok")],
2506            r#"{"ok":true}"#,
2507        ) else {
2508            return;
2509        };
2510        let state = test_state(
2511            vec![RouteEntry {
2512                pattern: "/pets".to_string(),
2513                method: HttpMethod::Post,
2514                operation_id: Some("createPet".to_string()),
2515                policy: PolicyDecision::DenyByDefault,
2516            }],
2517            server.base_url(),
2518        );
2519        let request = Request::builder()
2520            .method("POST")
2521            .uri("/pets?source=test")
2522            .header("content-type", "application/json")
2523            .header("accept", "application/json")
2524            .header("user-agent", "chio-test")
2525            .header("authorization", "Bearer upstream-token")
2526            .header("x-request-id", "req-123")
2527            .header(
2528                "x-chio-capability",
2529                signed_capability_token_json(&state.signer_keypair, "cap-proxy"),
2530            )
2531            .header("x-custom-app", "secret")
2532            .header("connection", "keep-alive")
2533            .body(Body::from(r#"{"name":"fido"}"#))
2534            .expect("request");
2535
2536        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2537        let receipt_id = response
2538            .headers()
2539            .get("x-chio-receipt-id")
2540            .and_then(|value| value.to_str().ok())
2541            .expect("receipt id header")
2542            .to_string();
2543        assert_eq!(response.status(), StatusCode::CREATED);
2544        assert_eq!(
2545            response
2546                .headers()
2547                .get("x-upstream")
2548                .and_then(|value| value.to_str().ok()),
2549            Some("ok")
2550        );
2551
2552        let body = to_bytes(response.into_body(), 1024 * 1024)
2553            .await
2554            .expect("response body");
2555        assert_eq!(body.as_ref(), br#"{"ok":true}"#);
2556
2557        let requests = server.requests();
2558        server.join();
2559
2560        assert_eq!(requests.len(), 1);
2561        let request_text = requests[0].to_ascii_lowercase();
2562        assert!(request_text.contains("post /pets?source=test http/1.1"));
2563        assert!(request_text.contains("content-type: application/json"));
2564        assert!(request_text.contains("accept: application/json"));
2565        assert!(request_text.contains("user-agent: chio-test"));
2566        assert!(request_text.contains("authorization: bearer upstream-token"));
2567        assert!(request_text.contains("x-request-id: req-123"));
2568        assert!(request_text.contains("x-custom-app: secret"));
2569        assert!(!request_text.contains("x-chio-capability:"));
2570        assert!(!request_text.contains("connection: keep-alive"));
2571        assert!(request_text.contains(r#"{"name":"fido"}"#));
2572
2573        let log = state.receipt_log.lock().await;
2574        assert_eq!(log.receipts.len(), 1);
2575        assert_eq!(log.receipts[0].id, receipt_id);
2576        assert_eq!(log.receipts[0].response_status, 201);
2577        assert_eq!(log.receipts[0].capability_id.as_deref(), Some("cap-proxy"));
2578        assert_eq!(
2579            http_status_scope(log.receipts[0].metadata.as_ref()),
2580            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
2581        );
2582    }
2583
2584    #[tokio::test]
2585    async fn proxy_handler_strips_query_capability_before_forwarding_upstream() {
2586        let Some(server) =
2587            MockUpstreamServer::spawn(200, vec![("content-type", "application/json")], "{}")
2588        else {
2589            return;
2590        };
2591        let state = test_state(
2592            vec![RouteEntry {
2593                pattern: "/pets".to_string(),
2594                method: HttpMethod::Post,
2595                operation_id: Some("createPet".to_string()),
2596                policy: PolicyDecision::DenyByDefault,
2597            }],
2598            server.base_url(),
2599        );
2600        let token = signed_capability_token_json(&state.signer_keypair, "cap-query");
2601        let query = url::form_urlencoded::Serializer::new(String::new())
2602            .append_pair("source", "test")
2603            .append_pair("chio_capability", &token)
2604            .append_pair("mode", "full")
2605            .finish();
2606        let request = Request::builder()
2607            .method("POST")
2608            .uri(format!("/pets?{query}"))
2609            .header("content-type", "application/json")
2610            .body(Body::from(r#"{"name":"fido"}"#))
2611            .expect("request");
2612
2613        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2614        assert_eq!(response.status(), StatusCode::OK);
2615
2616        let requests = server.requests();
2617        server.join();
2618
2619        assert_eq!(requests.len(), 1);
2620        let request_text = requests[0].to_ascii_lowercase();
2621        assert!(request_text.contains("post /pets?source=test&mode=full http/1.1"));
2622        assert!(!request_text.contains("chio_capability"));
2623
2624        let log = state.receipt_log.lock().await;
2625        assert_eq!(log.receipts.len(), 1);
2626        assert_eq!(log.receipts[0].capability_id.as_deref(), Some("cap-query"));
2627    }
2628
2629    #[tokio::test]
2630    async fn proxy_handler_rejects_unsupported_methods_before_evaluation() {
2631        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2632        let request = Request::builder()
2633            .method("TRACE")
2634            .uri("/pets")
2635            .body(Body::empty())
2636            .expect("request");
2637
2638        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2639        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
2640
2641        let body = to_bytes(response.into_body(), 1024 * 1024)
2642            .await
2643            .expect("response body");
2644        assert_eq!(body.as_ref(), b"unsupported method");
2645        let log = state.receipt_log.lock().await;
2646        assert!(log.receipts.is_empty());
2647    }
2648
2649    #[tokio::test]
2650    async fn proxy_handler_surfaces_upstream_failures_after_allowing_request() {
2651        let state = test_state(
2652            vec![RouteEntry {
2653                pattern: "/pets".to_string(),
2654                method: HttpMethod::Get,
2655                operation_id: Some("listPets".to_string()),
2656                policy: PolicyDecision::SessionAllow,
2657            }],
2658            "http://127.0.0.1:1".to_string(),
2659        );
2660        let request = Request::builder()
2661            .method("GET")
2662            .uri("/pets")
2663            .body(Body::empty())
2664            .expect("request");
2665
2666        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2667        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
2668
2669        let body = to_bytes(response.into_body(), 1024 * 1024)
2670            .await
2671            .expect("response body");
2672        let text = String::from_utf8(body.to_vec()).expect("utf8 body");
2673        assert!(text.contains("upstream error:"));
2674
2675        let log = state.receipt_log.lock().await;
2676        assert_eq!(log.receipts.len(), 1);
2677        assert_eq!(log.receipts[0].response_status, 502);
2678        assert_eq!(
2679            http_status_scope(log.receipts[0].metadata.as_ref()),
2680            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
2681        );
2682    }
2683
2684    #[tokio::test]
2685    async fn proxy_handler_denies_invalid_capability_tokens() {
2686        let state = test_state(
2687            vec![RouteEntry {
2688                pattern: "/pets".to_string(),
2689                method: HttpMethod::Post,
2690                operation_id: Some("createPet".to_string()),
2691                policy: PolicyDecision::DenyByDefault,
2692            }],
2693            "http://127.0.0.1:1".to_string(),
2694        );
2695        let request = Request::builder()
2696            .method("POST")
2697            .uri("/pets")
2698            .header("x-chio-capability", "not-json")
2699            .body(Body::from(r#"{"name":"fido"}"#))
2700            .expect("request");
2701
2702        let response = proxy_handler(State(Arc::clone(&state)), request).await;
2703        assert_eq!(response.status(), StatusCode::FORBIDDEN);
2704
2705        let body = to_bytes(response.into_body(), 1024 * 1024)
2706            .await
2707            .expect("response body");
2708        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
2709        assert_eq!(json["error"], "chio_access_denied");
2710
2711        let log = state.receipt_log.lock().await;
2712        assert_eq!(log.receipts.len(), 1);
2713        assert!(log.receipts[0].capability_id.is_none());
2714        assert_eq!(
2715            http_status_scope(log.receipts[0].metadata.as_ref()),
2716            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
2717        );
2718    }
2719
2720    #[tokio::test]
2721    async fn sidecar_evaluate_returns_200_with_deny_verdict() {
2722        let state = test_state(
2723            vec![RouteEntry {
2724                pattern: "/pets".to_string(),
2725                method: HttpMethod::Post,
2726                operation_id: Some("createPet".to_string()),
2727                policy: PolicyDecision::DenyByDefault,
2728            }],
2729            "http://127.0.0.1:1".to_string(),
2730        );
2731        let body = ChioHttpRequest::new(
2732            "req-sidecar-deny".to_string(),
2733            HttpMethod::Post,
2734            "/pets".to_string(),
2735            "/pets".to_string(),
2736            chio_http_core::CallerIdentity::anonymous(),
2737        );
2738        let request = Request::builder()
2739            .method("POST")
2740            .uri("/chio/evaluate")
2741            .header("content-type", "application/json")
2742            .body(Body::from(
2743                serde_json::to_vec(&body).expect("serialize request"),
2744            ))
2745            .expect("request");
2746
2747        let response = sidecar_evaluate_handler(State(Arc::clone(&state)), request).await;
2748        assert_eq!(response.status(), StatusCode::OK);
2749
2750        let bytes = to_bytes(response.into_body(), 1024 * 1024)
2751            .await
2752            .expect("response body");
2753        let evaluation: EvaluateResponse =
2754            serde_json::from_slice(&bytes).expect("decode evaluate response");
2755        assert!(evaluation
2756            .receipt
2757            .verify_signature()
2758            .expect("receipt signature"));
2759        assert!(evaluation.verdict.is_denied());
2760        assert!(evaluation.receipt.is_denied());
2761        assert_eq!(
2762            http_status_scope(evaluation.receipt.metadata.as_ref()),
2763            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
2764        );
2765    }
2766
2767    #[tokio::test]
2768    async fn sidecar_evaluate_validates_transport_capability_header() {
2769        let state = test_state(
2770            vec![RouteEntry {
2771                pattern: "/pets".to_string(),
2772                method: HttpMethod::Post,
2773                operation_id: Some("createPet".to_string()),
2774                policy: PolicyDecision::DenyByDefault,
2775            }],
2776            "http://127.0.0.1:1".to_string(),
2777        );
2778        let token = signed_capability_token_json(&state.signer_keypair, "cap-sidecar");
2779        let mut body = ChioHttpRequest::new(
2780            "req-sidecar-allow".to_string(),
2781            HttpMethod::Post,
2782            "/pets".to_string(),
2783            "/pets".to_string(),
2784            chio_http_core::CallerIdentity::anonymous(),
2785        );
2786        body.capability_id = Some("cap-sidecar".to_string());
2787        let request = Request::builder()
2788            .method("POST")
2789            .uri("/chio/evaluate")
2790            .header("content-type", "application/json")
2791            .header("x-chio-capability", token)
2792            .body(Body::from(
2793                serde_json::to_vec(&body).expect("serialize request"),
2794            ))
2795            .expect("request");
2796
2797        let response = sidecar_evaluate_handler(State(Arc::clone(&state)), request).await;
2798        assert_eq!(response.status(), StatusCode::OK);
2799
2800        let bytes = to_bytes(response.into_body(), 1024 * 1024)
2801            .await
2802            .expect("response body");
2803        let evaluation: EvaluateResponse =
2804            serde_json::from_slice(&bytes).expect("decode evaluate response");
2805        assert!(evaluation.verdict.is_allowed());
2806        assert_eq!(
2807            evaluation.receipt.capability_id.as_deref(),
2808            Some("cap-sidecar")
2809        );
2810        assert_eq!(
2811            http_status_scope(evaluation.receipt.metadata.as_ref()),
2812            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
2813        );
2814    }
2815
2816    #[tokio::test]
2817    async fn sidecar_verify_reports_signature_validity() {
2818        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2819        let keypair = Keypair::generate();
2820        let receipt = HttpReceipt::sign(
2821            chio_http_core::HttpReceiptBody {
2822                id: "receipt-verify".to_string(),
2823                request_id: "req-verify".to_string(),
2824                route_pattern: "/pets".to_string(),
2825                method: HttpMethod::Get,
2826                caller_identity_hash: "caller-hash".to_string(),
2827                session_id: None,
2828                verdict: chio_http_core::Verdict::Allow,
2829                evidence: Vec::new(),
2830                response_status: 200,
2831                timestamp: 1_700_000_000,
2832                content_hash: "hash".to_string(),
2833                policy_hash: "policy".to_string(),
2834                capability_id: None,
2835                metadata: None,
2836                kernel_key: keypair.public_key(),
2837            },
2838            &keypair,
2839        )
2840        .expect("sign receipt");
2841        let request = Request::builder()
2842            .method("POST")
2843            .uri("/chio/verify")
2844            .header("content-type", "application/json")
2845            .body(Body::from(
2846                serde_json::to_vec(&receipt).expect("serialize receipt"),
2847            ))
2848            .expect("request");
2849
2850        let response = sidecar_verify_handler(State(state), request).await;
2851        assert_eq!(response.status(), StatusCode::OK);
2852
2853        let bytes = to_bytes(response.into_body(), 1024 * 1024)
2854            .await
2855            .expect("response body");
2856        let verification: VerifyReceiptResponse =
2857            serde_json::from_slice(&bytes).expect("decode verify response");
2858        assert!(verification.valid);
2859    }
2860
2861    #[tokio::test]
2862    async fn sidecar_mint_returns_canonical_capability_tokens() {
2863        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2864        let request = with_loopback_peer(
2865            Request::builder()
2866                .method("POST")
2867                .uri("/v1/capabilities/mint")
2868                .header("content-type", "application/json")
2869                .body(Body::from(
2870                    serde_json::to_vec(&serde_json::json!({
2871                        "subject": "job/default/demo",
2872                        "scopes": ["tools:search", "tool:server-a:fetch:invoke"],
2873                        "job_uid": "job-uid-1",
2874                    }))
2875                    .expect("serialize mint request"),
2876                ))
2877                .expect("request"),
2878        );
2879
2880        let response = sidecar_mint_handler(State(Arc::clone(&state)), request).await;
2881        assert_eq!(response.status(), StatusCode::OK);
2882
2883        let bytes = to_bytes(response.into_body(), 1024 * 1024)
2884            .await
2885            .expect("response body");
2886        let mint: SidecarMintResponse =
2887            serde_json::from_slice(&bytes).expect("decode mint response");
2888
2889        assert_eq!(mint.capability.issuer, state.signer_keypair.public_key());
2890        assert_eq!(mint.capability.scope.grants.len(), 2);
2891        assert_eq!(mint.capability.scope.grants[0].server_id, "*");
2892        assert_eq!(mint.capability.scope.grants[0].tool_name, "search");
2893        assert!(mint
2894            .capability
2895            .verify_signature()
2896            .expect("capability signature"));
2897    }
2898
2899    #[tokio::test]
2900    async fn sidecar_mint_reuses_capability_id_for_retry_requests() {
2901        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2902        let request_body = serde_json::to_vec(&serde_json::json!({
2903            "subject": "job/default/demo",
2904            "scopes": ["tools:search", "tool:server-a:fetch:invoke"],
2905            "job_uid": "job-uid-1",
2906            "ttl_seconds": 300,
2907        }))
2908        .expect("serialize mint request");
2909
2910        let first_request = with_loopback_peer(
2911            Request::builder()
2912                .method("POST")
2913                .uri("/v1/capabilities/mint")
2914                .header("content-type", "application/json")
2915                .body(Body::from(request_body.clone()))
2916                .expect("request"),
2917        );
2918        let second_request = with_loopback_peer(
2919            Request::builder()
2920                .method("POST")
2921                .uri("/v1/capabilities/mint")
2922                .header("content-type", "application/json")
2923                .body(Body::from(request_body))
2924                .expect("request"),
2925        );
2926
2927        let first_response = sidecar_mint_handler(State(Arc::clone(&state)), first_request).await;
2928        let second_response = sidecar_mint_handler(State(Arc::clone(&state)), second_request).await;
2929        assert_eq!(first_response.status(), StatusCode::OK);
2930        assert_eq!(second_response.status(), StatusCode::OK);
2931
2932        let first_bytes = to_bytes(first_response.into_body(), 1024 * 1024)
2933            .await
2934            .expect("first response body");
2935        let second_bytes = to_bytes(second_response.into_body(), 1024 * 1024)
2936            .await
2937            .expect("second response body");
2938        let first_mint: SidecarMintResponse =
2939            serde_json::from_slice(&first_bytes).expect("decode first mint response");
2940        let second_mint: SidecarMintResponse =
2941            serde_json::from_slice(&second_bytes).expect("decode second mint response");
2942
2943        assert_eq!(
2944            first_mint.capability.body().id,
2945            second_mint.capability.body().id
2946        );
2947    }
2948
2949    #[tokio::test]
2950    async fn sidecar_mint_changes_capability_id_for_different_scope_requests() {
2951        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
2952
2953        let search_request = with_loopback_peer(
2954            Request::builder()
2955                .method("POST")
2956                .uri("/v1/capabilities/mint")
2957                .header("content-type", "application/json")
2958                .body(Body::from(
2959                    serde_json::to_vec(&serde_json::json!({
2960                        "subject": "job/default/demo",
2961                        "scopes": ["tools:search"],
2962                        "job_uid": "job-uid-1",
2963                    }))
2964                    .expect("serialize mint request"),
2965                ))
2966                .expect("request"),
2967        );
2968        let fetch_request = with_loopback_peer(
2969            Request::builder()
2970                .method("POST")
2971                .uri("/v1/capabilities/mint")
2972                .header("content-type", "application/json")
2973                .body(Body::from(
2974                    serde_json::to_vec(&serde_json::json!({
2975                        "subject": "job/default/demo",
2976                        "scopes": ["tool:server-a:fetch:invoke"],
2977                        "job_uid": "job-uid-1",
2978                    }))
2979                    .expect("serialize mint request"),
2980                ))
2981                .expect("request"),
2982        );
2983
2984        let search_response = sidecar_mint_handler(State(Arc::clone(&state)), search_request).await;
2985        let fetch_response = sidecar_mint_handler(State(Arc::clone(&state)), fetch_request).await;
2986        assert_eq!(search_response.status(), StatusCode::OK);
2987        assert_eq!(fetch_response.status(), StatusCode::OK);
2988
2989        let search_bytes = to_bytes(search_response.into_body(), 1024 * 1024)
2990            .await
2991            .expect("search response body");
2992        let fetch_bytes = to_bytes(fetch_response.into_body(), 1024 * 1024)
2993            .await
2994            .expect("fetch response body");
2995        let search_mint: SidecarMintResponse =
2996            serde_json::from_slice(&search_bytes).expect("decode search mint response");
2997        let fetch_mint: SidecarMintResponse =
2998            serde_json::from_slice(&fetch_bytes).expect("decode fetch mint response");
2999
3000        assert_ne!(
3001            search_mint.capability.body().id,
3002            fetch_mint.capability.body().id
3003        );
3004    }
3005
3006    #[tokio::test]
3007    async fn sidecar_submit_receipt_accepts_controller_job_receipts() {
3008        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
3009        let request = with_loopback_peer(
3010            Request::builder()
3011                .method("POST")
3012                .uri("/v1/receipts")
3013                .header("content-type", "application/json")
3014                .body(Body::from(
3015                    serde_json::to_vec(&serde_json::json!({
3016                        "job_name": "demo",
3017                        "namespace": "default",
3018                        "job_uid": "job-uid-1",
3019                        "capability_id": "cap-1",
3020                        "outcome": "succeeded",
3021                        "started_at": "2026-04-17T10:00:00Z",
3022                        "completed_at": "2026-04-17T10:05:00Z",
3023                        "steps": [{
3024                            "pod_name": "demo-pod",
3025                            "phase": "Succeeded",
3026                            "payload": "{\"ok\":true}",
3027                            "observed_at": "2026-04-17T10:05:00Z"
3028                        }]
3029                    }))
3030                    .expect("serialize receipt request"),
3031                ))
3032                .expect("request"),
3033        );
3034
3035        let response = sidecar_submit_receipt_handler(State(state), request).await;
3036        assert_eq!(response.status(), StatusCode::OK);
3037
3038        let bytes = to_bytes(response.into_body(), 1024 * 1024)
3039            .await
3040            .expect("response body");
3041        let receipt: SidecarSubmitReceiptResponse =
3042            serde_json::from_slice(&bytes).expect("decode receipt response");
3043        assert!(receipt.accepted);
3044        assert!(!receipt.receipt_id.is_empty());
3045    }
3046
3047    #[test]
3048    fn ttl_seconds_from_wire_accepts_seconds_and_nanoseconds() {
3049        assert_eq!(ttl_seconds_from_wire(None, None, None), 3600);
3050        assert_eq!(ttl_seconds_from_wire(Some(3600), None, None), 3600);
3051        assert_eq!(ttl_seconds_from_wire(None, Some(500_000_000), None), 1);
3052        assert_eq!(ttl_seconds_from_wire(None, None, Some(3600)), 3600);
3053        assert_eq!(
3054            ttl_seconds_from_wire(None, None, Some(3_600_000_000_000)),
3055            3600
3056        );
3057    }
3058
3059    #[test]
3060    fn parse_sidecar_operation_shorthand_read_preserves_read_scope() {
3061        assert_eq!(
3062            parse_sidecar_operation("read", true).expect("read shorthand"),
3063            Operation::Read
3064        );
3065    }
3066
3067    #[tokio::test]
3068    async fn sidecar_release_persists_revocation_and_blocks_reuse() {
3069        let receipt_db = temp_receipt_db_path();
3070        let state = test_state_with_receipt_db(
3071            vec![RouteEntry {
3072                pattern: "/pets".to_string(),
3073                method: HttpMethod::Post,
3074                operation_id: Some("createPet".to_string()),
3075                policy: PolicyDecision::DenyByDefault,
3076            }],
3077            "http://127.0.0.1:1".to_string(),
3078            Some(&receipt_db),
3079        );
3080
3081        let release_request = with_loopback_peer(
3082            Request::builder()
3083                .method("POST")
3084                .uri("/v1/capabilities/release")
3085                .header("content-type", "application/json")
3086                .body(Body::from(
3087                    serde_json::to_vec(&serde_json::json!({
3088                        "capability_id": "cap-revoked",
3089                        "job_uid": "job-uid-1",
3090                        "reason": "completed",
3091                    }))
3092                    .expect("serialize release request"),
3093                ))
3094                .expect("request"),
3095        );
3096        let release_response =
3097            sidecar_release_handler(State(Arc::clone(&state)), release_request).await;
3098        assert_eq!(release_response.status(), StatusCode::OK);
3099
3100        let reloaded = test_state_with_receipt_db(
3101            vec![RouteEntry {
3102                pattern: "/pets".to_string(),
3103                method: HttpMethod::Post,
3104                operation_id: Some("createPet".to_string()),
3105                policy: PolicyDecision::DenyByDefault,
3106            }],
3107            "http://127.0.0.1:1".to_string(),
3108            Some(&receipt_db),
3109        );
3110        let request = Request::builder()
3111            .method("POST")
3112            .uri("/pets")
3113            .header(
3114                "x-chio-capability",
3115                signed_capability_token_json(&reloaded.signer_keypair, "cap-revoked"),
3116            )
3117            .body(Body::from(r#"{"name":"fido"}"#))
3118            .expect("request");
3119        let response = proxy_handler(State(Arc::clone(&reloaded)), request).await;
3120        assert_eq!(response.status(), StatusCode::FORBIDDEN);
3121
3122        let body = to_bytes(response.into_body(), 1024 * 1024)
3123            .await
3124            .expect("response body");
3125        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
3126        assert_eq!(json["message"], "capability token has been revoked");
3127
3128        let _ = std::fs::remove_file(receipt_db);
3129    }
3130
3131    #[tokio::test]
3132    async fn sidecar_release_requires_persistent_receipt_store() {
3133        let state = test_state(
3134            vec![RouteEntry {
3135                pattern: "/pets".to_string(),
3136                method: HttpMethod::Post,
3137                operation_id: Some("createPet".to_string()),
3138                policy: PolicyDecision::DenyByDefault,
3139            }],
3140            "http://127.0.0.1:1".to_string(),
3141        );
3142
3143        let release_request = with_loopback_peer(
3144            Request::builder()
3145                .method("POST")
3146                .uri("/v1/capabilities/release")
3147                .header("content-type", "application/json")
3148                .body(Body::from(
3149                    serde_json::to_vec(&serde_json::json!({
3150                        "capability_id": "cap-revoked",
3151                        "job_uid": "job-uid-1",
3152                        "reason": "completed",
3153                    }))
3154                    .expect("serialize release request"),
3155                ))
3156                .expect("request"),
3157        );
3158        let release_response =
3159            sidecar_release_handler(State(Arc::clone(&state)), release_request).await;
3160        assert_eq!(release_response.status(), StatusCode::INTERNAL_SERVER_ERROR);
3161
3162        let bytes = to_bytes(release_response.into_body(), 1024 * 1024)
3163            .await
3164            .expect("response body");
3165        let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json body");
3166        assert_eq!(
3167            json["message"],
3168            "persistent receipt_db must be configured for capability release"
3169        );
3170
3171        assert!(!state
3172            .revoked_capability_ids
3173            .lock()
3174            .await
3175            .contains("cap-revoked"));
3176    }
3177
3178    #[tokio::test]
3179    async fn sidecar_submit_receipt_persists_submitted_job_receipt() {
3180        let receipt_db = temp_receipt_db_path();
3181        let state = test_state_with_receipt_db(
3182            Vec::new(),
3183            "http://127.0.0.1:1".to_string(),
3184            Some(&receipt_db),
3185        );
3186        let request = with_loopback_peer(
3187            Request::builder()
3188                .method("POST")
3189                .uri("/v1/receipts")
3190                .header("content-type", "application/json")
3191                .body(Body::from(
3192                    serde_json::to_vec(&serde_json::json!({
3193                        "job_name": "demo",
3194                        "namespace": "default",
3195                        "job_uid": "job-uid-1",
3196                        "capability_id": "cap-1",
3197                        "outcome": "succeeded",
3198                        "started_at": "2026-04-17T10:00:00Z",
3199                        "completed_at": "2026-04-17T10:05:00Z",
3200                        "steps": [{
3201                            "pod_name": "demo-pod",
3202                            "phase": "Succeeded",
3203                            "payload": "{\"ok\":true}",
3204                            "observed_at": "2026-04-17T10:05:00Z"
3205                        }]
3206                    }))
3207                    .expect("serialize receipt request"),
3208                ))
3209                .expect("request"),
3210        );
3211
3212        let response = sidecar_submit_receipt_handler(State(Arc::clone(&state)), request).await;
3213        assert_eq!(response.status(), StatusCode::OK);
3214        let bytes = to_bytes(response.into_body(), 1024 * 1024)
3215            .await
3216            .expect("response body");
3217        let submit_response: SidecarSubmitReceiptResponse =
3218            serde_json::from_slice(&bytes).expect("decode receipt response");
3219
3220        let reloaded = test_state_with_receipt_db(
3221            Vec::new(),
3222            "http://127.0.0.1:1".to_string(),
3223            Some(&receipt_db),
3224        );
3225        let log = reloaded.receipt_log.lock().await;
3226        let stored = log
3227            .receipts
3228            .iter()
3229            .find(|receipt| receipt.id == submit_response.receipt_id)
3230            .expect("stored receipt");
3231        assert_eq!(stored.capability_id.as_deref(), Some("cap-1"));
3232        assert_eq!(
3233            stored.metadata.as_ref().expect("metadata")["job_uid"],
3234            "job-uid-1"
3235        );
3236        assert_eq!(
3237            stored.metadata.as_ref().expect("metadata")["steps"][0]["pod_name"],
3238            "demo-pod"
3239        );
3240        assert!(stored.verify_signature().expect("receipt signature"));
3241
3242        let _ = std::fs::remove_file(receipt_db);
3243    }
3244
3245    #[tokio::test]
3246    async fn sidecar_control_endpoints_reject_non_loopback_callers() {
3247        let state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
3248        let remote = SocketAddr::from(([10, 1, 2, 3], 5200));
3249
3250        let mint_request = with_peer_addr(
3251            Request::builder()
3252                .method("POST")
3253                .uri("/v1/capabilities/mint")
3254                .header("content-type", "application/json")
3255                .body(Body::from(
3256                    serde_json::to_vec(&serde_json::json!({
3257                        "subject": "job/default/demo",
3258                        "scopes": ["tools:search"],
3259                        "job_uid": "job-uid-1",
3260                    }))
3261                    .expect("serialize mint request"),
3262                ))
3263                .expect("request"),
3264            remote,
3265        );
3266        let mint_response = sidecar_mint_handler(State(Arc::clone(&state)), mint_request).await;
3267        assert_eq!(mint_response.status(), StatusCode::FORBIDDEN);
3268
3269        let release_request = with_peer_addr(
3270            Request::builder()
3271                .method("POST")
3272                .uri("/v1/capabilities/release")
3273                .header("content-type", "application/json")
3274                .body(Body::from(
3275                    serde_json::to_vec(&serde_json::json!({
3276                        "capability_id": "cap-revoked",
3277                    }))
3278                    .expect("serialize release request"),
3279                ))
3280                .expect("request"),
3281            remote,
3282        );
3283        let release_response =
3284            sidecar_release_handler(State(Arc::clone(&state)), release_request).await;
3285        assert_eq!(release_response.status(), StatusCode::FORBIDDEN);
3286
3287        let receipt_request = with_peer_addr(
3288            Request::builder()
3289                .method("POST")
3290                .uri("/v1/receipts")
3291                .header("content-type", "application/json")
3292                .body(Body::from(
3293                    serde_json::to_vec(&serde_json::json!({
3294                        "job_name": "demo",
3295                        "namespace": "default",
3296                        "job_uid": "job-uid-1",
3297                        "outcome": "succeeded",
3298                    }))
3299                    .expect("serialize receipt request"),
3300                ))
3301                .expect("request"),
3302            remote,
3303        );
3304        let receipt_response =
3305            sidecar_submit_receipt_handler(State(Arc::clone(&state)), receipt_request).await;
3306        assert_eq!(receipt_response.status(), StatusCode::FORBIDDEN);
3307
3308        let body = to_bytes(receipt_response.into_body(), 1024 * 1024)
3309            .await
3310            .expect("response body");
3311        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
3312        assert_eq!(json["error"], "chio_control_forbidden");
3313        assert_eq!(
3314            json["message"],
3315            "sidecar control endpoints require a loopback caller"
3316        );
3317    }
3318
3319    #[tokio::test]
3320    async fn sidecar_control_endpoints_allow_authenticated_non_loopback_callers() {
3321        let receipt_db = temp_receipt_db_path();
3322        let mut state = test_state_with_receipt_db(
3323            Vec::new(),
3324            "http://127.0.0.1:1".to_string(),
3325            Some(&receipt_db),
3326        );
3327        Arc::get_mut(&mut state)
3328            .expect("exclusive state")
3329            .sidecar_control_token = Some("cluster-control-token".to_string());
3330        let remote = SocketAddr::from(([10, 1, 2, 3], 5200));
3331
3332        let mint_request = with_peer_addr(
3333            Request::builder()
3334                .method("POST")
3335                .uri("/v1/capabilities/mint")
3336                .header("content-type", "application/json")
3337                .header("authorization", "Bearer cluster-control-token")
3338                .body(Body::from(
3339                    serde_json::to_vec(&serde_json::json!({
3340                        "subject": "job/default/demo",
3341                        "scopes": ["tools:search"],
3342                        "job_uid": "job-uid-1",
3343                    }))
3344                    .expect("serialize mint request"),
3345                ))
3346                .expect("request"),
3347            remote,
3348        );
3349        let mint_response = sidecar_mint_handler(State(Arc::clone(&state)), mint_request).await;
3350        assert_eq!(mint_response.status(), StatusCode::OK);
3351
3352        let release_request = with_peer_addr(
3353            Request::builder()
3354                .method("POST")
3355                .uri("/v1/capabilities/release")
3356                .header("content-type", "application/json")
3357                .header("authorization", "Bearer cluster-control-token")
3358                .body(Body::from(
3359                    serde_json::to_vec(&serde_json::json!({
3360                        "capability_id": "cap-revoked",
3361                    }))
3362                    .expect("serialize release request"),
3363                ))
3364                .expect("request"),
3365            remote,
3366        );
3367        let release_response =
3368            sidecar_release_handler(State(Arc::clone(&state)), release_request).await;
3369        assert_eq!(release_response.status(), StatusCode::OK);
3370
3371        let receipt_request = with_peer_addr(
3372            Request::builder()
3373                .method("POST")
3374                .uri("/v1/receipts")
3375                .header("content-type", "application/json")
3376                .header("authorization", "Bearer cluster-control-token")
3377                .body(Body::from(
3378                    serde_json::to_vec(&serde_json::json!({
3379                        "job_name": "demo",
3380                        "namespace": "default",
3381                        "job_uid": "job-uid-1",
3382                        "outcome": "succeeded",
3383                    }))
3384                    .expect("serialize receipt request"),
3385                ))
3386                .expect("request"),
3387            remote,
3388        );
3389        let receipt_response =
3390            sidecar_submit_receipt_handler(State(Arc::clone(&state)), receipt_request).await;
3391        assert_eq!(receipt_response.status(), StatusCode::OK);
3392
3393        let _ = std::fs::remove_file(receipt_db);
3394    }
3395
3396    #[tokio::test]
3397    async fn sidecar_control_endpoints_accept_lowercase_bearer_scheme() {
3398        let mut state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
3399        Arc::get_mut(&mut state)
3400            .expect("exclusive state")
3401            .sidecar_control_token = Some("cluster-control-token".to_string());
3402        let remote = SocketAddr::from(([10, 1, 2, 3], 5200));
3403
3404        let mint_request = with_peer_addr(
3405            Request::builder()
3406                .method("POST")
3407                .uri("/v1/capabilities/mint")
3408                .header("content-type", "application/json")
3409                .header("authorization", "bearer cluster-control-token")
3410                .body(Body::from(
3411                    serde_json::to_vec(&serde_json::json!({
3412                        "subject": "job/default/demo",
3413                        "scopes": ["tools:search"],
3414                        "job_uid": "job-uid-1",
3415                    }))
3416                    .expect("serialize mint request"),
3417                ))
3418                .expect("request"),
3419            remote,
3420        );
3421
3422        let mint_response = sidecar_mint_handler(State(Arc::clone(&state)), mint_request).await;
3423        assert_eq!(mint_response.status(), StatusCode::OK);
3424    }
3425
3426    #[tokio::test]
3427    async fn sidecar_control_endpoints_require_bearer_auth_for_loopback_when_configured() {
3428        let mut state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
3429        Arc::get_mut(&mut state)
3430            .expect("exclusive state")
3431            .sidecar_control_token = Some("cluster-control-token".to_string());
3432
3433        let mint_request = with_loopback_peer(
3434            Request::builder()
3435                .method("POST")
3436                .uri("/v1/capabilities/mint")
3437                .header("content-type", "application/json")
3438                .body(Body::from(
3439                    serde_json::to_vec(&serde_json::json!({
3440                        "subject": "job/default/demo",
3441                        "scopes": ["tools:search"],
3442                        "job_uid": "job-uid-1",
3443                    }))
3444                    .expect("serialize mint request"),
3445                ))
3446                .expect("request"),
3447        );
3448
3449        let mint_response = sidecar_mint_handler(State(Arc::clone(&state)), mint_request).await;
3450        assert_eq!(mint_response.status(), StatusCode::FORBIDDEN);
3451
3452        let body = to_bytes(mint_response.into_body(), 1024 * 1024)
3453            .await
3454            .expect("response body");
3455        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
3456        assert_eq!(json["error"], "chio_control_forbidden");
3457        assert_eq!(
3458            json["message"],
3459            "sidecar control endpoints require a loopback caller or valid bearer token"
3460        );
3461    }
3462
3463    #[tokio::test]
3464    async fn sidecar_control_endpoints_reject_blank_control_token_configuration() {
3465        let mut state = test_state(Vec::new(), "http://127.0.0.1:1".to_string());
3466        Arc::get_mut(&mut state)
3467            .expect("exclusive state")
3468            .sidecar_control_token = Some("   ".to_string());
3469        let remote = SocketAddr::from(([10, 1, 2, 3], 5200));
3470
3471        let mint_request = with_peer_addr(
3472            Request::builder()
3473                .method("POST")
3474                .uri("/v1/capabilities/mint")
3475                .header("content-type", "application/json")
3476                .header("authorization", "Bearer ")
3477                .body(Body::from(
3478                    serde_json::to_vec(&serde_json::json!({
3479                        "subject": "job/default/demo",
3480                        "scopes": ["tools:search"],
3481                        "job_uid": "job-uid-1",
3482                    }))
3483                    .expect("serialize mint request"),
3484                ))
3485                .expect("request"),
3486            remote,
3487        );
3488
3489        let mint_response = sidecar_mint_handler(State(Arc::clone(&state)), mint_request).await;
3490        assert_eq!(mint_response.status(), StatusCode::FORBIDDEN);
3491
3492        let body = to_bytes(mint_response.into_body(), 1024 * 1024)
3493            .await
3494            .expect("response body");
3495        let json: serde_json::Value = serde_json::from_slice(&body).expect("json body");
3496        assert_eq!(json["error"], "chio_control_forbidden");
3497    }
3498
3499    #[tokio::test]
3500    async fn proxy_handler_persists_receipts_when_receipt_db_configured() {
3501        let receipt_db = temp_receipt_db_path();
3502        let state = test_state_with_receipt_db(
3503            vec![RouteEntry {
3504                pattern: "/pets".to_string(),
3505                method: HttpMethod::Post,
3506                operation_id: Some("createPet".to_string()),
3507                policy: PolicyDecision::DenyByDefault,
3508            }],
3509            "http://127.0.0.1:1".to_string(),
3510            Some(&receipt_db),
3511        );
3512        let request = Request::builder()
3513            .method("POST")
3514            .uri("/pets")
3515            .body(Body::from(r#"{"name":"fido"}"#))
3516            .expect("request");
3517
3518        let response = proxy_handler(State(Arc::clone(&state)), request).await;
3519        assert_eq!(response.status(), StatusCode::FORBIDDEN);
3520
3521        let reloaded = test_state_with_receipt_db(
3522            vec![RouteEntry {
3523                pattern: "/pets".to_string(),
3524                method: HttpMethod::Post,
3525                operation_id: Some("createPet".to_string()),
3526                policy: PolicyDecision::DenyByDefault,
3527            }],
3528            "http://127.0.0.1:1".to_string(),
3529            Some(&receipt_db),
3530        );
3531        let log = reloaded.receipt_log.lock().await;
3532        assert_eq!(log.receipts.len(), 1);
3533        assert!(log.receipts[0]
3534            .verify_signature()
3535            .expect("receipt signature"));
3536
3537        let _ = std::fs::remove_file(receipt_db);
3538    }
3539
3540    #[tokio::test]
3541    async fn persisted_receipts_are_visible_across_proxy_and_sidecar_flows() {
3542        let receipt_db = temp_receipt_db_path();
3543        let proxy_state = test_state_with_receipt_db(
3544            vec![RouteEntry {
3545                pattern: "/pets".to_string(),
3546                method: HttpMethod::Post,
3547                operation_id: Some("createPet".to_string()),
3548                policy: PolicyDecision::DenyByDefault,
3549            }],
3550            "http://127.0.0.1:1".to_string(),
3551            Some(&receipt_db),
3552        );
3553        let denied_request = Request::builder()
3554            .method("POST")
3555            .uri("/pets")
3556            .body(Body::from(r#"{"name":"fido"}"#))
3557            .expect("request");
3558        let denied_response = proxy_handler(State(Arc::clone(&proxy_state)), denied_request).await;
3559        assert_eq!(denied_response.status(), StatusCode::FORBIDDEN);
3560
3561        let sidecar_state = test_state_with_receipt_db(
3562            vec![RouteEntry {
3563                pattern: "/pets".to_string(),
3564                method: HttpMethod::Get,
3565                operation_id: Some("listPets".to_string()),
3566                policy: PolicyDecision::SessionAllow,
3567            }],
3568            "http://127.0.0.1:1".to_string(),
3569            Some(&receipt_db),
3570        );
3571        {
3572            let log = sidecar_state.receipt_log.lock().await;
3573            assert_eq!(log.receipts.len(), 1);
3574        }
3575
3576        let body = ChioHttpRequest::new(
3577            "req-sidecar-persisted".to_string(),
3578            HttpMethod::Get,
3579            "/pets".to_string(),
3580            "/pets".to_string(),
3581            chio_http_core::CallerIdentity::anonymous(),
3582        );
3583        let request = Request::builder()
3584            .method("POST")
3585            .uri("/chio/evaluate")
3586            .header("content-type", "application/json")
3587            .body(Body::from(
3588                serde_json::to_vec(&body).expect("serialize request"),
3589            ))
3590            .expect("request");
3591
3592        let response = sidecar_evaluate_handler(State(Arc::clone(&sidecar_state)), request).await;
3593        assert_eq!(response.status(), StatusCode::OK);
3594
3595        let reloaded = test_state_with_receipt_db(
3596            vec![RouteEntry {
3597                pattern: "/pets".to_string(),
3598                method: HttpMethod::Get,
3599                operation_id: Some("listPets".to_string()),
3600                policy: PolicyDecision::SessionAllow,
3601            }],
3602            "http://127.0.0.1:1".to_string(),
3603            Some(&receipt_db),
3604        );
3605        let log = reloaded.receipt_log.lock().await;
3606        assert_eq!(log.receipts.len(), 2);
3607
3608        let _ = std::fs::remove_file(receipt_db);
3609    }
3610}