dynamic_config_store_core/credential.rs
1//! A credential that expires, and getting another one before it does.
2//!
3//! Three store crates cache a token: Consul, Vault and Firestore. They used to
4//! keep three copies of the same `Session`/`Token` pair, and the copies had
5//! drifted only in the ways their stores actually differ. This is the part
6//! that was the same in all three — *when* to obtain a credential — with the
7//! parts that were not left where they belong, in the store.
8//!
9//! # What the three stores agree on
10//!
11//! | | Consul | Vault | Firestore |
12//! |---|---|---|---|
13//! | Refreshed within [`REFRESH_WITHIN`] of expiry | ✓ | ✓ | ✓ |
14//! | Expiry is a local `Instant` plus a server-reported TTL | ✓ | ✓ | ✓ |
15//! | No TTL means never refreshed | ✓ | ✓ | ✓ |
16//! | A TTL too large to represent means no expiry | ✓ | ✓ | ✓ |
17//! | One `Mutex`, held across obtaining | ✓ | ✓ | ✓ |
18//! | A failed obtain leaves the old credential in place | ✓ | ✓ | ✓ |
19//! | Reactively invalidated after the store refuses it | ✓ | ✓ | ✓ |
20//!
21//! That is [`Cached<T>`]: seven rows, no exceptions.
22//!
23//! # What they do not agree on, and where it stays
24//!
25//! | | Consul | Vault | Firestore |
26//! |---|---|---|---|
27//! | What a refusal looks like | 403 | 403 | 401 |
28//! | Can renew rather than re-obtain | — | when the token says so | — |
29//! | A credential-free mode | `Auth::Anonymous` | — | `Auth::Emulator` |
30//! | A credential handed in from outside | `Auth::Token` | `Auth::Token` | `Auth::AccessToken` |
31//!
32//! None of those four reach this module, and that is the design rather than an
33//! omission:
34//!
35//! - **The refusal status** is read from the store's own typed HTTP error, which
36//! this crate never sees. Sorting a `ureq::Error` here would mean this crate
37//! knowing which status each service uses to mean *your token is dead*, which
38//! is exactly the knowledge that belongs beside the endpoint.
39//! - **Renewal** is Vault's alone — Consul issues login tokens and expects
40//! another login, and Firestore's metadata server cannot extend anything. It
41//! is expressed by the `obtain` closure being handed the credential it is
42//! replacing: a store that can renew renews, and one that cannot ignores the
43//! argument.
44//! - **The credential-free and handed-in modes never reach a cache at all.**
45//! `Auth::Anonymous` presents no token and `Auth::Token` presents the same
46//! string forever; wrapping either in a cache would only add a lock to a
47//! value that cannot change. Each store answers those before it asks here.
48//!
49//! # The margin is the only defence against clock skew
50//!
51//! Expiry is computed from a *local* `Instant` plus a *server-reported* TTL, so
52//! any disagreement between the server's issue time and our receipt time comes
53//! straight out of the margin. That is why it is a minute rather than a second.
54
55use std::sync::Mutex;
56use std::time::{Duration, Instant};
57
58use dynamic_config::Error;
59
60/// How close to expiry a credential may get before it is refreshed.
61///
62/// One name and one value across the token-caching store crates, on purpose.
63/// The margin is also the only cushion against clock skew: expiry is computed
64/// from a *local* `Instant` plus a *server-reported* TTL, so any disagreement
65/// between the server's issue time and our receipt time eats into it. A minute
66/// absorbs the skew a real fleet actually has.
67pub const REFRESH_WITHIN: Duration = Duration::from_secs(60);
68
69/// Where a Kubernetes service-account token is mounted, by convention.
70pub const SERVICE_ACCOUNT_TOKEN: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
71
72/// What an `obtain` returns: the credential, and how long the server says it
73/// lives.
74///
75/// `ttl` is `None` for a credential with no expiry — a Vault root token, a
76/// Consul token its auth method put no expiry on. Filtering a server's zero
77/// into `None` is the store's job, because zero means *does not expire* in
78/// Vault's vocabulary and *no answer* in nobody's.
79pub struct Issued<T> {
80 /// The credential itself.
81 pub value: T,
82 /// How long the server said it lives, from now.
83 pub ttl: Option<Duration>,
84}
85
86/// What is currently held, and until when.
87struct Held<T> {
88 value: T,
89 /// `None` for a credential that does not expire.
90 expires_at: Option<Instant>,
91}
92
93/// A credential that expires and can be obtained again.
94///
95/// A `Mutex` rather than a lock-free cell: obtaining twice concurrently is
96/// harmless but wasteful, and this sits on the once-per-refresh path rather
97/// than the once-per-request one. The lock is held *across* obtaining, so N
98/// threads arriving at an expired credential together produce one request
99/// rather than N.
100pub struct Cached<T> {
101 held: Mutex<Option<Held<T>>>,
102 margin: Duration,
103}
104
105impl<T> Cached<T> {
106 /// An empty cache, refreshing within [`REFRESH_WITHIN`] of expiry.
107 #[must_use]
108 pub const fn new() -> Self {
109 Self::with_margin(REFRESH_WITHIN)
110 }
111
112 /// An empty cache with a margin of its own.
113 ///
114 /// For tests, which cannot wait a minute to watch a margin work.
115 #[must_use]
116 pub const fn with_margin(margin: Duration) -> Self {
117 Self {
118 held: Mutex::new(None),
119 margin,
120 }
121 }
122
123 /// Drops what is held, so the next [`get`](Self::get) obtains.
124 ///
125 /// The reactive door: a store that has just been told its credential is no
126 /// longer accepted calls this and tries once more.
127 pub fn invalidate(&self) {
128 *self.lock() = None;
129 }
130
131 /// Whether this credential is close enough to expiry to replace.
132 fn is_stale(&self, held: &Held<T>) -> bool {
133 held.expires_at.is_some_and(|expires_at| {
134 expires_at.saturating_duration_since(Instant::now()) < self.margin
135 })
136 }
137
138 fn lock(&self) -> std::sync::MutexGuard<'_, Option<Held<T>>> {
139 self.held
140 .lock()
141 .unwrap_or_else(std::sync::PoisonError::into_inner)
142 }
143}
144
145impl<T: Clone> Cached<T> {
146 /// The current credential, obtaining or refreshing it as needed.
147 ///
148 /// `obtain` is handed the credential it is replacing, if there is one and
149 /// it has merely gone stale — which is what lets Vault renew a token
150 /// rather than log in again. It is `None` on the first call and after
151 /// [`invalidate`](Self::invalidate), because there is then nothing to
152 /// extend.
153 ///
154 /// `obtain` is a closure rather than a trait method so this module stays
155 /// free of HTTP: what it decides is *when*, not *how*.
156 ///
157 /// # Errors
158 ///
159 /// Whatever `obtain` reports. What was held survives a failed obtain: a
160 /// credential that is merely close to expiry still works, and throwing it
161 /// away because a refresh failed would turn a recoverable moment into an
162 /// outage.
163 pub fn get(
164 &self,
165 obtain: impl FnOnce(Option<&T>) -> Result<Issued<T>, Error>,
166 ) -> Result<T, Error> {
167 let mut held = self.lock();
168
169 if let Some(current) = held.as_ref() {
170 if !self.is_stale(current) {
171 return Ok(current.value.clone());
172 }
173 }
174
175 let issued = obtain(held.as_ref().map(|current| ¤t.value))?;
176 let value = issued.value.clone();
177
178 *held = Some(Held {
179 value: issued.value,
180 // `checked_add` because the TTL comes from the server: one
181 // answering with a nonsense number would otherwise panic the
182 // process on the arithmetic. Too large to represent is treated as
183 // no expiry, which is what a number that large means anyway.
184 expires_at: issued.ttl.and_then(|ttl| Instant::now().checked_add(ttl)),
185 });
186
187 Ok(value)
188 }
189}
190
191impl<T> Default for Cached<T> {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197// Hand-written, never derived: `T` is a credential, and a derive would print
198// it. `{:?}` reaching a log is an ordinary accident — a `dbg!`, a
199// `tracing::debug!(?source)` — and an accident must not disclose a secret.
200// `try_lock`, because a `Debug` that can block is a `Debug` that can deadlock
201// the thread already holding the lock.
202impl<T> std::fmt::Debug for Cached<T> {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 let held = match self.held.try_lock() {
205 Ok(held) => held,
206 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
207 Err(std::sync::TryLockError::WouldBlock) => {
208 return f.debug_struct("Cached").finish_non_exhaustive();
209 }
210 };
211
212 f.debug_struct("Cached")
213 .field("value", &held.as_ref().map(|_| "***"))
214 .field(
215 "expires_at",
216 &held.as_ref().and_then(|held| held.expires_at),
217 )
218 .field("margin", &self.margin)
219 .finish()
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use std::sync::atomic::{AtomicUsize, Ordering};
226
227 use super::*;
228
229 /// An `obtain` that counts its calls and hands back a credential with
230 /// `ttl`.
231 fn counted(
232 calls: &AtomicUsize,
233 ttl: Option<Duration>,
234 ) -> impl Fn(Option<&String>) -> Result<Issued<String>, Error> + '_ {
235 move |_| {
236 let count = calls.fetch_add(1, Ordering::SeqCst);
237
238 Ok(Issued {
239 value: format!("token-{count}"),
240 ttl,
241 })
242 }
243 }
244
245 #[test]
246 fn a_credential_is_obtained_once_and_then_reused() {
247 let calls = AtomicUsize::new(0);
248 let cached = Cached::new();
249 let obtain = counted(&calls, Some(Duration::from_secs(3600)));
250
251 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
252 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
253 assert_eq!(calls.load(Ordering::SeqCst), 1);
254 }
255
256 #[test]
257 fn a_credential_inside_the_margin_is_obtained_again() {
258 let calls = AtomicUsize::new(0);
259 let cached = Cached::new();
260 // Half the margin: still valid, and close enough that a request
261 // starting now might outlive it.
262 let obtain = counted(&calls, Some(REFRESH_WITHIN / 2));
263
264 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
265 assert_eq!(cached.get(&obtain).unwrap(), "token-1");
266 assert_eq!(calls.load(Ordering::SeqCst), 2);
267 }
268
269 #[test]
270 fn a_credential_with_no_ttl_is_never_refreshed() {
271 let calls = AtomicUsize::new(0);
272 let cached = Cached::new();
273 let obtain = counted(&calls, None);
274
275 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
276 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
277 assert_eq!(
278 calls.load(Ordering::SeqCst),
279 1,
280 "a root token does not expire"
281 );
282 }
283
284 #[test]
285 fn a_ttl_too_large_to_represent_is_treated_as_no_expiry() {
286 // A server answering with nonsense must not be able to panic the
287 // process on `Instant + Duration`.
288 let calls = AtomicUsize::new(0);
289 let cached = Cached::new();
290 let obtain = counted(&calls, Some(Duration::from_secs(u64::MAX)));
291
292 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
293 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
294 assert_eq!(calls.load(Ordering::SeqCst), 1);
295 }
296
297 #[test]
298 fn invalidating_forces_the_next_get_to_obtain() {
299 let calls = AtomicUsize::new(0);
300 let cached = Cached::new();
301 let obtain = counted(&calls, Some(Duration::from_secs(3600)));
302
303 assert_eq!(cached.get(&obtain).unwrap(), "token-0");
304
305 cached.invalidate();
306
307 assert_eq!(
308 cached.get(&obtain).unwrap(),
309 "token-1",
310 "a refusal must be able to force a fresh credential"
311 );
312 }
313
314 #[test]
315 fn a_stale_credential_is_offered_to_obtain_so_it_can_be_renewed() {
316 let cached = Cached::new();
317
318 assert_eq!(
319 cached
320 .get(|previous| {
321 assert!(previous.is_none(), "there is nothing to renew yet");
322
323 Ok(Issued {
324 value: "first".to_owned(),
325 ttl: Some(REFRESH_WITHIN / 2),
326 })
327 })
328 .unwrap(),
329 "first"
330 );
331
332 assert_eq!(
333 cached
334 .get(|previous| {
335 // Vault's renewal presents the token it is extending; it
336 // can only do that if the stale one is handed over.
337 assert_eq!(previous.map(String::as_str), Some("first"));
338
339 Ok(Issued {
340 value: "renewed".to_owned(),
341 ttl: Some(Duration::from_secs(3600)),
342 })
343 })
344 .unwrap(),
345 "renewed"
346 );
347 }
348
349 #[test]
350 fn an_invalidated_credential_is_not_offered_to_obtain() {
351 // The reactive path exists because the credential stopped working:
352 // handing it back would invite a renewal of something the store has
353 // already refused.
354 let cached = Cached::new();
355
356 cached
357 .get(|_| {
358 Ok(Issued {
359 value: "first".to_owned(),
360 ttl: Some(Duration::from_secs(3600)),
361 })
362 })
363 .unwrap();
364
365 cached.invalidate();
366
367 cached
368 .get(|previous| {
369 assert!(previous.is_none(), "there is nothing left to renew");
370
371 Ok(Issued {
372 value: "second".to_owned(),
373 ttl: None,
374 })
375 })
376 .unwrap();
377 }
378
379 #[test]
380 fn a_failed_obtain_leaves_the_previous_credential_in_place() {
381 let cached = Cached::new();
382
383 assert_eq!(
384 cached
385 .get(|_| Ok(Issued {
386 value: "first".to_owned(),
387 // Inside the margin, so the next `get` tries to replace it.
388 ttl: Some(REFRESH_WITHIN / 2),
389 }))
390 .unwrap(),
391 "first"
392 );
393
394 let error = cached
395 .get(|_| Err::<Issued<String>, _>(Error::remote("the store is away")))
396 .expect_err("obtaining failed");
397
398 assert!(error.to_string().contains("the store is away"), "{error}");
399
400 cached
401 .get(|previous| {
402 assert_eq!(
403 previous.map(String::as_str),
404 Some("first"),
405 "a refresh that failed must not throw away a credential \
406 that still works"
407 );
408
409 Ok(Issued {
410 value: "second".to_owned(),
411 ttl: None,
412 })
413 })
414 .unwrap();
415 }
416
417 /// The thundering herd: N threads arriving at an empty cache together
418 /// must produce one login, not N. The lock is held across obtaining for
419 /// exactly this reason, and none of the three stores tested it before the
420 /// machinery lived in one place.
421 #[test]
422 fn concurrent_gets_obtain_once() {
423 const THREADS: usize = 8;
424
425 let calls = AtomicUsize::new(0);
426 let cached: Cached<String> = Cached::new();
427
428 std::thread::scope(|scope| {
429 for _ in 0..THREADS {
430 scope.spawn(|| {
431 let token = cached
432 .get(|_| {
433 calls.fetch_add(1, Ordering::SeqCst);
434 // Long enough that every other thread is waiting
435 // on the lock by the time this returns.
436 std::thread::sleep(Duration::from_millis(50));
437
438 Ok(Issued {
439 value: "shared".to_owned(),
440 ttl: Some(Duration::from_secs(3600)),
441 })
442 })
443 .unwrap();
444
445 assert_eq!(token, "shared");
446 });
447 }
448 });
449
450 assert_eq!(
451 calls.load(Ordering::SeqCst),
452 1,
453 "eight readers finding an empty cache is one login, not eight"
454 );
455 }
456
457 #[test]
458 fn debug_never_prints_the_credential() {
459 let cached = Cached::new();
460
461 cached
462 .get(|_| {
463 Ok(Issued {
464 value: "hunter2-token".to_owned(),
465 ttl: Some(Duration::from_secs(3600)),
466 })
467 })
468 .unwrap();
469
470 let printed = format!("{cached:?}");
471
472 assert!(!printed.contains("hunter2"), "{printed}");
473 assert!(printed.contains("***"), "{printed}");
474 }
475
476 #[test]
477 fn debug_does_not_block_on_a_held_lock() {
478 // A `Debug` that waits for the lock deadlocks the thread that already
479 // holds it — a `dbg!` inside an `obtain` would hang the process.
480 let cached: Cached<String> = Cached::new();
481
482 cached
483 .get(|_| {
484 let printed = format!("{cached:?}");
485
486 assert!(!printed.contains("hunter2"), "{printed}");
487
488 Ok(Issued {
489 value: "hunter2-token".to_owned(),
490 ttl: None,
491 })
492 })
493 .unwrap();
494 }
495}