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<()>,
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 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. Acquire in-flight lock → double-check → call inner.
159        let _guard = self.in_flight.lock().await;
160
161        {
162            let cache = self.cache.read().await;
163            if let Some(entry) = cache.get(&key) {
164                let ttl = self.ttl_for(&entry.decision);
165                if now.duration_since(entry.inserted_at) < ttl {
166                    tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "hit_after_wait");
167                    return Ok(entry.decision.clone());
168                }
169            }
170        }
171
172        tracing::debug!(target: "camel_auth::permission_cache", cache_outcome = "miss");
173
174        let decision = self.inner.evaluate(request).await?;
175
176        // 3. Lazy eviction.
177        self.evict_if_needed().await;
178
179        // 4. Insert.
180        {
181            let mut cache = self.cache.write().await;
182            cache.insert(
183                key,
184                CachedPermissionEntry {
185                    decision: decision.clone(),
186                    inserted_at: Instant::now(),
187                },
188            );
189        }
190
191        Ok(decision)
192    }
193}
194
195/// Recursively sort all JSON object keys in a value tree.
196///
197/// Arrays are recursed into; leaves (string, number, bool, null) are unchanged.
198/// Uses BTreeMap for deterministic ordering regardless of serde_json's
199/// `preserve_order` feature being enabled workspace-wide.
200fn canonicalize_json(value: &serde_json::Value) -> serde_json::Value {
201    match value {
202        serde_json::Value::Object(map) => {
203            let sorted: BTreeMap<String, serde_json::Value> = map
204                .iter()
205                .map(|(k, v)| (k.clone(), canonicalize_json(v)))
206                .collect();
207            serde_json::Value::Object(sorted.into_iter().collect())
208        }
209        serde_json::Value::Array(arr) => {
210            serde_json::Value::Array(arr.iter().map(canonicalize_json).collect())
211        }
212        other => other.clone(),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use camel_api::security_policy::Principal;
220    use serde_json::json;
221    use std::sync::atomic::{AtomicUsize, Ordering};
222
223    fn test_principal() -> Principal {
224        Principal {
225            subject: "alice".into(),
226            issuer: "https://keycloak.example.com/realms/test".into(),
227            audience: vec!["camel-api".into()],
228            roles: vec!["admin".into()],
229            scopes: vec!["read".into()],
230            claims: json!({}),
231        }
232    }
233
234    fn test_request(resource: &str, context: serde_json::Value) -> PermissionRequest {
235        PermissionRequest {
236            principal: test_principal(),
237            resource: resource.into(),
238            action: "read".into(),
239            requested_scopes: vec!["read".into()],
240            context,
241        }
242    }
243
244    struct CountingEvaluator {
245        count: AtomicUsize,
246        decision: PermissionDecision,
247    }
248
249    #[async_trait]
250    impl PermissionEvaluator for CountingEvaluator {
251        async fn evaluate(
252            &self,
253            _request: PermissionRequest,
254        ) -> Result<PermissionDecision, AuthError> {
255            self.count.fetch_add(1, Ordering::SeqCst);
256            Ok(self.decision.clone())
257        }
258    }
259
260    fn default_opts() -> PermissionCacheOptions {
261        PermissionCacheOptions {
262            positive_ttl: Duration::from_secs(30),
263            negative_ttl: Duration::from_secs(5),
264            max_entries: 10_000,
265        }
266    }
267
268    #[tokio::test]
269    async fn cache_hit_avoids_repeated_call() {
270        let inner = Arc::new(CountingEvaluator {
271            count: AtomicUsize::new(0),
272            decision: PermissionDecision::Granted,
273        });
274        let caching = CachingPermissionEvaluator::new(inner.clone(), default_opts());
275
276        let req = test_request("/orders/123", json!({}));
277        let d1 = caching.evaluate(req.clone()).await.unwrap();
278        let d2 = caching.evaluate(req.clone()).await.unwrap();
279
280        assert_eq!(d1, PermissionDecision::Granted);
281        assert_eq!(d2, PermissionDecision::Granted);
282        assert_eq!(inner.count.load(Ordering::SeqCst), 1);
283    }
284
285    #[tokio::test]
286    async fn cache_negative_ttl_shorter() {
287        let inner = Arc::new(CountingEvaluator {
288            count: AtomicUsize::new(0),
289            decision: PermissionDecision::Denied {
290                reason: "forbidden".into(),
291            },
292        });
293        let opts = PermissionCacheOptions {
294            positive_ttl: Duration::from_secs(30),
295            negative_ttl: Duration::from_millis(50),
296            max_entries: 10_000,
297        };
298        let caching = CachingPermissionEvaluator::new(inner.clone(), opts);
299
300        let req = test_request("/secret", json!({}));
301        let d1 = caching.evaluate(req.clone()).await.unwrap();
302        assert!(matches!(d1, PermissionDecision::Denied { .. }));
303
304        tokio::time::sleep(Duration::from_millis(100)).await;
305
306        let d2 = caching.evaluate(req.clone()).await.unwrap();
307        assert!(matches!(d2, PermissionDecision::Denied { .. }));
308
309        // Inner evaluator was called twice — once initially, once after negative TTL expired.
310        assert_eq!(inner.count.load(Ordering::SeqCst), 2);
311    }
312
313    #[test]
314    fn cache_key_is_deterministic() {
315        let req = test_request("/orders/123", json!({"source": "api"}));
316        let key1 = CachingPermissionEvaluator::cache_key(&req);
317        let key2 = CachingPermissionEvaluator::cache_key(&req);
318        assert_eq!(key1, key2, "same request must produce the same key");
319        assert_eq!(key1.len(), 64, "SHA-256 hex digest is 64 chars");
320    }
321
322    #[test]
323    fn cache_key_differs_for_different_resources() {
324        let req_a = test_request("/orders/123", json!({}));
325        let req_b = test_request("/orders/456", json!({}));
326        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
327        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
328        assert_ne!(
329            key_a, key_b,
330            "different resources must produce different keys"
331        );
332    }
333
334    #[test]
335    fn cache_key_stable_for_json_context_with_same_semantics() {
336        // serde_json serialises maps with sorted keys, so {"b":"2","a":"1"} and
337        // {"a":"1","b":"2"} must produce identical cache keys.
338        let req_a = test_request("/orders", json!({"b":"2","a":"1"}));
339        let req_b = test_request("/orders", json!({"a":"1","b":"2"}));
340        let key_a = CachingPermissionEvaluator::cache_key(&req_a);
341        let key_b = CachingPermissionEvaluator::cache_key(&req_b);
342        assert_eq!(
343            key_a, key_b,
344            "semantically equivalent JSON contexts must produce the same key"
345        );
346    }
347
348    #[test]
349    fn options_default_values() {
350        let opts = PermissionCacheOptions::default();
351        assert_eq!(opts.positive_ttl, Duration::from_secs(30));
352        assert_eq!(opts.negative_ttl, Duration::from_secs(5));
353        assert_eq!(opts.max_entries, 10_000);
354    }
355
356    #[test]
357    fn debug_does_not_leak_inner_state() {
358        let inner = Arc::new(CountingEvaluator {
359            count: AtomicUsize::new(0),
360            decision: PermissionDecision::Granted,
361        });
362        let caching = CachingPermissionEvaluator::new(inner, default_opts());
363        let debug = format!("{caching:?}");
364        assert!(debug.contains("CachingPermissionEvaluator"));
365        assert!(debug.contains("positive_ttl"));
366        assert!(debug.contains("negative_ttl"));
367    }
368}