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<()>,
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 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 _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 self.evict_if_needed().await;
178
179 {
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
195fn 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 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 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}