Skip to main content

rustlavel_vault/
database.rs

1//! Database accounts that expire on their own.
2//!
3//! This is the reason the package exists. Asking for credentials here makes
4//! Vault run a `CREATE ROLE` against the database, hand back the username and
5//! password it invented, and remember to run the matching `DROP` when the lease
6//! ends. Nobody types the password, it is never written down, and a copy pulled
7//! out of a leaked backup or an old log line is an account that no longer
8//! exists.
9//!
10//! The cost is that the credential has a deadline, so a long-lived process has
11//! to renew the lease — see [`crate::Lease::should_renew`] — or fetch new
12//! credentials and reconnect.
13
14use crate::client::VaultClient;
15use crate::error::VaultError;
16use rustlavel_core::Result;
17use crate::lease::Lease;
18use rustlavel_core::Json;
19
20/// The database secrets engine at one mount.
21pub struct DatabaseSecrets<'a> {
22    client: &'a VaultClient,
23    mount: String,
24}
25
26impl VaultClient {
27    /// The database secrets engine at the default `database/` mount.
28    pub fn database(&self) -> DatabaseSecrets<'_> {
29        DatabaseSecrets { client: self, mount: "database".into() }
30    }
31}
32
33impl<'a> DatabaseSecrets<'a> {
34    /// The same engine mounted elsewhere.
35    pub fn mount(mut self, mount: impl Into<String>) -> DatabaseSecrets<'a> {
36        self.mount = mount.into().trim_matches('/').to_string();
37        self
38    }
39
40    /// A new account for a role, created on the spot.
41    ///
42    /// Each call makes a *different* account. Calling it per query rather than
43    /// per process will fill the database with roles and exhaust whatever
44    /// connection limit it has, so hold the result for the life of the lease.
45    pub async fn credentials(&self, role: &str) -> Result<DatabaseCredentials> {
46        let path = format!("{}/creds/{role}", self.mount);
47        let response = self.client.get(&path).await?;
48
49        let missing = |field: &str| VaultError::Malformed {
50            path: path.clone(),
51            message: format!("the credentials carried no `{field}`"),
52        };
53
54        Ok(DatabaseCredentials {
55            username: response.string("username").ok_or_else(|| missing("username"))?,
56            password: response.string("password").ok_or_else(|| missing("password"))?,
57            lease: response.lease,
58        })
59    }
60
61    /// Change the password Vault itself uses to reach the database.
62    ///
63    /// Worth doing right after the engine is configured: until it runs, the
64    /// root password is whatever an operator typed into a terminal, and
65    /// afterwards it is a value only Vault has ever seen. It is one-way — there
66    /// is no call that reads the new password back — so an operator who needs
67    /// superuser access again has to reset it out of band.
68    pub async fn rotate_root(&self, name: &str) -> Result<()> {
69        self.client.post(&format!("{}/rotate-root/{name}", self.mount), Json::Null).await.map(|_| ())
70    }
71
72    /// The credentials for a *static* role: an account that already exists and
73    /// whose password Vault rotates on a schedule.
74    ///
75    /// Unlike [`DatabaseSecrets::credentials`] this returns the same username
76    /// every time, and the lease is only how long until the next rotation.
77    pub async fn static_credentials(&self, role: &str) -> Result<DatabaseCredentials> {
78        let path = format!("{}/static-creds/{role}", self.mount);
79        let response = self.client.get(&path).await?;
80
81        let missing = |field: &str| VaultError::Malformed {
82            path: path.clone(),
83            message: format!("the static credentials carried no `{field}`"),
84        };
85
86        // A static role reports `ttl` rather than a lease: there is nothing to
87        // renew, because the account outlives any one reader.
88        let ttl = response.json().get("data.ttl").and_then(Json::as_i64).unwrap_or(0).max(0);
89
90        Ok(DatabaseCredentials {
91            username: response.string("username").ok_or_else(|| missing("username"))?,
92            password: response.string("password").ok_or_else(|| missing("password"))?,
93            lease: Lease::new(String::new(), std::time::Duration::from_secs(ttl as u64), false),
94        })
95    }
96}
97
98/// A database account and the lease that keeps it alive.
99pub struct DatabaseCredentials {
100    username: String,
101    password: String,
102    lease: Lease,
103}
104
105impl DatabaseCredentials {
106    /// The username Vault invented, of the form `v-approle-app-read-…`.
107    ///
108    /// Not secret, and the one value worth logging: it is how an operator
109    /// matches a connection in `pg_stat_activity` back to the role and the
110    /// lease that issued it.
111    pub fn username(&self) -> &str {
112        &self.username
113    }
114
115    pub fn password(&self) -> &str {
116        &self.password
117    }
118
119    /// When the account will be deleted, and whether the deadline can be moved.
120    pub fn lease(&self) -> &Lease {
121        &self.lease
122    }
123}
124
125impl std::fmt::Debug for DatabaseCredentials {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("DatabaseCredentials")
128            .field("username", &self.username)
129            .field("password", &"<redacted>")
130            .field("lease", &self.lease)
131            .finish()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use rustlavel_client::{Fake, FakeResponse};
139    use std::time::Duration;
140
141    /// The exact body OpenBao 2.6.2 returns from `GET /v1/database/creds/…`.
142    fn issued() -> FakeResponse {
143        FakeResponse::json(
144            Json::parse(
145                r#"{"request_id":"a9fcdc81",
146                    "lease_id":"database/creds/app-readwrite/PTf7VNozTwLK2l8KYsstMdX6",
147                    "renewable":true,"lease_duration":3600,
148                    "data":{"password":"nW-5kDZyVj5YlYi6TOoN",
149                            "username":"v-token-app-read-J6rJCj3Vvkkp17lejPor-1788011369"},
150                    "wrap_info":null,"warnings":null,"auth":null}"#,
151            )
152            .unwrap(),
153        )
154    }
155
156    fn vault(fake: Fake) -> VaultClient {
157        VaultClient::new("https://vault.test:8200").faking(fake)
158    }
159
160    #[tokio::test]
161    async fn issuing_credentials_returns_the_account_and_its_deadline() {
162        let client = vault(Fake::new().on("database/creds/app-readwrite", issued()));
163
164        let creds = client.database().credentials("app-readwrite").await.unwrap();
165
166        assert!(creds.username().starts_with("v-token-app-read-"));
167        assert_eq!(creds.password(), "nW-5kDZyVj5YlYi6TOoN");
168        assert_eq!(creds.lease().duration(), Duration::from_secs(3600));
169        assert!(creds.lease().renewable());
170        // The lease id is what revokes the account, so losing it would leave a
171        // credential alive until its TTL with no way to shorten it.
172        assert_eq!(
173            creds.lease().id(),
174            "database/creds/app-readwrite/PTf7VNozTwLK2l8KYsstMdX6"
175        );
176    }
177
178    #[tokio::test]
179    async fn an_unknown_role_says_so_in_vaults_words() {
180        let unknown = FakeResponse::text(r#"{"errors":["unknown role: nope"]}"#).status(400);
181        let client = vault(Fake::new().fallback(unknown));
182
183        let error = client.database().credentials("nope").await.unwrap_err();
184
185        assert!(error.to_string().contains("unknown role: nope"), "{error}");
186        // Observable rather than introspective: a bad request is not retried,
187        // so exactly one went out.
188        assert_eq!(client.fake().unwrap().count(), 1);
189    }
190
191    #[tokio::test]
192    async fn rotating_the_root_password_answers_with_no_content() {
193        let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
194
195        client.database().rotate_root("appdb").await.unwrap();
196
197        client.fake().unwrap().assert_sent("/v1/database/rotate-root/appdb");
198    }
199
200    #[tokio::test]
201    async fn a_static_role_has_a_ttl_rather_than_a_lease() {
202        let body = FakeResponse::json(
203            Json::parse(
204                r#"{"lease_id":"","renewable":false,"lease_duration":0,
205                    "data":{"last_vault_rotation":"2026-08-29T13:00:00Z",
206                            "password":"rotated","rotation_period":86400,
207                            "ttl":3600,"username":"app_static"}}"#,
208            )
209            .unwrap(),
210        );
211        let client = vault(Fake::new().fallback(body));
212
213        let creds = client.database().static_credentials("app-static").await.unwrap();
214
215        assert_eq!(creds.username(), "app_static");
216        assert_eq!(creds.lease().duration(), Duration::from_secs(3600));
217        // Nothing to renew: the account outlives any one reader, and Vault
218        // rotates the password on its own schedule.
219        assert!(!creds.lease().renewable());
220        client.fake().unwrap().assert_sent("/v1/database/static-creds/app-static");
221    }
222
223    #[tokio::test]
224    async fn the_mount_is_configurable() {
225        let client = vault(Fake::new().fallback(issued()));
226
227        client.database().mount("postgres-prod").credentials("app").await.unwrap();
228
229        client.fake().unwrap().assert_sent("/v1/postgres-prod/creds/app");
230    }
231
232    #[tokio::test]
233    async fn a_response_missing_the_password_is_a_failure_not_an_empty_string() {
234        let half = FakeResponse::json(Json::parse(r#"{"data":{"username":"v-token-1"}}"#).unwrap());
235        let client = vault(Fake::new().fallback(half));
236
237        let error = client.database().credentials("app").await.unwrap_err().to_string();
238
239        assert!(error.contains("`password`"), "{error}");
240    }
241
242    #[test]
243    fn debug_prints_the_username_and_not_the_password() {
244        let creds = DatabaseCredentials {
245            username: "v-token-app-read-1".into(),
246            password: "nW-5kDZyVj5YlYi6TOoN".into(),
247            lease: Lease::none(),
248        };
249
250        let printed = format!("{creds:?}");
251        assert!(printed.contains("v-token-app-read-1"), "{printed}");
252        assert!(!printed.contains("nW-5kDZyVj5YlYi6TOoN"), "{printed}");
253    }
254}