Skip to main content

rustlavel_db/
credentials.rs

1//! Credentials that change while the process is running.
2//!
3//! A password in `.env` is read once and never changes. A *dynamic* credential
4//! — an account a secret store creates for this process and deletes when its
5//! lease ends — does change, and a long-lived process has to cope with that
6//! without restarting. This is the piece that lets it.
7//!
8//! # What actually breaks, and what does not
9//!
10//! Measured against PostgreSQL 16 rather than assumed, because the answer
11//! decides the whole design:
12//!
13//! - A connection that is **already open** keeps working after its account is
14//!   dropped. Authentication happens once, at connect time, and is not checked
15//!   again per query. No query fails mid-flight, and no transaction is torn in
16//!   half.
17//! - A **new** connection with the old credentials is refused outright
18//!   (`28P01 password authentication failed`).
19//!
20//! So rotation is not the frightening thing it sounds like. Nothing has to be
21//! interrupted; the pool only has to stop *reusing* connections that belong to
22//! a superseded credential, and open new ones with the current one. That is
23//! what the generation counter here is for, and it is the same shape as
24//! HikariCP's `softEvictConnections` — idle connections go now, busy ones go
25//! when their borrower is finished with them.
26//!
27//! Retiring the old connections matters even though they still work: the point
28//! of a short-lived credential is that access ends when the lease does, and a
29//! pool quietly holding a session opened with a revoked account keeps that
30//! access alive for as long as the process runs.
31//!
32//! # Wiring it up
33//!
34//! Deliberately not automatic, and deliberately not aware of any secret store —
35//! this crate does not depend on `rustlavel-vault`, and an application may get
36//! its credentials from somewhere this framework has never heard of:
37//!
38//! ```ignore
39//! let credentials = Credentials::new("v-token-app-abc", "…");
40//! let mut config = DatabaseConfig::from_url(&url)?;
41//! config.credentials = Some(credentials.clone());
42//! let db = Database::with_config(config).await?;
43//!
44//! // Wherever the new credential comes from, when the lease can no longer be
45//! // renewed:
46//! let fresh = vault.database().credentials("app").await?;
47//! credentials.rotate(fresh.username, fresh.password);
48//! ```
49
50use std::sync::atomic::{AtomicU64, Ordering};
51use std::sync::{Arc, RwLock};
52
53/// A username and password that may be replaced while the process runs.
54///
55/// Cheap to clone — every clone shares one set of credentials, which is the
56/// point: the task that fetches a new one and the pool that opens connections
57/// with it are looking at the same value.
58#[derive(Clone)]
59pub struct Credentials {
60    inner: Arc<Inner>,
61}
62
63struct Inner {
64    current: RwLock<(String, String)>,
65    /// Bumped on every rotation. A connection remembers the generation it was
66    /// opened under, which is how the pool tells a usable connection from one
67    /// belonging to a credential that has been replaced.
68    generation: AtomicU64,
69}
70
71impl Credentials {
72    pub fn new(user: impl Into<String>, password: impl Into<String>) -> Credentials {
73        Credentials {
74            inner: Arc::new(Inner {
75                current: RwLock::new((user.into(), password.into())),
76                generation: AtomicU64::new(1),
77            }),
78        }
79    }
80
81    /// The credentials to open the next connection with.
82    pub fn current(&self) -> (String, String) {
83        match self.inner.current.read() {
84            Ok(current) => current.clone(),
85            // A poisoned lock means a writer panicked mid-rotation. The value
86            // is still a complete pair — the write is one assignment — and
87            // refusing to connect over it would turn a panic somewhere else
88            // into an outage here.
89            Err(poisoned) => poisoned.into_inner().clone(),
90        }
91    }
92
93    pub fn user(&self) -> String {
94        self.current().0
95    }
96
97    /// Which generation the current credentials belong to.
98    pub fn generation(&self) -> u64 {
99        self.inner.generation.load(Ordering::Acquire)
100    }
101
102    /// Replace them, and retire every connection opened with the old ones.
103    ///
104    /// Returns the new generation. Rotating to the *same* values still counts
105    /// as a rotation: a caller doing that is saying the old connections are no
106    /// longer wanted, and second-guessing it here would silently keep them.
107    pub fn rotate(&self, user: impl Into<String>, password: impl Into<String>) -> u64 {
108        let pair = (user.into(), password.into());
109
110        match self.inner.current.write() {
111            Ok(mut current) => *current = pair,
112            Err(poisoned) => *poisoned.into_inner() = pair,
113        }
114
115        // Released after the write, so nothing can read the new generation and
116        // then the old credentials.
117        self.inner.generation.fetch_add(1, Ordering::AcqRel) + 1
118    }
119
120    /// Whether a connection opened under `generation` is still current.
121    pub fn is_current(&self, generation: u64) -> bool {
122        generation == self.generation()
123    }
124}
125
126/// Names the user, never the password.
127impl std::fmt::Debug for Credentials {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("Credentials")
130            .field("user", &self.user())
131            .field("password", &"<redacted>")
132            .field("generation", &self.generation())
133            .finish()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn hands_out_what_it_was_given() {
143        let credentials = Credentials::new("app", "s3cr3t");
144
145        assert_eq!(credentials.current(), ("app".to_string(), "s3cr3t".to_string()));
146        assert_eq!(credentials.user(), "app");
147    }
148
149    #[test]
150    fn rotating_replaces_the_pair_and_moves_the_generation_on() {
151        let credentials = Credentials::new("old-user", "old-pass");
152        let first = credentials.generation();
153
154        let second = credentials.rotate("new-user", "new-pass");
155
156        assert_eq!(credentials.current(), ("new-user".to_string(), "new-pass".to_string()));
157        assert_eq!(second, first + 1);
158        assert!(!credentials.is_current(first), "connections from before must be retired");
159        assert!(credentials.is_current(second));
160    }
161
162    #[test]
163    fn every_clone_sees_the_rotation() {
164        // The property the whole design rests on: the task fetching a new
165        // credential and the pool opening connections hold the same value.
166        let credentials = Credentials::new("old", "old");
167        let held_elsewhere = credentials.clone();
168
169        credentials.rotate("new", "new");
170
171        assert_eq!(held_elsewhere.user(), "new");
172        assert_eq!(held_elsewhere.generation(), credentials.generation());
173    }
174
175    #[test]
176    fn rotating_to_the_same_values_still_retires_the_old_connections() {
177        // Vault can hand out the same username twice in principle, and a caller
178        // that rotates is saying "stop using what you have" either way.
179        let credentials = Credentials::new("app", "same");
180        let before = credentials.generation();
181
182        credentials.rotate("app", "same");
183
184        assert!(!credentials.is_current(before));
185    }
186
187    #[test]
188    fn generations_keep_climbing_across_many_rotations() {
189        let credentials = Credentials::new("a", "a");
190        let start = credentials.generation();
191
192        for round in 1..=100 {
193            assert_eq!(credentials.rotate("a", "a"), start + round);
194        }
195    }
196
197    #[test]
198    fn debug_prints_the_user_but_never_the_password() {
199        // The user is genuinely useful in a log — it is how you tell which
200        // dynamic account a connection belongs to. The password is not.
201        let printed = format!("{:?}", Credentials::new("v-token-app-abc", "s3cr3t"));
202
203        assert!(printed.contains("v-token-app-abc"));
204        assert!(!printed.contains("s3cr3t"), "the password reached a log: {printed}");
205    }
206
207    #[test]
208    fn a_poisoned_lock_still_yields_credentials() {
209        // A panic in unrelated code must not take the database down with it.
210        let credentials = Credentials::new("app", "s3cr3t");
211        let clone = credentials.clone();
212
213        let _ = std::thread::spawn(move || {
214            let _guard = clone.inner.current.write().unwrap();
215            panic!("poisoning the lock");
216        })
217        .join();
218
219        assert_eq!(credentials.user(), "app");
220        assert_eq!(credentials.rotate("next", "next"), 2);
221        assert_eq!(credentials.user(), "next");
222    }
223}