rustlavel-vault 0.6.0

Secrets from OpenBao or HashiCorp Vault: KV, dynamic database credentials, leases
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
//! Talking to OpenBao or HashiCorp Vault.
//!
//! One client for both: OpenBao is a fork of Vault from before the licence
//! changed, and the HTTP API is the same one. Nothing here is compiled against
//! either project — it is an HTTP API, and this speaks it.

use crate::error::VaultError;
use crate::lease::Lease;
use rustlavel_client::Client;
use rustlavel_core::{Json, Result};
use rustlavel_http::Method;
use std::sync::{Arc, RwLock};
use std::time::Duration;

/// The header every request carries the token in.
pub const TOKEN_HEADER: &str = "X-Vault-Token";
/// Namespaces are an Enterprise feature in Vault and present in OpenBao.
pub const NAMESPACE_HEADER: &str = "X-Vault-Namespace";
/// What a KV v2 patch must declare, or Vault answers 415.
pub const MERGE_PATCH_CONTENT_TYPE: &str = "application/merge-patch+json";

/// What the store answered, unwrapped from the envelope every reply shares.
#[derive(Debug, Clone)]
pub struct VaultResponse {
    /// The `data` object — the part callers actually want.
    pub data: Json,
    /// The reply as it arrived, kept for fields the envelope does not name.
    raw: Json,
    /// The `auth` object, present on a login.
    pub auth: Json,
    pub lease: Lease,
    /// Warnings the store attached. Worth surfacing: this is how it tells you a
    /// path is deprecated or a policy nearly denied the request.
    pub warnings: Vec<String>,
}

impl VaultResponse {
    /// Parse the envelope.
    pub fn parse(body: &str) -> Result<VaultResponse> {
        let json = Json::parse(body)
            .map_err(|e| VaultError::Unexpected { status: 200, message: e.to_string() })?;

        let lease_id = json.get("lease_id").and_then(Json::as_str).unwrap_or("");
        let duration = json.get("lease_duration").and_then(Json::as_i64).unwrap_or(0);
        let renewable = json.get("renewable").and_then(Json::as_bool).unwrap_or(false);

        // A login puts its timing on the auth object rather than at the top
        // level, and reads the other way round. Taking whichever is present
        // avoids every caller having to know which shape it asked for.
        //
        // The *id* is deliberately not taken from the auth object. Vault's
        // nearest equivalent there is `client_token`, and that is the
        // credential itself — putting it in a field of a `Debug` type is how a
        // token reaches a log. A token needs no id to be renewed anyway:
        // `auth/token/renew-self` identifies it by the header it is sent with.
        let auth = json.get("auth").cloned().unwrap_or(Json::Null);
        let (duration, renewable) = if lease_id.is_empty() && !auth.is_null() {
            (
                auth.get("lease_duration").and_then(Json::as_i64).unwrap_or(0),
                auth.get("renewable").and_then(Json::as_bool).unwrap_or(false),
            )
        } else {
            (duration, renewable)
        };

        Ok(VaultResponse {
            data: json.get("data").cloned().unwrap_or(Json::Null),
            raw: json.clone(),
            auth,
            lease: Lease::new(
                lease_id.to_string(),
                Duration::from_secs(duration.max(0) as u64),
                renewable,
            ),
            warnings: json
                .get("warnings")
                .and_then(Json::as_array)
                .map(|items| items.iter().filter_map(Json::as_str).map(str::to_string).collect())
                .unwrap_or_default(),
        })
    }

    /// The whole reply, for the rare caller that needs a field the envelope
    /// does not name.
    pub fn json(&self) -> &Json {
        &self.raw
    }

    /// A string field out of `data`, by dotted path.
    pub fn string(&self, path: &str) -> Option<String> {
        self.data.get(path).and_then(Json::as_str).map(str::to_string)
    }
}

