Skip to main content

rustlavel_vault/
kv.rs

1//! The KV version 2 engine: static secrets, kept in versions.
2//!
3//! This is where a database username and password actually live, and the engine
4//! has two pieces of awkwardness that every caller would otherwise have to
5//! carry:
6//!
7//! 1. The secret is nested twice. `/v1/secret/data/myapp` answers
8//!    `{"data": {"data": {…}, "metadata": {…}}}`, so the obvious `data` is the
9//!    wrapper and the one below it is the secret.
10//! 2. The mount is split. Values are read and written under `secret/data/…`,
11//!    but listing, metadata and permanent deletion live under
12//!    `secret/metadata/…`, and versioned deletes under `secret/delete/…`,
13//!    `secret/undelete/…` and `secret/destroy/…`.
14//!
15//! Getting either wrong produces a working-looking call that returns the wrong
16//! thing, which is why this module exists rather than a note in the docs.
17
18use crate::client::VaultClient;
19use crate::error::VaultError;
20use rustlavel_core::{Error, Json, Result};
21
22/// The KV v2 engine at one mount.
23pub struct Kv<'a> {
24    client: &'a VaultClient,
25    mount: String,
26}
27
28impl VaultClient {
29    /// The KV v2 engine at the default `secret/` mount.
30    pub fn kv(&self) -> Kv<'_> {
31        Kv { client: self, mount: "secret".into() }
32    }
33}
34
35impl<'a> Kv<'a> {
36    /// The same engine mounted elsewhere. `secret` is only the dev-server
37    /// default; a real deployment usually mounts one per team.
38    pub fn mount(mut self, mount: impl Into<String>) -> Kv<'a> {
39        self.mount = mount.into().trim_matches('/').to_string();
40        self
41    }
42
43    /// The latest version of a secret, or `None` if there is none.
44    ///
45    /// `None` covers two states Vault reports identically: nothing was ever
46    /// written there, and the latest version was deleted. Neither is a failure.
47    pub async fn read(&self, path: &str) -> Result<Option<Secret>> {
48        self.read_at(&self.data_path(path)).await
49    }
50
51    /// One specific version, whatever has been written since.
52    ///
53    /// This is what pins a deployment to a known-good secret while somebody is
54    /// mid-rotation, and what reads back the value a rollback needs.
55    pub async fn read_version(&self, path: &str, version: u64) -> Result<Option<Secret>> {
56        self.read_at(&format!("{}?version={version}", self.data_path(path))).await
57    }
58
59    /// The latest version, or an error naming the path.
60    ///
61    /// For the case where the secret is not optional — a service that cannot
62    /// start without its database password should fail at boot saying which
63    /// path was empty, not carry a `None` into the first query.
64    pub async fn require(&self, path: &str) -> Result<Secret> {
65        let missing = || VaultError::NotFound { path: self.data_path(path) };
66        self.read(path).await?.ok_or_else(|| missing().into())
67    }
68
69    async fn read_at(&self, path: &str) -> Result<Option<Secret>> {
70        let Some(response) = self.client.get_optional(path).await? else {
71            return Ok(None);
72        };
73
74        // The inner `data`. A soft-deleted version answers 404 with the wrapper
75        // still populated and this null, so it is already handled above — but a
76        // KV v1 mount answers 200 with no nesting at all, and that is worth
77        // saying out loud rather than returning an empty secret.
78        let values = response.data.get("data").cloned().unwrap_or(Json::Null);
79        if values.is_null() {
80            return Err(VaultError::Malformed {
81                path: path.to_string(),
82                message: "the response has no nested `data`, which is what a KV version 1 mount \
83                          looks like when it is read as version 2"
84                    .to_string(),
85            }
86            .into());
87        }
88
89        let metadata = response.data.get("metadata").cloned().unwrap_or(Json::Null);
90        Ok(Some(Secret {
91            version: number(&metadata, "version"),
92            created_time: text(&metadata, "created_time"),
93            deletion_time: Some(text(&metadata, "deletion_time")).filter(|t| !t.is_empty()),
94            destroyed: metadata.get("destroyed").and_then(Json::as_bool).unwrap_or(false),
95            values,
96        }))
97    }
98
99    /// Write a new version, replacing every value at the path.
100    ///
101    /// Replacing, not merging: a key left out of `values` is gone from the new
102    /// version. [`Kv::patch`] is the one that merges.
103    pub async fn write<K, V, I>(&self, path: &str, values: I) -> Result<VersionInfo>
104    where
105        K: Into<String>,
106        V: Into<Json>,
107        I: IntoIterator<Item = (K, V)>,
108    {
109        let response = self
110            .client
111            .post(&self.data_path(path), wrap(values))
112            .await?;
113        Ok(VersionInfo::from_json(&response.data))
114    }
115
116    /// Merge values into the newest version, leaving the rest in place.
117    ///
118    /// Still creates a version — nothing in KV v2 is edited in place — but does
119    /// it without the read-modify-write race that doing it by hand would open.
120    /// The path has to exist already; Vault answers 404 otherwise.
121    pub async fn patch<K, V, I>(&self, path: &str, values: I) -> Result<VersionInfo>
122    where
123        K: Into<String>,
124        V: Into<Json>,
125        I: IntoIterator<Item = (K, V)>,
126    {
127        let response = self.client.patch(&self.data_path(path), wrap(values)).await?;
128        Ok(VersionInfo::from_json(&response.data))
129    }
130
131    /// Soft-delete the newest version. Recoverable with [`Kv::undelete`].
132    pub async fn delete(&self, path: &str) -> Result<()> {
133        self.client.delete(&self.data_path(path)).await?;
134        Ok(())
135    }
136
137    /// Soft-delete specific versions.
138    pub async fn delete_versions(&self, path: &str, versions: &[u64]) -> Result<()> {
139        self.client.post(&self.path("delete", path), versions_body(versions)).await.map(|_| ())
140    }
141
142    /// Bring soft-deleted versions back.
143    ///
144    /// The reason a delete is soft at all: a rotation that deletes the wrong
145    /// path is a mistake somebody should be able to take back, and only
146    /// [`Kv::destroy`] makes it permanent.
147    pub async fn undelete(&self, path: &str, versions: &[u64]) -> Result<()> {
148        self.client.post(&self.path("undelete", path), versions_body(versions)).await.map(|_| ())
149    }
150
151    /// Erase specific versions for good. Not recoverable.
152    pub async fn destroy(&self, path: &str, versions: &[u64]) -> Result<()> {
153        self.client.post(&self.path("destroy", path), versions_body(versions)).await.map(|_| ())
154    }
155
156    /// Erase the path, every version and its metadata. Not recoverable.
157    pub async fn destroy_all(&self, path: &str) -> Result<()> {
158        self.client.delete(&self.path("metadata", path)).await?;
159        Ok(())
160    }
161
162    /// The keys directly under a prefix; pass `""` for the top of the mount.
163    ///
164    /// A key ending in `/` is a sub-prefix rather than a secret, which is
165    /// Vault's only way of saying so.
166    pub async fn list(&self, prefix: &str) -> Result<Vec<String>> {
167        // A prefix with nothing under it is a 404, and an empty list is the
168        // honest answer: Vault has no empty folders, so "no keys" and "no such
169        // prefix" are the same state.
170        let Some(response) = self.client.get_optional(&format!(
171            "{}?list=true",
172            self.path("metadata", prefix)
173        )).await? else {
174            return Ok(Vec::new());
175        };
176
177        Ok(response
178            .data
179            .get("keys")
180            .and_then(Json::as_array)
181            .map(|keys| keys.iter().filter_map(Json::as_str).map(str::to_string).collect())
182            .unwrap_or_default())
183    }
184
185    /// Everything Vault records about a path except the values: which versions
186    /// exist, which are deleted, and how many are kept.
187    pub async fn metadata(&self, path: &str) -> Result<Option<SecretMetadata>> {
188        let Some(response) = self.client.get_optional(&self.path("metadata", path)).await? else {
189            return Ok(None);
190        };
191        Ok(Some(SecretMetadata::from_json(&response.data)))
192    }
193
194    fn data_path(&self, path: &str) -> String {
195        self.path("data", path)
196    }
197
198    /// KV v2 addresses the same secret through five prefixes depending on what
199    /// is being done to it. This is the only place that knows which.
200    fn path(&self, kind: &str, path: &str) -> String {
201        let path = path.trim_matches('/');
202        if path.is_empty() {
203            format!("{}/{kind}", self.mount)
204        } else {
205            format!("{}/{kind}/{path}", self.mount)
206        }
207    }
208}
209
210/// One version of a secret: its values, and where it sits in the history.
211///
212/// No `Debug` derive. The values are the secret, and a struct like this is
213/// exactly what ends up in a `dbg!` while somebody works out why a connection
214/// failed.
215pub struct Secret {
216    values: Json,
217    pub version: u64,
218    pub created_time: String,
219    /// Set when this version has been soft-deleted.
220    pub deletion_time: Option<String>,
221    pub destroyed: bool,
222}
223
224impl Secret {
225    /// One field, as a string.
226    pub fn get(&self, key: &str) -> Option<&str> {
227        self.values.get(key).and_then(Json::as_str)
228    }
229
230    /// One field, or an error naming the key.
231    ///
232    /// The failure this prevents is a service starting with an empty password
233    /// because a key was renamed in Vault and nothing here noticed.
234    pub fn require(&self, key: &str) -> Result<String> {
235        self.get(key).map(str::to_string).ok_or_else(|| Error::from(VaultError::Malformed {
236            path: format!("version {} of the secret", self.version),
237            message: format!(
238                "no `{key}` field. The secret holds: {}",
239                if self.keys().is_empty() { "nothing".to_string() } else { self.keys().join(", ") }
240            ),
241        }))
242    }
243
244    /// Every value, for the callers that want more than strings.
245    pub fn values(&self) -> &Json {
246        &self.values
247    }
248
249    /// The field names, which are safe to log where the values are not.
250    pub fn keys(&self) -> Vec<&str> {
251        self.values
252            .as_object()
253            .map(|map| map.keys().map(String::as_str).collect())
254            .unwrap_or_default()
255    }
256}
257
258impl std::fmt::Debug for Secret {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        f.debug_struct("Secret")
261            .field("version", &self.version)
262            .field("keys", &self.keys())
263            .field("values", &"<redacted>")
264            .field("deletion_time", &self.deletion_time)
265            .field("destroyed", &self.destroyed)
266            .finish()
267    }
268}
269
270/// What a write answers with: which version it became.
271#[derive(Debug, Clone)]
272pub struct VersionInfo {
273    pub version: u64,
274    pub created_time: String,
275    pub deletion_time: Option<String>,
276    pub destroyed: bool,
277}
278
279impl VersionInfo {
280    fn from_json(data: &Json) -> VersionInfo {
281        VersionInfo {
282            version: number(data, "version"),
283            created_time: text(data, "created_time"),
284            deletion_time: Some(text(data, "deletion_time")).filter(|t| !t.is_empty()),
285            destroyed: data.get("destroyed").and_then(Json::as_bool).unwrap_or(false),
286        }
287    }
288}
289
290/// The history of a path.
291#[derive(Debug, Clone)]
292pub struct SecretMetadata {
293    pub current_version: u64,
294    pub oldest_version: u64,
295    pub created_time: String,
296    pub updated_time: String,
297    /// How many versions the mount keeps; `0` means the mount's own default.
298    pub max_versions: u64,
299    /// Whether writes must carry the version they expect to replace.
300    pub cas_required: bool,
301    /// Every version still recorded, oldest first.
302    pub versions: Vec<VersionInfo>,
303}
304
305impl SecretMetadata {
306    fn from_json(data: &Json) -> SecretMetadata {
307        let mut versions: Vec<VersionInfo> = data
308            .get("versions")
309            .and_then(Json::as_object)
310            .map(|map| {
311                map.iter()
312                    .map(|(number, entry)| {
313                        let mut info = VersionInfo::from_json(entry);
314                        info.version = number.parse().unwrap_or(0);
315                        info
316                    })
317                    .collect()
318            })
319            .unwrap_or_default();
320        // The keys are strings, so a plain map ordering puts "10" before "2".
321        versions.sort_by_key(|version| version.version);
322
323        SecretMetadata {
324            current_version: number(data, "current_version"),
325            oldest_version: number(data, "oldest_version"),
326            created_time: text(data, "created_time"),
327            updated_time: text(data, "updated_time"),
328            max_versions: number(data, "max_versions"),
329            cas_required: data.get("cas_required").and_then(Json::as_bool).unwrap_or(false),
330            versions,
331        }
332    }
333}
334
335/// KV v2 wants the values under a `data` key, which is the other half of the
336/// nesting that makes this engine confusing.
337fn wrap<K, V, I>(values: I) -> Json
338where
339    K: Into<String>,
340    V: Into<Json>,
341    I: IntoIterator<Item = (K, V)>,
342{
343    let values = Json::object(values.into_iter().map(|(key, value)| (key.into(), value.into())));
344    Json::object([("data", values)])
345}
346
347fn versions_body(versions: &[u64]) -> Json {
348    Json::object([(
349        "versions",
350        Json::Array(versions.iter().map(|version| Json::Number(*version as f64)).collect()),
351    )])
352}
353
354fn number(value: &Json, key: &str) -> u64 {
355    value.get(key).and_then(Json::as_i64).unwrap_or(0).max(0) as u64
356}
357
358fn text(value: &Json, key: &str) -> String {
359    value.get(key).and_then(Json::as_str).unwrap_or_default().to_string()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use rustlavel_client::{Fake, FakeResponse};
366
367    /// The exact body OpenBao 2.6.2 returns for `GET /v1/secret/data/myapp`.
368    fn myapp() -> FakeResponse {
369        FakeResponse::json(
370            Json::parse(
371                r#"{"request_id":"39f7ee3d","lease_id":"","renewable":false,"lease_duration":0,
372                    "data":{"data":{"password":"s3cr3t","username":"app"},
373                            "metadata":{"created_time":"2026-08-29T13:43:43Z",
374                                        "custom_metadata":null,"deletion_time":"",
375                                        "destroyed":false,"version":1}},
376                    "wrap_info":null,"warnings":null,"auth":null}"#,
377            )
378            .unwrap(),
379        )
380    }
381
382    fn vault(fake: Fake) -> VaultClient {
383        VaultClient::new("https://vault.test:8200").faking(fake)
384    }
385
386    #[tokio::test]
387    async fn reading_a_secret_returns_the_inner_data_not_the_wrapper() {
388        // The bug this exists to prevent: `data` is the wrapper, and a caller
389        // who stops there gets `{"data": …, "metadata": …}` and a password
390        // field that is not there.
391        let client = vault(Fake::new().on("secret/data/myapp", myapp()));
392
393        let secret = client.kv().require("myapp").await.unwrap();
394
395        assert_eq!(secret.get("username"), Some("app"));
396        assert_eq!(secret.get("password"), Some("s3cr3t"));
397        assert_eq!(secret.version, 1);
398        assert!(secret.get("metadata").is_none());
399        client.fake().unwrap().assert_sent("/v1/secret/data/myapp");
400    }
401
402    #[tokio::test]
403    async fn a_missing_secret_is_none_and_a_required_one_names_the_path() {
404        let client = vault(Fake::new().fallback(FakeResponse::text(r#"{"errors":[]}"#).status(404)));
405
406        assert!(client.kv().read("nope").await.unwrap().is_none());
407
408        // `require` turns the same absence into a failure that names the path,
409        // which is the whole reason it exists beside `read`.
410        let error = client.kv().require("nope").await.unwrap_err().to_string();
411        assert!(error.contains("nothing is stored at"), "{error}");
412        assert!(error.contains("secret/data/nope"), "{error}");
413    }
414
415    #[tokio::test]
416    async fn a_version_can_be_pinned() {
417        let client = vault(Fake::new().fallback(myapp()));
418
419        client.kv().read_version("myapp", 2).await.unwrap();
420
421        client.fake().unwrap().assert_sent("/v1/secret/data/myapp?version=2");
422    }
423
424    #[tokio::test]
425    async fn writing_nests_the_values_under_data_and_reports_the_new_version() {
426        // The body OpenBao answers a write with.
427        let written = FakeResponse::json(
428            Json::parse(
429                r#"{"data":{"created_time":"2026-08-29T13:49:07Z","custom_metadata":null,
430                            "deletion_time":"","destroyed":false,"version":3}}"#,
431            )
432            .unwrap(),
433        );
434        let client = vault(Fake::new().fallback(written));
435
436        let info = client
437            .kv()
438            .write("myapp", [("username", "app"), ("password", "s3cr3t")])
439            .await
440            .unwrap();
441
442        assert_eq!(info.version, 3);
443        assert!(!info.destroyed);
444
445        let sent = client.fake().unwrap().recorded()[0].json().unwrap();
446        assert_eq!(sent.get("data.username").unwrap().as_str(), Some("app"));
447        // Not at the top level: Vault would store `{"username": …}` as the
448        // options object and write an empty secret.
449        assert!(sent.get("username").is_none());
450    }
451
452    #[tokio::test]
453    async fn patching_declares_the_merge_content_type() {
454        let client = vault(Fake::new().fallback(FakeResponse::json(
455            Json::parse(r#"{"data":{"version":4}}"#).unwrap(),
456        )));
457
458        client.kv().patch("myapp", [("password", "rotated")]).await.unwrap();
459
460        let sent = &client.fake().unwrap().recorded()[0];
461        assert_eq!(sent.headers.get("content-type"), Some("application/merge-patch+json"));
462        assert!(sent.url.ends_with("/v1/secret/data/myapp"), "{}", sent.url);
463    }
464
465    #[tokio::test]
466    async fn deleting_and_undeleting_use_the_prefixes_vault_reserves_for_them() {
467        let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
468        let kv = client.kv();
469
470        kv.delete("myapp").await.unwrap();
471        kv.delete_versions("myapp", &[1, 2]).await.unwrap();
472        kv.undelete("myapp", &[1]).await.unwrap();
473        kv.destroy("myapp", &[2]).await.unwrap();
474        kv.destroy_all("myapp").await.unwrap();
475
476        let fake = client.fake().unwrap();
477        let urls: Vec<String> = fake.recorded().iter().map(|r| r.url.clone()).collect();
478        assert!(urls[0].ends_with("/v1/secret/data/myapp"), "{:?}", urls[0]);
479        assert!(urls[1].ends_with("/v1/secret/delete/myapp"), "{:?}", urls[1]);
480        assert!(urls[2].ends_with("/v1/secret/undelete/myapp"), "{:?}", urls[2]);
481        assert!(urls[3].ends_with("/v1/secret/destroy/myapp"), "{:?}", urls[3]);
482        assert!(urls[4].ends_with("/v1/secret/metadata/myapp"), "{:?}", urls[4]);
483
484        let versions = fake.recorded()[1].json().unwrap();
485        assert_eq!(versions.get("versions.0").unwrap().as_i64(), Some(1));
486        assert_eq!(versions.get("versions.1").unwrap().as_i64(), Some(2));
487    }
488
489    #[tokio::test]
490    async fn listing_goes_to_the_metadata_prefix_and_marks_sub_prefixes() {
491        let listing = FakeResponse::json(
492            Json::parse(r#"{"data":{"keys":["myapp","team/"]}}"#).unwrap(),
493        );
494        let client = vault(Fake::new().fallback(listing));
495
496        let keys = client.kv().list("").await.unwrap();
497
498        assert_eq!(keys, vec!["myapp".to_string(), "team/".to_string()]);
499        client.fake().unwrap().assert_sent("/v1/secret/metadata?list=true");
500    }
501
502    #[tokio::test]
503    async fn metadata_reports_the_versions_in_numeric_order() {
504        // The keys are strings, so the naive ordering puts version 10 before 2.
505        let body = FakeResponse::json(
506            Json::parse(
507                r#"{"data":{"cas_required":false,"created_time":"2026-08-29T13:49:07Z",
508                            "current_version":10,"max_versions":0,"oldest_version":0,
509                            "updated_time":"2026-08-29T13:50:00Z",
510                            "versions":{"1":{"created_time":"a","deletion_time":"","destroyed":false},
511                                        "2":{"created_time":"b","deletion_time":"c","destroyed":false},
512                                        "10":{"created_time":"d","deletion_time":"","destroyed":true}}}}"#,
513            )
514            .unwrap(),
515        );
516        let client = vault(Fake::new().fallback(body));
517
518        let metadata = client.kv().metadata("myapp").await.unwrap().unwrap();
519
520        assert_eq!(metadata.current_version, 10);
521        let numbers: Vec<u64> = metadata.versions.iter().map(|v| v.version).collect();
522        assert_eq!(numbers, vec![1, 2, 10]);
523        assert_eq!(metadata.versions[1].deletion_time.as_deref(), Some("c"));
524        assert!(metadata.versions[2].destroyed);
525    }
526
527    #[tokio::test]
528    async fn a_kv_version_one_mount_says_so_instead_of_returning_nothing() {
529        // A v1 mount answers 200 with the values directly under `data`. Read as
530        // v2 that is an empty secret, and the service starts with a blank
531        // password rather than failing.
532        let v1 = FakeResponse::json(Json::parse(r#"{"data":{"password":"s3cr3t"}}"#).unwrap());
533        let client = vault(Fake::new().fallback(v1));
534
535        let error = client.kv().read("myapp").await.unwrap_err().to_string();
536
537        assert!(error.contains("version 1"), "{error}");
538    }
539
540    #[tokio::test]
541    async fn the_mount_is_configurable() {
542        let client = vault(Fake::new().fallback(myapp()));
543
544        client.kv().mount("/team-a/").read("myapp").await.unwrap();
545
546        client.fake().unwrap().assert_sent("/v1/team-a/data/myapp");
547    }
548
549    #[test]
550    fn a_missing_field_names_the_key_and_lists_what_is_there() {
551        let secret = Secret {
552            values: Json::parse(r#"{"username":"app","password":"s3cr3t"}"#).unwrap(),
553            version: 3,
554            created_time: String::new(),
555            deletion_time: None,
556            destroyed: false,
557        };
558
559        let error = secret.require("dsn").unwrap_err().to_string();
560        assert!(error.contains("`dsn`"), "{error}");
561        assert!(error.contains("username"), "{error}");
562        // Naming the keys is the point; naming the values would defeat it.
563        assert!(!error.contains("s3cr3t"), "{error}");
564    }
565
566    #[test]
567    fn debug_on_a_secret_shows_the_keys_and_not_the_values() {
568        let secret = Secret {
569            values: Json::parse(r#"{"password":"s3cr3t"}"#).unwrap(),
570            version: 1,
571            created_time: String::new(),
572            deletion_time: None,
573            destroyed: false,
574        };
575
576        let printed = format!("{secret:?}");
577        assert!(printed.contains("password"), "{printed}");
578        assert!(!printed.contains("s3cr3t"), "{printed}");
579    }
580}