Skip to main content

chio_tower/
evaluator.rs

1//! Request evaluator for the Chio tower middleware.
2//!
3//! Contains the core evaluation logic: matching routes, checking capabilities,
4//! and signing receipts.
5
6use std::collections::HashMap;
7
8use chio_core_types::crypto::Keypair;
9use chio_core_types::receipt::metadata::GuardEvidence;
10use chio_http_core::{
11    CallerIdentity, HttpAuthority, HttpAuthorityError, HttpAuthorityInput, HttpAuthorityPolicy,
12    HttpMethod, HttpReceipt, PreparedHttpEvaluation, TransportDenyInput, Verdict,
13};
14
15use crate::error::ChioTowerError;
16use crate::identity::IdentityExtractor;
17
18/// Result of evaluating an HTTP request.
19pub struct EvaluationResult {
20    /// The kernel's verdict.
21    pub verdict: Verdict,
22    /// Signed receipt for the evaluation.
23    pub receipt: HttpReceipt,
24    /// Per-guard evidence collected during evaluation.
25    pub evidence: Vec<GuardEvidence>,
26}
27
28/// Input payload for evaluating a single HTTP request.
29pub struct EvaluationInput<'a> {
30    pub method: &'a str,
31    pub path: &'a str,
32    pub query: &'a HashMap<String, String>,
33    pub caller: CallerIdentity,
34    pub headers: &'a http::HeaderMap,
35    pub body_hash: Option<String>,
36    pub body_length: u64,
37}
38
39pub(crate) type PreparedEvaluation = PreparedHttpEvaluation;
40
41/// Route pattern resolver function type.
42pub type RouteResolver = fn(&str, &str) -> String;
43
44/// Default route resolver returns the raw path.
45fn default_route_resolver(_method: &str, path: &str) -> String {
46    path.to_string()
47}
48
49/// Durable sink for the outer HTTP receipts that [`crate::ChioService`] signs.
50///
51/// The embedded authorization kernel already persists its own receipts to a
52/// configured store, but the HTTP decision and final-response receipts are signed
53/// at the tower edge and would otherwise live only in the response extensions.
54/// When the evaluator is built with a durable receipt store, those HTTP receipts
55/// are converted to core receipts (re-signed with the kernel keypair) and
56/// appended here so the HTTP audit trail survives a restart.
57#[derive(Clone)]
58struct HttpReceiptSink {
59    keypair: std::sync::Arc<Keypair>,
60    store: std::sync::Arc<dyn chio_kernel::ReceiptStore>,
61}
62
63/// Chio request evaluator. Holds the shared HTTP authority and tower-specific hooks.
64pub struct ChioEvaluator {
65    authority: HttpAuthority,
66    identity_extractor: IdentityExtractor,
67    route_resolver: RouteResolver,
68    /// When true, sidecar errors allow the request through (fail-open).
69    /// Default is false (fail-closed).
70    fail_open: bool,
71    /// Durable sink for the HTTP receipts signed at the tower edge. `Some` only
72    /// when the evaluator was built with a durable receipt store.
73    receipt_sink: Option<HttpReceiptSink>,
74    /// Whether the operator explicitly opted into ephemeral (in-memory)
75    /// receipts. With no durable sink and no opt-in the evaluator is
76    /// fail-closed, and every receipt-emitting path (including the transport
77    /// deny path) must refuse rather than sign a receipt that cannot be
78    /// durably recorded.
79    allow_ephemeral: bool,
80}
81
82impl ChioEvaluator {
83    /// Create a fail-closed evaluator with the given keypair and policy hash.
84    ///
85    /// No durable store is attached, so the first mediated call denies until a
86    /// durable store is wired through [`ChioEvaluator::builder`]. Use
87    /// [`ChioEvaluator::new_ephemeral`] for a local scaffold that intends
88    /// in-memory persistence.
89    pub fn new(keypair: Keypair, policy_hash: String) -> Self {
90        Self {
91            authority: HttpAuthority::new(keypair, policy_hash),
92            identity_extractor: crate::identity::extract_identity,
93            route_resolver: default_route_resolver,
94            fail_open: false,
95            receipt_sink: None,
96            allow_ephemeral: false,
97        }
98    }
99
100    /// Create an explicitly ephemeral evaluator (in-memory receipt log and
101    /// revocation store) for local scaffolds and tests.
102    pub fn new_ephemeral(keypair: Keypair, policy_hash: String) -> Self {
103        Self {
104            authority: HttpAuthority::new_ephemeral(keypair, policy_hash),
105            identity_extractor: crate::identity::extract_identity,
106            route_resolver: default_route_resolver,
107            fail_open: false,
108            receipt_sink: None,
109            allow_ephemeral: true,
110        }
111    }
112
113    /// Start building an evaluator backed by durable receipt and revocation
114    /// stores.
115    #[must_use]
116    pub fn builder(keypair: Keypair, policy_hash: String) -> ChioEvaluatorBuilder {
117        ChioEvaluatorBuilder {
118            keypair,
119            policy_hash,
120            receipt_store: None,
121            revocation_store: None,
122            durable_admission: None,
123            allow_ephemeral: false,
124            identity_extractor: crate::identity::extract_identity,
125            route_resolver: default_route_resolver,
126            fail_open: false,
127        }
128    }
129
130    /// Set a custom identity extractor.
131    #[must_use]
132    pub fn with_identity_extractor(mut self, extractor: IdentityExtractor) -> Self {
133        self.identity_extractor = extractor;
134        self
135    }
136
137    /// Set a custom route resolver.
138    #[must_use]
139    pub fn with_route_resolver(mut self, resolver: RouteResolver) -> Self {
140        self.route_resolver = resolver;
141        self
142    }
143
144    /// Set fail-open mode (allow requests when evaluation fails).
145    #[must_use]
146    pub fn with_fail_open(mut self, fail_open: bool) -> Self {
147        self.fail_open = fail_open;
148        self
149    }
150
151    /// Whether this evaluator is configured for fail-open.
152    pub fn is_fail_open(&self) -> bool {
153        self.fail_open
154    }
155
156    /// Get the identity extractor function.
157    pub fn identity_extractor(&self) -> IdentityExtractor {
158        self.identity_extractor
159    }
160
161    /// Get the route resolver function.
162    pub fn route_resolver(&self) -> RouteResolver {
163        self.route_resolver
164    }
165
166    /// Evaluate an HTTP request, producing a verdict and signed receipt.
167    pub fn evaluate(&self, input: EvaluationInput<'_>) -> Result<EvaluationResult, ChioTowerError> {
168        let prepared = self.prepare(input)?;
169        let receipt = self.sign_decision_receipt(&prepared)?;
170        Ok(EvaluationResult {
171            verdict: prepared.verdict,
172            receipt,
173            evidence: prepared.evidence,
174        })
175    }
176
177    pub(crate) fn prepare(
178        &self,
179        input: EvaluationInput<'_>,
180    ) -> Result<PreparedEvaluation, ChioTowerError> {
181        let presented_capability = extract_presented_capability(input.headers, input.query);
182        self.prepare_with_presented_capability(input, presented_capability)
183    }
184
185    pub(crate) fn prepare_with_presented_capability(
186        &self,
187        input: EvaluationInput<'_>,
188        presented_capability: Option<&str>,
189    ) -> Result<PreparedEvaluation, ChioTowerError> {
190        let EvaluationInput {
191            method,
192            path,
193            query,
194            caller,
195            headers: _headers,
196            body_hash,
197            body_length,
198        } = input;
199        let http_method = parse_method(method)?;
200        let route_pattern = (self.route_resolver)(method, path);
201        self.authority
202            .prepare(HttpAuthorityInput {
203                request_id: uuid::Uuid::now_v7().to_string(),
204                method: http_method,
205                route_pattern,
206                path,
207                query,
208                caller,
209                body_hash,
210                body_length,
211                session_id: None,
212                capability_id_hint: None,
213                presented_capability,
214                requested_tool_server: None,
215                requested_tool_name: None,
216                requested_arguments: None,
217                execution_nonce: None,
218                model_metadata: None,
219                unsupported_authorization_extension: None,
220                policy: policy_mode(http_method),
221            })
222            .map_err(Into::into)
223    }
224
225    pub(crate) fn sign_decision_receipt(
226        &self,
227        prepared: &PreparedEvaluation,
228    ) -> Result<HttpReceipt, ChioTowerError> {
229        self.authority
230            .sign_decision_receipt(prepared)
231            .map_err(Into::into)
232    }
233
234    pub(crate) fn finalize_receipt(
235        &self,
236        prepared: &PreparedEvaluation,
237        response_status: u16,
238    ) -> Result<HttpReceipt, ChioTowerError> {
239        self.authority
240            .finalize_receipt(prepared, response_status, None)
241            .map_err(Into::into)
242    }
243
244    /// Sign a deny receipt for a request that the transport layer rejected
245    /// before kernel evaluation (for example, an oversized body that the
246    /// middleware refused to buffer). Used by [`crate::ChioService`] to attach
247    /// a verifiable verdict to its `413 Payload Too Large` response so an
248    /// auditor cannot claim the rejection was a bare network error.
249    pub(crate) fn sign_transport_deny_receipt(
250        &self,
251        input: TransportDenyInput<'_>,
252    ) -> Result<HttpReceipt, ChioTowerError> {
253        self.authority
254            .sign_transport_deny_receipt(input)
255            .map_err(Into::into)
256    }
257
258    /// Whether the receipts this evaluator signs can be recorded: either a
259    /// durable receipt store is attached, or the operator explicitly opted into
260    /// ephemeral (in-memory) receipts. When neither holds the evaluator is
261    /// fail-closed - the embedded kernel denies normal requests for missing
262    /// durable persistence - so a transport-level denial must refuse the same
263    /// way instead of emitting a signed receipt whose audit record is silently
264    /// dropped. Denial evidence must be as durable as allow evidence.
265    pub(crate) fn receipts_are_audited(&self) -> bool {
266        self.receipt_sink.is_some() || self.allow_ephemeral
267    }
268
269    /// Whether outer HTTP receipts are appended to a durable store. When this is
270    /// false there is no durable audit trail to guarantee, so the service keeps
271    /// its receipts best-effort in the response extensions and skips the
272    /// pre-forward decision-receipt persist that would otherwise fail an allowed
273    /// request closed on a store error.
274    pub(crate) fn has_durable_receipt_sink(&self) -> bool {
275        self.receipt_sink.is_some()
276    }
277
278    /// Append an outer HTTP receipt to the durable receipt store when one is
279    /// configured. [`crate::ChioService`] signs decision, final-response, and
280    /// transport-deny receipts at the HTTP edge; without this they would live
281    /// only in the response extensions and vanish on restart, so a configured
282    /// durable store must receive them.
283    ///
284    /// With no durable sink attached the append is a no-op: an ephemeral or
285    /// store-less evaluator keeps its receipts best-effort.
286    ///
287    /// Only a receipt that converts into a verifiable core receipt may enter the
288    /// durable audit log; the store rejects anything it cannot re-verify. An
289    /// HTTP receipt that cannot be represented as a verifiable core receipt
290    /// (for example one whose embedded kernel key does not match the durable sink
291    /// keypair, so no well-formed signed receipt can be produced) is skipped
292    /// rather than failing the request closed or writing a record the store could
293    /// never verify. A record that IS verifiable but that the store then fails to
294    /// persist still propagates the error so the caller can fail the request
295    /// closed, matching durable-by-default: a protected effect must not complete
296    /// while a valid audit record is silently dropped.
297    pub(crate) fn persist_http_receipt(&self, receipt: &HttpReceipt) -> Result<(), ChioTowerError> {
298        let Some(sink) = &self.receipt_sink else {
299            return Ok(());
300        };
301        let chio_receipt = match receipt.to_chio_receipt_with_keypair(&sink.keypair) {
302            Ok(chio_receipt) => chio_receipt,
303            Err(error) => {
304                tracing::warn!(
305                    %error,
306                    receipt_id = %receipt.id,
307                    "skipping durable persistence of an HTTP receipt that cannot be converted into a verifiable core receipt"
308                );
309                return Ok(());
310            }
311        };
312        sink.store
313            .append_chio_receipt(&chio_receipt)
314            .map_err(|error| {
315                ChioTowerError::ReceiptPersist(format!(
316                    "failed to append HTTP receipt to durable receipt store: {error}"
317                ))
318            })?;
319        Ok(())
320    }
321}
322
323fn extract_presented_capability<'a>(
324    headers: &'a http::HeaderMap,
325    query: &'a HashMap<String, String>,
326) -> Option<&'a str> {
327    headers
328        .get("x-chio-capability")
329        .or_else(|| headers.get("X-Chio-Capability"))
330        .and_then(|value| value.to_str().ok())
331        .or_else(|| query.get("chio_capability").map(String::as_str))
332}
333
334fn policy_mode(method: HttpMethod) -> HttpAuthorityPolicy {
335    if method.is_safe() {
336        HttpAuthorityPolicy::SessionAllow
337    } else {
338        HttpAuthorityPolicy::DenyByDefault
339    }
340}
341
342/// Builder for a [`ChioEvaluator`] backed by durable stores. `build` is
343/// fallible because attaching a durable receipt store hydrates checkpoint
344/// counters and can fail. `allow_ephemeral` defaults to `false` (fail-closed).
345pub struct ChioEvaluatorBuilder {
346    keypair: Keypair,
347    policy_hash: String,
348    receipt_store: Option<std::sync::Arc<dyn chio_kernel::ReceiptStore>>,
349    revocation_store: Option<std::sync::Arc<dyn chio_kernel::RevocationStore>>,
350    durable_admission: Option<DurableAdmissionStores>,
351    allow_ephemeral: bool,
352    identity_extractor: IdentityExtractor,
353    route_resolver: RouteResolver,
354    fail_open: bool,
355}
356
357struct DurableAdmissionStores {
358    store: std::sync::Arc<dyn chio_kernel::QualifiedAdmissionProjectionStore>,
359    outcome_store: std::sync::Arc<dyn chio_kernel::tool_outcome::QualifiedToolOutcomeStore>,
360    fence: chio_kernel::admission_operation::StoreMutationFence,
361}
362
363impl ChioEvaluatorBuilder {
364    #[must_use]
365    pub fn receipt_store(mut self, store: std::sync::Arc<dyn chio_kernel::ReceiptStore>) -> Self {
366        self.receipt_store = Some(store);
367        self
368    }
369
370    #[must_use]
371    pub fn revocation_store(
372        mut self,
373        store: std::sync::Arc<dyn chio_kernel::RevocationStore>,
374    ) -> Self {
375        self.revocation_store = Some(store);
376        self
377    }
378
379    #[must_use]
380    pub fn durable_admission_stores(
381        mut self,
382        store: std::sync::Arc<dyn chio_kernel::QualifiedAdmissionProjectionStore>,
383        outcome_store: std::sync::Arc<dyn chio_kernel::tool_outcome::QualifiedToolOutcomeStore>,
384        fence: chio_kernel::admission_operation::StoreMutationFence,
385    ) -> Self {
386        self.durable_admission = Some(DurableAdmissionStores {
387            store,
388            outcome_store,
389            fence,
390        });
391        self
392    }
393
394    #[must_use]
395    pub fn allow_ephemeral(mut self, allow: bool) -> Self {
396        self.allow_ephemeral = allow;
397        self
398    }
399
400    #[must_use]
401    pub fn with_identity_extractor(mut self, extractor: IdentityExtractor) -> Self {
402        self.identity_extractor = extractor;
403        self
404    }
405
406    #[must_use]
407    pub fn with_route_resolver(mut self, resolver: RouteResolver) -> Self {
408        self.route_resolver = resolver;
409        self
410    }
411
412    #[must_use]
413    pub fn with_fail_open(mut self, fail_open: bool) -> Self {
414        self.fail_open = fail_open;
415        self
416    }
417
418    pub fn build(self) -> Result<ChioEvaluator, ChioTowerError> {
419        let mut builder = HttpAuthority::builder()
420            .allow_ephemeral_receipt_log(self.allow_ephemeral)
421            .allow_ephemeral_revocation_store(self.allow_ephemeral);
422        // A configured durable receipt store backs both the embedded kernel and
423        // the tower edge: the store handle moves into the authority builder, and
424        // a clone (with the kernel keypair) stays on the evaluator so the outer
425        // HTTP receipts the service signs are appended to the same store.
426        let receipt_sink = self.receipt_store.as_ref().map(|store| HttpReceiptSink {
427            keypair: std::sync::Arc::new(self.keypair.clone()),
428            store: std::sync::Arc::clone(store),
429        });
430        if let Some(store) = self.receipt_store {
431            builder = builder.receipt_store(store);
432        }
433        if let Some(store) = self.revocation_store {
434            builder = builder.revocation_store(store);
435        }
436        if let Some(durable) = self.durable_admission {
437            builder = builder.durable_admission_stores(
438                durable.store,
439                durable.outcome_store,
440                durable.fence,
441            );
442        }
443        let authority = builder
444            .build(self.keypair, self.policy_hash)
445            .map_err(ChioTowerError::from)?;
446        Ok(ChioEvaluator {
447            authority,
448            identity_extractor: self.identity_extractor,
449            route_resolver: self.route_resolver,
450            fail_open: self.fail_open,
451            receipt_sink,
452            allow_ephemeral: self.allow_ephemeral,
453        })
454    }
455}
456
457impl From<HttpAuthorityError> for ChioTowerError {
458    fn from(value: HttpAuthorityError) -> Self {
459        match value {
460            HttpAuthorityError::CallerIdentity(message) => {
461                Self::IdentityExtraction(format!("hash failed: {message}"))
462            }
463            HttpAuthorityError::ContentHash(message) => {
464                Self::Evaluation(format!("content hash failed: {message}"))
465            }
466            HttpAuthorityError::Kernel(message) => Self::Evaluation(message),
467            HttpAuthorityError::PendingApproval {
468                approval_id,
469                kernel_receipt_id,
470            } => Self::Evaluation(match approval_id {
471                Some(approval_id) => format!(
472                    "request requires approval; approval_id={approval_id}; kernel_receipt_id={kernel_receipt_id}"
473                ),
474                None => format!(
475                    "request requires approval; kernel_receipt_id={kernel_receipt_id}"
476                ),
477            }),
478            HttpAuthorityError::ReceiptSign(message) => {
479                Self::ReceiptSign(format!("signing failed: {message}"))
480            }
481        }
482    }
483}
484
485impl Clone for ChioEvaluator {
486    fn clone(&self) -> Self {
487        Self {
488            authority: self.authority.clone(),
489            identity_extractor: self.identity_extractor,
490            route_resolver: self.route_resolver,
491            fail_open: self.fail_open,
492            receipt_sink: self.receipt_sink.clone(),
493            allow_ephemeral: self.allow_ephemeral,
494        }
495    }
496}
497
498/// Parse an HTTP method string into the chio-http-core HttpMethod enum.
499pub(crate) fn parse_method(method: &str) -> Result<HttpMethod, ChioTowerError> {
500    match method.to_uppercase().as_str() {
501        "GET" => Ok(HttpMethod::Get),
502        "POST" => Ok(HttpMethod::Post),
503        "PUT" => Ok(HttpMethod::Put),
504        "PATCH" => Ok(HttpMethod::Patch),
505        "DELETE" => Ok(HttpMethod::Delete),
506        "HEAD" => Ok(HttpMethod::Head),
507        "OPTIONS" => Ok(HttpMethod::Options),
508        other => Err(ChioTowerError::Evaluation(format!(
509            "unsupported HTTP method: {other}"
510        ))),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use chio_core_types::capability::{
518        scope::ChioScope,
519        token::{CapabilityToken, CapabilityTokenBody},
520    };
521    use chio_http_core::{
522        http_authority_tool_grant, http_status_scope, CHIO_HTTP_STATUS_SCOPE_DECISION,
523        CHIO_HTTP_STATUS_SCOPE_FINAL,
524    };
525
526    fn valid_capability_token_json(id: &str, issuer: &Keypair) -> String {
527        let now = chrono::Utc::now().timestamp() as u64;
528        let token = CapabilityToken::sign(
529            CapabilityTokenBody {
530                id: id.to_string(),
531                issuer: issuer.public_key(),
532                subject: issuer.public_key(),
533                scope: ChioScope {
534                    grants: vec![http_authority_tool_grant()],
535                    ..ChioScope::default()
536                },
537                issued_at: now.saturating_sub(60),
538                expires_at: now + 3600,
539                delegation_chain: Vec::new(),
540                aggregate_invocation_budget: None,
541            },
542            issuer,
543        )
544        .unwrap_or_else(|e| panic!("token sign failed: {e}"));
545        serde_json::to_string(&token).unwrap_or_else(|e| panic!("token serialize failed: {e}"))
546    }
547
548    fn evaluate(
549        evaluator: &ChioEvaluator,
550        method: &str,
551        path: &str,
552        query: &HashMap<String, String>,
553        caller: CallerIdentity,
554        headers: &http::HeaderMap,
555    ) -> Result<EvaluationResult, ChioTowerError> {
556        evaluator.evaluate(EvaluationInput {
557            method,
558            path,
559            query,
560            caller,
561            headers,
562            body_hash: None,
563            body_length: 0,
564        })
565    }
566
567    #[test]
568    fn evaluate_safe_method_allowed() {
569        let keypair = Keypair::generate();
570        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
571        let caller = CallerIdentity::anonymous();
572        let headers = http::HeaderMap::new();
573
574        let result = evaluate(
575            &evaluator,
576            "GET",
577            "/pets",
578            &HashMap::new(),
579            caller,
580            &headers,
581        )
582        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
583
584        assert!(result.verdict.is_allowed());
585        assert!(result
586            .receipt
587            .verify_signature()
588            .unwrap_or_else(|e| panic!("verify failed: {e}")));
589        assert_eq!(
590            http_status_scope(result.receipt.metadata.as_ref()),
591            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
592        );
593    }
594
595    #[test]
596    fn builder_with_durable_stores_allows_safe_method() -> Result<(), Box<dyn std::error::Error>> {
597        let dir = tempfile::tempdir()?;
598        let receipt_store: std::sync::Arc<dyn chio_kernel::ReceiptStore> = std::sync::Arc::new(
599            chio_store_sqlite::SqliteReceiptStore::open(dir.path().join("receipts.db"))?,
600        );
601        let revocation_store: std::sync::Arc<dyn chio_kernel::RevocationStore> =
602            std::sync::Arc::new(chio_store_sqlite::SqliteRevocationStore::open(
603                dir.path().join("revocations.db"),
604            )?);
605        let authority_database = dir.path().join("authority.db");
606        let authority_locks = dir.path().join("authority-locks");
607        std::fs::create_dir(&authority_locks)?;
608        #[cfg(unix)]
609        {
610            use std::os::unix::fs::PermissionsExt;
611            std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
612            std::fs::set_permissions(&authority_locks, std::fs::Permissions::from_mode(0o700))?;
613        }
614        chio_store_sqlite::SqliteAuthorityStore::provision(&authority_database, &authority_locks)?;
615        let authority_store = chio_store_sqlite::SqliteAuthorityStore::open_serving(
616            &authority_database,
617            &authority_locks,
618        )?;
619        let admission_store = std::sync::Arc::new(authority_store.admission_operation_store());
620        let outcome_store = std::sync::Arc::new(authority_store.tool_outcome_store());
621        let evaluator = ChioEvaluator::builder(
622            Keypair::generate(),
623            chio_core_types::crypto::sha256_hex(b"test-policy"),
624        )
625        .receipt_store(receipt_store)
626        .revocation_store(revocation_store)
627        .durable_admission_stores(
628            admission_store,
629            outcome_store,
630            authority_store.mutation_fence(),
631        )
632        .build()?;
633        assert!(!evaluator.is_fail_open());
634
635        let caller = CallerIdentity::anonymous();
636        let headers = http::HeaderMap::new();
637        let result = evaluate(
638            &evaluator,
639            "GET",
640            "/pets",
641            &HashMap::new(),
642            caller,
643            &headers,
644        )?;
645        assert!(
646            result.verdict.is_allowed(),
647            "a durable-backed evaluator must allow a safe request"
648        );
649        Ok(())
650    }
651
652    /// A receipt store that counts appends without verifying, so a test can
653    /// observe whether `persist_http_receipt` reached the append at all.
654    #[derive(Default)]
655    struct CountingReceiptStore {
656        appended: std::sync::atomic::AtomicUsize,
657    }
658
659    impl CountingReceiptStore {
660        fn append_count(&self) -> usize {
661            self.appended.load(std::sync::atomic::Ordering::SeqCst)
662        }
663    }
664
665    impl chio_kernel::ReceiptStore for CountingReceiptStore {
666        fn append_chio_receipt(
667            &self,
668            _receipt: &chio_core_types::receipt::body::ChioReceipt,
669        ) -> Result<(), chio_kernel::ReceiptStoreError> {
670            self.appended
671                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
672            Ok(())
673        }
674
675        fn append_child_receipt(
676            &self,
677            _receipt: &chio_core_types::receipt::lineage::ChildRequestReceipt,
678        ) -> Result<(), chio_kernel::ReceiptStoreError> {
679            Ok(())
680        }
681    }
682
683    #[test]
684    fn persists_a_well_formed_http_receipt_to_a_verifying_store(
685    ) -> Result<(), Box<dyn std::error::Error>> {
686        // A real durable store recomputes the action parameter hash and rejects a
687        // receipt whose hash does not match its parameters. The outer HTTP receipt
688        // must convert into a core receipt that this verification accepts, or a
689        // durable deployment fails closed on every request instead of serving with
690        // persistence.
691        let dir = tempfile::tempdir()?;
692        let keypair = Keypair::generate();
693        let store: std::sync::Arc<dyn chio_kernel::ReceiptStore> = std::sync::Arc::new(
694            chio_store_sqlite::SqliteReceiptStore::open(dir.path().join("receipts.db"))?,
695        );
696        let evaluator = ChioEvaluator::builder(keypair, "durable-policy".to_string())
697            .receipt_store(store)
698            .allow_ephemeral(true)
699            .build()?;
700
701        let result = evaluate(
702            &evaluator,
703            "GET",
704            "/pets",
705            &HashMap::new(),
706            CallerIdentity::anonymous(),
707            &http::HeaderMap::new(),
708        )?;
709
710        // The verifying durable store must accept the converted HTTP receipt.
711        evaluator.persist_http_receipt(&result.receipt)?;
712        Ok(())
713    }
714
715    #[test]
716    fn skips_an_http_receipt_that_cannot_convert_to_a_verifiable_record(
717    ) -> Result<(), Box<dyn std::error::Error>> {
718        // A receipt whose embedded kernel key does not match the durable sink
719        // keypair cannot be re-signed into a well-formed core receipt. It must be
720        // skipped, never appended, and must not fail the request closed: the
721        // durable audit log only ever receives verifiable records, and a record
722        // that cannot be made verifiable is dropped rather than forcing a
723        // fail-closed on every request.
724        let store = std::sync::Arc::new(CountingReceiptStore::default());
725        let sink_store: std::sync::Arc<dyn chio_kernel::ReceiptStore> = store.clone();
726        let evaluator = ChioEvaluator::builder(Keypair::generate(), "durable-policy".to_string())
727            .receipt_store(sink_store)
728            .allow_ephemeral(true)
729            .build()?;
730
731        // A receipt signed by a different kernel keypair carries a foreign
732        // kernel_key, so converting it under the sink keypair cannot produce a
733        // signed core receipt.
734        let foreign_evaluator =
735            ChioEvaluator::new_ephemeral(Keypair::generate(), "foreign-policy".to_string());
736        let foreign = evaluate(
737            &foreign_evaluator,
738            "GET",
739            "/pets",
740            &HashMap::new(),
741            CallerIdentity::anonymous(),
742            &http::HeaderMap::new(),
743        )?;
744
745        evaluator.persist_http_receipt(&foreign.receipt)?;
746        assert_eq!(
747            store.append_count(),
748            0,
749            "an HTTP receipt that cannot be converted into a verifiable core receipt must not be appended"
750        );
751        Ok(())
752    }
753
754    #[test]
755    fn evaluate_unsafe_method_denied_without_capability() {
756        let keypair = Keypair::generate();
757        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
758        let caller = CallerIdentity::anonymous();
759        let headers = http::HeaderMap::new();
760
761        let result = evaluate(
762            &evaluator,
763            "POST",
764            "/pets",
765            &HashMap::new(),
766            caller,
767            &headers,
768        )
769        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
770
771        assert!(result.verdict.is_denied());
772        assert_eq!(result.receipt.response_status, 403);
773        assert!(result
774            .receipt
775            .verify_signature()
776            .unwrap_or_else(|e| panic!("verify failed: {e}")));
777        assert_eq!(
778            http_status_scope(result.receipt.metadata.as_ref()),
779            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
780        );
781    }
782
783    #[test]
784    fn evaluate_unsafe_method_allowed_with_capability() {
785        let keypair = Keypair::generate();
786        let evaluator = ChioEvaluator::new_ephemeral(keypair.clone(), "test-policy".to_string());
787        let caller = CallerIdentity::anonymous();
788        let mut headers = http::HeaderMap::new();
789        headers.insert(
790            "x-chio-capability",
791            http::HeaderValue::from_str(&valid_capability_token_json("cap-123", &keypair))
792                .unwrap_or_else(|e| panic!("header build failed: {e}")),
793        );
794
795        let result = evaluate(
796            &evaluator,
797            "POST",
798            "/pets",
799            &HashMap::new(),
800            caller,
801            &headers,
802        )
803        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
804
805        assert!(result.verdict.is_allowed());
806        assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
807        assert_eq!(
808            http_status_scope(result.receipt.metadata.as_ref()),
809            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
810        );
811    }
812
813    #[test]
814    fn evaluate_invalid_method() {
815        let keypair = Keypair::generate();
816        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
817        let caller = CallerIdentity::anonymous();
818        let headers = http::HeaderMap::new();
819
820        let err = evaluate(
821            &evaluator,
822            "FOOBAR",
823            "/pets",
824            &HashMap::new(),
825            caller,
826            &headers,
827        );
828        assert!(err.is_err());
829    }
830
831    #[test]
832    fn evaluate_all_safe_methods() {
833        let keypair = Keypair::generate();
834        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
835        let headers = http::HeaderMap::new();
836
837        for method in &["GET", "HEAD", "OPTIONS"] {
838            let caller = CallerIdentity::anonymous();
839            let result = evaluate(
840                &evaluator,
841                method,
842                "/test",
843                &HashMap::new(),
844                caller,
845                &headers,
846            )
847            .unwrap_or_else(|e| panic!("evaluation failed for {method}: {e}"));
848            assert!(result.verdict.is_allowed(), "{method} should be allowed");
849        }
850    }
851
852    #[test]
853    fn evaluate_all_unsafe_methods_denied() {
854        let keypair = Keypair::generate();
855        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
856        let headers = http::HeaderMap::new();
857
858        for method in &["POST", "PUT", "PATCH", "DELETE"] {
859            let caller = CallerIdentity::anonymous();
860            let result = evaluate(
861                &evaluator,
862                method,
863                "/test",
864                &HashMap::new(),
865                caller,
866                &headers,
867            )
868            .unwrap_or_else(|e| panic!("evaluation failed for {method}: {e}"));
869            assert!(
870                result.verdict.is_denied(),
871                "{method} should be denied without capability"
872            );
873        }
874    }
875
876    #[test]
877    fn fail_open_mode() {
878        let keypair = Keypair::generate();
879        let evaluator =
880            ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string()).with_fail_open(true);
881        assert!(evaluator.is_fail_open());
882    }
883
884    #[test]
885    fn custom_route_resolver() {
886        fn resolver(_method: &str, path: &str) -> String {
887            // Normalize by stripping trailing slashes
888            path.trim_end_matches('/').to_string()
889        }
890        let keypair = Keypair::generate();
891        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string())
892            .with_route_resolver(resolver);
893
894        let caller = CallerIdentity::anonymous();
895        let headers = http::HeaderMap::new();
896        let result = evaluate(
897            &evaluator,
898            "GET",
899            "/pets/",
900            &HashMap::new(),
901            caller,
902            &headers,
903        )
904        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
905        assert!(result.verdict.is_allowed());
906        // Route pattern should have trailing slash stripped
907        assert_eq!(result.receipt.route_pattern, "/pets");
908    }
909
910    #[test]
911    fn parse_method_case_insensitive() {
912        // parse_method uppercases internally
913        assert!(parse_method("get").is_ok());
914        assert!(parse_method("Get").is_ok());
915        assert!(parse_method("GET").is_ok());
916    }
917
918    #[test]
919    fn evaluator_clone() {
920        let keypair = Keypair::generate();
921        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
922        let cloned = evaluator.clone();
923
924        let caller = CallerIdentity::anonymous();
925        let headers = http::HeaderMap::new();
926
927        let r1 = evaluate(
928            &evaluator,
929            "GET",
930            "/test",
931            &HashMap::new(),
932            caller.clone(),
933            &headers,
934        )
935        .unwrap_or_else(|e| panic!("r1 failed: {e}"));
936        let r2 = evaluate(&cloned, "GET", "/test", &HashMap::new(), caller, &headers)
937            .unwrap_or_else(|e| panic!("r2 failed: {e}"));
938
939        // Both should produce valid receipts with the same kernel key.
940        assert!(r1.verdict.is_allowed());
941        assert!(r2.verdict.is_allowed());
942        assert_eq!(r1.receipt.kernel_key, r2.receipt.kernel_key);
943    }
944
945    #[test]
946    fn evaluate_invalid_capability_is_denied() {
947        let keypair = Keypair::generate();
948        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
949        let caller = CallerIdentity::anonymous();
950        let mut headers = http::HeaderMap::new();
951        headers.insert(
952            "x-chio-capability",
953            http::HeaderValue::from_static("not-json"),
954        );
955
956        let result = evaluate(
957            &evaluator,
958            "POST",
959            "/pets",
960            &HashMap::new(),
961            caller,
962            &headers,
963        )
964        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
965
966        assert!(result.verdict.is_denied());
967        assert!(result.receipt.capability_id.is_none());
968        assert_eq!(
969            http_status_scope(result.receipt.metadata.as_ref()),
970            Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
971        );
972    }
973
974    #[test]
975    fn evaluate_query_parameters_affect_content_hash() {
976        let keypair = Keypair::generate();
977        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
978        let caller = CallerIdentity::anonymous();
979        let headers = http::HeaderMap::new();
980        let mut query_a = HashMap::new();
981        query_a.insert("q".to_string(), "cats".to_string());
982        let mut query_b = HashMap::new();
983        query_b.insert("q".to_string(), "dogs".to_string());
984
985        let result_a = evaluate(
986            &evaluator,
987            "GET",
988            "/search",
989            &query_a,
990            caller.clone(),
991            &headers,
992        )
993        .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
994        let result_b = evaluate(&evaluator, "GET", "/search", &query_b, caller, &headers)
995            .unwrap_or_else(|e| panic!("evaluation failed: {e}"));
996
997        assert_ne!(result_a.receipt.content_hash, result_b.receipt.content_hash);
998    }
999
1000    #[test]
1001    fn finalize_receipt_marks_final_scope() {
1002        let keypair = Keypair::generate();
1003        let evaluator = ChioEvaluator::new_ephemeral(keypair, "test-policy".to_string());
1004        let caller = CallerIdentity::anonymous();
1005        let headers = http::HeaderMap::new();
1006        let query = HashMap::new();
1007
1008        let prepared = evaluator
1009            .prepare(EvaluationInput {
1010                method: "GET",
1011                path: "/pets",
1012                query: &query,
1013                caller,
1014                headers: &headers,
1015                body_hash: None,
1016                body_length: 0,
1017            })
1018            .unwrap_or_else(|e| panic!("prepare failed: {e}"));
1019        let receipt = evaluator
1020            .finalize_receipt(&prepared, 201)
1021            .unwrap_or_else(|e| panic!("finalize failed: {e}"));
1022
1023        assert_eq!(receipt.response_status, 201);
1024        assert_eq!(
1025            http_status_scope(receipt.metadata.as_ref()),
1026            Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
1027        );
1028        assert!(receipt
1029            .verify_signature()
1030            .unwrap_or_else(|e| panic!("verify failed: {e}")));
1031    }
1032}