1use std::collections::HashMap;
4use std::sync::Arc;
5
6use chio_core_types::crypto::{Keypair, PublicKey};
7use chio_core_types::receipt::GuardEvidence;
8use chio_http_core::{
9 AuthMethod, CallerIdentity, ChioHttpRequest, HttpAuthority, HttpAuthorityError,
10 HttpAuthorityEvaluation, HttpAuthorityInput, HttpAuthorityPolicy, HttpMethod, HttpReceipt,
11 Verdict,
12};
13use chio_kernel::ApprovalStore;
14use chio_openapi::PolicyDecision;
15use serde_json::Value;
16
17pub struct EvaluationResult {
19 pub verdict: Verdict,
20 pub receipt: HttpReceipt,
21 pub evidence: Vec<GuardEvidence>,
22}
23
24#[derive(Debug, Clone)]
26pub struct RouteEntry {
27 pub pattern: String,
28 pub method: HttpMethod,
29 pub operation_id: Option<String>,
30 pub policy: PolicyDecision,
31}
32
33pub struct RequestEvaluator {
35 routes: Vec<RouteEntry>,
36 authority: HttpAuthority,
37}
38
39impl RequestEvaluator {
40 pub fn new(routes: Vec<RouteEntry>, keypair: Keypair, policy_hash: String) -> Self {
41 Self::new_with_trusted_capability_issuers(routes, keypair, policy_hash, Vec::new())
42 }
43
44 pub fn new_with_trusted_capability_issuers(
45 routes: Vec<RouteEntry>,
46 keypair: Keypair,
47 policy_hash: String,
48 trusted_capability_issuers: Vec<PublicKey>,
49 ) -> Self {
50 Self {
51 routes,
52 authority: HttpAuthority::new_with_approval_store_and_trusted_issuers(
53 keypair,
54 policy_hash,
55 Arc::new(chio_kernel::InMemoryApprovalStore::new()),
56 trusted_capability_issuers,
57 ),
58 }
59 }
60
61 pub fn new_with_approval_store(
62 routes: Vec<RouteEntry>,
63 keypair: Keypair,
64 policy_hash: String,
65 approval_store: Arc<dyn ApprovalStore>,
66 ) -> Self {
67 Self::new_with_approval_store_and_trusted_capability_issuers(
68 routes,
69 keypair,
70 policy_hash,
71 approval_store,
72 Vec::new(),
73 )
74 }
75
76 pub fn new_with_approval_store_and_trusted_capability_issuers(
77 routes: Vec<RouteEntry>,
78 keypair: Keypair,
79 policy_hash: String,
80 approval_store: Arc<dyn ApprovalStore>,
81 trusted_capability_issuers: Vec<PublicKey>,
82 ) -> Self {
83 Self {
84 routes,
85 authority: HttpAuthority::new_with_approval_store_and_trusted_issuers(
86 keypair,
87 policy_hash,
88 approval_store,
89 trusted_capability_issuers,
90 ),
91 }
92 }
93
94 #[cfg(test)]
95 #[must_use]
96 pub fn approval_store(&self) -> Arc<dyn ApprovalStore> {
97 self.authority.approval_store()
98 }
99
100 pub fn evaluate(
102 &self,
103 method: HttpMethod,
104 path: &str,
105 query: &HashMap<String, String>,
106 headers: &HashMap<String, String>,
107 body_hash: Option<String>,
108 body_length: u64,
109 ) -> Result<EvaluationResult, crate::error::ProtectError> {
110 let request_id = uuid::Uuid::now_v7().to_string();
111 let caller = extract_caller(headers);
112 let (route_pattern, matched_policy) = self.match_route(method, path);
113 let result = self.authority.evaluate(HttpAuthorityInput {
114 request_id,
115 method,
116 route_pattern,
117 path,
118 query,
119 caller,
120 body_hash,
121 body_length,
122 session_id: None,
123 capability_id_hint: None,
124 presented_capability: extract_presented_capability(headers, query),
125 requested_tool_server: None,
126 requested_tool_name: None,
127 requested_arguments: None,
128 model_metadata: None,
129 policy: policy_mode(matched_policy),
130 })?;
131 Ok(result.into())
132 }
133
134 pub fn evaluate_chio_request(
136 &self,
137 request: ChioHttpRequest,
138 presented_capability: Option<&str>,
139 ) -> Result<EvaluationResult, crate::error::ProtectError> {
140 let ChioHttpRequest {
141 request_id,
142 method,
143 path,
144 query,
145 headers,
146 caller,
147 body_hash,
148 body_length,
149 session_id,
150 capability_id,
151 tool_server,
152 tool_name,
153 arguments,
154 model_metadata,
155 ..
156 } = request;
157 let (route_pattern, matched_policy) = self.match_route(method, &path);
158 let raw_capability =
159 presented_capability.or_else(|| extract_presented_capability(&headers, &query));
160 let arguments = arguments.unwrap_or(Value::Null);
161 let result = self.authority.evaluate(HttpAuthorityInput {
162 request_id,
163 method,
164 route_pattern,
165 path: &path,
166 query: &query,
167 caller,
168 body_hash,
169 body_length,
170 session_id,
171 capability_id_hint: capability_id.as_deref(),
172 presented_capability: raw_capability,
173 requested_tool_server: tool_server.as_deref(),
174 requested_tool_name: tool_name.as_deref(),
175 requested_arguments: Some(&arguments),
176 model_metadata: model_metadata.as_ref(),
177 policy: policy_mode(matched_policy),
178 })?;
179 Ok(result.into())
180 }
181
182 fn match_route(&self, method: HttpMethod, path: &str) -> (String, PolicyDecision) {
185 for route in &self.routes {
187 if route.method == method && path_matches_pattern(path, &route.pattern) {
188 return (route.pattern.clone(), route.policy);
189 }
190 }
191
192 let pattern = path.to_string();
194 let policy = if method.is_safe() {
195 PolicyDecision::SessionAllow
196 } else {
197 PolicyDecision::DenyByDefault
198 };
199 (pattern, policy)
200 }
201}
202
203fn extract_presented_capability<'a>(
204 headers: &'a HashMap<String, String>,
205 query: &'a HashMap<String, String>,
206) -> Option<&'a str> {
207 headers
208 .get("x-chio-capability")
209 .or_else(|| headers.get("X-Chio-Capability"))
210 .map(String::as_str)
211 .or_else(|| query.get("chio_capability").map(String::as_str))
212}
213
214fn policy_mode(policy: PolicyDecision) -> HttpAuthorityPolicy {
215 match policy {
216 PolicyDecision::SessionAllow => HttpAuthorityPolicy::SessionAllow,
217 PolicyDecision::DenyByDefault => HttpAuthorityPolicy::DenyByDefault,
218 }
219}
220
221impl RequestEvaluator {
222 pub fn finalize_receipt(
223 &self,
224 decision_receipt: &HttpReceipt,
225 response_status: u16,
226 ) -> Result<HttpReceipt, crate::error::ProtectError> {
227 self.authority
228 .finalize_decision_receipt(decision_receipt, response_status)
229 .map_err(Into::into)
230 }
231}
232
233impl From<HttpAuthorityEvaluation> for EvaluationResult {
234 fn from(value: HttpAuthorityEvaluation) -> Self {
235 Self {
236 verdict: value.verdict,
237 receipt: value.receipt,
238 evidence: value.evidence,
239 }
240 }
241}
242
243impl From<HttpAuthorityError> for crate::error::ProtectError {
244 fn from(value: HttpAuthorityError) -> Self {
245 match value {
246 HttpAuthorityError::CallerIdentity(message)
247 | HttpAuthorityError::ContentHash(message)
248 | HttpAuthorityError::Kernel(message) => Self::Evaluation(message),
249 HttpAuthorityError::PendingApproval {
250 approval_id,
251 kernel_receipt_id,
252 } => Self::PendingApproval {
253 approval_id,
254 kernel_receipt_id,
255 },
256 HttpAuthorityError::ReceiptSign(message) => Self::ReceiptSign(message),
257 }
258 }
259}
260
261fn path_matches_pattern(path: &str, pattern: &str) -> bool {
263 let mut path_segments = path.split('/');
264 let mut pattern_segments = pattern.split('/');
265
266 loop {
267 match (path_segments.next(), pattern_segments.next()) {
268 (Some(path_segment), Some(pattern_segment))
269 if path_segment_matches_pattern(path_segment, pattern_segment) => {}
270 (None, None) => return true,
271 _ => return false,
272 }
273 }
274}
275
276fn path_segment_matches_pattern(path_segment: &str, pattern_segment: &str) -> bool {
277 pattern_segment.starts_with('{') && pattern_segment.ends_with('}')
278 || path_segment == pattern_segment
279}
280
281fn extract_caller(headers: &HashMap<String, String>) -> CallerIdentity {
283 if let Some(auth) = headers
285 .get("authorization")
286 .or_else(|| headers.get("Authorization"))
287 {
288 if let Some(token) = auth.strip_prefix("Bearer ") {
289 let token_hash = chio_core_types::sha256_hex(token.as_bytes());
290 return CallerIdentity {
291 subject: format!("bearer:{}", &token_hash[..16]),
292 auth_method: AuthMethod::Bearer { token_hash },
293 verified: false,
294 tenant: None,
295 agent_id: None,
296 };
297 }
298 }
299
300 for key_header in &["x-api-key", "X-Api-Key", "X-API-Key"] {
302 if let Some(key_value) = headers.get(*key_header) {
303 let key_hash = chio_core_types::sha256_hex(key_value.as_bytes());
304 return CallerIdentity {
305 subject: format!("apikey:{}", &key_hash[..16]),
306 auth_method: AuthMethod::ApiKey {
307 key_name: key_header.to_string(),
308 key_hash,
309 },
310 verified: false,
311 tenant: None,
312 agent_id: None,
313 };
314 }
315 }
316
317 CallerIdentity::anonymous()
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use chio_core_types::capability::{
324 CapabilityToken, CapabilityTokenBody, ChioScope, Constraint, ModelMetadata,
325 ModelSafetyTier, Operation, ProvenanceEvidenceClass, ToolGrant,
326 };
327 use chio_http_core::{
328 http_status_scope, CHIO_DECISION_RECEIPT_ID_KEY, CHIO_HTTP_STATUS_SCOPE_DECISION,
329 CHIO_HTTP_STATUS_SCOPE_FINAL,
330 };
331
332 fn signed_capability_token_json(issuer: &Keypair, id: &str) -> String {
333 signed_capability_token_json_with_scope(issuer, id, ChioScope::default())
334 }
335
336 fn signed_capability_token_json_with_scope(
337 issuer: &Keypair,
338 id: &str,
339 scope: ChioScope,
340 ) -> String {
341 let now = chrono::Utc::now().timestamp() as u64;
342 let token = CapabilityToken::sign(
343 CapabilityTokenBody {
344 id: id.to_string(),
345 issuer: issuer.public_key(),
346 subject: issuer.public_key(),
347 scope,
348 issued_at: now.saturating_sub(60),
349 expires_at: now + 3600,
350 delegation_chain: Vec::new(),
351 },
352 &issuer,
353 )
354 .expect("token should sign");
355 serde_json::to_string(&token).expect("token should serialize")
356 }
357
358 #[test]
359 fn path_matching() {
360 assert!(path_matches_pattern("/pets/42", "/pets/{petId}"));
361 assert!(path_matches_pattern("/pets", "/pets"));
362 assert!(!path_matches_pattern("/pets/42/toys", "/pets/{petId}"));
363 assert!(!path_matches_pattern("/dogs/42", "/pets/{petId}"));
364 }
365
366 #[test]
367 fn extract_bearer_caller() {
368 let mut headers = HashMap::new();
369 headers.insert("Authorization".to_string(), "Bearer mytoken123".to_string());
370 let caller = extract_caller(&headers);
371 assert!(caller.subject.starts_with("bearer:"));
372 assert!(matches!(caller.auth_method, AuthMethod::Bearer { .. }));
373 }
374
375 #[test]
376 fn extract_anonymous_caller() {
377 let headers = HashMap::new();
378 let caller = extract_caller(&headers);
379 assert_eq!(caller.subject, "anonymous");
380 }
381
382 #[test]
383 fn evaluate_get_allowed() {
384 let keypair = Keypair::generate();
385 let routes = vec![RouteEntry {
386 pattern: "/pets".to_string(),
387 method: HttpMethod::Get,
388 operation_id: Some("listPets".to_string()),
389 policy: PolicyDecision::SessionAllow,
390 }];
391 let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
392
393 let result = evaluator
394 .evaluate(
395 HttpMethod::Get,
396 "/pets",
397 &HashMap::new(),
398 &HashMap::new(),
399 None,
400 0,
401 )
402 .unwrap();
403 assert!(result.verdict.is_allowed());
404 assert!(result.receipt.verify_signature().unwrap());
405 assert_eq!(
406 http_status_scope(result.receipt.metadata.as_ref()),
407 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
408 );
409 }
410
411 #[test]
412 fn evaluate_chio_request_denies_capability_for_different_tool_identity() {
413 let keypair = Keypair::generate();
414 let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
415 let capability = signed_capability_token_json_with_scope(
416 &keypair,
417 "cap-tool-scope",
418 ChioScope {
419 grants: vec![ToolGrant {
420 server_id: "math".to_string(),
421 tool_name: "double".to_string(),
422 operations: vec![Operation::Invoke],
423 constraints: Vec::new(),
424 max_invocations: None,
425 max_cost_per_invocation: None,
426 max_total_cost: None,
427 dpop_required: None,
428 }],
429 ..ChioScope::default()
430 },
431 );
432
433 let mut request = ChioHttpRequest::new(
434 "req-sidecar-tool-mismatch".to_string(),
435 HttpMethod::Post,
436 "/chio/tools/math/increment".to_string(),
437 "/chio/tools/math/increment".to_string(),
438 CallerIdentity::anonymous(),
439 );
440 request.tool_server = Some("math".to_string());
441 request.tool_name = Some("increment".to_string());
442 request.arguments = Some(serde_json::json!({ "value": 1 }));
443 request.body_hash = Some("tool-body".to_string());
444 request.body_length = 1;
445
446 let result = evaluator
447 .evaluate_chio_request(request, Some(&capability))
448 .unwrap();
449
450 assert!(result.verdict.is_denied());
451 assert_eq!(
452 result.receipt.evidence[0].details.as_deref(),
453 Some("capability does not authorize tool increment on server math")
454 );
455 }
456
457 #[test]
458 fn evaluate_chio_request_allows_model_constrained_capability_when_metadata_matches() {
459 let keypair = Keypair::generate();
460 let evaluator = RequestEvaluator::new(vec![], keypair.clone(), "test-policy".to_string());
461 let capability = signed_capability_token_json_with_scope(
462 &keypair,
463 "cap-model-scope",
464 ChioScope {
465 grants: vec![ToolGrant {
466 server_id: "math".to_string(),
467 tool_name: "double".to_string(),
468 operations: vec![Operation::Invoke],
469 constraints: vec![Constraint::ModelConstraint {
470 allowed_model_ids: vec!["gpt-5".to_string()],
471 min_safety_tier: Some(ModelSafetyTier::Standard),
472 }],
473 max_invocations: None,
474 max_cost_per_invocation: None,
475 max_total_cost: None,
476 dpop_required: None,
477 }],
478 ..ChioScope::default()
479 },
480 );
481
482 let mut request = ChioHttpRequest::new(
483 "req-model-scope".to_string(),
484 HttpMethod::Post,
485 "/chio/tools/math/double".to_string(),
486 "/chio/tools/math/double".to_string(),
487 CallerIdentity::anonymous(),
488 );
489 request.tool_server = Some("math".to_string());
490 request.tool_name = Some("double".to_string());
491 request.arguments = Some(serde_json::json!({ "value": 2 }));
492 request.model_metadata = Some(ModelMetadata {
493 model_id: "gpt-5".to_string(),
494 safety_tier: Some(ModelSafetyTier::Standard),
495 provider: Some("openai".to_string()),
496 provenance_class: ProvenanceEvidenceClass::Asserted,
497 });
498 request.body_hash = Some("tool-body".to_string());
499 request.body_length = 1;
500
501 let result = evaluator
502 .evaluate_chio_request(request, Some(&capability))
503 .unwrap();
504
505 assert!(result.verdict.is_allowed());
506 assert_eq!(
507 result.receipt.capability_id.as_deref(),
508 Some("cap-model-scope")
509 );
510 }
511
512 #[test]
513 fn evaluate_chio_request_allows_capability_from_configured_external_issuer() {
514 let signer = Keypair::generate();
515 let external_issuer = Keypair::generate();
516 let evaluator = RequestEvaluator::new_with_trusted_capability_issuers(
517 vec![],
518 signer,
519 "test-policy".to_string(),
520 vec![external_issuer.public_key()],
521 );
522 let capability = signed_capability_token_json(&external_issuer, "cap-external");
523
524 let mut request = ChioHttpRequest::new(
525 "req-external-issuer".to_string(),
526 HttpMethod::Post,
527 "/pets".to_string(),
528 "/pets".to_string(),
529 CallerIdentity::anonymous(),
530 );
531 request.body_hash = Some("body".to_string());
532 request.body_length = 1;
533
534 let result = evaluator
535 .evaluate_chio_request(request, Some(&capability))
536 .unwrap();
537
538 assert!(result.verdict.is_allowed());
539 assert_eq!(
540 result.receipt.capability_id.as_deref(),
541 Some("cap-external")
542 );
543 }
544
545 #[test]
546 fn evaluate_post_denied_without_capability() {
547 let keypair = Keypair::generate();
548 let routes = vec![RouteEntry {
549 pattern: "/pets".to_string(),
550 method: HttpMethod::Post,
551 operation_id: Some("createPet".to_string()),
552 policy: PolicyDecision::DenyByDefault,
553 }];
554 let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
555
556 let result = evaluator
557 .evaluate(
558 HttpMethod::Post,
559 "/pets",
560 &HashMap::new(),
561 &HashMap::new(),
562 None,
563 0,
564 )
565 .unwrap();
566 assert!(result.verdict.is_denied());
567 assert_eq!(result.receipt.response_status, 403);
568 assert!(result.receipt.verify_signature().unwrap());
569 assert_eq!(
570 http_status_scope(result.receipt.metadata.as_ref()),
571 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
572 );
573 }
574
575 #[test]
576 fn evaluate_post_allowed_with_capability() {
577 let keypair = Keypair::generate();
578 let routes = vec![RouteEntry {
579 pattern: "/pets".to_string(),
580 method: HttpMethod::Post,
581 operation_id: Some("createPet".to_string()),
582 policy: PolicyDecision::DenyByDefault,
583 }];
584 let evaluator = RequestEvaluator::new(routes, keypair.clone(), "test-policy".to_string());
585
586 let mut headers = HashMap::new();
587 headers.insert(
588 "X-Chio-Capability".to_string(),
589 signed_capability_token_json(&keypair, "cap-123"),
590 );
591
592 let result = evaluator
593 .evaluate(
594 HttpMethod::Post,
595 "/pets",
596 &HashMap::new(),
597 &headers,
598 None,
599 0,
600 )
601 .unwrap();
602 assert!(result.verdict.is_allowed());
603 assert_eq!(result.receipt.capability_id.as_deref(), Some("cap-123"));
604 assert_eq!(
605 http_status_scope(result.receipt.metadata.as_ref()),
606 Some(CHIO_HTTP_STATUS_SCOPE_DECISION)
607 );
608 }
609
610 #[test]
611 fn finalize_receipt_rebinds_status_and_links_decision_receipt() {
612 let keypair = Keypair::generate();
613 let routes = vec![RouteEntry {
614 pattern: "/pets".to_string(),
615 method: HttpMethod::Get,
616 operation_id: Some("listPets".to_string()),
617 policy: PolicyDecision::SessionAllow,
618 }];
619 let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
620
621 let decision = evaluator
622 .evaluate(
623 HttpMethod::Get,
624 "/pets",
625 &HashMap::new(),
626 &HashMap::new(),
627 None,
628 0,
629 )
630 .unwrap()
631 .receipt;
632 let final_receipt = evaluator.finalize_receipt(&decision, 204).unwrap();
633
634 assert_ne!(final_receipt.id, decision.id);
635 assert_eq!(final_receipt.response_status, 204);
636 assert_eq!(
637 http_status_scope(final_receipt.metadata.as_ref()),
638 Some(CHIO_HTTP_STATUS_SCOPE_FINAL)
639 );
640 assert_eq!(
641 final_receipt
642 .metadata
643 .as_ref()
644 .and_then(|meta| meta.get(CHIO_DECISION_RECEIPT_ID_KEY))
645 .and_then(|value| value.as_str()),
646 Some(decision.id.as_str())
647 );
648 assert!(final_receipt.verify_signature().unwrap());
649 }
650
651 #[test]
652 fn path_matching_trailing_slash_mismatch() {
653 assert!(!path_matches_pattern("/pets/", "/pets"));
655 assert!(!path_matches_pattern("/pets", "/pets/"));
656 }
657
658 #[test]
659 fn path_matching_double_slashes() {
660 assert!(!path_matches_pattern("//pets", "/pets"));
662 }
663
664 #[test]
665 fn path_matching_case_sensitivity() {
666 assert!(!path_matches_pattern("/Pets", "/pets"));
668 assert!(path_matches_pattern("/Pets", "/Pets"));
669 }
670
671 #[test]
672 fn path_matching_multiple_params() {
673 assert!(path_matches_pattern(
674 "/orgs/123/members/456",
675 "/orgs/{orgId}/members/{memberId}"
676 ));
677 assert!(!path_matches_pattern(
678 "/orgs/123/members",
679 "/orgs/{orgId}/members/{memberId}"
680 ));
681 }
682
683 #[test]
684 fn path_matching_root() {
685 assert!(path_matches_pattern("/", "/"));
686 assert!(!path_matches_pattern("/pets", "/"));
687 }
688
689 #[test]
690 fn extract_api_key_caller() {
691 let mut headers = HashMap::new();
692 headers.insert("X-API-Key".to_string(), "my-api-key-value".to_string());
693 let caller = extract_caller(&headers);
694 assert!(caller.subject.starts_with("apikey:"));
695 assert!(matches!(caller.auth_method, AuthMethod::ApiKey { .. }));
696 }
697
698 #[test]
699 fn evaluate_with_body_hash() {
700 let keypair = Keypair::generate();
701 let routes = vec![RouteEntry {
702 pattern: "/data".to_string(),
703 method: HttpMethod::Get,
704 operation_id: Some("getData".to_string()),
705 policy: PolicyDecision::SessionAllow,
706 }];
707 let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
708
709 let result = evaluator
710 .evaluate(
711 HttpMethod::Get,
712 "/data",
713 &HashMap::new(),
714 &HashMap::new(),
715 Some("bodyhash123".to_string()),
716 1024,
717 )
718 .unwrap();
719 assert!(result.verdict.is_allowed());
720 assert!(result.receipt.verify_signature().unwrap());
721 }
722
723 #[test]
724 fn fallback_policy_for_unmatched_route() {
725 let keypair = Keypair::generate();
726 let evaluator = RequestEvaluator::new(vec![], keypair, "test-policy".to_string());
727
728 let result = evaluator
730 .evaluate(
731 HttpMethod::Get,
732 "/unknown",
733 &HashMap::new(),
734 &HashMap::new(),
735 None,
736 0,
737 )
738 .unwrap();
739 assert!(result.verdict.is_allowed());
740
741 let result = evaluator
743 .evaluate(
744 HttpMethod::Delete,
745 "/unknown",
746 &HashMap::new(),
747 &HashMap::new(),
748 None,
749 0,
750 )
751 .unwrap();
752 assert!(result.verdict.is_denied());
753 }
754
755 #[test]
756 fn evaluate_invalid_capability_denied_fail_closed() {
757 let keypair = Keypair::generate();
758 let routes = vec![RouteEntry {
759 pattern: "/pets".to_string(),
760 method: HttpMethod::Post,
761 operation_id: Some("createPet".to_string()),
762 policy: PolicyDecision::DenyByDefault,
763 }];
764 let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
765 let mut headers = HashMap::new();
766 headers.insert("X-Chio-Capability".to_string(), "not-json".to_string());
767
768 let result = evaluator
769 .evaluate(
770 HttpMethod::Post,
771 "/pets",
772 &HashMap::new(),
773 &headers,
774 None,
775 0,
776 )
777 .unwrap();
778
779 assert!(result.verdict.is_denied());
780 assert!(result.receipt.capability_id.as_deref().is_none());
781 }
782
783 #[test]
784 fn evaluate_query_parameters_affect_content_hash() {
785 let keypair = Keypair::generate();
786 let routes = vec![RouteEntry {
787 pattern: "/search".to_string(),
788 method: HttpMethod::Get,
789 operation_id: Some("search".to_string()),
790 policy: PolicyDecision::SessionAllow,
791 }];
792 let evaluator = RequestEvaluator::new(routes, keypair, "test-policy".to_string());
793 let mut query_a = HashMap::new();
794 query_a.insert("q".to_string(), "cats".to_string());
795 let mut query_b = HashMap::new();
796 query_b.insert("q".to_string(), "dogs".to_string());
797
798 let result_a = evaluator
799 .evaluate(
800 HttpMethod::Get,
801 "/search",
802 &query_a,
803 &HashMap::new(),
804 None,
805 0,
806 )
807 .unwrap();
808 let result_b = evaluator
809 .evaluate(
810 HttpMethod::Get,
811 "/search",
812 &query_b,
813 &HashMap::new(),
814 None,
815 0,
816 )
817 .unwrap();
818
819 assert_ne!(result_a.receipt.content_hash, result_b.receipt.content_hash);
820 }
821}