1use crate::types::{
8 CandidateDecision, ConflictResolutionStrategy, PolicyDecision, PolicyScope, ResolutionResult,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::{Mutex, RwLock};
13use std::time::Instant;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PolicyRule {
18 pub name: String,
19 #[serde(rename = "type")]
20 pub rule_type: String,
21 #[serde(default)]
22 pub allowed_actions: Vec<String>,
23 #[serde(default)]
24 pub denied_actions: Vec<String>,
25 #[serde(default)]
26 pub actions: Vec<String>,
27 #[serde(default)]
28 pub min_approvals: u32,
29 #[serde(default)]
30 pub max_calls: u32,
31 #[serde(default)]
32 pub window: String,
33 #[serde(default)]
34 pub conditions: HashMap<String, serde_yaml::Value>,
35 #[serde(default)]
37 pub priority: u32,
38 #[serde(default)]
40 pub scope: PolicyScope,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PolicyProfile {
46 pub version: String,
47 pub agent: String,
48 pub policies: Vec<PolicyRule>,
49}
50
51pub struct PolicyEngine {
56 profile: RwLock<Option<PolicyProfile>>,
57 rate_counters: Mutex<HashMap<String, (u64, Instant)>>,
58 conflict_strategy: ConflictResolutionStrategy,
59}
60
61impl PolicyEngine {
62 pub fn new() -> Self {
65 Self {
66 profile: RwLock::new(None),
67 rate_counters: Mutex::new(HashMap::new()),
68 conflict_strategy: ConflictResolutionStrategy::PriorityFirstMatch,
69 }
70 }
71
72 pub fn with_strategy(strategy: ConflictResolutionStrategy) -> Self {
74 Self {
75 profile: RwLock::new(None),
76 rate_counters: Mutex::new(HashMap::new()),
77 conflict_strategy: strategy,
78 }
79 }
80
81 pub fn strategy(&self) -> ConflictResolutionStrategy {
83 self.conflict_strategy
84 }
85
86 pub fn resolve_conflicts(&self, candidates: &[CandidateDecision]) -> ResolutionResult {
93 if candidates.is_empty() {
94 return ResolutionResult {
95 winning_decision: PolicyDecision::Allow,
96 strategy_used: self.conflict_strategy,
97 conflict_detected: false,
98 candidates_evaluated: 0,
99 };
100 }
101
102 if candidates.len() == 1 {
103 return ResolutionResult {
104 winning_decision: candidates[0].decision.clone(),
105 strategy_used: self.conflict_strategy,
106 conflict_detected: false,
107 candidates_evaluated: 1,
108 };
109 }
110
111 let has_allow = candidates.iter().any(|c| c.decision.is_allowed());
112 let has_deny = candidates
113 .iter()
114 .any(|c| matches!(c.decision, PolicyDecision::Deny(_)));
115 let conflict_detected = has_allow && has_deny;
116
117 let mut sorted = candidates.to_vec();
118
119 let winning = match self.conflict_strategy {
120 ConflictResolutionStrategy::DenyOverrides => {
121 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
122 match sorted
123 .iter()
124 .find(|c| matches!(c.decision, PolicyDecision::Deny(_)))
125 {
126 Some(d) => d.clone(),
127 None => sorted[0].clone(),
128 }
129 }
130 ConflictResolutionStrategy::AllowOverrides => {
131 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
132 match sorted.iter().find(|c| c.decision.is_allowed()) {
133 Some(a) => a.clone(),
134 None => sorted[0].clone(),
135 }
136 }
137 ConflictResolutionStrategy::PriorityFirstMatch => {
138 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
139 sorted[0].clone()
140 }
141 ConflictResolutionStrategy::MostSpecificWins => {
142 sorted.sort_by(|a, b| {
143 b.scope
144 .specificity()
145 .cmp(&a.scope.specificity())
146 .then(b.priority.cmp(&a.priority))
147 });
148 sorted[0].clone()
149 }
150 };
151
152 ResolutionResult {
153 winning_decision: winning.decision,
154 strategy_used: self.conflict_strategy,
155 conflict_detected,
156 candidates_evaluated: candidates.len(),
157 }
158 }
159
160 pub fn is_loaded(&self) -> bool {
162 self.profile
163 .read()
164 .expect("policy profile lock poisoned")
165 .is_some()
166 }
167
168 pub fn load_from_yaml(&self, yaml: &str) -> Result<(), PolicyError> {
170 let profile: PolicyProfile =
171 serde_yaml::from_str(yaml).map_err(PolicyError::InvalidYaml)?;
172 *self.profile.write().expect("policy profile lock poisoned") = Some(profile);
173 Ok(())
174 }
175
176 pub fn load_from_file(&self, path: &str) -> Result<(), PolicyError> {
178 let yaml = std::fs::read_to_string(path).map_err(PolicyError::Io)?;
179 self.load_from_yaml(&yaml)
180 }
181
182 pub fn evaluate(
187 &self,
188 action: &str,
189 context: Option<&HashMap<String, serde_yaml::Value>>,
190 ) -> PolicyDecision {
191 let guard = self.profile.read().expect("policy profile lock poisoned");
192 let profile = match guard.as_ref() {
193 Some(p) => p,
194 None => return PolicyDecision::Allow,
195 };
196
197 for rule in &profile.policies {
198 if !conditions_match(&rule.conditions, context) {
199 continue;
200 }
201
202 match rule.rule_type.as_str() {
203 "capability" => {
204 for denied in &rule.denied_actions {
206 if action_matches(action, denied) {
207 return PolicyDecision::Deny(format!(
208 "Blocked by policy '{}': action '{}' is denied",
209 rule.name, action
210 ));
211 }
212 }
213 if !rule.allowed_actions.is_empty() {
218 if rule
219 .allowed_actions
220 .iter()
221 .any(|a| action_matches(action, a))
222 {
223 return PolicyDecision::Allow;
224 }
225 let in_scope = rule.denied_actions.iter().any(|d| {
228 let ns = d.trim_end_matches('*').trim_end_matches(':');
229 action.starts_with(ns)
230 }) || rule.allowed_actions.iter().any(|a| {
231 let ns = a.split('.').next().unwrap_or("");
232 action.starts_with(ns)
233 });
234 if in_scope {
235 return PolicyDecision::Deny(format!(
236 "Blocked by policy '{}': action '{}' not in allowlist",
237 rule.name, action
238 ));
239 }
240 }
241 }
242 "approval" => {
243 for pattern in &rule.actions {
244 if action_matches(action, pattern) {
245 return PolicyDecision::RequiresApproval(format!(
246 "Policy '{}' requires {} approval(s) for '{}'",
247 rule.name, rule.min_approvals, action
248 ));
249 }
250 }
251 }
252 "rate_limit" => {
253 if rule.max_calls > 0 {
254 for pattern in &rule.actions {
255 if action_matches(action, pattern) {
256 return self.check_rate_limit(
257 &rule.name,
258 rule.max_calls,
259 &rule.window,
260 );
261 }
262 }
263 }
264 }
265 _ => {}
266 }
267 }
268
269 PolicyDecision::Allow
270 }
271
272 fn check_rate_limit(&self, name: &str, max_calls: u32, window: &str) -> PolicyDecision {
273 let window_secs = parse_duration(window);
274 let mut counters = self
275 .rate_counters
276 .lock()
277 .expect("rate counter lock poisoned");
278 let entry = counters
279 .entry(name.to_string())
280 .or_insert((0, Instant::now()));
281
282 if entry.1.elapsed().as_secs() > window_secs {
283 *entry = (1, Instant::now());
284 PolicyDecision::Allow
285 } else if entry.0 >= max_calls as u64 {
286 let retry_after = window_secs.saturating_sub(entry.1.elapsed().as_secs());
287 PolicyDecision::RateLimited {
288 retry_after_secs: retry_after,
289 }
290 } else {
291 entry.0 += 1;
292 PolicyDecision::Allow
293 }
294 }
295}
296
297impl Default for PolicyEngine {
298 fn default() -> Self {
299 Self::new()
300 }
301}
302
303#[derive(Debug, thiserror::Error)]
305pub enum PolicyError {
306 #[error("invalid YAML: {0}")]
307 InvalidYaml(serde_yaml::Error),
308 #[error("I/O error: {0}")]
309 Io(std::io::Error),
310}
311
312fn action_matches(action: &str, pattern: &str) -> bool {
314 if pattern == "*" {
315 return true;
316 }
317 if let Some(prefix) = pattern.strip_suffix(".*") {
318 return action.starts_with(&format!("{}.", prefix));
319 }
320 if let Some(prefix) = pattern.strip_suffix('*') {
321 return action.starts_with(prefix);
322 }
323 action == pattern
324}
325
326fn conditions_match(
327 conditions: &HashMap<String, serde_yaml::Value>,
328 context: Option<&HashMap<String, serde_yaml::Value>>,
329) -> bool {
330 if conditions.is_empty() {
331 return true;
332 }
333 let ctx = match context {
334 Some(c) => c,
335 None => return false,
336 };
337 for (key, expected) in conditions {
338 match ctx.get(key) {
339 Some(actual) if actual == expected => {}
340 _ => return false,
341 }
342 }
343 true
344}
345
346fn parse_duration(s: &str) -> u64 {
347 if let Some(val) = s.strip_suffix('m') {
348 val.parse::<u64>().unwrap_or(1) * 60
349 } else if let Some(val) = s.strip_suffix('s') {
350 val.parse::<u64>().unwrap_or(60)
351 } else if let Some(val) = s.strip_suffix('h') {
352 val.parse::<u64>().unwrap_or(1) * 3600
353 } else {
354 s.parse::<u64>().unwrap_or(60)
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 const POLICY_YAML: &str = r#"
363version: "1.0"
364agent: test-agent
365policies:
366 - name: capability-gate
367 type: capability
368 allowed_actions:
369 - "data.read"
370 - "data.write"
371 denied_actions:
372 - "shell:*"
373 - name: deploy-approval
374 type: approval
375 actions:
376 - "deploy.*"
377 min_approvals: 2
378 - name: api-rate-limit
379 type: rate_limit
380 actions:
381 - "api.call"
382 max_calls: 3
383 window: "60s"
384"#;
385
386 #[test]
387 fn test_allow_listed_action() {
388 let engine = PolicyEngine::new();
389 engine.load_from_yaml(POLICY_YAML).unwrap();
390 assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
391 }
392
393 #[test]
394 fn test_deny_shell() {
395 let engine = PolicyEngine::new();
396 engine.load_from_yaml(POLICY_YAML).unwrap();
397 let decision = engine.evaluate("shell:rm", None);
398 assert!(matches!(decision, PolicyDecision::Deny(_)));
399 }
400
401 #[test]
402 fn test_not_in_allowlist_in_scope() {
403 let engine = PolicyEngine::new();
404 engine.load_from_yaml(POLICY_YAML).unwrap();
405 let decision = engine.evaluate("data.delete", None);
407 assert!(matches!(decision, PolicyDecision::Deny(_)));
408 }
409
410 #[test]
411 fn test_out_of_scope_falls_through() {
412 let engine = PolicyEngine::new();
413 engine.load_from_yaml(POLICY_YAML).unwrap();
414 let decision = engine.evaluate("admin.delete", None);
416 assert_eq!(decision, PolicyDecision::Allow);
417 }
418
419 #[test]
420 fn test_approval_required() {
421 let engine = PolicyEngine::new();
422 engine.load_from_yaml(POLICY_YAML).unwrap();
423 let decision = engine.evaluate("deploy.production", None);
424 assert!(matches!(decision, PolicyDecision::RequiresApproval(_)));
425 }
426
427 #[test]
428 fn test_rate_limiting() {
429 let engine = PolicyEngine::new();
430 engine.load_from_yaml(POLICY_YAML).unwrap();
431 for _ in 0..3 {
433 assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
434 }
435 let decision = engine.evaluate("api.call", None);
437 assert!(matches!(decision, PolicyDecision::RateLimited { .. }));
438 }
439
440 #[test]
441 fn test_no_profile_allows_all() {
442 let engine = PolicyEngine::new();
443 assert_eq!(engine.evaluate("anything", None), PolicyDecision::Allow);
444 }
445
446 #[test]
447 fn test_action_matches() {
448 assert!(action_matches("shell:ls", "shell:*"));
449 assert!(action_matches("data.read", "data.*"));
450 assert!(action_matches("deploy.staging", "deploy.*"));
451 assert!(!action_matches("data.read", "shell:*"));
452 assert!(action_matches("anything", "*"));
453 assert!(action_matches("data.read", "data.read"));
454 assert!(!action_matches("data.write", "data.read"));
455 }
456
457 #[test]
458 fn test_with_strategy_constructor() {
459 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
460 assert_eq!(engine.strategy(), ConflictResolutionStrategy::DenyOverrides);
461 }
462
463 #[test]
464 fn test_default_strategy_is_priority_first_match() {
465 let engine = PolicyEngine::new();
466 assert_eq!(
467 engine.strategy(),
468 ConflictResolutionStrategy::PriorityFirstMatch
469 );
470 }
471
472 #[test]
473 fn test_resolve_conflicts_empty() {
474 let engine = PolicyEngine::new();
475 let result = engine.resolve_conflicts(&[]);
476 assert_eq!(result.winning_decision, PolicyDecision::Allow);
477 assert!(!result.conflict_detected);
478 assert_eq!(result.candidates_evaluated, 0);
479 }
480
481 #[test]
482 fn test_resolve_conflicts_single() {
483 let engine = PolicyEngine::new();
484 let candidates = vec![CandidateDecision {
485 decision: PolicyDecision::Deny("blocked".into()),
486 priority: 1,
487 scope: PolicyScope::Global,
488 rule_name: "rule-1".into(),
489 }];
490 let result = engine.resolve_conflicts(&candidates);
491 assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
492 assert!(!result.conflict_detected);
493 assert_eq!(result.candidates_evaluated, 1);
494 }
495
496 #[test]
497 fn test_resolve_conflicts_deny_overrides() {
498 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
499 let candidates = vec![
500 CandidateDecision {
501 decision: PolicyDecision::Allow,
502 priority: 10,
503 scope: PolicyScope::Global,
504 rule_name: "allow-rule".into(),
505 },
506 CandidateDecision {
507 decision: PolicyDecision::Deny("no".into()),
508 priority: 5,
509 scope: PolicyScope::Global,
510 rule_name: "deny-rule".into(),
511 },
512 ];
513 let result = engine.resolve_conflicts(&candidates);
514 assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
515 assert!(result.conflict_detected);
516 }
517
518 #[test]
519 fn test_resolve_conflicts_allow_overrides() {
520 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::AllowOverrides);
521 let candidates = vec![
522 CandidateDecision {
523 decision: PolicyDecision::Deny("blocked".into()),
524 priority: 10,
525 scope: PolicyScope::Global,
526 rule_name: "deny-rule".into(),
527 },
528 CandidateDecision {
529 decision: PolicyDecision::Allow,
530 priority: 5,
531 scope: PolicyScope::Global,
532 rule_name: "allow-rule".into(),
533 },
534 ];
535 let result = engine.resolve_conflicts(&candidates);
536 assert_eq!(result.winning_decision, PolicyDecision::Allow);
537 assert!(result.conflict_detected);
538 }
539
540 #[test]
541 fn test_resolve_conflicts_priority_first_match() {
542 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::PriorityFirstMatch);
543 let candidates = vec![
544 CandidateDecision {
545 decision: PolicyDecision::Deny("low".into()),
546 priority: 1,
547 scope: PolicyScope::Global,
548 rule_name: "low-rule".into(),
549 },
550 CandidateDecision {
551 decision: PolicyDecision::Allow,
552 priority: 10,
553 scope: PolicyScope::Global,
554 rule_name: "high-rule".into(),
555 },
556 ];
557 let result = engine.resolve_conflicts(&candidates);
558 assert_eq!(result.winning_decision, PolicyDecision::Allow);
559 assert!(result.conflict_detected);
560 }
561
562 #[test]
563 fn test_resolve_conflicts_most_specific_wins() {
564 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
565 let candidates = vec![
566 CandidateDecision {
567 decision: PolicyDecision::Allow,
568 priority: 100,
569 scope: PolicyScope::Global,
570 rule_name: "global-allow".into(),
571 },
572 CandidateDecision {
573 decision: PolicyDecision::Deny("agent-deny".into()),
574 priority: 1,
575 scope: PolicyScope::Agent,
576 rule_name: "agent-deny".into(),
577 },
578 ];
579 let result = engine.resolve_conflicts(&candidates);
580 assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
581 assert!(result.conflict_detected);
582 }
583
584 #[test]
585 fn test_resolve_conflicts_most_specific_tiebreaker() {
586 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
587 let candidates = vec![
588 CandidateDecision {
589 decision: PolicyDecision::Deny("low".into()),
590 priority: 1,
591 scope: PolicyScope::Tenant,
592 rule_name: "tenant-low".into(),
593 },
594 CandidateDecision {
595 decision: PolicyDecision::Allow,
596 priority: 10,
597 scope: PolicyScope::Tenant,
598 rule_name: "tenant-high".into(),
599 },
600 ];
601 let result = engine.resolve_conflicts(&candidates);
602 assert_eq!(result.winning_decision, PolicyDecision::Allow);
603 }
604
605 #[test]
606 fn test_policy_rule_priority_and_scope_defaults() {
607 let yaml = r#"
608version: "1.0"
609agent: test
610policies:
611 - name: simple-rule
612 type: capability
613 allowed_actions:
614 - "data.read"
615"#;
616 let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
617 let rule = &profile.policies[0];
618 assert_eq!(rule.priority, 0);
619 assert_eq!(rule.scope, PolicyScope::Global);
620 }
621
622 #[test]
623 fn test_policy_rule_with_priority_and_scope() {
624 let yaml = r#"
625version: "1.0"
626agent: test
627policies:
628 - name: agent-rule
629 type: capability
630 allowed_actions:
631 - "data.read"
632 priority: 10
633 scope: agent
634"#;
635 let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
636 let rule = &profile.policies[0];
637 assert_eq!(rule.priority, 10);
638 assert_eq!(rule.scope, PolicyScope::Agent);
639 }
640
641 #[test]
642 fn test_no_conflict_when_all_same_decision() {
643 let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
644 let candidates = vec![
645 CandidateDecision {
646 decision: PolicyDecision::Allow,
647 priority: 5,
648 scope: PolicyScope::Global,
649 rule_name: "r1".into(),
650 },
651 CandidateDecision {
652 decision: PolicyDecision::Allow,
653 priority: 10,
654 scope: PolicyScope::Tenant,
655 rule_name: "r2".into(),
656 },
657 ];
658 let result = engine.resolve_conflicts(&candidates);
659 assert!(!result.conflict_detected);
660 assert_eq!(result.winning_decision, PolicyDecision::Allow);
661 }
662
663 #[test]
664 fn test_multiple_capability_rules_first_match_wins() {
665 let yaml = r#"
666version: "1.0"
667agent: test
668policies:
669 - name: deny-first
670 type: capability
671 denied_actions:
672 - "data.read"
673 - name: allow-second
674 type: capability
675 allowed_actions:
676 - "data.read"
677"#;
678 let engine = PolicyEngine::new();
679 engine.load_from_yaml(yaml).unwrap();
680 let decision = engine.evaluate("data.read", None);
682 assert!(matches!(decision, PolicyDecision::Deny(_)));
683 }
684
685 #[test]
686 fn test_policy_with_only_deny_rules() {
687 let yaml = r#"
688version: "1.0"
689agent: test
690policies:
691 - name: deny-only
692 type: capability
693 denied_actions:
694 - "shell:*"
695 - "admin.*"
696"#;
697 let engine = PolicyEngine::new();
698 engine.load_from_yaml(yaml).unwrap();
699 assert!(matches!(
700 engine.evaluate("shell:ls", None),
701 PolicyDecision::Deny(_)
702 ));
703 assert!(matches!(
704 engine.evaluate("admin.delete", None),
705 PolicyDecision::Deny(_)
706 ));
707 assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
709 }
710
711 #[test]
712 fn test_policy_with_only_allow_rules() {
713 let yaml = r#"
714version: "1.0"
715agent: test
716policies:
717 - name: allow-only
718 type: capability
719 allowed_actions:
720 - "data.read"
721 - "data.write"
722"#;
723 let engine = PolicyEngine::new();
724 engine.load_from_yaml(yaml).unwrap();
725 assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
726 assert_eq!(engine.evaluate("data.write", None), PolicyDecision::Allow);
727 assert!(matches!(
729 engine.evaluate("data.delete", None),
730 PolicyDecision::Deny(_)
731 ));
732 }
733
734 #[test]
735 fn test_conditions_matching() {
736 let yaml = r#"
737version: "1.0"
738agent: test
739policies:
740 - name: env-gate
741 type: capability
742 denied_actions:
743 - "deploy.*"
744 conditions:
745 environment: "production"
746"#;
747 let engine = PolicyEngine::new();
748 engine.load_from_yaml(yaml).unwrap();
749 let mut context = HashMap::new();
750 context.insert(
751 "environment".to_string(),
752 serde_yaml::Value::String("production".to_string()),
753 );
754 let decision = engine.evaluate("deploy.app", Some(&context));
755 assert!(matches!(decision, PolicyDecision::Deny(_)));
756 }
757
758 #[test]
759 fn test_conditions_not_matching() {
760 let yaml = r#"
761version: "1.0"
762agent: test
763policies:
764 - name: env-gate
765 type: capability
766 denied_actions:
767 - "deploy.*"
768 conditions:
769 environment: "production"
770"#;
771 let engine = PolicyEngine::new();
772 engine.load_from_yaml(yaml).unwrap();
773 let mut context = HashMap::new();
774 context.insert(
775 "environment".to_string(),
776 serde_yaml::Value::String("staging".to_string()),
777 );
778 let decision = engine.evaluate("deploy.app", Some(&context));
780 assert_eq!(decision, PolicyDecision::Allow);
781 }
782
783 #[test]
784 fn test_conditions_no_context_skips_rule() {
785 let yaml = r#"
786version: "1.0"
787agent: test
788policies:
789 - name: env-gate
790 type: capability
791 denied_actions:
792 - "deploy.*"
793 conditions:
794 environment: "production"
795"#;
796 let engine = PolicyEngine::new();
797 engine.load_from_yaml(yaml).unwrap();
798 let decision = engine.evaluate("deploy.app", None);
800 assert_eq!(decision, PolicyDecision::Allow);
801 }
802
803 #[test]
804 fn test_loading_invalid_yaml_returns_error() {
805 let engine = PolicyEngine::new();
806 let result = engine.load_from_yaml("{{not valid yaml");
807 assert!(result.is_err());
808 assert!(matches!(result.unwrap_err(), PolicyError::InvalidYaml(_)));
809 }
810
811 #[test]
812 fn test_loading_from_temp_file() {
813 let dir = tempfile::tempdir().unwrap();
814 let path = dir.path().join("policy.yaml");
815 std::fs::write(&path, POLICY_YAML).unwrap();
816 let engine = PolicyEngine::new();
817 engine.load_from_file(path.to_str().unwrap()).unwrap();
818 assert!(engine.is_loaded());
819 assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
820 }
821
822 #[test]
823 fn test_multiple_rate_limit_rules_for_different_actions() {
824 let yaml = r#"
825version: "1.0"
826agent: test
827policies:
828 - name: api-limit
829 type: rate_limit
830 actions:
831 - "api.call"
832 max_calls: 2
833 window: "60s"
834 - name: db-limit
835 type: rate_limit
836 actions:
837 - "db.query"
838 max_calls: 1
839 window: "60s"
840"#;
841 let engine = PolicyEngine::new();
842 engine.load_from_yaml(yaml).unwrap();
843 assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
845 assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
846 assert!(matches!(
847 engine.evaluate("api.call", None),
848 PolicyDecision::RateLimited { .. }
849 ));
850 assert_eq!(engine.evaluate("db.query", None), PolicyDecision::Allow);
852 assert!(matches!(
853 engine.evaluate("db.query", None),
854 PolicyDecision::RateLimited { .. }
855 ));
856 }
857
858 #[test]
859 fn test_rate_limit_resets_after_window() {
860 let yaml = r#"
861version: "1.0"
862agent: test
863policies:
864 - name: fast-limit
865 type: rate_limit
866 actions:
867 - "api.call"
868 max_calls: 1
869 window: "0s"
870"#;
871 let engine = PolicyEngine::new();
872 engine.load_from_yaml(yaml).unwrap();
873 assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
875 assert!(matches!(
877 engine.evaluate("api.call", None),
878 PolicyDecision::RateLimited { .. }
879 ));
880 std::thread::sleep(std::time::Duration::from_millis(1100));
882 assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
884 }
885
886 #[test]
887 fn test_wildcard_matches_everything() {
888 let yaml = r#"
889version: "1.0"
890agent: test
891policies:
892 - name: deny-all
893 type: capability
894 denied_actions:
895 - "*"
896"#;
897 let engine = PolicyEngine::new();
898 engine.load_from_yaml(yaml).unwrap();
899 assert!(matches!(
900 engine.evaluate("anything", None),
901 PolicyDecision::Deny(_)
902 ));
903 assert!(matches!(
904 engine.evaluate("data.read", None),
905 PolicyDecision::Deny(_)
906 ));
907 assert!(matches!(
908 engine.evaluate("shell:ls", None),
909 PolicyDecision::Deny(_)
910 ));
911 }
912
913 #[test]
914 fn test_parse_duration_minutes() {
915 assert_eq!(parse_duration("5m"), 300);
916 }
917
918 #[test]
919 fn test_parse_duration_seconds() {
920 assert_eq!(parse_duration("30s"), 30);
921 }
922
923 #[test]
924 fn test_parse_duration_hours() {
925 assert_eq!(parse_duration("2h"), 7200);
926 }
927
928 #[test]
929 fn test_parse_duration_bare_number() {
930 assert_eq!(parse_duration("120"), 120);
931 }
932
933 #[test]
934 fn test_is_loaded_false_initially() {
935 let engine = PolicyEngine::new();
936 assert!(!engine.is_loaded());
937 }
938
939 #[test]
940 fn test_is_loaded_true_after_load() {
941 let engine = PolicyEngine::new();
942 engine.load_from_yaml(POLICY_YAML).unwrap();
943 assert!(engine.is_loaded());
944 }
945
946 #[test]
947 fn test_rules_present_but_none_match_falls_through() {
948 let yaml = r#"
949version: "1.0"
950agent: test
951policies:
952 - name: gate
953 type: capability
954 denied_actions:
955 - "shell:*"
956"#;
957 let engine = PolicyEngine::new();
958 engine.load_from_yaml(yaml).unwrap();
959 assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
961 }
962
963 #[test]
964 fn test_action_matches_empty_strings() {
965 assert!(action_matches("", ""));
966 assert!(!action_matches("", "data.read"));
967 assert!(!action_matches("data.read", ""));
968 }
969
970 #[test]
971 fn test_action_matches_exact_match() {
972 assert!(action_matches("data.read", "data.read"));
973 assert!(!action_matches("data.read", "data.write"));
974 }
975
976 #[test]
977 fn test_action_matches_partial_non_match() {
978 assert!(!action_matches("data", "data.read"));
980 assert!(!action_matches("data.rea", "data.read"));
982 }
983}