Files
dirigent/crates/dirigent_tools/src/permission/cache.rs
T
2026-05-08 01:59:04 +02:00

390 lines
12 KiB
Rust

//! 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<Mutex<DecisionCache>>` 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<CacheKey, CachedDecision>,
}
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<PermissionDecision> {
// 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));
}
}