1use 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
41pub struct ProtectConfig {
43 pub upstream: String,
45 pub spec_content: Option<String>,
47 pub spec_path: Option<String>,
49 pub listen_addr: String,
51 pub receipt_db: Option<String>,
53 pub sidecar_control_token: Option<String>,
55 pub signer_seed_hex: Option<String>,
57 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
88struct 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
178struct 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
191pub 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 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 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 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
437async 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 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 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 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 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 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 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 for (name, value) in &response_headers {
580 response_builder = response_builder.header(name.as_str(), value.as_bytes());
581 }
582
583 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 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 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}