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}