sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user