Skip to main content

camel_auth/
permission_cache.rs

1//! Caching wrapper for [`PermissionEvaluator`] with separate positive/negative TTLs.
2//!
3//! Mirrors [`CachingTokenIntrospector`](crate::introspection::CachingTokenIntrospector):
4//! `RwLock<HashMap>` for reads, `Mutex<()>` to prevent thundering-herd stampedes,
5//! and lazy eviction when the cache exceeds capacity.
6
7use std::collections::HashMap;
8use std::fmt;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use async_trait::async_trait;
13use sha2::{Digest, Sha256};
14use tokio::sync::{Mutex, RwLock};
15
16use crate::permission::{PermissionDecision, PermissionEvaluator, PermissionRequest};
17use crate::types::AuthError;
18
19/// Configuration for [`CachingPermissionEvaluator`].
20#[derive(Debug, Clone)]
21pub struct PermissionCacheOptions {
22    /// TTL for granted decisions. Default 30 s — shorter than token introspection (60 s)
23    /// because authorization decisions can change faster than identity claims.
24    pub positive_ttl: Duration,
25    /// TTL for denied decisions. Default 5 s — allows quick recovery after permissions are granted.
26    pub negative_ttl: Duration,
27    /// Maximum number of cache entries before eviction kicks in.
28    pub max_entries: usize,
29}
30
31impl Default for PermissionCacheOptions {
32    fn default() -> Self {
33        Self {
34            positive_ttl: Duration::from_secs(30),
35            negative_ttl: Duration::from_secs(5),
36            max_entries: 10_000,
37        }
38    }
39}
40
41struct CachedPermissionEntry {
42    decision: PermissionDecision,
43    inserted_at: Instant,
44}
45
46/// Generic caching wrapper around any [`PermissionEvaluator`].
47///
48/// Uses SHA-256 over the canonicalised request fields (null-byte separated) as
49/// the cache key, so no sensitive principal data is stored verbatim.
50pub struct CachingPermissionEvaluator {
51    inner: Arc<dyn PermissionEvaluator>,
52    cache: Arc<RwLock<HashMap<String, CachedPermissionEntry>>>,
53    in_flight: Mutex<()>,
54    options: PermissionCacheOptions,
55}
56
57impl CachingPermissionEvaluator {
58    pub fn new(inner: Arc<dyn PermissionEvaluator>, options: PermissionCacheOptions) -> Self {
59        Self {
60            inner,
61            cache: Arc::new(RwLock::new(HashMap::new())),
62            in_flight: Mutex::new(()),
63            options,
64        }
65    }
66
67    /// Deterministic SHA-256 cache key derived from all request fields.
68    ///
69    /// Each field is separated by a `\x00` null byte so that `"ab" + "c"` and
70    /// `"a" + "bc"` cannot collide. Scopes are hashed in order with their own
71    /// separators. The JSON `context` is serialised with `serde_json::to_string`
72    /// which produces deterministic output (sorted keys for maps).
73    fn cache_key(request: &PermissionRequest) -> String {
74        let mut hasher = Sha256::new();
75        hasher.update(request.principal.subject.as_bytes());
76        hasher.update(b"\x00");
77        hasher.update(request.principal.issuer.as_bytes());
78        hasher.update(b"\x00");
79        hasher.update(request.resource.as_bytes());
80        hasher.update(b"\x00");
81        hasher.update(request.action.as_bytes());
82        hasher.update(b"\x00");
83        for s in &request.requested_scopes {
84            hasher.update(s.as_bytes());
85            hasher.update(b"\x00");
86        }
87        let context_str = serde_json::to_string(&request.context).unwrap_or_default();
88        hasher.update(context_str.as_bytes());
89        hex::encode(hasher.finalize())
90    }
91
92    /// Return the TTL that applies to a given decision.
93    fn ttl_for(&self, decision: &PermissionDecision) -> Duration {
94        match decision {
95            PermissionDecision::Granted => self.options.positive_ttl,
96            PermissionDecision::Denied { .. } => self.options.negative_ttl,
97        }
98    }
99
100    async fn evict_if_needed(&self) {
101        let mut cache = self.cache.write().await;
102        if cache.len() < self.options.max_entries {
103            return;
104        }
105        let now = Instant::now();
106        // First pass: remove expired entries.
107        cache.retain(|_, entry| {
108            let ttl = match &entry.decision {
109                PermissionDecision::Granted => self.options.positive_ttl,
110                PermissionDecision::Denied { .. } => self.options.negative_ttl,
111            };
112            now.duration_since(entry.inserted_at) < ttl
113        });
114        // Second pass: if still over capacity, evict the oldest entry.
115        if cache.len() >= self.options.max_entries {
116            let oldest_key = cache
117                .iter()
118                .min_by_key(|(_, e)| e.inserted_at)
119                .map(|(k, _)| k.clone());
120            if let Some(key) = oldest_key {
121                cache.remove(&key);
122            }
123        }
124    }
125}
126
127impl fmt::Debug for CachingPermissionEvaluator {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.debug_struct("CachingPermissionEvaluator")
130            .field("positive_ttl", &self.options.positive_ttl)
131            .field("negative_ttl", &self.options.negative_ttl)
132            .field("max_entries", &self.options.max_entries)
133            .finish_non_exhaustive()
134    }
135}
136
137#[async_trait]
138impl PermissionEvaluator for CachingPermissionEvaluator {
139    async fn evaluate(&self, request: PermissionRequest) -> Result<PermissionDecision, AuthError> {
140        let key = Self::cache_key(&request);
141        let now = Instant::now();
142
143        // 1. Fast-path: read cache, check TTL based on decision type.
144        {
145            let cache = self.cache.read().await;
146            if let Some(entry) = cache.get(&key) {
147                let ttl = self.ttl_for(&entry.decision);
148                if now.duration_since(entry.inserted_at) < ttl {
149                    tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "hit");
150                    return Ok(entry.decision.clone());
151                }
152            }
153        }
154
155        // 2. Acquire in-flight lock → double-check → call inner.
156        let _guard = self.in_flight.lock().await;
157
158        {
159            let cache = self.cache.read().await;
160            if let Some(entry) = cache.get(&key) {
161                let ttl = self.ttl_for(&entry.decision);
162                if now.duration_since(entry.inserted_at) < ttl {
163                    tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "hit_after_wait");
164                    return Ok(entry.decision.clone());
165                }
166            }
167        }
168
169        tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "miss");
170
171        let decision = self.inner.evaluate(request).await?;
172
173        // 3. Lazy eviction.
174        self.evict_if_needed().await;
175
176        // 4. Insert.
177        {
178            let mut cache = self.cache.write().await;
179            cache.insert(
180                key,
181                CachedPermissionEntry {
182                    decision: decision.clone(),
183                    inserted_at: Instant::now(),
184                },
185            );
186        }
187
188        Ok(decision)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use camel_api::security_policy::Principal;
196    use serde_json::json;
197    use std::sync::atomic::{AtomicUsize, Ordering};
198
199    fn test_principal() -> Principal {
200        Principal {
201            subject: "alice".into(),
202            issuer: "https://keycloak.example.com/realms/test".into(),
203            audience: vec!["camel-api".into()],
204            roles: vec!["admin".into()],
205            scopes: vec!["read".into()],
206            claims: json!({}),
207        }
208    }
209
210    fn test_request(resource: &str, context: serde_json::Value) -> PermissionRequest {
211        PermissionRequest {
212            principal: test_principal(),
213            resource: resource.into(),
214            action: "read".into(),
215            requested_scopes: vec!["read".into()],
216            context,
217        }
218    }
219
220    struct CountingEvaluator {
221        count: AtomicUsize,
222        decision: PermissionDecision,
223    }
224
225    #[async_trait]
226    impl PermissionEvaluator for CountingEvaluator {
227        async fn evaluate(
228            &self,
229            _request: PermissionRequest,
230        ) -> Result<PermissionDecision, AuthError> {
231            self.count.fetch_add(1, Ordering::SeqCst);
232            Ok(self.decision.clone())
233        }
234    }
235
236    fn default_opts() -> PermissionCacheOptions {
237        PermissionCacheOptions {
238            positive_ttl: Duration::from_secs(30),
239            negative_ttl: Duration::from_secs(5),
240            max_entries: 10_000,
241        }
242    }
243
244    #[tokio::test]
245    async fn cache_hit_avoids_repeated_call() {
246        let inner = Arc::new(CountingEvaluator {
247            count: AtomicUsize::new(0),
248            decision: PermissionDecision::Granted,
249        });
250        let caching = CachingPermissionEvaluator::new(inner.clone(), default_opts());
251
252        let req = test_request("/orders/123", json!({}));
253        let d1 = caching.evaluate(req.clone()).await.unwrap();
254        let d2 = caching.evaluate(req.clone()).await.unwrap();
255
256        assert_eq!(d1, PermissionDecision::Granted);
257        assert_eq!(d2, PermissionDecision::Granted);
258        assert_eq!(inner.count.load(Ordering::SeqCst), 1);
259    }
260
261    #[tokio::test]
262    async fn cache_negative_ttl_shorter() {
263        let inner = Arc::new(CountingEvaluator {
264            count: AtomicUsize::new(0),
265            decision: PermissionDecision::Denied {
266                reason: "forbidden".into(),
267            },
268        });
269        let opts = PermissionCacheOptions {
270            positive_ttl: Duration::from_secs(30),
271            negative_ttl: Duration::from_millis(50),
272            max_entries: 10_000,
273        };
274        let caching = CachingPermissionEvaluator::new(inner.clone(), opts);
275
276        let req = test_request("/secret", json!({}));
277        let d1 = caching.evaluate(req.clone()).await.unwrap();
278        assert!(matches!(d1, PermissionDecision::Denied { .. }));
279
280        tokio::time::sleep(Duration::from_millis(100)).await;
281
282        let d2 = caching.evaluate(req.clone()).await.unwrap();
283        assert!(matches!(d2, PermissionDecision::Denied { .. }));
284
285        // Inner evaluator was called twice — once initially, once after negative TTL expired.
286        assert_eq!(inner.count.load(Ordering::SeqCst), 2);
287    }
288
289    #[test]
290    fn cache_key_is_deterministic() {
291        let req = test_request("/orders/123", json!({"source": "api"}));
292        let key1 = CachingPermissionEvaluator::cache_key(&req);
293        let key2 = CachingPermissionEvaluator::cache_key(&req);
294        assert_eq!(key1, key2, "same request must produce the same key");
295        assert_eq!(key1.len(), 64, "SHA-256 hex digest is 64 chars");
296    }
297
298    #[test]
299    fn cache_key_differs_for_different_resources() {
300        let req_a = test_request("/orders/123", json!({}));
301        let req_b = test_request("/orders/456", json!({}));
302        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
303        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
304        assert_ne!(
305            key_a, key_b,
306            "different resources must produce different keys"
307        );
308    }
309
310    #[test]
311    fn cache_key_stable_for_json_context_with_same_semantics() {
312        // serde_json serialises maps with sorted keys, so {"b":"2","a":"1"} and
313        // {"a":"1","b":"2"} must produce identical cache keys.
314        let req_a = test_request("/orders", json!({"b":"2","a":"1"}));
315        let req_b = test_request("/orders", json!({"a":"1","b":"2"}));
316        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
317        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
318        assert_eq!(
319            key_a, key_b,
320            "semantically equivalent JSON contexts must produce the same key"
321        );
322    }
323
324    #[test]
325    fn options_default_values() {
326        let opts = PermissionCacheOptions::default();
327        assert_eq!(opts.positive_ttl, Duration::from_secs(30));
328        assert_eq!(opts.negative_ttl, Duration::from_secs(5));
329        assert_eq!(opts.max_entries, 10_000);
330    }
331
332    #[test]
333    fn debug_does_not_leak_inner_state() {
334        let inner = Arc::new(CountingEvaluator {
335            count: AtomicUsize::new(0),
336            decision: PermissionDecision::Granted,
337        });
338        let caching = CachingPermissionEvaluator::new(inner, default_opts());
339        let debug = format!("{caching:?}");
340        assert!(debug.contains("CachingPermissionEvaluator"));
341        assert!(debug.contains("positive_ttl"));
342        assert!(debug.contains("negative_ttl"));
343    }
344}