Skip to main content

kellnr_http/
token_cache.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Duration;
3
4use kellnr_common::account_name::UserName;
5use moka::future::Cache;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct CachedTokenData {
9    pub user: UserName,
10    pub is_admin: bool,
11    pub is_read_only: bool,
12}
13
14pub struct TokenCacheManager {
15    cache: Option<Cache<String, CachedTokenData>>,
16    /// Bumped by every [`TokenCacheManager::invalidate_all`]. A lookup that
17    /// started before an invalidation must not cache its (possibly stale)
18    /// result, so inserts are tied to the generation read before the lookup.
19    generation: AtomicU64,
20}
21
22impl TokenCacheManager {
23    /// Creates a new `TokenCacheManager`.
24    ///
25    /// # Arguments
26    /// * `enabled` - Whether caching is enabled
27    /// * `ttl_seconds` - Time-to-live for cached tokens. Lower values provide
28    ///   better security (revoked tokens expire faster) but increase database load.
29    /// * `max_capacity` - Maximum number of tokens to cache
30    pub fn new(enabled: bool, ttl: Duration, max_capacity: u64) -> Self {
31        let cache = if enabled {
32            Some(
33                Cache::builder()
34                    .max_capacity(max_capacity)
35                    .time_to_live(ttl)
36                    .build(),
37            )
38        } else {
39            None
40        };
41
42        Self {
43            cache,
44            generation: AtomicU64::new(0),
45        }
46    }
47
48    pub async fn get(&self, token: &str) -> Option<CachedTokenData> {
49        match &self.cache {
50            Some(cache) => cache.get(token).await,
51            None => None,
52        }
53    }
54
55    /// Current invalidation generation. Read it before the database lookup
56    /// whose result is passed to [`TokenCacheManager::insert`].
57    pub fn generation(&self) -> u64 {
58        self.generation.load(Ordering::SeqCst)
59    }
60
61    /// Caches `data` unless the cache was invalidated since `generation` was
62    /// read. The generation is checked again after the insert: an
63    /// invalidation racing with the insert removes the fresh entry, so a
64    /// revoked token or outdated privileges never outlive an invalidation.
65    pub async fn insert(&self, token: String, data: CachedTokenData, generation: u64) {
66        let Some(cache) = &self.cache else {
67            return;
68        };
69        if self.generation() != generation {
70            return;
71        }
72        cache.insert(token.clone(), data).await;
73        if self.generation() != generation {
74            cache.invalidate(&token).await;
75        }
76    }
77
78    pub fn invalidate_all(&self) {
79        // Bump first so in-flight lookups observe the invalidation before the
80        // entries are dropped.
81        self.generation.fetch_add(1, Ordering::SeqCst);
82        if let Some(cache) = &self.cache {
83            cache.invalidate_all();
84        }
85    }
86
87    /// Invalidate a single token from the cache.
88    pub async fn invalidate(&self, token: &str) {
89        if let Some(cache) = &self.cache {
90            cache.invalidate(token).await;
91        }
92    }
93
94    pub fn is_enabled(&self) -> bool {
95        self.cache.is_some()
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn data(is_admin: bool, is_read_only: bool) -> CachedTokenData {
104        CachedTokenData {
105            user: UserName::new("test_user").unwrap(),
106            is_admin,
107            is_read_only,
108        }
109    }
110
111    #[tokio::test]
112    async fn test_cache_disabled() {
113        let cache = TokenCacheManager::new(false, Duration::from_secs(60), 100);
114        assert!(!cache.is_enabled());
115
116        cache
117            .insert(
118                "token123".to_string(),
119                data(false, false),
120                cache.generation(),
121            )
122            .await;
123        assert!(cache.get("token123").await.is_none());
124    }
125
126    #[tokio::test]
127    async fn test_cache_enabled() {
128        let cache = TokenCacheManager::new(true, Duration::from_secs(60), 100);
129        assert!(cache.is_enabled());
130
131        cache
132            .insert(
133                "token123".to_string(),
134                data(true, false),
135                cache.generation(),
136            )
137            .await;
138
139        let retrieved = cache.get("token123").await;
140        assert!(retrieved.is_some());
141        let retrieved = retrieved.unwrap();
142        assert_eq!(retrieved.user, "test_user");
143        assert!(retrieved.is_admin);
144        assert!(!retrieved.is_read_only);
145    }
146
147    #[tokio::test]
148    async fn test_cache_miss() {
149        let cache = TokenCacheManager::new(true, Duration::from_secs(60), 100);
150        assert!(cache.get("nonexistent").await.is_none());
151    }
152
153    #[tokio::test]
154    async fn test_invalidate_all() {
155        let cache = TokenCacheManager::new(true, Duration::from_secs(60), 100);
156
157        let generation = cache.generation();
158        cache
159            .insert("token1".to_string(), data(false, true), generation)
160            .await;
161        cache
162            .insert("token2".to_string(), data(false, true), generation)
163            .await;
164
165        assert!(cache.get("token1").await.is_some());
166        assert!(cache.get("token2").await.is_some());
167
168        cache.invalidate_all();
169
170        assert!(cache.get("token1").await.is_none());
171        assert!(cache.get("token2").await.is_none());
172    }
173
174    /// A lookup that read the generation before a revocation must not cache
175    /// its result afterwards.
176    #[tokio::test]
177    async fn insert_after_invalidation_is_dropped() {
178        let cache = TokenCacheManager::new(true, Duration::from_secs(60), 100);
179
180        let generation = cache.generation();
181        cache.invalidate_all();
182        cache
183            .insert("token".to_string(), data(true, false), generation)
184            .await;
185
186        assert!(cache.get("token").await.is_none());
187    }
188
189    #[tokio::test]
190    async fn invalidate_all_bumps_generation() {
191        let cache = TokenCacheManager::new(false, Duration::from_secs(60), 100);
192        let before = cache.generation();
193
194        cache.invalidate_all();
195
196        assert_ne!(before, cache.generation());
197    }
198}