1use crate::error::VaultError;
8use crate::lease::Lease;
9use rustlavel_client::Client;
10use rustlavel_core::{Json, Result};
11use rustlavel_http::Method;
12use std::sync::{Arc, RwLock};
13use std::time::Duration;
14
15pub const TOKEN_HEADER: &str = "X-Vault-Token";
17pub const NAMESPACE_HEADER: &str = "X-Vault-Namespace";
19pub const MERGE_PATCH_CONTENT_TYPE: &str = "application/merge-patch+json";
21
22#[derive(Debug, Clone)]
24pub struct VaultResponse {
25 pub data: Json,
27 raw: Json,
29 pub auth: Json,
31 pub lease: Lease,
32 pub warnings: Vec<String>,
35}
36
37impl VaultResponse {
38 pub fn parse(body: &str) -> Result<VaultResponse> {
40 let json = Json::parse(body)
41 .map_err(|e| VaultError::Unexpected { status: 200, message: e.to_string() })?;
42
43 let lease_id = json.get("lease_id").and_then(Json::as_str).unwrap_or("");
44 let duration = json.get("lease_duration").and_then(Json::as_i64).unwrap_or(0);
45 let renewable = json.get("renewable").and_then(Json::as_bool).unwrap_or(false);
46
47 let auth = json.get("auth").cloned().unwrap_or(Json::Null);
57 let (duration, renewable) = if lease_id.is_empty() && !auth.is_null() {
58 (
59 auth.get("lease_duration").and_then(Json::as_i64).unwrap_or(0),
60 auth.get("renewable").and_then(Json::as_bool).unwrap_or(false),
61 )
62 } else {
63 (duration, renewable)
64 };
65
66 Ok(VaultResponse {
67 data: json.get("data").cloned().unwrap_or(Json::Null),
68 raw: json.clone(),
69 auth,
70 lease: Lease::new(
71 lease_id.to_string(),
72 Duration::from_secs(duration.max(0) as u64),
73 renewable,
74 ),
75 warnings: json
76 .get("warnings")
77 .and_then(Json::as_array)
78 .map(|items| items.iter().filter_map(Json::as_str).map(str::to_string).collect())
79 .unwrap_or_default(),
80 })
81 }
82
83 pub fn json(&self) -> &Json {
86 &self.raw
87 }
88
89 pub fn string(&self, path: &str) -> Option<String> {
91 self.data.get(path).and_then(Json::as_str).map(str::to_string)
92 }
93}
94
95#[derive(Clone)]
100pub struct VaultClient {
101 address: String,
102 namespace: Option<String>,
103 token: Arc<RwLock<String>>,
104 http: Client,
105 retries: u32,
106}
107
108impl VaultClient {
109 pub fn new(address: impl Into<String>) -> VaultClient {
111 let address = address.into().trim_end_matches('/').to_string();
112
113 VaultClient {
114 address,
115 namespace: None,
116 token: Arc::new(RwLock::new(String::new())),
117 http: Client::new().timeout(Duration::from_secs(15)),
120 retries: 2,
121 }
122 }
123
124 pub fn from_env() -> Result<VaultClient> {
130 let address = first_set(&["VAULT_ADDR", "BAO_ADDR"]).ok_or_else(|| {
131 rustlavel_core::Error::msg(
132 "no secret store address. Set VAULT_ADDR (or BAO_ADDR) to something like \
133 https://vault.internal:8200.",
134 )
135 })?;
136
137 let mut client = VaultClient::new(address);
138 if let Some(token) = first_set(&["VAULT_TOKEN", "BAO_TOKEN"]) {
139 client.set_token(token);
140 }
141 if let Some(namespace) = first_set(&["VAULT_NAMESPACE", "BAO_NAMESPACE"]) {
142 client = client.namespace(namespace);
143 }
144 Ok(client)
145 }
146
147 pub fn namespace(mut self, namespace: impl Into<String>) -> VaultClient {
148 let namespace = namespace.into();
149 self.namespace = (!namespace.is_empty()).then_some(namespace);
150 self
151 }
152
153 pub fn timeout(mut self, timeout: Duration) -> VaultClient {
154 self.http = self.http.timeout(timeout);
155 self
156 }
157
158 pub fn retries(mut self, retries: u32) -> VaultClient {
160 self.retries = retries;
161 self
162 }
163
164 pub fn faking(mut self, fake: rustlavel_client::Fake) -> VaultClient {
166 self.http = self.http.faking(fake);
167 self
168 }
169
170 pub fn address(&self) -> &str {
171 &self.address
172 }
173
174 pub fn fake(&self) -> Option<&std::sync::Arc<rustlavel_client::Fake>> {
176 self.http.fake()
177 }
178
179 pub fn set_token(&self, token: impl Into<String>) {
180 if let Ok(mut current) = self.token.write() {
181 *current = token.into();
182 }
183 }
184
185 pub fn token(&self) -> String {
186 self.token.read().map(|token| token.clone()).unwrap_or_default()
187 }
188
189 pub fn clear_token(&self) {
192 self.set_token("");
193 }
194
195 pub fn has_token(&self) -> bool {
196 !self.token().is_empty()
197 }
198
199 pub fn url(&self, path: &str) -> String {
205 format!("{}/v1/{}", self.address, path.trim_start_matches('/'))
206 }
207
208 pub async fn get(&self, path: &str) -> Result<VaultResponse> {
209 self.send(Method::Get, path, None).await
210 }
211
212 pub async fn post(&self, path: &str, body: Json) -> Result<VaultResponse> {
213 self.send(Method::Post, path, Some(body)).await
214 }
215
216 pub async fn delete(&self, path: &str) -> Result<VaultResponse> {
217 self.send(Method::Delete, path, None).await
218 }
219
220 pub async fn patch(&self, path: &str, body: Json) -> Result<VaultResponse> {
226 self.send(Method::Patch, path, Some(body)).await
227 }
228
229 pub async fn list(&self, path: &str) -> Result<VaultResponse> {
232 self.send(Method::Get, &format!("{path}?list=true"), None).await
233 }
234
235 pub async fn get_optional(&self, path: &str) -> Result<Option<VaultResponse>> {
238 match self.get(path).await {
239 Ok(response) => Ok(Some(response)),
240 Err(error) if is_not_found(&error) => Ok(None),
241 Err(error) => Err(error),
242 }
243 }
244
245 async fn send(&self, method: Method, path: &str, body: Option<Json>) -> Result<VaultResponse> {
246 let mut attempt = 0;
247
248 loop {
249 let error = match self.send_once(method, path, body.clone()).await {
250 Ok(response) => return Ok(response),
251 Err(error) => error,
252 };
253
254 if attempt >= self.retries || !error.is_retryable() {
255 return Err(error.into());
256 }
257
258 tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(attempt))).await;
261 attempt += 1;
262 }
263 }
264
265 async fn send_once(
266 &self,
267 method: Method,
268 path: &str,
269 body: Option<Json>,
270 ) -> std::result::Result<VaultResponse, VaultError> {
271 let mut request = self.http.request(method, self.url(path));
272
273 let token = self.token();
274 if !token.is_empty() {
275 request = request.header(TOKEN_HEADER, token);
276 }
277 if let Some(namespace) = &self.namespace {
278 request = request.header(NAMESPACE_HEADER, namespace.clone());
279 }
280 if let Some(body) = body {
281 request = request.json(body);
282 if method == Method::Patch {
283 request = request.header("content-type", MERGE_PATCH_CONTENT_TYPE);
285 }
286 }
287
288 let response = request.send().await.map_err(|e| VaultError::Transport(e.to_string()))?;
289 let status = response.status.code();
290 let text = response.text();
291
292 if !response.status.is_success() {
293 let clean = path.split('?').next().unwrap_or(path);
296 return Err(VaultError::from_response(status, clean, &text));
297 }
298
299 if text.trim().is_empty() {
302 return Ok(VaultResponse {
303 data: Json::Null,
304 raw: Json::Null,
305 auth: Json::Null,
306 lease: Lease::none(),
307 warnings: Vec::new(),
308 });
309 }
310
311 VaultResponse::parse(&text).map_err(|e| VaultError::Unexpected {
312 status,
313 message: e.to_string(),
314 })
315 }
316}
317
318impl std::fmt::Debug for VaultClient {
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 f.debug_struct("VaultClient")
322 .field("address", &self.address)
323 .field("namespace", &self.namespace)
324 .field("token", &if self.has_token() { "<redacted>" } else { "<none>" })
325 .finish()
326 }
327}
328
329fn first_set(names: &[&str]) -> Option<String> {
330 names
331 .iter()
332 .filter_map(|name| std::env::var(name).ok())
333 .find(|value| !value.trim().is_empty())
334 .map(|value| value.trim().to_string())
335}
336
337fn is_not_found(error: &rustlavel_core::Error) -> bool {
343 error.to_string().starts_with("nothing is stored at")
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use rustlavel_client::{Fake, FakeResponse};
350
351 fn client(fake: Fake) -> VaultClient {
352 let client = VaultClient::new("https://vault.test:8200").faking(fake);
353 client.set_token("root-token");
354 client
355 }
356
357 fn json(body: &str) -> Json {
358 Json::parse(body).expect("valid JSON in a test")
359 }
360
361 #[test]
362 fn builds_urls_under_v1_whatever_the_slashes() {
363 let client = VaultClient::new("https://vault.test:8200/");
364
365 assert_eq!(client.url("secret/data/x"), "https://vault.test:8200/v1/secret/data/x");
366 assert_eq!(client.url("/secret/data/x"), "https://vault.test:8200/v1/secret/data/x");
367 }
368
369 #[test]
370 fn debug_never_prints_the_token() {
371 let client = VaultClient::new("https://vault.test:8200");
372 client.set_token("s.verysecrettoken");
373
374 let printed = format!("{client:?}");
375 assert!(!printed.contains("verysecrettoken"), "the token reached a log: {printed}");
376 assert!(printed.contains("redacted"));
377 }
378
379 #[test]
380 fn debug_distinguishes_no_token_from_a_hidden_one() {
381 let printed = format!("{:?}", VaultClient::new("https://vault.test:8200"));
384 assert!(printed.contains("none"), "got {printed}");
385 }
386
387 #[test]
388 fn parses_the_envelope_a_read_comes_in() {
389 let response = VaultResponse::parse(
390 r#"{"lease_id":"","renewable":false,"lease_duration":0,
391 "data":{"data":{"password":"s3cr3t"},"metadata":{"version":1}},
392 "warnings":null}"#,
393 )
394 .unwrap();
395
396 assert_eq!(response.string("data.password").as_deref(), Some("s3cr3t"));
397 assert!(!response.lease.exists());
398 }
399
400 #[test]
401 fn takes_the_timing_off_the_auth_object_on_a_login() {
402 let response = VaultResponse::parse(
405 r#"{"lease_id":"","renewable":false,"lease_duration":0,"data":null,
406 "auth":{"client_token":"s.abc","lease_duration":3600,"renewable":true}}"#,
407 )
408 .unwrap();
409
410 assert_eq!(response.lease.duration, Duration::from_secs(3600));
411 assert!(response.lease.renewable);
412 assert_eq!(response.auth.get("client_token").and_then(Json::as_str), Some("s.abc"));
413 }
414
415 #[test]
416 fn a_login_never_puts_the_token_into_the_lease() {
417 let response = VaultResponse::parse(
421 r#"{"lease_id":"","lease_duration":3600,"data":null,
422 "auth":{"client_token":"s.verysecrettoken","lease_duration":3600,
423 "renewable":true}}"#,
424 )
425 .unwrap();
426
427 assert!(response.lease.id.is_empty(), "the token became a lease id");
428 assert!(
429 !format!("{:?}", response.lease).contains("verysecrettoken"),
430 "the token reached a Debug output"
431 );
432 }
433
434 #[test]
435 fn keeps_the_warnings_the_store_attached() {
436 let response = VaultResponse::parse(
437 r#"{"data":null,"warnings":["this mount is deprecated"]}"#,
438 )
439 .unwrap();
440
441 assert_eq!(response.warnings, vec!["this mount is deprecated".to_string()]);
442 }
443
444 #[test]
445 fn a_negative_lease_duration_does_not_wrap_into_eternity() {
446 let response =
447 VaultResponse::parse(r#"{"lease_id":"x","lease_duration":-1,"data":null}"#).unwrap();
448
449 assert_eq!(response.lease.duration, Duration::ZERO);
450 }
451
452 #[tokio::test]
453 async fn sends_the_token_and_namespace_headers() {
454 let client = VaultClient::new("https://vault.test:8200")
455 .namespace("team-a")
456 .faking(Fake::new().on("vault.test", FakeResponse::json(json("{}"))));
457 client.set_token("s.abc");
458
459 client.get("secret/data/x").await.unwrap();
460
461 let sent = client.fake().unwrap().recorded();
462 let headers = &sent[0].headers;
463 assert_eq!(headers.get(TOKEN_HEADER), Some("s.abc"));
464 assert_eq!(headers.get(NAMESPACE_HEADER), Some("team-a"));
465 }
466
467 #[tokio::test]
468 async fn an_empty_namespace_sends_no_header_at_all() {
469 let client = VaultClient::new("https://vault.test:8200")
472 .namespace("")
473 .faking(Fake::new().on("vault.test", FakeResponse::json(json("{}"))));
474
475 client.get("secret/data/x").await.unwrap();
476
477 assert_eq!(client.fake().unwrap().recorded()[0].headers.get(NAMESPACE_HEADER), None);
478 }
479
480 #[tokio::test]
481 async fn a_missing_secret_can_be_asked_for_without_it_being_an_error() {
482 let fake = Fake::new().on(
483 "vault.test",
484 FakeResponse::text(r#"{"errors":[]}"#).status(404),
485 );
486 let client = client(fake);
487
488 assert!(client.get_optional("secret/data/missing").await.unwrap().is_none());
489 assert!(client.get("secret/data/missing").await.is_err());
490 }
491
492 #[tokio::test]
493 async fn a_refusal_is_not_retried_and_says_what_it_might_be() {
494 let fake = Fake::new().on(
495 "vault.test",
496 FakeResponse::text(r#"{"errors":["permission denied"]}"#).status(403),
497 );
498 let client = client(fake);
499
500 let error = client.get("secret/data/x").await.unwrap_err().to_string();
501
502 assert_eq!(client.fake().unwrap().count(), 1, "a refusal must not be retried");
503 assert!(error.contains("expired"), "the message should name the likely causes: {error}");
504 }
505
506 #[tokio::test]
507 async fn a_standby_is_retried() {
508 let fake = Fake::new()
509 .on("vault.test", FakeResponse::text(r#"{"errors":["standby"]}"#).status(429));
510 let client = client(fake).retries(2);
511
512 assert!(client.get("secret/data/x").await.is_err());
513 assert_eq!(client.fake().unwrap().count(), 3, "the first attempt plus two retries");
514 }
515
516 #[tokio::test]
517 async fn a_sealed_store_fails_immediately() {
518 let fake = Fake::new()
519 .on("vault.test", FakeResponse::text(r#"{"errors":["sealed"]}"#).status(503));
520 let client = client(fake).retries(5);
521
522 let error = client.get("secret/data/x").await.unwrap_err().to_string();
523
524 assert_eq!(client.fake().unwrap().count(), 1, "retrying cannot unseal it");
525 assert!(error.contains("unsealed"), "got {error}");
526 }
527
528 #[tokio::test]
529 async fn a_204_with_no_body_is_a_success() {
530 let fake = Fake::new().on("vault.test", FakeResponse::text("").status(204));
533 let client = client(fake);
534
535 let response = client.post("secret/data/x", Json::Null).await.unwrap();
536 assert!(response.data.is_null());
537 }
538
539 #[tokio::test]
540 async fn a_list_does_not_leave_the_query_string_in_the_error() {
541 let fake = Fake::new().on("vault.test", FakeResponse::text(r#"{"errors":[]}"#).status(404));
542 let client = client(fake);
543
544 let error = client.list("secret/metadata").await.unwrap_err().to_string();
545 assert!(!error.contains("list=true"), "the query string is noise here: {error}");
546 }
547}