1use std::collections::HashMap;
4use std::sync::Arc;
5
6use chio_core_types::crypto::{Keypair, PublicKey};
7use chio_core_types::receipt::metadata::GuardEvidence;
8use chio_http_core::{
9 AuthMethod, CallerIdentity, ChioHttpRequest, HttpAuthority, HttpAuthorityError,
10 HttpAuthorityEvaluation, HttpAuthorityInput, HttpAuthorityPolicy, HttpMethod, HttpReceipt,
11 Verdict,
12};
13use chio_kernel::{ApprovalStore, ReceiptStore, RevocationStore, SignedExecutionNonce};
14use chio_openapi::PolicyDecision;
15use serde_json::Value;
16
17const BACKEND_DURABLE: &str = "durable";
20const BACKEND_EPHEMERAL: &str = "ephemeral";
21
22pub struct EvaluationResult {
24 pub verdict: Verdict,
25 pub receipt: HttpReceipt,
26 pub evidence: Vec<GuardEvidence>,
27 pub execution_nonce: Option<SignedExecutionNonce>,
28}
29
30#[derive(Debug, Clone)]
32pub struct RouteEntry {
33 pub pattern: String,
34 pub method: HttpMethod,
35 pub operation_id: Option<String>,
36 pub policy: PolicyDecision,
37}
38
39pub struct RequestEvaluator {
41 routes: Vec<RouteEntry>,
42 authority: HttpAuthority,
43 receipt_backend: &'static str,
44 revocation_backend: &'static str,
45}
46
47#[derive(Clone)]
48pub(crate) struct DurableAdmissionStores {
49 pub(crate) store: Arc<dyn chio_kernel::QualifiedAdmissionProjectionStore>,
50 pub(crate) outcome_store: Arc<dyn chio_kernel::tool_outcome::QualifiedToolOutcomeStore>,
51 pub(crate) fence: chio_kernel::admission_operation::StoreMutationFence,
52}
53
54impl RequestEvaluator {
55 #[deprecated(
59 note = "renamed to `new_ephemeral`: this keeps the receipt log and revocation state in memory, lost on restart"
60 )]
61 pub fn new(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
62 Self::new_ephemeral(routes, keypair, policy_hash)
63 }
64
65 #[deprecated(
68 note = "renamed to `new_ephemeral_with_trusted_capability_issuers`: this keeps the receipt log and revocation state in memory, lost on restart"
69 )]
70 pub fn new_with_trusted_capability_issuers(
71 routes: Vec<RouteEntry>,
72 keypair: Keypair,
73 policy_hash: String,
74 trusted_capability_issuers: Vec<PublicKey>,
75 ) -> Self {
76 Self::new_ephemeral_with_trusted_capability_issuers(
77 routes,
78 keypair,
79 policy_hash,
80 trusted_capability_issuers,
81 )
82 }
83
84 #[deprecated(
87 note = "renamed to `new_ephemeral_with_approval_store`: this keeps the receipt log and revocation state in memory, lost on restart"
88 )]
89 pub fn new_with_approval_store(
90 routes: Vec<RouteEntry>,
91 keypair: Keypair,
92 policy_hash: String,
93 approval_store: Arc<dyn ApprovalStore>,
94 ) -> Self {
95 Self::new_ephemeral_with_approval_store(routes, keypair, policy_hash, approval_store)
96 }
97
98 #[deprecated(
101 note = "renamed to `new_ephemeral_with_approval_store_and_trusted_capability_issuers`: this keeps the receipt log and revocation state in memory, lost on restart"
102 )]
103 pub fn new_with_approval_store_and_trusted_capability_issuers(
104 routes: Vec<RouteEntry>,
105 keypair: Keypair,
106 policy_hash: String,
107 approval_store: Arc<dyn ApprovalStore>,
108 trusted_capability_issuers: Vec<PublicKey>,
109 ) -> Self {
110 Self::new_ephemeral_with_approval_store_and_trusted_capability_issuers(
111 routes,
112 keypair,
113 policy_hash,
114 approval_store,
115 trusted_capability_issuers,
116 )
117 }
118
119 pub fn new_ephemeral(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
125 Self::new_ephemeral_with_trusted_capability_issuers(
126 routes,
127 keypair,
128 policy_hash,
129 Vec::new(),
130 )
131 }
132
133 pub fn new_ephemeral_with_trusted_capability_issuers(
136 routes: Vec<RouteEntry>,
137 keypair: Keypair,
138 policy_hash: String,
139 trusted_capability_issuers: Vec<PublicKey>,
140 ) -> Self {
141 Self {
142 routes,
143 authority: HttpAuthority::new_ephemeral_with_approval_store_and_trusted_issuers(
144 keypair,
145 policy_hash,
146 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
147 trusted_capability_issuers,
148 ),
149 receipt_backend: BACKEND_EPHEMERAL,
150 revocation_backend: BACKEND_EPHEMERAL,
151 }
152 }
153
154 pub fn new_ephemeral_with_approval_store(
157 routes: Vec<RouteEntry>,
158 keypair: Keypair,
159 policy_hash: String,
160 approval_store: Arc<dyn ApprovalStore>,
161 ) -> Self {
162 Self::new_ephemeral_with_approval_store_and_trusted_capability_issuers(
163 routes,
164 keypair,
165 policy_hash,
166 approval_store,
167 Vec::new(),
168 )
169 }
170
171 pub fn new_ephemeral_with_approval_store_and_trusted_capability_issuers(
174 routes: Vec<RouteEntry>,
175 keypair: Keypair,
176 policy_hash: String,
177 approval_store: Arc<dyn ApprovalStore>,
178 trusted_capability_issuers: Vec<PublicKey>,
179 ) -> Self {
180 Self {
181 routes,
182 authority: HttpAuthority::new_ephemeral_with_approval_store_and_trusted_issuers(
183 keypair,
184 policy_hash,
185 approval_store,
186 trusted_capability_issuers,
187 ),
188 receipt_backend: BACKEND_EPHEMERAL,
189 revocation_backend: BACKEND_EPHEMERAL,
190 }
191 }
192
193 #[allow(clippy::too_many_arguments)]
200 pub fn new_with_durable_stores(
201 routes: Vec<RouteEntry>,
202 keypair: Keypair,
203 policy_hash: String,
204 approval_store: Arc<dyn ApprovalStore>,
205 trusted_capability_issuers: Vec<PublicKey>,
206 receipt_store: Option<Arc<dyn ReceiptStore>>,
207 revocation_store: Option<Arc<dyn RevocationStore>>,
208 allow_ephemeral: bool,
209 ) -> Result<Self, HttpAuthorityError> {
210 Self::new_with_durable_stores_and_admission(
211 routes,
212 keypair,
213 policy_hash,
214 approval_store,
215 trusted_capability_issuers,
216 receipt_store,
217 revocation_store,
218 None,
219 allow_ephemeral,
220 )
221 }
222
223 #[allow(clippy::too_many_arguments)]
224 pub(crate) fn new_with_durable_stores_and_admission(
225 routes: Vec<RouteEntry>,
226 keypair: Keypair,
227 policy_hash: String,
228 approval_store: Arc<dyn ApprovalStore>,
229 trusted_capability_issuers: Vec<PublicKey>,
230 receipt_store: Option<Arc<dyn ReceiptStore>>,
231 revocation_store: Option<Arc<dyn RevocationStore>>,
232 durable_admission: Option<DurableAdmissionStores>,
233 allow_ephemeral: bool,
234 ) -> Result<Self, HttpAuthorityError> {
235 let receipt_backend = if receipt_store.is_some() {
236 BACKEND_DURABLE
237 } else {
238 BACKEND_EPHEMERAL
239 };
240 let revocation_is_ephemeral = revocation_store
246 .as_ref()
247 .is_none_or(|store| store.is_ephemeral());
248 let revocation_backend = if revocation_is_ephemeral {
249 BACKEND_EPHEMERAL
250 } else {
251 BACKEND_DURABLE
252 };
253
254 let mut builder = HttpAuthority::builder()
261 .approval_store(approval_store)
262 .trusted_capability_issuers(trusted_capability_issuers)
263 .allow_ephemeral_receipt_log(allow_ephemeral)
264 .allow_ephemeral_revocation_store(allow_ephemeral);
265 if let Some(store) = receipt_store {
266 builder = builder.receipt_store(store);
267 }
268 if let Some(store) = revocation_store {
269 builder = builder.revocation_store(store);
270 }
271 if let Some(durable) = durable_admission {
272 builder = builder.durable_admission_stores(
273 durable.store,
274 durable.outcome_store,
275 durable.fence,
276 );
277 }
278 let authority = builder.build(keypair, policy_hash)?;
279
280 Ok(Self {
281 routes,
282 authority,
283 receipt_backend,
284 revocation_backend,
285 })
286 }
287
288 pub fn receipt_backend(&self) -> &'static str {
290 self.receipt_backend
291 }
292
293 pub fn revocation_backend(&self) -> &'static str {
295 self.revocation_backend
296 }
297
298 #[cfg(test)]
299 #[must_use]
300 pub fn approval_store(&self) -> Arc<dyn ApprovalStore> {
301 self.authority.approval_store()
302 }
303
304 #[cfg(test)]
305 pub fn enable_strict_execution_nonce_for_tests(&mut self) {
306 let cfg = chio_kernel::ExecutionNonceConfig {
307 nonce_ttl_secs: 30,
308 nonce_store_capacity: 1024,
309 require_nonce: true,
310 };
311 let store = Box::new(chio_kernel::InMemoryExecutionNonceStore::from_config(&cfg));
312 if let Err(error) = self.authority.set_execution_nonce_store(cfg, store) {
313 panic!("strict execution nonce test setup failed: {error}");
314 }
315 }
316
317 pub fn evaluate(
319 &self,
320 method: HttpMethod,
321 path: &str,
322 query: &HashMap<String, String>,
323 headers: &HashMap<String, String>,
324 body_hash: Option<String>,
325 body_length: u64,
326 ) -> Result<EvaluationResult, crate::error::ProtectError> {
327 self.evaluate_with_execution_nonce(
328 method,
329 path,
330 query,
331 headers,
332 body_hash,
333 body_length,
334 None,
335 )
336 }
337
338 #[allow(clippy::too_many_arguments)]
340 pub fn evaluate_with_execution_nonce(
341 &self,
342 method: HttpMethod,
343 path: &str,
344 query: &HashMap<String, String>,
345 headers: &HashMap<String, String>,
346 body_hash: Option<String>,
347 body_length: u64,
348 execution_nonce: Option<&SignedExecutionNonce>,
349 ) -> Result<EvaluationResult, crate::error::ProtectError> {
350 let request_id = execution_nonce.map_or_else(
352 || uuid::Uuid::now_v7().to_string(),
353 |nonce| nonce.nonce.bound_to.request_id.clone(),
354 );
355 let caller = caller_identity_from_headers(headers);
356 let (route_pattern, matched_policy) = self.match_route(method, path);
357 let result = self.authority.evaluate(HttpAuthorityInput {
358 request_id,
359 method,
360 route_pattern,
361 path,
362 query,
363 caller,
364 body_hash,
365 body_length,
366 session_id: None,
367 capability_id_hint: None,
368 presented_capability: extract_presented_capability(headers, query),
369 requested_tool_server: None,
370 requested_tool_name: None,
371 requested_arguments: None,
372 execution_nonce,
373 model_metadata: None,
374 unsupported_authorization_extension: None,
375 policy: policy_mode(matched_policy),
376 })?;
377 Ok(result.into())
378 }
379
380 pub fn evaluate_chio_request(
382 &self,
383 request: ChioHttpRequest,
384 presented_capability: Option<&str>,
385 ) -> Result<EvaluationResult, crate::error::ProtectError> {
386 let unsupported_authorization_extension = request.unsupported_authorization_extension();
387 let ChioHttpRequest {
388 request_id,
389 method,
390 route_pattern: _client_route_pattern,
391 path,
392 query,
393 headers,
394 caller,
395 body_hash,
396 body_length,
397 session_id,
398 capability_id,
399 tool_server,
400 tool_name,
401 arguments,
402 model_metadata,
403 execution_nonce,
404 ..
405 } = request;
406 let (route_pattern, matched_policy, _) = self.match_route_with_status(method, &path);
407 let raw_capability =
408 presented_capability.or_else(|| extract_presented_capability(&headers, &query));
409 let arguments = arguments.unwrap_or(Value::Null);
410 let result = self.authority.evaluate(HttpAuthorityInput {
411 request_id,
412 method,
413 route_pattern,
414 path: &path,
415 query: &query,
416 caller,
417 body_hash,
418 body_length,
419 session_id,
420 capability_id_hint: capability_id.as_deref(),
421 presented_capability: raw_capability,
422 requested_tool_server: tool_server.as_deref(),
423 requested_tool_name: tool_name.as_deref(),
424 requested_arguments: Some(&arguments),
425 execution_nonce: execution_nonce.as_ref(),
426 model_metadata: model_metadata.as_ref(),
427 unsupported_authorization_extension,
428 policy: policy_mode(matched_policy),
429 })?;
430 Ok(result.into())
431 }
432
433 fn match_route(&self, method: HttpMethod, path: &str) -> (String, PolicyDecision) {
436 let (pattern, policy, _) = self.match_route_with_status(method, path);
437 (pattern, policy)
438 }
439
440 fn match_route_with_status(
441 &self,
442 method: HttpMethod,
443 path: &str,
444 ) -> (String, PolicyDecision, bool) {
445 for route in &self.routes {
447 if route.method == method && path_matches_pattern(path, &route.pattern) {
448 return (route.pattern.clone(), route.policy, true);
449 }
450 }
451
452 let pattern = path.to_string();
454 let policy = if method.is_safe() {
455 PolicyDecision::SessionAllow
456 } else {
457 PolicyDecision::DenyByDefault
458 };
459 (pattern, policy, false)
460 }
461}
462
463fn extract_presented_capability<'a>(
464 headers: &'a HashMap<String, String>,
465 query: &'a HashMap<String, String>,
466) -> Option<&'a str> {
467 header_value(headers, "x-chio-capability")
468 .or_else(|| query.get("chio_capability").map(String::as_str))
469}
470
471fn policy_mode(policy: PolicyDecision) -> HttpAuthorityPolicy {
472 match policy {
473 PolicyDecision::SessionAllow => HttpAuthorityPolicy::SessionAllow,
474 PolicyDecision::DenyByDefault => HttpAuthorityPolicy::DenyByDefault,
475 }
476}
477
478impl RequestEvaluator {
479 pub fn finalize_receipt(
480 &self,
481 decision_receipt: &HttpReceipt,
482 response_status: u16,
483 ) -> Result<HttpReceipt, crate::error::ProtectError> {
484 self.authority
485 .finalize_decision_receipt(decision_receipt, response_status)
486 .map_err(Into::into)
487 }
488}
489
490impl From<HttpAuthorityEvaluation> for EvaluationResult {
491 fn from(value: HttpAuthorityEvaluation) -> Self {
492 Self {
493 verdict: value.verdict,
494 receipt: value.receipt,
495 evidence: value.evidence,
496 execution_nonce: value.execution_nonce,
497 }
498 }
499}
500
501impl From<HttpAuthorityError> for crate::error::ProtectError {
502 fn from(value: HttpAuthorityError) -> Self {
503 match value {
504 HttpAuthorityError::CallerIdentity(message)
505 | HttpAuthorityError::ContentHash(message)
506 | HttpAuthorityError::Kernel(message) => Self::Evaluation(message),
507 HttpAuthorityError::PendingApproval {
508 approval_id,
509 kernel_receipt_id,
510 } => Self::PendingApproval {
511 approval_id,
512 kernel_receipt_id,
513 },
514 HttpAuthorityError::ReceiptSign(message) => Self::ReceiptSign(message),
515 }
516 }
517}
518
519fn path_matches_pattern(path: &str, pattern: &str) -> bool {
521 let mut path_segments = path.split('/');
522 let mut pattern_segments = pattern.split('/');
523
524 loop {
525 match (path_segments.next(), pattern_segments.next()) {
526 (Some(path_segment), Some(pattern_segment))
527 if path_segment_matches_pattern(path_segment, pattern_segment) => {}
528 (None, None) => return true,
529 _ => return false,
530 }
531 }
532}
533
534fn path_segment_matches_pattern(path_segment: &str, pattern_segment: &str) -> bool {
535 pattern_segment.starts_with('{') && pattern_segment.ends_with('}')
536 || path_segment == pattern_segment
537}
538
539pub(crate) fn caller_identity_from_headers(headers: &HashMap<String, String>) -> CallerIdentity {
541 if let Some(auth) = header_value(headers, "authorization") {
543 if let Some(token) = auth.strip_prefix("Bearer ") {
544 if credential_value_is_well_formed(token) {
545 let token_hash = chio_core_types::sha256_hex(token.as_bytes());
546 return CallerIdentity {
547 subject: format!("bearer:{}", &token_hash[..16]),
548 auth_method: AuthMethod::Bearer { token_hash },
549 verified: false,
550 tenant: None,
551 agent_id: None,
552 };
553 }
554 }
555 }
556
557 if let Some(key_value) = header_value(headers, "x-api-key") {
559 if credential_value_is_well_formed(key_value) {
560 let key_hash = chio_core_types::sha256_hex(key_value.as_bytes());
561 return CallerIdentity {
562 subject: format!("apikey:{}", &key_hash[..16]),
563 auth_method: AuthMethod::ApiKey {
564 key_name: "x-api-key".to_string(),
565 key_hash,
566 },
567 verified: false,
568 tenant: None,
569 agent_id: None,
570 };
571 }
572 }
573
574 CallerIdentity::anonymous()
575}
576
577pub(crate) fn header_value<'a>(
578 headers: &'a HashMap<String, String>,
579 name: &str,
580) -> Option<&'a str> {
581 headers
582 .iter()
583 .find(|(key, _)| key.eq_ignore_ascii_case(name))
584 .map(|(_, value)| value.as_str())
585}
586
587fn credential_value_is_well_formed(value: &str) -> bool {
588 !value.trim().is_empty()
589 && value.trim() == value
590 && !value.chars().any(|character| character.is_control())
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use chio_core_types::capability::{
597 governance::ProvenanceEvidenceClass,
598 scope::{ChioScope, Constraint, ModelMetadata, ModelSafetyTier, Operation, ToolGrant},
599 token::{CapabilityToken, CapabilityTokenBody},
600 };
601 use chio_http_core::{
602 http_status_scope, CHIO_DECISION_RECEIPT_ID_KEY, CHIO_HTTP_STATUS_SCOPE_DECISION,
603 CHIO_HTTP_STATUS_SCOPE_FINAL,
604 };
605
606 use chio_test_support::prelude::*;
607
608 #[test]
609 fn durable_evaluator_reports_durable_backends() -> Result<(), Box<dyn std::error::Error>> {
610 let dir = tempfile::tempdir()?;
611 #[cfg(unix)]
612 {
613 use std::os::unix::fs::PermissionsExt;
614 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?;
615 }
616 let receipt_store: Arc<dyn chio_kernel::ReceiptStore> = Arc::new(
617 chio_store_sqlite::SqliteReceiptStore::open(dir.path().join("receipts.db"))?,
618 );
619 let revocation_store: Arc<dyn chio_kernel::RevocationStore> = Arc::new(
620 chio_store_sqlite::SqliteRevocationStore::open(dir.path().join("revocations.db"))?,
621 );
622 let authority_database = dir.path().join("authority.db");
623 let authority_locks = dir.path().join("authority-locks");
624 let mut lock_root_builder = std::fs::DirBuilder::new();
625 #[cfg(unix)]
626 {
627 use std::os::unix::fs::DirBuilderExt;
628 lock_root_builder.mode(0o700);
629 }
630 lock_root_builder.create(&authority_locks)?;
631 chio_store_sqlite::SqliteAuthorityStore::provision(&authority_database, &authority_locks)?;
632 let authority = chio_store_sqlite::SqliteAuthorityStore::open_serving(
633 &authority_database,
634 &authority_locks,
635 )?;
636 let evaluator = RequestEvaluator::new_with_durable_stores_and_admission(
637 Vec::new(),
638 Keypair::generate(),
639 chio_core_types::sha256_hex(b"test-policy"),
640 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
641 Vec::new(),
642 Some(receipt_store),
643 Some(revocation_store),
644 Some(DurableAdmissionStores {
645 store: Arc::new(authority.admission_operation_store()),
646 outcome_store: Arc::new(authority.tool_outcome_store()),
647 fence: authority.mutation_fence(),
648 }),
649 false,
650 )?;
651 assert_eq!(evaluator.receipt_backend(), "durable");
652 assert_eq!(evaluator.revocation_backend(), "durable");
653 let evaluated = evaluator.evaluate(
654 HttpMethod::Get,
655 "/pets",
656 &HashMap::new(),
657 &HashMap::new(),
658 None,
659 0,
660 )?;
661 assert!(evaluated.verdict.is_allowed());
662 Ok(())
663 }
664
665 #[test]
672 fn durable_evaluator_requires_explicit_opt_in_for_ephemeral_revocation(
673 ) -> Result<(), Box<dyn std::error::Error>> {
674 let dir = tempfile::tempdir()?;
675 let keypair = Keypair::generate();
676 let mut headers = HashMap::new();
677 headers.insert(
678 "X-Chio-Capability".to_string(),
679 signed_capability_token_json(&keypair, "cap-revocation-gate"),
680 );
681
682 let build = |file: &str,
683 allow_ephemeral: bool|
684 -> Result<RequestEvaluator, Box<dyn std::error::Error>> {
685 let receipt_store: Arc<dyn chio_kernel::ReceiptStore> = Arc::new(
686 chio_store_sqlite::SqliteReceiptStore::open(dir.path().join(file))?,
687 );
688 Ok(RequestEvaluator::new_with_durable_stores(
689 vec![],
690 keypair.clone(),
691 "test-policy".to_string(),
692 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
693 Vec::new(),
694 Some(receipt_store),
695 None,
696 allow_ephemeral,
697 )?)
698 };
699
700 let denied = build("gated.db", false)?.evaluate(
703 HttpMethod::Post,
704 "/pets",
705 &HashMap::new(),
706 &headers,
707 None,
708 0,
709 );
710 let error = match denied {
711 Ok(_) => panic!("in-memory revocation without an opt-in must fail closed"),
712 Err(error) => error,
713 };
714 assert!(
715 error.to_string().contains("revocation"),
716 "the failure must name the missing revocation durability, got: {error}"
717 );
718
719 let allowed = build("opted.db", true)?.evaluate(
721 HttpMethod::Post,
722 "/pets",
723 &HashMap::new(),
724 &headers,
725 None,
726 0,
727 )?;
728 assert!(
729 allowed.verdict.is_allowed(),
730 "an explicit ephemeral opt-in restores in-memory revocation serving"
731 );
732 Ok(())
733 }
734
735 #[test]
739 #[allow(deprecated)]
740 fn deprecated_constructor_shims_delegate_to_ephemeral() {
741 let keypair = Keypair::generate();
742
743 let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
744 assert_eq!(evaluator.receipt_backend(), "ephemeral");
745 assert_eq!(evaluator.revocation_backend(), "ephemeral");
746
747 let evaluator = RequestEvaluator::new_with_trusted_capability_issuers(
748 vec![],
749 keypair.clone(),
750 "test-policy".to_string(),
751 vec![],
752 );
753 assert_eq!(evaluator.receipt_backend(), "ephemeral");
754
755 let evaluator = RequestEvaluator::new_with_approval_store(
756 vec![],
757 keypair.clone(),
758 "test-policy".to_string(),
759 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
760 );
761 assert_eq!(evaluator.receipt_backend(), "ephemeral");
762
763 let evaluator = RequestEvaluator::new_with_approval_store_and_trusted_capability_issuers(
764 vec![],
765 keypair,
766 "test-policy".to_string(),
767 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
768 vec![],
769 );
770 assert_eq!(evaluator.revocation_backend(), "ephemeral");
771 }
772
773 #[test]
774 fn evaluator_without_durable_stores_reports_ephemeral_backends(
775 ) -> Result<(), Box<dyn std::error::Error>> {
776 let evaluator = RequestEvaluator::new_with_durable_stores(
777 Vec::new(),
778 Keypair::generate(),
779 "test-policy".to_string(),
780 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
781 Vec::new(),
782 None,
783 None,
784 true,
785 )?;
786 assert_eq!(evaluator.receipt_backend(), "ephemeral");
787 assert_eq!(evaluator.revocation_backend(), "ephemeral");
788 Ok(())
789 }
790
791 fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
792 signed_capability_token_json_with_scope(
793 issuer,
794 id,
795 ChioScope {
796 grants: vec![chio_http_core::http_authority_tool_grant()],
797 ..ChioScope::default()
798 },
799 )
800 }
801
802 fn signed_capability_token_json_with_scope(
803 issuer: &Keypair,
804 id: &str,
805 scope: ChioScope,
806 ) -> String {
807 let now = chrono::Utc::now().timestamp() as u64;
808 let token = CapabilityToken::sign(
809 CapabilityTokenBody {
810 id: id.to_string(),
811 issuer: issuer.public_key(),
812 subject: issuer.public_key(),
813 scope,
814 issued_at: now.saturating_sub(60),
815 expires_at: now + 3600,
816 delegation_chain: Vec::new(),
817 aggregate_invocation_budget: None,
818 },
819 issuer,
820 )
821 .test_unwrap();
822 serde_json::to_string(&token).test_unwrap()
823 }
824
825 #[test]
826 fn path_matching() {
827 assert!(path_matches_pattern("/pets/42", "/pets/{petId}"));
828 assert!(path_matches_pattern("/pets", "/pets"));
829 assert!(!path_matches_pattern("/pets/42/toys", "/pets/{petId}"));
830 assert!(!path_matches_pattern("/dogs/42", "/pets/{petId}"));
831 }
832
833 #[test]
834 fn extract_bearer_caller() {
835 let mut headers = HashMap::new();
836 headers.insert("Authorization".to_string(), "Bearer mytoken123".to_string());
837 let caller = caller_identity_from_headers(&headers);
838 assert!(caller.subject.starts_with("bearer:"));
839 assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
840 }
841
842 #[test]
843 fn extract_bearer_caller_accepts_case_insensitive_authorization_header() {
844 let mut headers = HashMap::new();
845 headers.insert("AUTHORIZATION".to_string(), "Bearer mytoken123".to_string());
846 let caller = caller_identity_from_headers(&headers);
847 assert!(caller.subject.starts_with("bearer:"));
848 assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
849 }
850
851 #[test]
852 fn extract_caller_ignores_blank_bearer_token() {
853 let mut headers = HashMap::new();
854 headers.insert("authorization".to_string(), "Bearer ".to_string());
855
856 let caller = caller_identity_from_headers(&headers);
857
858 assert!(matches!(caller.auth_method, AuthMethod::Anonymous));
859 assert_eq!(caller.subject, "anonymous");
860 }
861
862 #[test]
863 fn extract_anonymous_caller() {
864 let headers = HashMap::new();
865 let caller = caller_identity_from_headers(&headers);
866 assert_eq!(caller.subject, "anonymous");
867 }
868
869 #[test]
870 fn evaluate_get_allowed() {
871 let keypair = Keypair::generate();
872 let routes = vec![RouteEntry {
873 pattern: "/pets".to_string(),
874 method: HttpMethod::Get,
875 operation_id: Some("listPets".to_string()),
876 policy: PolicyDecision::SessionAllow,
877 }];
878 let evaluator =
879 RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
880
881 let result = evaluator
882 .evaluate(
883 HttpMethod::Get,
884 "/pets",
885 &HashMap::new(),
886 &HashMap::new(),
887 None,
888 0,
889 )
890 .test_unwrap();
891 assert!(result.verdict.is_allowed());
892 assert!(result.receipt.verify_signature().test_unwrap());
893 assert_eq!(
894 http_status_scope(result.receipt.metadata.as_ref()),
895 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
896 );
897 }
898
899 #[test]
900 fn evaluate_chio_request_signed_denies_unsupported_approval_sets() {
901 let keypair = Keypair::generate();
902 let routes = vec![RouteEntry {
903 pattern: "/pets".to_string(),
904 method: HttpMethod::Get,
905 operation_id: Some("listPets".to_string()),
906 policy: PolicyDecision::SessionAllow,
907 }];
908 let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
909 let mut request = ChioHttpRequest::new(
910 "req-unsupported-approvals".to_string(),
911 HttpMethod::Get,
912 "/pets".to_string(),
913 "/pets".to_string(),
914 CallerIdentity::anonymous(),
915 );
916 request.approval_tokens = Some(serde_json::json!([
917 { "id": "approval-a" },
918 { "id": "approval-b" }
919 ]));
920
921 let result = evaluator.evaluate_chio_request(request, None).test_unwrap();
922
923 assert!(result.verdict.is_denied());
924 assert!(result.receipt.verify_signature().test_unwrap());
925 assert!(result.receipt.evidence[0]
926 .details
927 .as_deref()
928 .is_some_and(|details| details.contains(
929 "HTTP authority projection does not support authorization field approval_tokens"
930 )));
931 }
932
933 #[test]
934 fn evaluate_chio_request_denies_spoofed_tool_identity_on_http_route() {
935 let keypair = Keypair::generate();
936 let routes = vec![RouteEntry {
937 pattern: "/pets".to_string(),
938 method: HttpMethod::Post,
939 operation_id: Some("createPet".to_string()),
940 policy: PolicyDecision::DenyByDefault,
941 }];
942 let evaluator =
943 RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
944 let capability = signed_capability_token_json_with_scope(
945 &keypair,
946 "cap-math-only",
947 ChioScope {
948 grants: vec![ToolGrant {
949 server_id: "math".to_string(),
950 tool_name: "double".to_string(),
951 operations: vec![Operation::Invoke],
952 constraints: Vec::new(),
953 max_invocations: None,
954 max_cost_per_invocation: None,
955 max_total_cost: None,
956 dpop_required: None,
957 }],
958 ..ChioScope::default()
959 },
960 );
961
962 let mut request = ChioHttpRequest::new(
963 "req-sidecar-spoofed-tool".to_string(),
964 HttpMethod::Post,
965 "/pets".to_string(),
966 "/pets".to_string(),
967 CallerIdentity::anonymous(),
968 );
969 request.tool_server = Some("math".to_string());
970 request.tool_name = Some("double".to_string());
971 request.arguments = Some(serde_json::json!({ "value": 1 }));
972 request.body_hash = Some("pet-body".to_string());
973 request.body_length = 8;
974
975 let result = evaluator
976 .evaluate_chio_request(request, Some(&capability))
977 .test_unwrap();
978
979 assert!(result.verdict.is_denied());
980 assert!(result.receipt.capability_id.is_none());
981 assert!(result.receipt.evidence[0]
982 .details
983 .as_deref()
984 .is_some_and(|details| {
985 details.contains("capability does not authorize tool authorize_http_request")
986 }));
987 }
988
989 #[test]
990 fn evaluate_denies_get_reserved_tools_path_without_capability() {
991 let keypair = Keypair::generate();
992 let evaluator = RequestEvaluator::new_ephemeral(vec![], keypair, "test-policy".to_string());
993
994 let result = evaluator
995 .evaluate(
996 HttpMethod::Get,
997 "/chio/tools/billing/read",
998 &HashMap::new(),
999 &HashMap::new(),
1000 None,
1001 0,
1002 )
1003 .test_unwrap();
1004
1005 assert!(result.verdict.is_denied());
1006 assert!(result.receipt.capability_id.is_none());
1007 assert_eq!(
1008 result.receipt.evidence[0].details.as_deref(),
1009 Some("side-effect route requires a valid capability token")
1010 );
1011 }
1012
1013 #[test]
1014 fn evaluate_chio_request_allows_reserved_tools_path_context() {
1015 let keypair = Keypair::generate();
1016 let evaluator =
1017 RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1018 let capability = signed_capability_token_json_with_scope(
1019 &keypair,
1020 "cap-matrix-read",
1021 ChioScope {
1022 grants: vec![ToolGrant {
1023 server_id: "matrix".to_string(),
1024 tool_name: "files.read".to_string(),
1025 operations: vec![Operation::Invoke],
1026 constraints: Vec::new(),
1027 max_invocations: None,
1028 max_cost_per_invocation: None,
1029 max_total_cost: None,
1030 dpop_required: None,
1031 }],
1032 ..ChioScope::default()
1033 },
1034 );
1035
1036 let mut request = ChioHttpRequest::new(
1037 "req-sidecar-matrix-tool".to_string(),
1038 HttpMethod::Post,
1039 "/chio/tools/matrix/files.read".to_string(),
1040 "/chio/tools/matrix/files.read".to_string(),
1041 CallerIdentity::anonymous(),
1042 );
1043 request.tool_server = Some("matrix".to_string());
1044 request.tool_name = Some("files.read".to_string());
1045 request.arguments = Some(serde_json::json!({ "path": "/tmp/a" }));
1046 request.body_hash = Some("tool-body".to_string());
1047 request.body_length = 8;
1048
1049 let result = evaluator
1050 .evaluate_chio_request(request, Some(&capability))
1051 .test_unwrap();
1052
1053 assert!(result.verdict.is_allowed());
1054 assert_eq!(
1055 result.receipt.capability_id.as_deref(),
1056 Some("cap-matrix-read")
1057 );
1058 }
1059
1060 #[test]
1061 fn evaluate_chio_request_denies_unmatched_http_path_with_spoofed_synthetic_pattern() {
1062 let keypair = Keypair::generate();
1063 let evaluator =
1064 RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1065 let capability = signed_capability_token_json_with_scope(
1066 &keypair,
1067 "cap-matrix-admin-delete",
1068 ChioScope {
1069 grants: vec![ToolGrant {
1070 server_id: "matrix".to_string(),
1071 tool_name: "admin.delete".to_string(),
1072 operations: vec![Operation::Invoke],
1073 constraints: Vec::new(),
1074 max_invocations: None,
1075 max_cost_per_invocation: None,
1076 max_total_cost: None,
1077 dpop_required: None,
1078 }],
1079 ..ChioScope::default()
1080 },
1081 );
1082
1083 let mut request = ChioHttpRequest::new(
1084 "req-unmatched-spoofed-synthetic-pattern".to_string(),
1085 HttpMethod::Post,
1086 "matrix:admin.delete".to_string(),
1087 "/admin/delete".to_string(),
1088 CallerIdentity::anonymous(),
1089 );
1090 request.tool_server = Some("matrix".to_string());
1091 request.tool_name = Some("admin.delete".to_string());
1092 request.arguments = Some(serde_json::json!({ "path": "/tmp/a" }));
1093 request.body_hash = Some("tool-body".to_string());
1094 request.body_length = 8;
1095
1096 let result = evaluator
1097 .evaluate_chio_request(request, Some(&capability))
1098 .test_unwrap();
1099
1100 assert!(result.verdict.is_denied());
1101 assert_eq!(result.receipt.route_pattern, "/admin/delete");
1102 assert!(result.receipt.capability_id.is_none());
1103 assert!(result.receipt.evidence[0]
1104 .details
1105 .as_deref()
1106 .is_some_and(|details| {
1107 details.contains("capability does not authorize tool authorize_http_request")
1108 }));
1109 }
1110
1111 #[test]
1112 fn evaluate_chio_request_denies_capability_for_different_tool_identity() {
1113 let keypair = Keypair::generate();
1114 let evaluator =
1115 RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1116 let capability = signed_capability_token_json_with_scope(
1117 &keypair,
1118 "cap-tool-scope",
1119 ChioScope {
1120 grants: vec![ToolGrant {
1121 server_id: "math".to_string(),
1122 tool_name: "double".to_string(),
1123 operations: vec![Operation::Invoke],
1124 constraints: Vec::new(),
1125 max_invocations: None,
1126 max_cost_per_invocation: None,
1127 max_total_cost: None,
1128 dpop_required: None,
1129 }],
1130 ..ChioScope::default()
1131 },
1132 );
1133
1134 let mut request = ChioHttpRequest::new(
1135 "req-sidecar-tool-mismatch".to_string(),
1136 HttpMethod::Post,
1137 "/chio/tools/math/increment".to_string(),
1138 "/chio/tools/math/increment".to_string(),
1139 CallerIdentity::anonymous(),
1140 );
1141 request.tool_server = Some("math".to_string());
1142 request.tool_name = Some("increment".to_string());
1143 request.arguments = Some(serde_json::json!({ "value": 1 }));
1144 request.body_hash = Some("tool-body".to_string());
1145 request.body_length = 1;
1146
1147 let result = evaluator
1148 .evaluate_chio_request(request, Some(&capability))
1149 .test_unwrap();
1150
1151 assert!(result.verdict.is_denied());
1152 assert_eq!(
1153 result.receipt.evidence[0].details.as_deref(),
1154 Some("capability does not authorize tool increment on server math")
1155 );
1156 }
1157
1158 #[test]
1159 fn evaluate_chio_request_allows_model_constrained_capability_when_metadata_matches() {
1160 let keypair = Keypair::generate();
1161 let evaluator =
1162 RequestEvaluator::new_ephemeral(vec![], keypair.clone(), "test-policy".to_string());
1163 let capability = signed_capability_token_json_with_scope(
1164 &keypair,
1165 "cap-model-scope",
1166 ChioScope {
1167 grants: vec![ToolGrant {
1168 server_id: "math".to_string(),
1169 tool_name: "double".to_string(),
1170 operations: vec![Operation::Invoke],
1171 constraints: vec![Constraint::ModelConstraint {
1172 allowed_model_ids: vec!["gpt-5".to_string()],
1173 min_safety_tier: Some(ModelSafetyTier::Standard),
1174 }],
1175 max_invocations: None,
1176 max_cost_per_invocation: None,
1177 max_total_cost: None,
1178 dpop_required: None,
1179 }],
1180 ..ChioScope::default()
1181 },
1182 );
1183
1184 let mut request = ChioHttpRequest::new(
1185 "req-model-scope".to_string(),
1186 HttpMethod::Post,
1187 "/chio/tools/math/double".to_string(),
1188 "/chio/tools/math/double".to_string(),
1189 CallerIdentity::anonymous(),
1190 );
1191 request.tool_server = Some("math".to_string());
1192 request.tool_name = Some("double".to_string());
1193 request.arguments = Some(serde_json::json!({ "value": 2 }));
1194 request.model_metadata = Some(ModelMetadata {
1195 model_id: "gpt-5".to_string(),
1196 safety_tier: Some(ModelSafetyTier::Standard),
1197 provider: Some("openai".to_string()),
1198 provenance_class: ProvenanceEvidenceClass::Asserted,
1199 });
1200 request.body_hash = Some("tool-body".to_string());
1201 request.body_length = 1;
1202
1203 let result = evaluator
1204 .evaluate_chio_request(request, Some(&capability))
1205 .test_unwrap();
1206
1207 assert!(result.verdict.is_allowed());
1208 assert_eq!(
1209 result.receipt.capability_id.as_deref(),
1210 Some("cap-model-scope")
1211 );
1212 }
1213
1214 #[test]
1215 fn evaluate_chio_request_allows_capability_from_configured_external_issuer() {
1216 let signer = Keypair::generate();
1217 let external_issuer = Keypair::generate();
1218 let evaluator = RequestEvaluator::new_ephemeral_with_trusted_capability_issuers(
1219 vec![],
1220 signer,
1221 "test-policy".to_string(),
1222 vec![external_issuer.public_key()],
1223 );
1224 let capability = signed_capability_token_json(&external_issuer, "cap-external");
1225
1226 let mut request = ChioHttpRequest::new(
1227 "req-external-issuer".to_string(),
1228 HttpMethod::Post,
1229 "/pets".to_string(),
1230 "/pets".to_string(),
1231 CallerIdentity::anonymous(),
1232 );
1233 request.body_hash = Some("body".to_string());
1234 request.body_length = 1;
1235
1236 let result = evaluator
1237 .evaluate_chio_request(request, Some(&capability))
1238 .test_unwrap();
1239
1240 assert!(result.verdict.is_allowed());
1241 assert_eq!(
1242 result.receipt.capability_id.as_deref(),
1243 Some("cap-external")
1244 );
1245 }
1246
1247 #[test]
1248 fn evaluate_post_denied_without_capability() {
1249 let keypair = Keypair::generate();
1250 let routes = vec![RouteEntry {
1251 pattern: "/pets".to_string(),
1252 method: HttpMethod::Post,
1253 operation_id: Some("createPet".to_string()),
1254 policy: PolicyDecision::DenyByDefault,
1255 }];
1256 let evaluator =
1257 RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1258
1259 let result = evaluator
1260 .evaluate(
1261 HttpMethod::Post,
1262 "/pets",
1263 &HashMap::new(),
1264 &HashMap::new(),
1265 None,
1266 0,
1267 )
1268 .test_unwrap();
1269 assert!(result.verdict.is_denied());
1270 assert_eq!(result.receipt.response_status, 403);
1271 assert!(result.receipt.verify_signature().test_unwrap());
1272 assert_eq!(
1273 http_status_scope(result.receipt.metadata.as_ref()),
1274 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
1275 );
1276 }
1277
1278 #[test]
1279 fn evaluate_post_allowed_with_capability() {
1280 let keypair = Keypair::generate();
1281 let routes = vec![RouteEntry {
1282 pattern: "/pets".to_string(),
1283 method: HttpMethod::Post,
1284 operation_id: Some("createPet".to_string()),
1285 policy: PolicyDecision::DenyByDefault,
1286 }];
1287 let evaluator =
1288 RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1289
1290 let mut headers = HashMap::new();
1291 headers.insert(
1292 "X-Chio-Capability".to_string(),
1293 signed_capability_token_json(&keypair, "cap-123"),
1294 );
1295
1296 let result = evaluator
1297 .evaluate(
1298 HttpMethod::Post,
1299 "/pets",
1300 &HashMap::new(),
1301 &headers,
1302 None,
1303 0,
1304 )
1305 .test_unwrap();
1306 assert!(result.verdict.is_allowed());
1307 assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
1308 assert_eq!(
1309 http_status_scope(result.receipt.metadata.as_ref()),
1310 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
1311 );
1312 }
1313
1314 #[test]
1315 fn evaluate_post_accepts_case_insensitive_capability_header() {
1316 let keypair = Keypair::generate();
1317 let routes = vec![RouteEntry {
1318 pattern: "/pets".to_string(),
1319 method: HttpMethod::Post,
1320 operation_id: Some("createPet".to_string()),
1321 policy: PolicyDecision::DenyByDefault,
1322 }];
1323 let evaluator =
1324 RequestEvaluator::new_ephemeral(routes, keypair.clone(), "test-policy".to_string());
1325
1326 let mut headers = HashMap::new();
1327 headers.insert(
1328 "X-CHIO-CAPABILITY".to_string(),
1329 signed_capability_token_json(&keypair, "cap-uppercase"),
1330 );
1331
1332 let result = evaluator
1333 .evaluate(
1334 HttpMethod::Post,
1335 "/pets",
1336 &HashMap::new(),
1337 &headers,
1338 None,
1339 0,
1340 )
1341 .test_unwrap();
1342 assert!(result.verdict.is_allowed());
1343 assert_eq!(
1344 result.receipt.capability_id.as_deref(),
1345 Some("cap-uppercase")
1346 );
1347 }
1348
1349 #[test]
1350 fn finalize_receipt_rebinds_status_and_links_decision_receipt() {
1351 let keypair = Keypair::generate();
1352 let routes = vec![RouteEntry {
1353 pattern: "/pets".to_string(),
1354 method: HttpMethod::Get,
1355 operation_id: Some("listPets".to_string()),
1356 policy: PolicyDecision::SessionAllow,
1357 }];
1358 let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1359
1360 let decision = evaluator
1361 .evaluate(
1362 HttpMethod::Get,
1363 "/pets",
1364 &HashMap::new(),
1365 &HashMap::new(),
1366 None,
1367 0,
1368 )
1369 .test_unwrap()
1370 .receipt;
1371 let final_receipt = evaluator.finalize_receipt(&decision, 204).test_unwrap();
1372
1373 assert_ne!(final_receipt.id, decision.id);
1374 assert_eq!(final_receipt.response_status, 204);
1375 assert_eq!(
1376 http_status_scope(final_receipt.metadata.as_ref()),
1377 Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
1378 );
1379 assert_eq!(
1380 final_receipt
1381 .metadata
1382 .as_ref()
1383 .and_then(|meta| meta.get(CHIO_DECISION_RECEIPT_ID_KEY))
1384 .and_then(|value| value.as_str()),
1385 Some(decision.id.as_str())
1386 );
1387 assert!(final_receipt.verify_signature().test_unwrap());
1388 }
1389
1390 #[test]
1391 fn path_matching_trailing_slash_mismatch() {
1392 assert!(!path_matches_pattern("/pets/", "/pets"));
1394 assert!(!path_matches_pattern("/pets", "/pets/"));
1395 }
1396
1397 #[test]
1398 fn path_matching_double_slashes() {
1399 assert!(!path_matches_pattern("//pets", "/pets"));
1401 }
1402
1403 #[test]
1404 fn path_matching_case_sensitivity() {
1405 assert!(!path_matches_pattern("/Pets", "/pets"));
1407 assert!(path_matches_pattern("/Pets", "/Pets"));
1408 }
1409
1410 #[test]
1411 fn path_matching_multiple_params() {
1412 assert!(path_matches_pattern(
1413 "/orgs/123/members/456",
1414 "/orgs/{orgId}/members/{memberId}"
1415 ));
1416 assert!(!path_matches_pattern(
1417 "/orgs/123/members",
1418 "/orgs/{orgId}/members/{memberId}"
1419 ));
1420 }
1421
1422 #[test]
1423 fn path_matching_root() {
1424 assert!(path_matches_pattern("/", "/"));
1425 assert!(!path_matches_pattern("/pets", "/"));
1426 }
1427
1428 #[test]
1429 fn extract_api_key_caller() {
1430 let mut headers = HashMap::new();
1431 headers.insert("X-API-Key".to_string(), "my-api-key-value".to_string());
1432 let caller = caller_identity_from_headers(&headers);
1433 assert!(caller.subject.starts_with("apikey:"));
1434 assert!(matches!(caller.auth_method, AuthMethod::ApiKey { .. }));
1435 }
1436
1437 #[test]
1438 fn evaluate_with_body_hash() {
1439 let keypair = Keypair::generate();
1440 let routes = vec![RouteEntry {
1441 pattern: "/data".to_string(),
1442 method: HttpMethod::Get,
1443 operation_id: Some("getData".to_string()),
1444 policy: PolicyDecision::SessionAllow,
1445 }];
1446 let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1447
1448 let result = evaluator
1449 .evaluate(
1450 HttpMethod::Get,
1451 "/data",
1452 &HashMap::new(),
1453 &HashMap::new(),
1454 Some("bodyhash123".to_string()),
1455 1024,
1456 )
1457 .test_unwrap();
1458 assert!(result.verdict.is_allowed());
1459 assert!(result.receipt.verify_signature().test_unwrap());
1460 }
1461
1462 #[test]
1463 fn fallback_policy_for_unmatched_route() {
1464 let keypair = Keypair::generate();
1465 let evaluator = RequestEvaluator::new_ephemeral(vec![], keypair, "test-policy".to_string());
1466
1467 let result = evaluator
1469 .evaluate(
1470 HttpMethod::Get,
1471 "/unknown",
1472 &HashMap::new(),
1473 &HashMap::new(),
1474 None,
1475 0,
1476 )
1477 .test_unwrap();
1478 assert!(result.verdict.is_allowed());
1479
1480 let result = evaluator
1482 .evaluate(
1483 HttpMethod::Delete,
1484 "/unknown",
1485 &HashMap::new(),
1486 &HashMap::new(),
1487 None,
1488 0,
1489 )
1490 .test_unwrap();
1491 assert!(result.verdict.is_denied());
1492 }
1493
1494 #[test]
1495 fn evaluate_invalid_capability_denied_fail_closed() {
1496 let keypair = Keypair::generate();
1497 let routes = vec![RouteEntry {
1498 pattern: "/pets".to_string(),
1499 method: HttpMethod::Post,
1500 operation_id: Some("createPet".to_string()),
1501 policy: PolicyDecision::DenyByDefault,
1502 }];
1503 let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1504 let mut headers = HashMap::new();
1505 headers.insert("X-Chio-Capability".to_string(), "not-json".to_string());
1506
1507 let result = evaluator
1508 .evaluate(
1509 HttpMethod::Post,
1510 "/pets",
1511 &HashMap::new(),
1512 &headers,
1513 None,
1514 0,
1515 )
1516 .test_unwrap();
1517
1518 assert!(result.verdict.is_denied());
1519 assert!(result.receipt.capability_id.as_deref().is_none());
1520 }
1521
1522 #[test]
1523 fn evaluate_query_parameters_affect_content_hash() {
1524 let keypair = Keypair::generate();
1525 let routes = vec![RouteEntry {
1526 pattern: "/search".to_string(),
1527 method: HttpMethod::Get,
1528 operation_id: Some("search".to_string()),
1529 policy: PolicyDecision::SessionAllow,
1530 }];
1531 let evaluator = RequestEvaluator::new_ephemeral(routes, keypair, "test-policy".to_string());
1532 let mut query_a = HashMap::new();
1533 query_a.insert("q".to_string(), "cats".to_string());
1534 let mut query_b = HashMap::new();
1535 query_b.insert("q".to_string(), "dogs".to_string());
1536
1537 let result_a = evaluator
1538 .evaluate(
1539 HttpMethod::Get,
1540 "/search",
1541 &query_a,
1542 &HashMap::new(),
1543 None,
1544 0,
1545 )
1546 .test_unwrap();
1547 let result_b = evaluator
1548 .evaluate(
1549 HttpMethod::Get,
1550 "/search",
1551 &query_b,
1552 &HashMap::new(),
1553 None,
1554 0,
1555 )
1556 .test_unwrap();
1557
1558 assert_ne!(result_a.receipt.content_hash, result_b.receipt.content_hash);
1559 }
1560}