/// A connection to the secret store.
///
/// Cheap to clone — the token is shared, so re-authenticating in one place is
/// visible everywhere, which is what a background renewer needs.
#[derive(Clone)]
pub struct VaultClient {
    address: String,
    namespace: Option<String>,
    token: Arc<RwLock<String>>,
    http: Client,
    retries: u32,
}

impl VaultClient {
    /// Point at a store: `https://vault.internal:8200`.
    pub fn new(address: impl Into<String>) -> VaultClient {
        let address = address.into().trim_end_matches('/').to_string();

        VaultClient {
            address,
            namespace: None,
            token: Arc::new(RwLock::new(String::new())),
            // Long enough for a store under load, short enough that a hung
            // request does not hold a connection open through a whole deploy.
            http: Client::new().timeout(Duration::from_secs(15)),
            retries: 2,
        }
    }

    /// Read the address and token from the environment, the way every Vault
    /// and OpenBao tool does: `VAULT_ADDR`/`BAO_ADDR`, `VAULT_TOKEN`/`BAO_TOKEN`.
    ///
    /// Reading the environment rather than a config file is deliberate — a
    /// token in `.env` is a token in a file somebody will eventually commit.
    pub fn from_env() -> Result<VaultClient> {
        let address = first_set(&["VAULT_ADDR", "BAO_ADDR"]).ok_or_else(|| {
            rustlavel_core::Error::msg(
                "no secret store address. Set VAULT_ADDR (or BAO_ADDR) to something like \
                 https://vault.internal:8200.",
            )
        })?;

        let mut client = VaultClient::new(address);
        if let Some(token) = first_set(&["VAULT_TOKEN", "BAO_TOKEN"]) {
            client.set_token(token);
        }
        if let Some(namespace) = first_set(&["VAULT_NAMESPACE", "BAO_NAMESPACE"]) {
            client = client.namespace(namespace);
        }
        Ok(client)
    }

    pub fn namespace(mut self, namespace: impl Into<String>) -> VaultClient {
        let namespace = namespace.into();
        self.namespace = (!namespace.is_empty()).then_some(namespace);
        self
    }

    pub fn timeout(mut self, timeout: Duration) -> VaultClient {
        self.http = self.http.timeout(timeout);
        self
    }

    /// How many times to retry a request that failed in a way retrying can fix.
    pub fn retries(mut self, retries: u32) -> VaultClient {
        self.retries = retries;
        self
    }

    /// Answer from a script instead of the network, for tests.
    pub fn faking(mut self, fake: rustlavel_client::Fake) -> VaultClient {
        self.http = self.http.faking(fake);
        self
    }

    pub fn address(&self) -> &str {
        &self.address
    }

    /// The script this client is answering from, for assertions in a test.
    pub fn fake(&self) -> Option<&std::sync::Arc<rustlavel_client::Fake>> {
        self.http.fake()
    }

    pub fn set_token(&self, token: impl Into<String>) {
        if let Ok(mut current) = self.token.write() {
            *current = token.into();
        }
    }

    pub fn token(&self) -> String {
        self.token.read().map(|token| token.clone()).unwrap_or_default()
    }

    /// Forget the token — after revoking it, so a later call fails as
    /// "no token" rather than silently reusing a dead one.
    pub fn clear_token(&self) {
        self.set_token("");
    }

    pub fn has_token(&self) -> bool {
        !self.token().is_empty()
    }

    /// The full URL for an API path, which is always under `/v1/`.
    ///
    /// Leading slashes are trimmed so `secret/data/x` and `/secret/data/x` both
    /// work — the difference is invisible in a config file and produces a 404
    /// that looks like a missing secret.
    pub fn url(&self, path: &str) -> String {
        format!("{}/v1/{}", self.address, path.trim_start_matches('/'))
    }

    pub async fn get(&self, path: &str) -> Result<VaultResponse> {
        self.send(Method::Get, path, None).await
    }

    pub async fn post(&self, path: &str, body: Json) -> Result<VaultResponse> {
        self.send(Method::Post, path, Some(body)).await
    }

