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}