Skip to main content

rustlavel_vault/
client.rs

1//! Talking to OpenBao or HashiCorp Vault.
2//!
3//! One client for both: OpenBao is a fork of Vault from before the licence
4//! changed, and the HTTP API is the same one. Nothing here is compiled against
5//! either project — it is an HTTP API, and this speaks it.
6
7use 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
15/// The header every request carries the token in.
16pub const TOKEN_HEADER: &str = "X-Vault-Token";
17/// Namespaces are an Enterprise feature in Vault and present in OpenBao.
18pub const NAMESPACE_HEADER: &str = "X-Vault-Namespace";
19/// What a KV v2 patch must declare, or Vault answers 415.
20pub const MERGE_PATCH_CONTENT_TYPE: &str = "application/merge-patch+json";
21
22/// What the store answered, unwrapped from the envelope every reply shares.
23#[derive(Debug, Clone)]
24pub struct VaultResponse {
25    /// The `data` object — the part callers actually want.
26    pub data: Json,
27    /// The reply as it arrived, kept for fields the envelope does not name.
28    raw: Json,
29    /// The `auth` object, present on a login.
30    pub auth: Json,
31    pub lease: Lease,
32    /// Warnings the store attached. Worth surfacing: this is how it tells you a
33    /// path is deprecated or a policy nearly denied the request.
34    pub warnings: Vec<String>,
35}
36
37impl VaultResponse {
38    /// Parse the envelope.
39    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        // A login puts its timing on the auth object rather than at the top
48        // level, and reads the other way round. Taking whichever is present
49        // avoids every caller having to know which shape it asked for.
50        //
51        // The *id* is deliberately not taken from the auth object. Vault's
52        // nearest equivalent there is `client_token`, and that is the
53        // credential itself — putting it in a field of a `Debug` type is how a
54        // token reaches a log. A token needs no id to be renewed anyway:
55        // `auth/token/renew-self` identifies it by the header it is sent with.
56        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    /// The whole reply, for the rare caller that needs a field the envelope
84    /// does not name.
85    pub fn json(&self) -> &Json {
86        &self.raw
87    }
88
89    /// A string field out of `data`, by dotted path.
90    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/// A connection to the secret store.
96///
97/// Cheap to clone — the token is shared, so re-authenticating in one place is
98/// visible everywhere, which is what a background renewer needs.
99#[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    /// Point at a store: `https://vault.internal:8200`.
110    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            // Long enough for a store under load, short enough that a hung
118            // request does not hold a connection open through a whole deploy.
119            http: Client::new().timeout(Duration::from_secs(15)),
120            retries: 2,
121        }
122    }
123
124    /// Read the address and token from the environment, the way every Vault
125    /// and OpenBao tool does: `VAULT_ADDR`/`BAO_ADDR`, `VAULT_TOKEN`/`BAO_TOKEN`.
126    ///
127    /// Reading the environment rather than a config file is deliberate — a
128    /// token in `.env` is a token in a file somebody will eventually commit.
129    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    /// How many times to retry a request that failed in a way retrying can fix.
159    pub fn retries(mut self, retries: u32) -> VaultClient {
160        self.retries = retries;
161        self
162    }
163
164    /// Answer from a script instead of the network, for tests.
165    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    /// The script this client is answering from, for assertions in a test.
175    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    /// Forget the token — after revoking it, so a later call fails as
190    /// "no token" rather than silently reusing a dead one.
191    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    /// The full URL for an API path, which is always under `/v1/`.
200    ///
201    /// Leading slashes are trimmed so `secret/data/x` and `/secret/data/x` both
202    /// work — the difference is invisible in a config file and produces a 404
203    /// that looks like a missing secret.
204    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    /// A PATCH, which KV v2 uses to change some fields and leave the rest.
221    ///
222    /// Sent as `application/merge-patch+json`, which is not a detail: Vault
223    /// answers a PATCH declaring plain `application/json` with a 415, and the
224    /// error says nothing about content types.
225    pub async fn patch(&self, path: &str, body: Json) -> Result<VaultResponse> {
226        self.send(Method::Patch, path, Some(body)).await
227    }
228
229    /// A LIST, which Vault spells as a query parameter rather than a method,
230    /// because not every proxy in the world forwards a `LIST` verb.
231    pub async fn list(&self, path: &str) -> Result<VaultResponse> {
232        self.send(Method::Get, &format!("{path}?list=true"), None).await
233    }
234
235    /// Like [`VaultClient::get`], but a missing path is `None` rather than an
236    /// error — reading a secret that may not exist yet is ordinary.
237    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            // A standby catching up is a matter of milliseconds, so the backoff
259            // starts small.
260            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                // After `json`, which sets `application/json` — see `patch`.
284                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            // The path is passed for the message, and it is the path without
294            // the query string: `?list=true` in a "not found" reads as noise.
295            let clean = path.split('?').next().unwrap_or(path);
296            return Err(VaultError::from_response(status, clean, &text));
297        }
298
299        // 204 No Content is a success with nothing in it, which several write
300        // endpoints answer with. Parsing an empty body would fail.
301        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
318/// Deliberately hand-written so the token cannot reach a log.
319impl 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
337/// Whether an `Error` came from a `VaultError::NotFound`.
338///
339/// The round trip through a string is not elegant, but `Error` is the
340/// framework's one error type and this keeps `VaultError` out of every public
341/// signature in the crate.
342fn 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        // Otherwise "why is it saying permission denied" is impossible to
382        // diagnose from a log.
383        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        // A login puts it there instead of at the top level, and a renewer that
403        // looked only at the top level would never renew a token.
404        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        // `Lease` derives `Debug`, so anything stored in it is one `{:?}` away
418        // from a log file. Vault's only id-shaped field on an auth reply is the
419        // token itself, which is exactly what must not go there.
420        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        // A blank namespace header is not the same as none, and Vault treats it
470        // as a namespace called "".
471        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        // Several write endpoints answer this way; parsing an empty body as
531        // JSON would turn a successful write into an error.
532        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}