    pub async fn delete(&self, path: &str) -> Result<VaultResponse> {
        self.send(Method::Delete, path, None).await
    }

    /// A PATCH, which KV v2 uses to change some fields and leave the rest.
    ///
    /// Sent as `application/merge-patch+json`, which is not a detail: Vault
    /// answers a PATCH declaring plain `application/json` with a 415, and the
    /// error says nothing about content types.
    pub async fn patch(&self, path: &str, body: Json) -> Result<VaultResponse> {
        self.send(Method::Patch, path, Some(body)).await
    }

    /// A LIST, which Vault spells as a query parameter rather than a method,
    /// because not every proxy in the world forwards a `LIST` verb.
    pub async fn list(&self, path: &str) -> Result<VaultResponse> {
        self.send(Method::Get, &format!("{path}?list=true"), None).await
    }

    /// Like [`VaultClient::get`], but a missing path is `None` rather than an
    /// error — reading a secret that may not exist yet is ordinary.
    pub async fn get_optional(&self, path: &str) -> Result<Option<VaultResponse>> {
        match self.get(path).await {
            Ok(response) => Ok(Some(response)),
            Err(error) if is_not_found(&error) => Ok(None),
            Err(error) => Err(error),
        }
    }

    async fn send(&self, method: Method, path: &str, body: Option<Json>) -> Result<VaultResponse> {
        let mut attempt = 0;

        loop {
            let error = match self.send_once(method, path, body.clone()).await {
                Ok(response) => return Ok(response),
                Err(error) => error,
            };

            if attempt >= self.retries || !error.is_retryable() {
                return Err(error.into());
            }

            // A standby catching up is a matter of milliseconds, so the backoff
            // starts small.
            tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(attempt))).await;
            attempt += 1;
        }
    }

    async fn send_once(
        &self,
        method: Method,
        path: &str,
        body: Option<Json>,
    ) -> std::result::Result<VaultResponse, VaultError> {
        let mut request = self.http.request(method, self.url(path));

        let token = self.token();
        if !token.is_empty() {
            request = request.header(TOKEN_HEADER, token);
        }
        if let Some(namespace) = &self.namespace {
            request = request.header(NAMESPACE_HEADER, namespace.clone());
        }
        if let Some(body) = body {
            request = request.json(body);
            if method == Method::Patch {
                // After `json`, which sets `application/json` — see `patch`.
                request = request.header("content-type", MERGE_PATCH_CONTENT_TYPE);
            }
        }

        let response = request.send().await.map_err(|e| VaultError::Transport(e.to_string()))?;
        let status = response.status.code();
        let text = response.text();

        if !response.status.is_success() {
            // The path is passed for the message, and it is the path without
            // the query string: `?list=true` in a "not found" reads as noise.
            let clean = path.split('?').next().unwrap_or(path);
            return Err(VaultError::from_response(status, clean, &text));
        }

        // 204 No Content is a success with nothing in it, which several write
        // endpoints answer with. Parsing an empty body would fail.
        if text.trim().is_empty() {
            return Ok(VaultResponse {
                data: Json::Null,
                raw: Json::Null,
                auth: Json::Null,
                lease: Lease::none(),
                warnings: Vec::new(),
            });
        }

        VaultResponse::parse(&text).map_err(|e| VaultError::Unexpected {
            status,
            message: e.to_string(),
        })
    }
}

/// Deliberately hand-written so the token cannot reach a log.
impl std::fmt::Debug for VaultClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("VaultClient")
            .field("address", &self.address)
            .field("namespace", &self.namespace)
            .field("token", &if self.has_token() { "<redacted>" } else { "<none>" })
            .finish()
    }
}

fn first_set(names: &[&str]) -> Option<String> {
    names
        .iter()
        .filter_map(|name| std::env::var(name).ok())
        .find(|value| !value.trim().is_empty())
        .map(|value| value.trim().to_string())
}

