1use crate::decision::canonical::{canonical_json, write_canonical_value};
4use crate::request::AgentRequest;
5use crate::ttl::Clock;
6use cedar_policy::Entities;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct CacheKey(pub [u8; 32]);
16
17impl CacheKey {
18 pub fn for_request(req: &AgentRequest, policy_version: u64) -> Self {
38 Self::for_request_with_entities(req, &Entities::empty(), policy_version)
39 }
40
41 pub fn for_request_with_entities(
46 req: &AgentRequest,
47 entities: &Entities,
48 policy_version: u64,
49 ) -> Self {
50 let mut hasher = Sha256::new();
51 let mut buf: Vec<u8> = Vec::with_capacity(256);
56
57 let mut hash_value = |h: &mut Sha256, value: &serde_json::Value| {
71 buf.clear();
72 write_canonical_value(&mut buf, value).expect("canonical write to Vec is infallible");
73 let len = (buf.len() as u32).to_be_bytes();
74 sha2::Digest::update(h, len);
75 sha2::Digest::update(h, &buf);
76 };
77
78 let principal = serde_json::to_value(&req.principal)
79 .expect("Principal is always serializable; see principal.rs round-trip tests");
80 hash_value(&mut hasher, &principal);
81 let action = serde_json::to_value(&req.action)
82 .expect("AgentAction is always serializable; see action.rs round-trip tests");
83 hash_value(&mut hasher, &action);
84 let resource = serde_json::to_value(&req.resource)
85 .expect("Resource is always serializable; see resource.rs round-trip tests");
86 hash_value(&mut hasher, &resource);
87 let ctx_bytes = canonical_json(&req.context)
89 .expect("AgentContext is always serializable; see context.rs round-trip tests");
90 let ctx_len = (ctx_bytes.len() as u32).to_be_bytes();
91 sha2::Digest::update(&mut hasher, ctx_len);
92 sha2::Digest::update(&mut hasher, &ctx_bytes);
93 let mut entity_values: Vec<Vec<u8>> = entities
94 .iter()
95 .map(|entity| {
96 let value = entity
97 .to_json_value()
98 .expect("Cedar entities must be serializable for cache keys");
99 canonical_json(&value).expect("Cedar entity JSON must be canonicalizable")
100 })
101 .collect();
102 entity_values.sort();
103 for entity in entity_values {
104 let len = (entity.len() as u32).to_be_bytes();
105 sha2::Digest::update(&mut hasher, len);
106 sha2::Digest::update(&mut hasher, entity);
107 }
108 if let Some(t) = &req.trace {
109 buf.clear();
110 let s = t.to_string();
111 let len = (s.len() as u32).to_be_bytes();
112 sha2::Digest::update(&mut hasher, len);
113 sha2::Digest::update(&mut hasher, s.as_bytes());
114 }
115 sha2::Digest::update(&mut hasher, policy_version.to_be_bytes());
116
117 let hash: [u8; 32] = hasher.finalize().into();
118 Self(hash)
119 }
120
121 pub fn as_hex(&self) -> String {
122 hex::encode(self.0)
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CachedDecision {
134 pub effect: String,
135 pub policies: Vec<String>,
136 pub reasons: Vec<String>,
137 pub cached_at_policy_version: u64,
138}
139
140impl CachedDecision {
141 pub fn allow() -> Self {
143 Self {
144 effect: "allow".into(),
145 policies: vec![],
146 reasons: vec![],
147 cached_at_policy_version: 0,
148 }
149 }
150
151 pub fn deny() -> Self {
153 Self {
154 effect: "deny".into(),
155 policies: vec![],
156 reasons: vec![],
157 cached_at_policy_version: 0,
158 }
159 }
160}
161
162#[derive(Debug, Clone)]
164pub struct CacheConfig {
165 pub capacity: usize,
167 pub allow_ttl: Duration,
169 pub deny_ttl: Duration,
172 pub cache_denies: bool,
174}
175
176impl Default for CacheConfig {
177 fn default() -> Self {
178 Self {
179 capacity: 10_000,
180 allow_ttl: Duration::from_secs(60),
181 deny_ttl: Duration::from_secs(5),
182 cache_denies: true,
183 }
184 }
185}
186
187pub struct DecisionCache {
198 config: CacheConfig,
199 clock: Arc<dyn Clock>,
200 policy_version: AtomicU64,
201 inner: parking_lot::RwLock<lru::LruCache<CacheKey, (CachedDecision, std::time::Instant)>>,
202 hits: AtomicU64,
203 misses: AtomicU64,
204 evictions: AtomicU64,
205}
206
207impl DecisionCache {
208 pub fn try_new(
213 config: CacheConfig,
214 clock: Arc<dyn Clock>,
215 ) -> std::result::Result<Self, String> {
216 let capacity = std::num::NonZeroUsize::new(config.capacity)
217 .ok_or_else(|| "cache capacity must be greater than zero".to_owned())?;
218 if std::time::Instant::now()
219 .checked_add(config.allow_ttl)
220 .is_none()
221 {
222 return Err("allow cache TTL exceeds the monotonic clock range".to_owned());
223 }
224 if std::time::Instant::now()
225 .checked_add(config.deny_ttl)
226 .is_none()
227 {
228 return Err("deny cache TTL exceeds the monotonic clock range".to_owned());
229 }
230 Ok(Self {
231 config,
232 clock,
233 policy_version: AtomicU64::new(0),
234 inner: parking_lot::RwLock::new(lru::LruCache::new(capacity)),
235 hits: AtomicU64::new(0),
236 misses: AtomicU64::new(0),
237 evictions: AtomicU64::new(0),
238 })
239 }
240
241 pub fn new(config: CacheConfig, clock: Arc<dyn Clock>) -> Self {
248 Self::try_new(config, clock).expect("DecisionCache configuration must be valid")
249 }
250
251 pub fn disabled(clock: Arc<dyn Clock>) -> Self {
253 let c = CacheConfig {
254 capacity: 1,
255 ..CacheConfig::default()
256 };
257 let cap = std::num::NonZeroUsize::new(c.capacity).unwrap();
258 Self {
259 config: c,
260 clock,
261 policy_version: AtomicU64::new(0),
262 inner: parking_lot::RwLock::new(lru::LruCache::new(cap)),
263 hits: AtomicU64::new(0),
264 misses: AtomicU64::new(0),
265 evictions: AtomicU64::new(0),
266 }
267 }
268
269 pub fn policy_version(&self) -> u64 {
270 self.policy_version.load(Ordering::Relaxed)
271 }
272
273 pub fn invalidate_all(&self) {
277 self.policy_version.fetch_add(1, Ordering::Relaxed);
278 }
279
280 pub fn set_policy_version(&self, v: u64) {
282 self.policy_version.store(v, Ordering::Relaxed);
283 }
284
285 pub fn get(&self, key: &CacheKey) -> Option<CachedDecision> {
286 let now = self.clock.now();
287 let policy_version = self.policy_version();
288 let guard = self.inner.read();
295 if let Some((cached, expires_at)) = guard.peek(key).cloned() {
296 if cached.cached_at_policy_version != policy_version {
297 self.misses.fetch_add(1, Ordering::Relaxed);
298 return None;
299 }
300 if now >= expires_at {
301 self.misses.fetch_add(1, Ordering::Relaxed);
302 return None;
303 }
304 self.hits.fetch_add(1, Ordering::Relaxed);
312 return Some(cached);
313 }
314 self.misses.fetch_add(1, Ordering::Relaxed);
315 None
316 }
317
318 pub fn sweep_stale(&self) -> usize {
326 let now = self.clock.now();
327 let policy_version = self.policy_version();
328 let mut guard = self.inner.write();
329 let before = guard.len();
330 let stale: Vec<CacheKey> = guard
331 .iter()
332 .filter_map(|(k, (cached, expires_at))| {
333 if cached.cached_at_policy_version != policy_version || now >= *expires_at {
334 Some(k.clone())
335 } else {
336 None
337 }
338 })
339 .collect();
340 for k in &stale {
341 guard.pop(k);
342 }
343 before.saturating_sub(guard.len())
344 }
345
346 pub fn put(&self, key: CacheKey, decision: CachedDecision) {
347 let now = self.clock.now();
348 let ttl = match decision.effect.as_str() {
349 "allow" => self.config.allow_ttl,
350 "deny" if self.config.cache_denies => self.config.deny_ttl,
351 _ => return,
352 };
353 let mut guard = self.inner.write();
355 let prev = guard.push(key.clone(), (decision, now + ttl));
356 if let Some((evicted_key, _)) = prev {
363 if evicted_key != key {
364 self.evictions.fetch_add(1, Ordering::Relaxed);
365 }
366 }
367 }
368
369 pub fn stats(&self) -> CacheStats {
370 CacheStats {
371 hits: self.hits.load(Ordering::Relaxed),
372 misses: self.misses.load(Ordering::Relaxed),
373 evictions: self.evictions.load(Ordering::Relaxed),
374 size: self.inner.read().len(),
375 policy_version: self.policy_version(),
376 }
377 }
378
379 #[deprecated(note = "use try_config_from_env to reject malformed settings")]
393 pub fn config_from_env() -> CacheConfig {
394 Self::config_from_overrides(
395 std::env::var("AGENTGUARD_CACHE_TTL").ok(),
396 std::env::var("AGENTGUARD_DENY_CACHE_TTL").ok(),
397 std::env::var("AGENTGUARD_CACHE_CAPACITY").ok(),
398 )
399 }
400
401 pub fn try_config_from_env() -> std::result::Result<CacheConfig, String> {
404 fn read(name: &str) -> std::result::Result<Option<String>, String> {
405 match std::env::var(name) {
406 Ok(value) => Ok(Some(value)),
407 Err(std::env::VarError::NotPresent) => Ok(None),
408 Err(std::env::VarError::NotUnicode(_)) => {
409 Err(format!("{name} must be valid Unicode"))
410 }
411 }
412 }
413 Self::try_config_from_overrides(
414 read("AGENTGUARD_CACHE_TTL")?.as_deref(),
415 read("AGENTGUARD_DENY_CACHE_TTL")?.as_deref(),
416 read("AGENTGUARD_CACHE_CAPACITY")?.as_deref(),
417 )
418 }
419
420 pub fn try_config_from_overrides(
422 cache_ttl: Option<&str>,
423 deny_ttl: Option<&str>,
424 capacity: Option<&str>,
425 ) -> std::result::Result<CacheConfig, String> {
426 let mut cfg = CacheConfig::default();
427 if let Some(ttl) = cache_ttl {
428 cfg.allow_ttl = crate::ttl::parse_duration(ttl)
429 .map_err(|error| format!("AGENTGUARD_CACHE_TTL: {error}"))?;
430 }
431 if let Some(ttl) = deny_ttl {
432 cfg.deny_ttl = crate::ttl::parse_duration(ttl)
433 .map_err(|error| format!("AGENTGUARD_DENY_CACHE_TTL: {error}"))?;
434 }
435 if let Some(capacity) = capacity {
436 cfg.capacity = capacity
437 .parse::<usize>()
438 .ok()
439 .filter(|capacity| *capacity > 0)
440 .ok_or_else(|| {
441 "AGENTGUARD_CACHE_CAPACITY must be a positive integer when set".to_owned()
442 })?;
443 }
444 Ok(cfg)
445 }
446
447 pub fn config_from_overrides(
451 cache_ttl: Option<String>,
452 deny_ttl: Option<String>,
453 capacity: Option<String>,
454 ) -> CacheConfig {
455 let mut cfg = CacheConfig::default();
456 if let Some(ttl) = cache_ttl {
457 match crate::ttl::parse_duration(&ttl) {
458 Ok(d) => cfg.allow_ttl = d,
459 Err(_) => tracing::warn!(
460 ttl = %ttl,
461 "AGENTGUARD_CACHE_TTL is not a valid duration; using default"
462 ),
463 }
464 }
465 if let Some(ttl) = deny_ttl {
466 match crate::ttl::parse_duration(&ttl) {
467 Ok(d) => cfg.deny_ttl = d,
468 Err(_) => tracing::warn!(
469 ttl = %ttl,
470 "AGENTGUARD_DENY_CACHE_TTL is not a valid duration; using default"
471 ),
472 }
473 }
474 if let Some(cap) = capacity {
475 if let Ok(n) = cap.parse::<usize>() {
476 if n > 0 {
477 cfg.capacity = n;
478 }
479 }
480 }
481 cfg
482 }
483}
484
485#[derive(Debug, Clone, Copy)]
486pub struct CacheStats {
487 pub hits: u64,
488 pub misses: u64,
489 pub evictions: u64,
490 pub size: usize,
491 pub policy_version: u64,
492}
493
494impl CacheStats {
495 pub fn hit_rate(&self) -> f64 {
496 let total = self.hits + self.misses;
497 if total == 0 {
498 0.0
499 } else {
500 self.hits as f64 / total as f64
501 }
502 }
503}
504
505#[cfg(test)]
507fn cache_key_for(req: &AgentRequest, policy_version: u64) -> CacheKey {
508 CacheKey::for_request(req, policy_version)
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::request::AgentRequestBuilder;
515 use crate::ttl::MockClock;
516 use crate::{AgentAction, AgentContext, Principal, Resource};
517 use std::sync::Arc;
518
519 fn req() -> AgentRequest {
520 AgentRequestBuilder::new(Principal::user("alice"))
521 .action(AgentAction::tool("send_email"))
522 .resource(Resource::new("Mailbox", "alice@acme"))
523 .context(AgentContext::new().with_arg("to", "[email protected]"))
524 .build()
525 .unwrap()
526 }
527
528 fn req_with_marker(marker: u64) -> AgentRequest {
529 let principal_id = format!("alice-{marker}");
530 let resource_id = format!("alice-{marker}@acme");
531 AgentRequestBuilder::new(Principal::user(principal_id))
532 .action(AgentAction::tool("send_email"))
533 .resource(Resource::new("Mailbox", resource_id))
534 .context(AgentContext::new().with_arg("to", "[email protected]"))
535 .build()
536 .unwrap()
537 }
538
539 #[test]
540 fn try_new_rejects_zero_capacity_without_panicking() {
541 let clock: Arc<dyn Clock> = Arc::new(MockClock::new());
542 let config = CacheConfig {
543 capacity: 0,
544 ..CacheConfig::default()
545 };
546 let error = DecisionCache::try_new(config, clock)
547 .err()
548 .expect("zero capacity should return a configuration error");
549 assert_eq!(error, "cache capacity must be greater than zero");
550 }
551
552 #[test]
553 fn try_new_rejects_ttl_outside_monotonic_clock_range() {
554 let clock: Arc<dyn Clock> = Arc::new(MockClock::new());
555 let config = CacheConfig {
556 allow_ttl: Duration::MAX,
557 ..CacheConfig::default()
558 };
559 let error = DecisionCache::try_new(config, clock)
560 .err()
561 .expect("unrepresentable TTL should return a configuration error");
562 assert_eq!(error, "allow cache TTL exceeds the monotonic clock range");
563 }
564
565 #[test]
566 fn cache_miss_then_hit() {
567 let clock = Arc::new(MockClock::new());
568 let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
569 let key = CacheKey::for_request(&req(), 0);
570 assert!(cache.get(&key).is_none());
571 cache.put(key.clone(), CachedDecision::allow());
572 let got = cache.get(&key).unwrap();
573 assert_eq!(got.effect, "allow");
574 assert_eq!(cache.stats().hits, 1);
575 assert_eq!(cache.stats().misses, 1);
576 }
577
578 #[test]
579 fn entity_attributes_change_cache_identity() {
580 let entity = cedar_policy::Entity::from_json_str(
581 r#"{"uid":{"type":"User","id":"alice"},"attrs":{"admin":true},"parents":[]}"#,
582 None,
583 )
584 .unwrap();
585 let entities = cedar_policy::Entities::from_entities([entity], None).unwrap();
586 assert_ne!(
587 CacheKey::for_request(&req(), 0),
588 CacheKey::for_request_with_entities(&req(), &entities, 0)
589 );
590 }
591
592 #[test]
593 #[allow(clippy::field_reassign_with_default)]
594 fn expire_after_ttl() {
595 let clock = Arc::new(MockClock::new());
596 let mut cfg = CacheConfig::default();
597 cfg.allow_ttl = Duration::from_secs(5);
598 let cache = DecisionCache::new(cfg, clock.clone());
599 let key = CacheKey::for_request(&req(), 0);
600 cache.put(key.clone(), CachedDecision::allow());
601 clock.advance_unix(Duration::from_secs(10));
602 assert!(cache.get(&key).is_none(), "should have expired");
603 }
604
605 #[test]
606 fn invalidate_on_policy_version_bump() {
607 let clock = Arc::new(MockClock::new());
608 let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
609 let key = CacheKey::for_request(&req(), 0);
610 cache.put(key.clone(), CachedDecision::allow());
611 assert!(cache.get(&key).is_some());
612 cache.invalidate_all();
613 assert!(cache.get(&key).is_none(), "policy bump should invalidate");
614 }
615
616 #[test]
617 fn deny_cached_when_enabled() {
618 let clock = Arc::new(MockClock::new());
619 let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
620 let key = CacheKey::for_request(&req(), 0);
621 cache.put(key.clone(), CachedDecision::deny());
622 assert_eq!(cache.get(&key).unwrap().effect, "deny");
623 }
624
625 #[test]
626 #[allow(clippy::field_reassign_with_default)]
627 fn deny_not_cached_when_disabled() {
628 let clock = Arc::new(MockClock::new());
629 let mut cfg = CacheConfig::default();
630 cfg.cache_denies = false;
631 let cache = DecisionCache::new(cfg, clock.clone());
632 let key = CacheKey::for_request(&req(), 0);
633 cache.put(key.clone(), CachedDecision::deny());
634 assert!(cache.get(&key).is_none(), "denies not cached");
635 }
636
637 #[test]
638 fn hit_rate_calculation() {
639 let s = CacheStats {
640 hits: 7,
641 misses: 3,
642 evictions: 0,
643 size: 5,
644 policy_version: 0,
645 };
646 assert!((s.hit_rate() - 0.7).abs() < 1e-9);
647 }
648
649 #[test]
650 fn concurrent_reads_dont_block_writers() {
651 use std::sync::Arc;
652 use std::sync::Barrier;
653 use std::thread;
654
655 let clock = Arc::new(MockClock::new());
656 let cache = Arc::new(DecisionCache::new(CacheConfig::default(), clock));
657 let key = cache_key_for(&req(), 0);
658
659 cache.put(key.clone(), CachedDecision::allow());
661
662 const N_READERS: usize = 4;
667 const LOOPS_PER_READER: usize = 50;
668 let ready = Arc::new(Barrier::new(N_READERS + 1));
669 let mut handles = Vec::new();
670 for _ in 0..N_READERS {
671 let cache = Arc::clone(&cache);
672 let key = key.clone();
673 let ready = Arc::clone(&ready);
674 handles.push(thread::spawn(move || {
675 ready.wait();
676 for _ in 0..LOOPS_PER_READER {
677 let _ = cache.get(&key);
678 }
679 }));
680 }
681
682 ready.wait();
684 for v in 1..=5 {
685 cache.set_policy_version(v);
686 cache.put(key.clone(), CachedDecision::allow());
687 }
688 for h in handles {
689 h.join().unwrap();
690 }
691 }
698
699 #[test]
700 fn eviction_counter_increments() {
701 let clock = Arc::new(MockClock::new());
702 let cfg = CacheConfig {
703 capacity: 2,
704 ..CacheConfig::default()
705 };
706 let cache = DecisionCache::new(cfg, clock.clone());
707
708 for i in 0..4u64 {
709 let key = CacheKey::for_request(&req_with_marker(i), i);
710 cache.put(key, CachedDecision::allow());
711 }
712
713 assert!(
714 cache.stats().evictions >= 2,
715 "evictions counter should reflect LRU churn, got {}",
716 cache.stats().evictions
717 );
718 }
719
720 #[test]
721 fn concurrent_eviction_throughput() {
722 use std::sync::Barrier;
729 let clock = Arc::new(MockClock::new());
730 let cache = Arc::new(DecisionCache::new(CacheConfig::default(), clock.clone()));
731
732 let key = CacheKey::for_request(&req(), 0);
733 cache.put(key.clone(), CachedDecision::allow());
734 cache.invalidate_all();
735
736 let barrier = Arc::new(Barrier::new(16));
737 let mut handles = Vec::new();
738 for _ in 0..16 {
739 let cache = cache.clone();
740 let key = key.clone();
741 let barrier = barrier.clone();
742 handles.push(std::thread::spawn(move || {
743 barrier.wait();
744 for _ in 0..1000 {
745 assert!(cache.get(&key).is_none());
746 }
747 }));
748 }
749 for h in handles {
750 h.join().unwrap();
751 }
752 let drained = cache.sweep_stale();
754 assert!(
755 drained >= 1,
756 "sweep_stale must have evicted the stale entry, got {drained}"
757 );
758 }
759
760 #[test]
761 fn config_from_overrides_sets_both_independently() {
762 let cfg = DecisionCache::config_from_overrides(
766 Some("600s".to_string()),
767 Some("2s".to_string()),
768 None,
769 );
770 assert_eq!(cfg.allow_ttl, Duration::from_secs(600));
771 assert_eq!(cfg.deny_ttl, Duration::from_secs(2));
772 }
773
774 #[test]
775 fn strict_config_parser_rejects_invalid_ttls_and_capacity() {
776 assert!(
777 DecisionCache::try_config_from_overrides(Some("soon"), None, None)
778 .unwrap_err()
779 .contains("AGENTGUARD_CACHE_TTL")
780 );
781 assert!(
782 DecisionCache::try_config_from_overrides(None, Some("soon"), None)
783 .unwrap_err()
784 .contains("AGENTGUARD_DENY_CACHE_TTL")
785 );
786 for capacity in ["0", "-1", "many"] {
787 assert!(DecisionCache::try_config_from_overrides(None, None, Some(capacity)).is_err());
788 }
789 }
790
791 #[test]
792 fn strict_config_parser_applies_explicit_overrides() {
793 let cfg =
794 DecisionCache::try_config_from_overrides(Some("600s"), Some("2s"), Some("42")).unwrap();
795 assert_eq!(cfg.allow_ttl, Duration::from_secs(600));
796 assert_eq!(cfg.deny_ttl, Duration::from_secs(2));
797 assert_eq!(cfg.capacity, 42);
798 }
799}