1use 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#[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 {
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 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 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 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 {
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 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 self.evict_if_needed().await;
175
176 {
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 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 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}