config_vault_source/
lib.rs

1//! # config-vault-source
2//!
3//! `config-vault-source` is an extension for the [`config`](https://docs.rs/config)
4//! crate that allows loading configuration values directly from **HashiCorp Vault**.
5//!
6//! This crate provides:
7//! - Support for **KV1** and **KV2** Vault engines  
8//! - Optional **TLS** support (via the `tls` feature)  
9//! - Optional **async loading** (via the `async` feature)  
10//! - Automatic **flattening** of nested JSON secrets into config keys  
11//! - A clean and ergonomic **Builder API**  
12//!
13//! It is designed as a drop-in additional `Source` (or `AsyncSource`) for the
14//! `config` crate and works the same way as other config sources.
15//!
16//! ---
17//!
18//! ## ✨ Example (Synchronous)
19//!
20//! ```rust
21//! use config::Config;
22//! use config_vault_source::VaultSource;
23//!
24//! fn load_config() -> Result<Config, config::ConfigError> {
25//!     let vault = VaultSource::builder()
26//!         .address("http://127.0.0.1:8200")
27//!         .token("hvs.EXAMPLE_TOKEN")
28//!         .mount("secret")
29//!         .path("dev")
30//!         .build()?;
31//!
32//!     let config = Config::builder()
33//!         .add_source(vault)
34//!         .build()?;
35//!
36//!     Ok(config)
37//! }
38//! ```
39//!
40//! ---
41//!
42//! ## ⚡ Example (Asynchronous)
43//!
44//! Requires:
45//! ```toml
46//! config-vault-source = { version = "...", features = ["async"] }
47//! ```
48//!
49//! ```rust
50//! use config_vault_source::VaultSource;
51//!
52//! pub async fn get_configuration_async() -> Result<Settings, config::ConfigError> {
53//!     let vault_async_source = VaultSource::builder()
54//!         .address(std::env::var("VAULT_ADDR").unwrap_or("http://0.0.0.0:8200".into()))
55//!         .token(std::env::var("VAULT_TOKEN").unwrap_or("root".into()))
56//!         .mount(std::env::var("VAULT_MOUNT").unwrap_or("secret".into()))
57//!         .path(std::env::var("VAULT_PATH").unwrap_or("dev".into()))
58//!         .build()?;
59//!
60//!     let settings = config::Config::builder()
61//!         .add_source(config::File::with_name("config"))
62//!         .add_async_source(vault_async_source)
63//!         .build()
64//!         .await?;
65//!
66//!     settings.try_deserialize()
67//! }
68//! ```
69//!
70//! ---
71//!
72//! ## 🔐 TLS Support
73//!
74//! Enable the `tls` feature:
75//! ```toml
76//! config-vault-source = { version = "...", features = ["tls"] }
77//! ```
78//!
79//! Builder options become available for CA certificates, client certificates,
80//! client keys, and allowing invalid certs (development mode).
81//!
82//! ---
83//!
84//! ## 🧩 KV Engine Support
85//!
86//! By default the source uses **KV2**.  
87//! To use **KV1**, call:
88//!
89//! ```rust
90//! let vault = VaultSource::builder()
91//!     .kv_version(KvVersion::V1)
92//!     // ...
93//!     .build()?;
94//! ```
95//!
96//! ---
97//!
98//! ## 📦 Flattening of Nested Secrets
99//!
100//! Vault secrets like:
101//!
102//! ```json
103//! {
104//!   "database": {
105//!       "host": "localhost",
106//!       "port": 5432
107//!   }
108//! }
109//! ```
110//!
111//! automatically become:
112//!
113//! ```
114//! database.host = "localhost"
115//! database.port = 5432
116//! ```
117//!
118//! This makes them compatible with `config` merging and with `serde` deserialization.
119//!
120//! ---
121//!
122//! For full usage examples, see the README or the builder documentation.
123pub mod builder;
124mod utils;
125
126pub use builder::VaultSourceBuilder;
127
128use std::ops::Deref;
129use std::str::FromStr;
130
131use config::{Config, Source};
132use config::{ConfigError, Map, Value};
133
134#[cfg(feature = "async")]
135use async_trait::async_trait;
136#[cfg(feature = "async")]
137use config::AsyncSource;
138use url::Url;
139
140use crate::utils::flatten_json;
141
142/// A `Source` for the `config` library that loads configurations from HashiCorp Vault.
143///
144/// This source connects to a HashiCorp Vault server and loads a secret from
145/// the version 2 of the KV (Key-Value) engine. The values from the secret are included
146/// in the configuration as flat key-value pairs.
147///
148/// # Example
149///
150/// ```
151/// use config_vault::VaultSource;
152///
153/// let vault = VaultSource::new(
154///     "http://vault.example.com:8200".to_string(),
155///     "my-token".to_string(),
156///     "secret".to_string(),
157///     "dev".to_string(),
158/// );
159/// ```
160#[derive(Debug, Clone, PartialEq)]
161pub struct VaultSource {
162    config: VaultConfig,
163    kv_version: KvVersion,
164}
165
166#[derive(Debug, Clone, PartialEq)]
167pub struct VaultAddr(Url);
168
169impl Deref for VaultAddr {
170    type Target = Url;
171
172    fn deref(&self) -> &Self::Target {
173        &self.0
174    }
175}
176
177impl FromStr for VaultAddr {
178    type Err = ConfigError;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        let url = url::Url::parse(s)
182            .map_err(|e| ConfigError::Message(format!("Invalid Vault address: {e}")))?;
183
184        if url.cannot_be_a_base() {
185            return Err(ConfigError::Message(
186                "Vault address cannot be a base URL".into(),
187            ));
188        }
189
190        if !url.path().trim_matches('/').is_empty() {
191            return Err(ConfigError::Message(
192                "Vault address must not contain a path (e.g. use https://host:8200, not https://host:8200/v1)"
193                    .into(),
194            ));
195        }
196
197        Ok(VaultAddr(url))
198    }
199}
200
201impl TryFrom<&str> for VaultAddr {
202    type Error = ConfigError;
203
204    fn try_from(value: &str) -> Result<Self, Self::Error> {
205        value.parse()
206    }
207}
208
209impl TryFrom<String> for VaultAddr {
210    type Error = ConfigError;
211
212    fn try_from(value: String) -> Result<Self, Self::Error> {
213        value.parse()
214    }
215}
216
217#[derive(Debug, Clone, PartialEq)]
218pub struct VaultConfig {
219    pub address: VaultAddr,
220    pub token: String,
221    pub mount: String,
222    pub path: String,
223
224    #[cfg(feature = "tls")]
225    pub tls: Option<TlsConfig>,
226}
227
228#[cfg(feature = "tls")]
229#[derive(Debug, Clone, PartialEq)]
230pub struct TlsConfig {
231    pub ca_cert_bytes: Option<Vec<u8>>,
232    pub client_cert: Option<Vec<u8>>,
233    pub client_key: Option<Vec<u8>>,
234    pub danger_accept_invalid_certs: bool,
235}
236
237#[derive(Debug, Clone, PartialEq, Default)]
238pub enum KvVersion {
239    V1,
240    #[default]
241    V2,
242}
243
244impl VaultSource {
245    pub fn builder() -> VaultSourceBuilder {
246        VaultSourceBuilder::new()
247    }
248
249    fn build_read_url(&self) -> Result<url::Url, ConfigError> {
250        let api_path = match self.kv_version {
251            KvVersion::V1 => format!("v1/{}/{}", self.config.mount, self.config.path),
252            KvVersion::V2 => format!("v1/{}/data/{}", self.config.mount, self.config.path),
253        };
254
255        let mut url = self.config.address.0.clone();
256
257        url.path_segments_mut()
258            .map_err(|_| ConfigError::Message("Vault base URL cannot be a base".into()))?
259            .pop_if_empty()
260            .extend(api_path.split("/"));
261
262        Ok(url)
263    }
264
265    fn build_blocking_client(&self) -> Result<reqwest::blocking::Client, ConfigError> {
266        let mut builder = reqwest::blocking::Client::builder();
267
268        #[cfg(feature = "tls")]
269        if let Some(tls) = &self.config.tls {
270            if let Some(ca_bytes) = &tls.ca_cert_bytes {
271                let cert = reqwest::Certificate::from_pem(&ca_bytes)
272                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
273
274                builder = builder.add_root_certificate(cert);
275            }
276
277            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
278                let mut identity_bytes = cert.clone();
279                identity_bytes.extend_from_slice(key);
280                let identity = reqwest::Identity::from_pem(&identity_bytes)
281                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
282                builder = builder.identity(identity);
283            }
284
285            if tls.danger_accept_invalid_certs {
286                builder = builder.danger_accept_invalid_certs(true);
287            }
288        }
289
290        builder
291            .build()
292            .map_err(|e| ConfigError::Foreign(Box::new(e)))
293    }
294
295    #[cfg(feature = "async")]
296    fn build_async_client(&self) -> Result<reqwest::Client, ConfigError> {
297        let mut builder = reqwest::Client::builder();
298
299        #[cfg(feature = "tls")]
300        if let Some(tls) = &self.config.tls {
301            if let Some(ca_bytes) = &tls.ca_cert_bytes {
302                let cert = reqwest::Certificate::from_pem(&ca_bytes)
303                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
304
305                builder = builder.add_root_certificate(cert);
306            }
307
308            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
309                let mut identity_bytes = cert.clone();
310                identity_bytes.extend_from_slice(key);
311                let identity = reqwest::Identity::from_pem(&identity_bytes)
312                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
313                builder = builder.identity(identity);
314            }
315
316            if tls.danger_accept_invalid_certs {
317                builder = builder.danger_accept_invalid_certs(true);
318            }
319        }
320
321        builder
322            .build()
323            .map_err(|e| ConfigError::Foreign(Box::new(e)))
324    }
325}
326
327impl Source for VaultSource {
328    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
329        Box::new(self.clone())
330    }
331
332    fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
333        let client = self.build_blocking_client()?;
334        let url = self.build_read_url()?;
335        let resp = client
336            .get(url)
337            .header("X-Vault-Token", &self.config.token)
338            .send()
339            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
340
341        if !resp.status().is_success() {
342            return Err(ConfigError::Message("Vault request failed".into()));
343        }
344
345        let raw: serde_json::Value = resp.json().map_err(|e| ConfigError::Foreign(Box::new(e)))?;
346        let json_obj = raw
347            .get("data")
348            .and_then(|x| {
349                if self.kv_version == KvVersion::V2 {
350                    x.get("data")
351                } else {
352                    Some(x)
353                }
354            })
355            .and_then(|x| x.as_object())
356            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
357
358        let mut secret = std::collections::HashMap::new();
359        flatten_json(
360            "",
361            &serde_json::Value::Object(json_obj.clone()),
362            &mut secret,
363        );
364        Ok(secret)
365    }
366}
367
368#[cfg(feature = "async")]
369#[async_trait]
370impl AsyncSource for VaultSource {
371    async fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
372        let client = self.build_async_client()?;
373        let url = self.build_read_url()?;
374        let resp = client
375            .get(url)
376            .header("X-Vault-Token", &self.config.token)
377            .send()
378            .await
379            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
380
381        if !resp.status().is_success() {
382            return Err(ConfigError::Message("Vault request failed".into()));
383        }
384
385        let raw: serde_json::Value = resp
386            .json()
387            .await
388            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
389        let json_obj = raw
390            .get("data")
391            .and_then(|x| {
392                if self.kv_version == KvVersion::V2 {
393                    x.get("data")
394                } else {
395                    Some(x)
396                }
397            })
398            .and_then(|x| x.as_object())
399            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
400
401        let mut secret = std::collections::HashMap::new();
402        flatten_json(
403            "",
404            &serde_json::Value::Object(json_obj.clone()),
405            &mut secret,
406        );
407        Ok(secret)
408    }
409}