1use 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#[derive(Debug, Clone)]
21pub struct PermissionCacheOptions {
22 pub positive_ttl: Duration,
25 pub negative_ttl: Duration,
27 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
46pub 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 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 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 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 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 {
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 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 {
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 self.evict_if_needed().await;
190
191 {
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 {
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
220fn 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 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 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 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}