1use 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
18pub struct EvaluationResult {
20 pub verdict: Verdict,
22 pub receipt: HttpReceipt,
24 pub evidence: Vec<GuardEvidence>,
26}
27
28pub 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
41pub type RouteResolver = fn(&str, &str) -> String;
43
44fn default_route_resolver(_method: &str, path: &str) -> String {
46 path.to_string()
47}
48
49#[derive(Clone)]
58struct HttpReceiptSink {
59 keypair: std::sync::Arc<Keypair>,
60 store: std::sync::Arc<dyn chio_kernel::ReceiptStore>,
61}
62
63pub struct ChioEvaluator {
65 authority: HttpAuthority,
66 identity_extractor: IdentityExtractor,
67 route_resolver: RouteResolver,
68 fail_open: bool,
71 receipt_sink: Option<HttpReceiptSink>,
74 allow_ephemeral: bool,
80}
81
82impl ChioEvaluator {
83 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 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 #[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 #[must_use]
132 pub fn with_identity_extractor(mut self, extractor: IdentityExtractor) -> Self {
133 self.identity_extractor = extractor;
134 self
135 }
136
137 #[must_use]
139 pub fn with_route_resolver(mut self, resolver: RouteResolver) -> Self {
140 self.route_resolver = resolver;
141 self
142 }
143
144 #[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 pub fn is_fail_open(&self) -> bool {
153 self.fail_open
154 }
155
156 pub fn identity_extractor(&self) -> IdentityExtractor {
158 self.identity_extractor
159 }
160
161 pub fn route_resolver(&self) -> RouteResolver {
163 self.route_resolver
164 }
165
166 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 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 pub(crate) fn receipts_are_audited(&self) -> bool {
266 self.receipt_sink.is_some() || self.allow_ephemeral
267 }
268
269 pub(crate) fn has_durable_receipt_sink(&self) -> bool {
275 self.receipt_sink.is_some()
276 }
277
278 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
342pub 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 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
498pub(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 #[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 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 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 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 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 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 assert_eq!(result.receipt.route_pattern, "/pets");
908 }
909
910 #[test]
911 fn parse_method_case_insensitive() {
912 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 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}