Skip to main content

adk_auth/secrets/
cached.rs

1//! Cached secret provider wrapper.
2//!
3//! [`CachedSecretProvider`] wraps any [`SecretProvider`] with an in-memory cache
4//! that respects a configurable TTL, a capacity bound, and explicit revocation.
5//!
6//! # Threat model
7//!
8//! A TTL controls what the cache *returns*, not how long a value stays in process
9//! memory. This cache drops and zeroizes an entry when it expires, is evicted, or is
10//! invalidated, which shortens residency to roughly the TTL rather than the process
11//! lifetime. It cannot guarantee erasure: a `String` may have been reallocated,
12//! copied by the allocator, swapped to disk, or captured in a core dump before the
13//! zeroization runs. Treat it as reducing the window, not closing it.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19use adk_core::AdkError;
20use async_trait::async_trait;
21use tokio::sync::RwLock;
22use tokio::time::Instant;
23use zeroize::Zeroize;
24
25use super::provider::SecretProvider;
26
27/// Default number of distinct secret names held at once.
28pub const DEFAULT_MAX_ENTRIES: usize = 128;
29
30/// A cached secret value that is zeroized when dropped.
31struct CachedEntry {
32    value: String,
33    expires_at: Instant,
34    /// Monotonic read sequence, used to choose an eviction victim.
35    ///
36    /// A counter rather than a timestamp: two reads can share an `Instant`, which
37    /// would leave the victim to be decided by hash order.
38    last_access: u64,
39}
40
41impl CachedEntry {
42    fn is_expired(&self, now: Instant) -> bool {
43        self.expires_at <= now
44    }
45}
46
47impl Drop for CachedEntry {
48    fn drop(&mut self) {
49        self.value.zeroize();
50    }
51}
52
53/// Wraps a [`SecretProvider`] with a bounded in-memory cache.
54///
55/// Cached values are returned within the configured TTL. After expiry the inner
56/// provider is called again and the cache is refreshed. Expired entries are removed
57/// on the next write rather than lingering until their name is requested again, and
58/// the cache never holds more than its capacity.
59///
60/// # Example
61///
62/// ```rust,ignore
63/// use adk_auth::secrets::{CachedSecretProvider, SecretProvider};
64/// use std::time::Duration;
65///
66/// let cached = CachedSecretProvider::new(inner_provider, Duration::from_secs(300))
67///     .with_max_entries(32);
68/// let secret = cached.get_secret("my-key").await?;
69///
70/// // A rotated secret can be dropped before its TTL elapses.
71/// cached.invalidate("my-key").await;
72/// ```
73pub struct CachedSecretProvider<P: SecretProvider> {
74    inner: P,
75    cache: Arc<RwLock<HashMap<String, CachedEntry>>>,
76    ttl: Duration,
77    max_entries: usize,
78    /// Hands out the read sequence numbers used for eviction ordering.
79    access_counter: std::sync::atomic::AtomicU64,
80}
81
82impl<P: SecretProvider> CachedSecretProvider<P> {
83    /// Create a new cached provider wrapping `inner` with the given TTL.
84    pub fn new(inner: P, ttl: Duration) -> Self {
85        Self {
86            inner,
87            cache: Arc::new(RwLock::new(HashMap::new())),
88            ttl,
89            max_entries: DEFAULT_MAX_ENTRIES,
90            access_counter: std::sync::atomic::AtomicU64::new(0),
91        }
92    }
93
94    /// Set how many distinct secret names may be cached at once.
95    ///
96    /// When the cache is full the least recently used entry is dropped. A capacity of
97    /// zero disables caching. Without a bound, code that derives secret names from
98    /// input can grow the cache for the lifetime of the process.
99    #[must_use]
100    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
101        self.max_entries = max_entries;
102        self
103    }
104
105    /// Drop a single cached secret, zeroizing its value.
106    ///
107    /// Call this when a secret is rotated or revoked so the old value is not served
108    /// for the remainder of its TTL.
109    pub async fn invalidate(&self, name: &str) {
110        self.cache.write().await.remove(name);
111    }
112
113    /// Drop every cached secret, zeroizing the values.
114    pub async fn invalidate_all(&self) {
115        self.cache.write().await.clear();
116    }
117
118    /// Drop every expired entry and return how many were removed.
119    ///
120    /// Expiry is otherwise noticed only when the same name is read again, so this is
121    /// what a caller uses to bound residency without waiting for traffic.
122    pub async fn purge_expired(&self) -> usize {
123        let now = Instant::now();
124        let mut cache = self.cache.write().await;
125        let before = cache.len();
126        cache.retain(|_, entry| !entry.is_expired(now));
127        before - cache.len()
128    }
129
130    /// Number of entries currently held, expired or not.
131    pub async fn len(&self) -> usize {
132        self.cache.read().await.len()
133    }
134
135    /// Whether the cache holds no entries.
136    pub async fn is_empty(&self) -> bool {
137        self.cache.read().await.is_empty()
138    }
139
140    /// The next read sequence number.
141    fn next_access(&self) -> u64 {
142        self.access_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
143    }
144
145    /// Insert a freshly fetched value, purging expired entries and enforcing capacity.
146    async fn store(&self, name: &str, value: &str) {
147        if self.max_entries == 0 {
148            return;
149        }
150        let now = Instant::now();
151        let mut cache = self.cache.write().await;
152        cache.retain(|_, entry| !entry.is_expired(now));
153
154        while cache.len() >= self.max_entries {
155            // Least recently used victim. The cache is small by construction, so a
156            // scan costs less than maintaining a separate ordering structure.
157            let victim = cache
158                .iter()
159                .min_by_key(|(_, entry)| entry.last_access)
160                .map(|(name, _)| name.clone());
161            match victim {
162                Some(victim) => {
163                    cache.remove(&victim);
164                }
165                None => break,
166            }
167        }
168
169        cache.insert(
170            name.to_string(),
171            CachedEntry {
172                value: value.to_string(),
173                expires_at: now + self.ttl,
174                last_access: self.next_access(),
175            },
176        );
177    }
178}
179
180/// Redacts cached values so a debug print cannot leak a secret.
181impl<P: SecretProvider> std::fmt::Debug for CachedSecretProvider<P> {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("CachedSecretProvider")
184            .field("ttl", &self.ttl)
185            .field("max_entries", &self.max_entries)
186            .field("cache", &"<redacted>")
187            .finish()
188    }
189}
190
191#[async_trait]
192impl<P: SecretProvider> SecretProvider for CachedSecretProvider<P> {
193    async fn get_secret(&self, name: &str) -> Result<String, AdkError> {
194        // Check the cache first, recording the read so eviction can pick a victim.
195        {
196            let mut cache = self.cache.write().await;
197            let now = Instant::now();
198            if let Some(entry) = cache.get_mut(name) {
199                if entry.is_expired(now) {
200                    cache.remove(name);
201                } else {
202                    entry.last_access = self.next_access();
203                    return Ok(entry.value.clone());
204                }
205            }
206        }
207
208        let value = self.inner.get_secret(name).await?;
209        self.store(name, &value).await;
210        Ok(value)
211    }
212}