rustlavel_vault/
database.rs1use crate::client::VaultClient;
15use crate::error::VaultError;
16use rustlavel_core::Result;
17use crate::lease::Lease;
18use rustlavel_core::Json;
19
20pub struct DatabaseSecrets<'a> {
22 client: &'a VaultClient,
23 mount: String,
24}
25
26impl VaultClient {
27 pub fn database(&self) -> DatabaseSecrets<'_> {
29 DatabaseSecrets { client: self, mount: "database".into() }
30 }
31}
32
33impl<'a> DatabaseSecrets<'a> {
34 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 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 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 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 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
98pub struct DatabaseCredentials {
100 username: String,
101 password: String,
102 lease: Lease,
103}
104
105impl DatabaseCredentials {
106 pub fn username(&self) -> &str {
112 &self.username
113 }
114
115 pub fn password(&self) -> &str {
116 &self.password
117 }
118
119 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 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 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 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 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}