//! Decision cache with TTL and scope support. //! //! **Status**: Implemented (TOOLS-PERM-02) //! //! This module provides thread-safe caching of permission decisions with: //! - TTL (time-to-live) for cached decisions //! - Scope support (per-connector or per-session) //! - Automatic expiration of stale entries //! - Hash-based cache keys for efficient lookups use crate::config::DecisionScope; use std::collections::HashMap; use std::hash::Hash; use std::time::{Duration, Instant}; /// Permission decision outcome. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionDecision { Allowed, Denied, Cancelled, } /// Cache key for permission decisions. /// /// Keys are constructed from: /// - Operation kind (read, write, execute) /// - Normalized path or command /// - Scope identifier (connector_id or session_id) #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct CacheKey { /// Operation discriminant operation_kind: OperationKind, /// Normalized path or command string target: String, /// Scope identifier (connector or session) scope_id: String, } /// Operation kind for cache key discrimination. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum OperationKind { Read, Write, Execute, } impl CacheKey { /// Create a cache key for a read operation. pub fn read(path: &str, connector_id: &str, scope: DecisionScope) -> Self { Self { operation_kind: OperationKind::Read, target: path.to_string(), scope_id: Self::scope_id(connector_id, None, scope), } } /// Create a cache key for a write operation. pub fn write(path: &str, connector_id: &str, session_id: Option<&str>, scope: DecisionScope) -> Self { Self { operation_kind: OperationKind::Write, target: path.to_string(), scope_id: Self::scope_id(connector_id, session_id, scope), } } /// Create a cache key for an execute operation. pub fn execute(command: &str, connector_id: &str, session_id: Option<&str>, scope: DecisionScope) -> Self { Self { operation_kind: OperationKind::Execute, target: command.to_string(), scope_id: Self::scope_id(connector_id, session_id, scope), } } /// Construct scope identifier based on scope policy. fn scope_id(connector_id: &str, session_id: Option<&str>, scope: DecisionScope) -> String { match scope { DecisionScope::PerConnector => connector_id.to_string(), DecisionScope::PerSession => { format!("{}:{}", connector_id, session_id.unwrap_or("default")) } } } } /// Cached permission decision with expiration. #[derive(Debug, Clone)] struct CachedDecision { /// The permission decision decision: PermissionDecision, /// When this entry expires expires_at: Instant, } impl CachedDecision { /// Create a new cached decision with TTL. fn new(decision: PermissionDecision, ttl: Duration) -> Self { Self { decision, expires_at: Instant::now() + ttl, } } /// Check if this entry has expired. fn is_expired(&self) -> bool { Instant::now() >= self.expires_at } } /// Thread-safe decision cache with TTL support. /// /// The cache stores permission decisions keyed by operation, path/command, and scope. /// Entries automatically expire after their TTL, and expired entries are pruned on access. /// /// ## Thread Safety /// /// The cache is designed to be wrapped in `Arc>` for thread-safe access. /// Individual operations (get, insert, clear) should acquire the lock briefly. /// /// ## Example /// /// ```rust /// use dirigent_tools::permission::cache::{DecisionCache, PermissionDecision, CacheKey}; /// use dirigent_tools::config::DecisionScope; /// use std::time::Duration; /// /// let mut cache = DecisionCache::new(); /// let key = CacheKey::write("/path/to/file", "connector-1", None, DecisionScope::PerConnector); /// /// // Cache a decision /// cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_secs(300)); /// /// // Retrieve it /// assert_eq!(cache.get(&key), Some(PermissionDecision::Allowed)); /// ``` #[derive(Debug)] pub struct DecisionCache { entries: HashMap, } impl DecisionCache { /// Create a new empty decision cache. pub fn new() -> Self { Self { entries: HashMap::new(), } } /// Get a cached decision if it exists and hasn't expired. /// /// Expired entries are automatically removed. /// /// Returns `Some(decision)` if a valid cached entry exists, `None` otherwise. pub fn get(&mut self, key: &CacheKey) -> Option { // Check if entry exists if let Some(cached) = self.entries.get(key) { // Check if expired if cached.is_expired() { // Remove expired entry self.entries.remove(key); None } else { // Return valid decision Some(cached.decision) } } else { None } } /// Insert a new decision into the cache with the given TTL. /// /// If an entry already exists for this key, it will be replaced. pub fn insert(&mut self, key: CacheKey, decision: PermissionDecision, ttl: Duration) { self.entries.insert(key, CachedDecision::new(decision, ttl)); } /// Clear all cached decisions. /// /// Useful for manual cache invalidation or testing. pub fn clear(&mut self) { self.entries.clear(); } /// Remove all expired entries from the cache. /// /// This is automatically done during `get()` operations, but can be called /// periodically to prune the cache and free memory. /// /// Returns the number of expired entries removed. pub fn clear_expired(&mut self) -> usize { let before = self.entries.len(); self.entries.retain(|_, cached| !cached.is_expired()); before - self.entries.len() } /// Get the number of cached entries (including expired). /// /// For testing and monitoring purposes. pub fn len(&self) -> usize { self.entries.len() } /// Check if the cache is empty. pub fn is_empty(&self) -> bool { self.entries.is_empty() } } impl Default for DecisionCache { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_cache_key_creation() { let key1 = CacheKey::write("/path/to/file", "conn-1", None, DecisionScope::PerConnector); let key2 = CacheKey::write("/path/to/file", "conn-1", None, DecisionScope::PerConnector); let key3 = CacheKey::write("/path/to/file", "conn-2", None, DecisionScope::PerConnector); // Same connector and path should produce equal keys assert_eq!(key1, key2); // Different connector should produce different keys assert_ne!(key1, key3); } #[test] fn test_cache_key_scope() { let key_connector = CacheKey::write( "/path", "conn-1", Some("session-1"), DecisionScope::PerConnector, ); let key_session = CacheKey::write( "/path", "conn-1", Some("session-1"), DecisionScope::PerSession, ); // Different scopes should produce different keys assert_ne!(key_connector, key_session); } #[test] fn test_cache_key_operation_kind() { let key_read = CacheKey::read("/path", "conn-1", DecisionScope::PerConnector); let key_write = CacheKey::write("/path", "conn-1", None, DecisionScope::PerConnector); // Different operations should produce different keys assert_ne!(key_read, key_write); } #[test] fn test_cache_insert_and_get() { let mut cache = DecisionCache::new(); let key = CacheKey::write("/path", "conn-1", None, DecisionScope::PerConnector); // Initially empty assert_eq!(cache.get(&key), None); // Insert decision cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_secs(300)); // Should retrieve it assert_eq!(cache.get(&key), Some(PermissionDecision::Allowed)); } #[test] fn test_cache_ttl_expiration() { let mut cache = DecisionCache::new(); let key = CacheKey::write("/path", "conn-1", None, DecisionScope::PerConnector); // Insert with very short TTL cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_millis(1)); // Wait for expiration std::thread::sleep(Duration::from_millis(10)); // Should not retrieve expired entry assert_eq!(cache.get(&key), None); // Entry should be removed from cache assert_eq!(cache.len(), 0); } #[test] fn test_cache_clear() { let mut cache = DecisionCache::new(); let key1 = CacheKey::write("/path1", "conn-1", None, DecisionScope::PerConnector); let key2 = CacheKey::write("/path2", "conn-1", None, DecisionScope::PerConnector); cache.insert(key1.clone(), PermissionDecision::Allowed, Duration::from_secs(300)); cache.insert(key2.clone(), PermissionDecision::Denied, Duration::from_secs(300)); assert_eq!(cache.len(), 2); // Clear all entries cache.clear(); assert_eq!(cache.len(), 0); assert_eq!(cache.get(&key1), None); assert_eq!(cache.get(&key2), None); } #[test] fn test_cache_clear_expired() { let mut cache = DecisionCache::new(); let key1 = CacheKey::write("/path1", "conn-1", None, DecisionScope::PerConnector); let key2 = CacheKey::write("/path2", "conn-1", None, DecisionScope::PerConnector); // Insert one short-lived and one long-lived entry cache.insert(key1.clone(), PermissionDecision::Allowed, Duration::from_millis(1)); cache.insert(key2.clone(), PermissionDecision::Denied, Duration::from_secs(300)); assert_eq!(cache.len(), 2); // Wait for first to expire std::thread::sleep(Duration::from_millis(10)); // Clear expired entries let removed = cache.clear_expired(); assert_eq!(removed, 1); assert_eq!(cache.len(), 1); // Second entry should still be accessible assert_eq!(cache.get(&key2), Some(PermissionDecision::Denied)); } #[test] fn test_cache_replace_entry() { let mut cache = DecisionCache::new(); let key = CacheKey::write("/path", "conn-1", None, DecisionScope::PerConnector); // Insert initial decision cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_secs(300)); assert_eq!(cache.get(&key), Some(PermissionDecision::Allowed)); // Replace with different decision cache.insert(key.clone(), PermissionDecision::Denied, Duration::from_secs(300)); assert_eq!(cache.get(&key), Some(PermissionDecision::Denied)); // Should only have one entry assert_eq!(cache.len(), 1); } #[test] fn test_cache_different_sessions() { let mut cache = DecisionCache::new(); let key_session1 = CacheKey::write( "/path", "conn-1", Some("session-1"), DecisionScope::PerSession, ); let key_session2 = CacheKey::write( "/path", "conn-1", Some("session-2"), DecisionScope::PerSession, ); // Insert different decisions for different sessions cache.insert(key_session1.clone(), PermissionDecision::Allowed, Duration::from_secs(300)); cache.insert(key_session2.clone(), PermissionDecision::Denied, Duration::from_secs(300)); // Each session should have its own decision assert_eq!(cache.get(&key_session1), Some(PermissionDecision::Allowed)); assert_eq!(cache.get(&key_session2), Some(PermissionDecision::Denied)); } #[test] fn test_cache_cancelled_decision() { let mut cache = DecisionCache::new(); let key = CacheKey::write("/path", "conn-1", None, DecisionScope::PerConnector); // Cancelled decisions can also be cached cache.insert(key.clone(), PermissionDecision::Cancelled, Duration::from_secs(300)); assert_eq!(cache.get(&key), Some(PermissionDecision::Cancelled)); } }