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::{BTreeMap, 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<HashMap<String, Arc<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(HashMap::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 canonicalised (object keys sorted
72    /// recursively via BTreeMap) before serialisation so that semantically
73    /// equivalent inputs `{"b":2,"a":1}` and `{"a":1,"b":2}` produce the same
74    /// key regardless of serde_json's `preserve_order` feature being enabled.
75    fn cache_key(request: &PermissionRequest) -> String {
76        let mut hasher = Sha256::new();
77        hasher.update(request.principal.subject.as_bytes());
78        hasher.update(b"\x00");
79        hasher.update(request.principal.issuer.as_bytes());
80        hasher.update(b"\x00");
81        hasher.update(request.resource.as_bytes());
82        hasher.update(b"\x00");
83        hasher.update(request.action.as_bytes());
84        hasher.update(b"\x00");
85        for s in &request.requested_scopes {
86            hasher.update(s.as_bytes());
87            hasher.update(b"\x00");
88        }
89        let canonical = canonicalize_json(&request.context);
90        let context_str = serde_json::to_string(&canonical).unwrap_or_default();
91        hasher.update(context_str.as_bytes());
92        hex::encode(hasher.finalize())
93    }
94
95    /// Return the TTL that applies to a given decision.
96    fn ttl_for(&self, decision: &PermissionDecision) -> Duration {
97        match decision {
98            PermissionDecision::Granted => self.options.positive_ttl,
99            PermissionDecision::Denied { .. } => self.options.negative_ttl,
100        }
101    }
102
103    async fn evict_if_needed(&self) {
104        let mut cache = self.cache.write().await;
105        if cache.len() < self.options.max_entries {
106            return;
107        }
108        let now = Instant::now();
109        // First pass: remove expired entries.
110        cache.retain(|_, entry| {
111            let ttl = match &entry.decision {
112                PermissionDecision::Granted => self.options.positive_ttl,
113                PermissionDecision::Denied { .. } => self.options.negative_ttl,
114            };
115            now.duration_since(entry.inserted_at) < ttl
116        });
117        // Second pass: if still over capacity, evict the oldest entry.
118        if cache.len() >= self.options.max_entries {
119            let oldest_key = cache
120                .iter()
121                .min_by_key(|(_, e)| e.inserted_at)
122                .map(|(k, _)| k.clone());
123            if let Some(key) = oldest_key {
124                cache.remove(&key);
125            }
126        }
127    }
128}
129
130impl fmt::Debug for CachingPermissionEvaluator {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.debug_struct("CachingPermissionEvaluator")
133            .field("positive_ttl", &self.options.positive_ttl)
134            .field("negative_ttl", &self.options.negative_ttl)
135            .field("max_entries", &self.options.max_entries)
136            .finish_non_exhaustive()
137    }
138}
139
140#[async_trait]
141impl PermissionEvaluator for CachingPermissionEvaluator {
142    async fn evaluate(&self, request: PermissionRequest) -> Result<PermissionDecision, AuthError> {
143        let key = Self::cache_key(&request);
144        let now = Instant::now();
145
146        // 1. Fast-path: read cache, check TTL based on decision type.
147        {
148            let cache = self.cache.read().await;
149            if let Some(entry) = cache.get(&key) {
150                let ttl = self.ttl_for(&entry.decision);
151                if now.duration_since(entry.inserted_at) < ttl {
152                    tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "hit");
153                    return Ok(entry.decision.clone());
154                }
155            }
156        }
157
158        // 2. Per-key dedup: only one caller per cache key enters the inner evaluator.
159        //
160        // Duplicated from CachingTokenIntrospector::introspect (introspection.rs).
161        // Two identical call sites — extraction would add indirection without
162        // reducing total LoC. Keep both in sync when modifying.
163        let key_mutex = {
164            let mut in_flight_map = self.in_flight.lock().await;
165            in_flight_map
166                .entry(key.clone())
167                .or_insert_with(|| Arc::new(Mutex::new(())))
168                .clone()
169        };
170        let result: Result<PermissionDecision, AuthError> = async {
171            let _guard = key_mutex.lock().await;
172            // Double-check cache after acquiring the per-key lock (hit-after-wait).
173            {
174                let cache = self.cache.read().await;
175                if let Some(entry) = cache.get(&key) {
176                    let ttl = self.ttl_for(&entry.decision);
177                    if now.duration_since(entry.inserted_at) < ttl {
178                        tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "hit_after_wait");
179                        return Ok(entry.decision.clone());
180                    }
181                }
182            }
183
184            tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "miss");
185
186            let decision = self.inner.evaluate(request).await?;
187
188            // 3. Lazy eviction.
189            self.evict_if_needed().await;
190
191            // 4. Insert.
192            {
193                let mut cache = self.cache.write().await;
194                cache.insert(
195                    key.clone(),
196                    CachedPermissionEntry {
197                        decision: decision.clone(),
198                        inserted_at: Instant::now(),
199                    },
200                );
201            }
202
203            Ok(decision)
204        }
205        .await;
206        drop(key_mutex);
207        // Cleanup: remove the per-key mutex from the map when no other task holds a reference.
208        {
209            let mut in_flight_map = self.in_flight.lock().await;
210            if let Some(arc) = in_flight_map.get(&key)
211                && Arc::strong_count(arc) == 1
212            {
213                in_flight_map.remove(&key);
214            }
215        }
216        result
217    }
218}
219
220/// Recursively sort all JSON object keys in a value tree.
221///
222/// Arrays are recursed into; leaves (string, number, bool, null) are unchanged.
223/// Uses BTreeMap for deterministic ordering regardless of serde_json's
224/// `preserve_order` feature being enabled workspace-wide.
225fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
226    match value {
227        serde_json::Value::Object(map) => {
228            let sorted: BTreeMap<String, serde_json::Value> = map
229                .iter()
230                .map(|(k, v)| (k.clone(), canonicalize_json(v)))
231                .collect();
232            serde_json::Value::Object(sorted.into_iter().collect())
233        }
234        serde_json::Value::Array(arr) => {
235            serde_json::Value::Array(arr.iter().map(canonicalize_json).collect())
236        }
237        other => other.clone(),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use camel_api::security_policy::Principal;
245    use serde_json::json;
246    use std::sync::atomic::{AtomicUsize, Ordering};
247
248    fn test_principal() -> Principal {
249        Principal {
250            subject: "alice".into(),
251            issuer: "https://keycloak.example.com/realms/test".into(),
252            audience: vec!["camel-api".into()],
253            roles: vec!["admin".into()],
254            scopes: vec!["read".into()],
255            claims: json!({}),
256        }
257    }
258
259    fn test_request(resource: &str, context: serde_json::Value) -> PermissionRequest {
260        PermissionRequest {
261            principal: test_principal(),
262            resource: resource.into(),
263            action: "read".into(),
264            requested_scopes: vec!["read".into()],
265            context,
266        }
267    }
268
269    struct CountingEvaluator {
270        count: AtomicUsize,
271        decision: PermissionDecision,
272    }
273
274    #[async_trait]
275    impl PermissionEvaluator for CountingEvaluator {
276        async fn evaluate(
277            &self,
278            _request: PermissionRequest,
279        ) -> Result<PermissionDecision, AuthError> {
280            self.count.fetch_add(1, Ordering::SeqCst);
281            Ok(self.decision.clone())
282        }
283    }
284
285    /// Like [`CountingEvaluator`] but injects a configurable delay before responding.
286    struct SlowCountingEvaluator {
287        count: AtomicUsize,
288        decision: PermissionDecision,
289        delay: Duration,
290    }
291
292    #[async_trait]
293    impl PermissionEvaluator for SlowCountingEvaluator {
294        async fn evaluate(
295            &self,
296            _request: PermissionRequest,
297        ) -> Result<PermissionDecision, AuthError> {
298            tokio::time::sleep(self.delay).await;
299            self.count.fetch_add(1, Ordering::SeqCst);
300            Ok(self.decision.clone())
301        }
302    }
303
304    fn default_opts() -> PermissionCacheOptions {
305        PermissionCacheOptions {
306            positive_ttl: Duration::from_secs(30),
307            negative_ttl: Duration::from_secs(5),
308            max_entries: 10_000,
309        }
310    }
311
312    #[tokio::test]
313    async fn cache_hit_avoids_repeated_call() {
314        let inner = Arc::new(CountingEvaluator {
315            count: AtomicUsize::new(0),
316            decision: PermissionDecision::Granted,
317        });
318        let caching = CachingPermissionEvaluator::new(inner.clone(), default_opts());
319
320        let req = test_request("/orders/123", json!({}));
321        let d1 = caching.evaluate(req.clone()).await.unwrap();
322        let d2 = caching.evaluate(req.clone()).await.unwrap();
323
324        assert_eq!(d1, PermissionDecision::Granted);
325        assert_eq!(d2, PermissionDecision::Granted);
326        assert_eq!(inner.count.load(Ordering::SeqCst), 1);
327    }
328
329    #[tokio::test]
330    async fn cache_negative_ttl_shorter() {
331        let inner = Arc::new(CountingEvaluator {
332            count: AtomicUsize::new(0),
333            decision: PermissionDecision::Denied {
334                reason: "forbidden".into(),
335            },
336        });
337        let opts = PermissionCacheOptions {
338            positive_ttl: Duration::from_secs(30),
339            negative_ttl: Duration::from_millis(50),
340            max_entries: 10_000,
341        };
342        let caching = CachingPermissionEvaluator::new(inner.clone(), opts);
343
344        let req = test_request("/secret", json!({}));
345        let d1 = caching.evaluate(req.clone()).await.unwrap();
346        assert!(matches!(d1, PermissionDecision::Denied { .. }));
347
348        tokio::time::sleep(Duration::from_millis(100)).await;
349
350        let d2 = caching.evaluate(req.clone()).await.unwrap();
351        assert!(matches!(d2, PermissionDecision::Denied { .. }));
352
353        // Inner evaluator was called twice — once initially, once after negative TTL expired.
354        assert_eq!(inner.count.load(Ordering::SeqCst), 2);
355    }
356
357    #[test]
358    fn cache_key_is_deterministic() {
359        let req = test_request("/orders/123", json!({"source": "api"}));
360        let key1 = CachingPermissionEvaluator::cache_key(&req);
361        let key2 = CachingPermissionEvaluator::cache_key(&req);
362        assert_eq!(key1, key2, "same request must produce the same key");
363        assert_eq!(key1.len(), 64, "SHA-256 hex digest is 64 chars");
364    }
365
366    #[test]
367    fn cache_key_differs_for_different_resources() {
368        let req_a = test_request("/orders/123", json!({}));
369        let req_b = test_request("/orders/456", json!({}));
370        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
371        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
372        assert_ne!(
373            key_a, key_b,
374            "different resources must produce different keys"
375        );
376    }
377
378    #[test]
379    fn cache_key_stable_for_json_context_with_same_semantics() {
380        // serde_json serialises maps with sorted keys, so {"b":"2","a":"1"} and
381        // {"a":"1","b":"2"} must produce identical cache keys.
382        let req_a = test_request("/orders", json!({"b":"2","a":"1"}));
383        let req_b = test_request("/orders", json!({"a":"1","b":"2"}));
384        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
385        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
386        assert_eq!(
387            key_a, key_b,
388            "semantically equivalent JSON contexts must produce the same key"
389        );
390    }
391
392    #[test]
393    fn options_default_values() {
394        let opts = PermissionCacheOptions::default();
395        assert_eq!(opts.positive_ttl, Duration::from_secs(30));
396        assert_eq!(opts.negative_ttl, Duration::from_secs(5));
397        assert_eq!(opts.max_entries, 10_000);
398    }
399
400    #[test]
401    fn debug_does_not_leak_inner_state() {
402        let inner = Arc::new(CountingEvaluator {
403            count: AtomicUsize::new(0),
404            decision: PermissionDecision::Granted,
405        });
406        let caching = CachingPermissionEvaluator::new(inner, default_opts());
407        let debug = format!("{caching:?}");
408        assert!(debug.contains("CachingPermissionEvaluator"));
409        assert!(debug.contains("positive_ttl"));
410        assert!(debug.contains("negative_ttl"));
411    }
412
413    #[tokio::test]
414    async fn concurrent_same_request_dedup_preserved() {
415        let inner = Arc::new(CountingEvaluator {
416            count: AtomicUsize::new(0),
417            decision: PermissionDecision::Granted,
418        });
419        let caching = Arc::new(CachingPermissionEvaluator::new(
420            inner.clone(),
421            default_opts(),
422        ));
423
424        let req = test_request("/orders/123", json!({}));
425        let c1 = caching.clone();
426        let c2 = caching.clone();
427        let req1 = req.clone();
428        let req2 = req;
429
430        let (d1, d2) = tokio::join!(c1.evaluate(req1), c2.evaluate(req2));
431
432        assert_eq!(inner.count.load(Ordering::SeqCst), 1);
433        assert!(d1.is_ok());
434        assert!(d2.is_ok());
435    }
436
437    #[tokio::test]
438    async fn concurrent_different_requests_no_head_of_line_blocking() {
439        let inner = Arc::new(SlowCountingEvaluator {
440            count: AtomicUsize::new(0),
441            decision: PermissionDecision::Granted,
442            delay: Duration::from_millis(500),
443        });
444        let caching = Arc::new(CachingPermissionEvaluator::new(
445            inner.clone(),
446            default_opts(),
447        ));
448
449        let req_a = test_request("/orders/123", json!({}));
450        let req_b = test_request("/orders/456", json!({}));
451
452        let c_a = caching.clone();
453        let c_b = caching.clone();
454
455        let start = tokio::time::Instant::now();
456        let (d1, d2) = tokio::join!(c_a.evaluate(req_a), c_b.evaluate(req_b));
457        let elapsed = start.elapsed();
458
459        assert!(
460            elapsed < Duration::from_millis(800),
461            "expected < 800 ms but took {elapsed:?}"
462        );
463        assert_eq!(inner.count.load(Ordering::SeqCst), 2);
464        assert!(d1.is_ok());
465        assert!(d2.is_ok());
466    }
467}