Skip to main content

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| &current.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}