/// Whether an `Error` came from a `VaultError::NotFound`.
///
/// The round trip through a string is not elegant, but `Error` is the
/// framework's one error type and this keeps `VaultError` out of every public
/// signature in the crate.
fn is_not_found(error: &rustlavel_core::Error) -> bool {
    error.to_string().starts_with("nothing is stored at")
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustlavel_client::{Fake, FakeResponse};

    fn client(fake: Fake) -> VaultClient {
        let client = VaultClient::new("https://vault.test:8200").faking(fake);
        client.set_token("root-token");
        client
    }

    fn json(body: &str) -> Json {
        Json::parse(body).expect("valid JSON in a test")
    }

    #[test]
    fn builds_urls_under_v1_whatever_the_slashes() {
        let client = VaultClient::new("https://vault.test:8200/");

        assert_eq!(client.url("secret/data/x"), "https://vault.test:8200/v1/secret/data/x");
        assert_eq!(client.url("/secret/data/x"), "https://vault.test:8200/v1/secret/data/x");
    }

    #[test]
    fn debug_never_prints_the_token() {
        let client = VaultClient::new("https://vault.test:8200");
        client.set_token("s.verysecrettoken");

        let printed = format!("{client:?}");
        assert!(!printed.contains("verysecrettoken"), "the token reached a log: {printed}");
        assert!(printed.contains("redacted"));
    }

    #[test]
    fn debug_distinguishes_no_token_from_a_hidden_one() {
        // Otherwise "why is it saying permission denied" is impossible to
        // diagnose from a log.
        let printed = format!("{:?}", VaultClient::new("https://vault.test:8200"));
        assert!(printed.contains("none"), "got {printed}");
    }

    #[test]
    fn parses_the_envelope_a_read_comes_in() {
        let response = VaultResponse::parse(
            r#"{"lease_id":"","renewable":false,"lease_duration":0,
                "data":{"data":{"password":"s3cr3t"},"metadata":{"version":1}},
                "warnings":null}"#,
        )
        .unwrap();

        assert_eq!(response.string("data.password").as_deref(), Some("s3cr3t"));
        assert!(!response.lease.exists());
    }

    #[test]
    fn takes_the_timing_off_the_auth_object_on_a_login() {
        // A login puts it there instead of at the top level, and a renewer that
        // looked only at the top level would never renew a token.
        let response = VaultResponse::parse(
            r#"{"lease_id":"","renewable":false,"lease_duration":0,"data":null,
                "auth":{"client_token":"s.abc","lease_duration":3600,"renewable":true}}"#,
        )
        .unwrap();

        assert_eq!(response.lease.duration, Duration::from_secs(3600));
        assert!(response.lease.renewable);
        assert_eq!(response.auth.get("client_token").and_then(Json::as_str), Some("s.abc"));
    }

    #[test]
    fn a_login_never_puts_the_token_into_the_lease() {
        // `Lease` derives `Debug`, so anything stored in it is one `{:?}` away
        // from a log file. Vault's only id-shaped field on an auth reply is the
        // token itself, which is exactly what must not go there.
        let response = VaultResponse::parse(
            r#"{"lease_id":"","lease_duration":3600,"data":null,
                "auth":{"client_token":"s.verysecrettoken","lease_duration":3600,
                        "renewable":true}}"#,
        )
        .unwrap();

        assert!(response.lease.id.is_empty(), "the token became a lease id");
        assert!(
            !format!("{:?}", response.lease).contains("verysecrettoken"),
            "the token reached a Debug output"
        );
    }

    #[test]
    fn keeps_the_warnings_the_store_attached() {
        let response = VaultResponse::parse(
            r#"{"data":null,"warnings":["this mount is deprecated"]}"#,
        )
        .unwrap();

        assert_eq!(response.warnings, vec!["this mount is deprecated".to_string()]);
    }

    #[test]
    fn a_negative_lease_duration_does_not_wrap_into_eternity() {
        let response =
            VaultResponse::parse(r#"{"lease_id":"x","lease_duration":-1,"data":null}"#).unwrap();

        assert_eq!(response.lease.duration, Duration::ZERO);
    }

    #[tokio::test]
    async fn sends_the_token_and_namespace_headers() {
        let client = VaultClient::new("https://vault.test:8200")
            .namespace("team-a")
            .faking(Fake::new().on("vault.test", FakeResponse::json(json("{}"))));
        client.set_token("s.abc");

        client.get("secret/data/x").await.unwrap();

        let sent = client.fake().unwrap().recorded();
        let headers = &sent[0].headers;
        assert_eq!(headers.get(TOKEN_HEADER), Some("s.abc"));
        assert_eq!(headers.get(NAMESPACE_HEADER), Some("team-a"));
    }

    #[tokio::test]
    async fn an_empty_namespace_sends_no_header_at_all() {
        // A blank namespace header is not the same as none, and Vault treats it
        // as a namespace called "".
        let client = VaultClient::new("https://vault.test:8200")
            .namespace("")
            .faking(Fake::new().on("vault.test", FakeResponse::json(json("{}"))));

        client.get("secret/data/x").await.unwrap();

        assert_eq!(client.fake().unwrap().recorded()[0].headers.get(NAMESPACE_HEADER), None);
    }

    #[tokio::test]
    async fn a_missing_secret_can_be_asked_for_without_it_being_an_error() {
        let fake = Fake::new().on(
            "vault.test",
            FakeResponse::text(r#"{"errors":[]}"#).status(404),
        );
        let client = client(fake);

        assert!(client.get_optional("secret/data/missing").await.unwrap().is_none());
        assert!(client.get("secret/data/missing").await.is_err());
    }

    #[tokio::test]
    async fn a_refusal_is_not_retried_and_says_what_it_might_be() {
        let fake = Fake::new().on(
            "vault.test",
            FakeResponse::text(r#"{"errors":["permission denied"]}"#).status(403),
        );
        let client = client(fake);

        let error = client.get("secret/data/x").await.unwrap_err().to_string();

        assert_eq!(client.fake().unwrap().count(), 1, "a refusal must not be retried");
        assert!(error.contains("expired"), "the message should name the likely causes: {error}");
    }

    #[tokio::test]
    async fn a_standby_is_retried() {
        let fake = Fake::new()
            .on("vault.test", FakeResponse::text(r#"{"errors":["standby"]}"#).status(429));
        let client = client(fake).retries(2);

        assert!(client.get("secret/data/x").await.is_err());
        assert_eq!(client.fake().unwrap().count(), 3, "the first attempt plus two retries");
    }

    #[tokio::test]
    async fn a_sealed_store_fails_immediately() {
        let fake = Fake::new()
            .on("vault.test", FakeResponse::text(r#"{"errors":["sealed"]}"#).status(503));
        let client = client(fake).retries(5);

        let error = client.get("secret/data/x").await.unwrap_err().to_string();

        assert_eq!(client.fake().unwrap().count(), 1, "retrying cannot unseal it");
        assert!(error.contains("unsealed"), "got {error}");
    }

    #[tokio::test]
    async fn a_204_with_no_body_is_a_success() {
        // Several write endpoints answer this way; parsing an empty body as
        // JSON would turn a successful write into an error.
        let fake = Fake::new().on("vault.test", FakeResponse::text("").status(204));
        let client = client(fake);

        let response = client.post("secret/data/x", Json::Null).await.unwrap();
        assert!(response.data.is_null());
    }

    #[tokio::test]
    async fn a_list_does_not_leave_the_query_string_in_the_error() {
        let fake = Fake::new().on("vault.test", FakeResponse::text(r#"{"errors":[]}"#).status(404));
        let client = client(fake);

        let error = client.list("secret/metadata").await.unwrap_err().to_string();
        assert!(!error.contains("list=true"), "the query string is noise here: {error}");
    }
}