config_vault_source/
lib.rs

1//! # config-vault-source
2//!
3//! `config-vault-source` is an extension for the `config` crate that allows loading configurations
4//! directly from HashiCorp Vault.
5//!
6//! Source code of `config-vault-source` is pretty similar as in parent crate `config-vault` but with support
7//! of tls, async and nested configuration keys.
8//!
9//!
10//!
11//! This library implements a custom `Source` or `AsyncSource` via async feature for the `config` crate that can
12//! connect to a HashiCorp Vault server and load secrets from the KV1/KV2 engines as
13//! configuration values.
14//!
15//! ## Example
16//!
17//! ```
18//! use config::{Config, ConfigError};
19//! use config_vault::VaultSource;
20//!
21//! fn load_config() -> Result<Config, ConfigError> {
22//!     let vault_source = VaultSource::new(
23//!         "http://127.0.0.1:8200".to_string(),  // Vault address
24//!         "hvs.EXAMPLE_TOKEN".to_string(),      // Vault token
25//!         "secret".to_string(),                 // KV mount name
26//!         "dev".to_string(),        // Secret path
27//!     );
28//!
29//!     Config::builder()
30//!         .add_source(vault_source)
31//!         // You can add other sources
32//!         .build()
33//! }
34//! ```
35//!
36//! If you want to use the KV1 engine, you can use the `new_v1` method instead of `new`:
37//!
38//! ```
39//! use config_vault::VaultSource;
40//!
41//! let vault_source = VaultSource::new_v1(
42//!         "http://127.0.0.1:8200".to_string(),  // Vault address
43//!         "hvs.EXAMPLE_TOKEN".to_string(),      // Vault token
44//!         "secret".to_string(),                 // KV mount name
45//!         "dev".to_string(),        // Secret path
46//! );
47//! ```
48
49pub mod builder;
50mod utils;
51
52pub use builder::VaultSourceBuilder;
53
54use std::ops::Deref;
55use std::str::FromStr;
56
57use config::{Config, Source};
58use config::{ConfigError, Map, Value};
59
60#[cfg(feature = "async")]
61use async_trait::async_trait;
62#[cfg(feature = "async")]
63use config::AsyncSource;
64use url::Url;
65
66use crate::utils::flatten_json;
67
68/// A `Source` for the `config` library that loads configurations from HashiCorp Vault.
69///
70/// This source connects to a HashiCorp Vault server and loads a secret from
71/// the version 2 of the KV (Key-Value) engine. The values from the secret are included
72/// in the configuration as flat key-value pairs.
73///
74/// # Example
75///
76/// ```
77/// use config_vault::VaultSource;
78///
79/// let vault = VaultSource::new(
80///     "http://vault.example.com:8200".to_string(),
81///     "my-token".to_string(),
82///     "secret".to_string(),
83///     "dev".to_string(),
84/// );
85/// ```
86#[derive(Debug, Clone, PartialEq)]
87pub struct VaultSource {
88    config: VaultConfig,
89    kv_version: KvVersion,
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct VaultAddr(Url);
94
95impl Deref for VaultAddr {
96    type Target = Url;
97
98    fn deref(&self) -> &Self::Target {
99        &self.0
100    }
101}
102
103impl FromStr for VaultAddr {
104    type Err = ConfigError;
105
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        let url = url::Url::parse(s)
108            .map_err(|e| ConfigError::Message(format!("Invalid Vault address: {e}")))?;
109
110        if url.cannot_be_a_base() {
111            return Err(ConfigError::Message(
112                "Vault address cannot be a base URL".into(),
113            ));
114        }
115
116        if !url.path().trim_matches('/').is_empty() {
117            return Err(ConfigError::Message(
118                "Vault address must not contain a path (e.g. use https://host:8200, not https://host:8200/v1)"
119                    .into(),
120            ));
121        }
122
123        Ok(VaultAddr(url))
124    }
125}
126
127impl TryFrom<&str> for VaultAddr {
128    type Error = ConfigError;
129
130    fn try_from(value: &str) -> Result<Self, Self::Error> {
131        value.parse()
132    }
133}
134
135impl TryFrom<String> for VaultAddr {
136    type Error = ConfigError;
137
138    fn try_from(value: String) -> Result<Self, Self::Error> {
139        value.parse()
140    }
141}
142
143#[derive(Debug, Clone, PartialEq)]
144pub struct VaultConfig {
145    pub address: VaultAddr,
146    pub token: String,
147    pub mount: String,
148    pub path: String,
149
150    #[cfg(feature = "tls")]
151    pub tls: Option<TlsConfig>,
152}
153
154#[cfg(feature = "tls")]
155#[derive(Debug, Clone, PartialEq)]
156pub struct TlsConfig {
157    pub ca_cert_bytes: Option<Vec<u8>>,
158    pub client_cert: Option<Vec<u8>>,
159    pub client_key: Option<Vec<u8>>,
160    pub danger_accept_invalid_certs: bool,
161}
162
163#[derive(Debug, Clone, PartialEq, Default)]
164pub enum KvVersion {
165    V1,
166    #[default]
167    V2,
168}
169
170impl VaultSource {
171    pub fn builder() -> VaultSourceBuilder {
172        VaultSourceBuilder::new()
173    }
174
175    fn build_read_url(&self) -> Result<url::Url, ConfigError> {
176        let api_path = match self.kv_version {
177            KvVersion::V1 => format!("v1/{}/{}", self.config.mount, self.config.path),
178            KvVersion::V2 => format!("v1/{}/data/{}", self.config.mount, self.config.path),
179        };
180
181        let mut url = self.config.address.0.clone();
182
183        url.path_segments_mut()
184            .map_err(|_| ConfigError::Message("Vault base URL cannot be a base".into()))?
185            .pop_if_empty()
186            .extend(api_path.split("/"));
187
188        Ok(url)
189    }
190
191    fn build_blocking_client(&self) -> Result<reqwest::blocking::Client, ConfigError> {
192        let mut builder = reqwest::blocking::Client::builder();
193
194        #[cfg(feature = "tls")]
195        if let Some(tls) = &self.config.tls {
196            if let Some(ca_bytes) = &tls.ca_cert_bytes {
197                let cert = reqwest::Certificate::from_pem(&ca_bytes)
198                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
199
200                builder = builder.add_root_certificate(cert);
201            }
202
203            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
204                let mut identity_bytes = cert.clone();
205                identity_bytes.extend_from_slice(key);
206                let identity = reqwest::Identity::from_pem(&identity_bytes)
207                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
208                builder = builder.identity(identity);
209            }
210
211            if tls.danger_accept_invalid_certs {
212                builder = builder.danger_accept_invalid_certs(true);
213            }
214        }
215
216        builder
217            .build()
218            .map_err(|e| ConfigError::Foreign(Box::new(e)))
219    }
220
221    #[cfg(feature = "async")]
222    fn build_async_client(&self) -> Result<reqwest::Client, ConfigError> {
223        let mut builder = reqwest::Client::builder();
224
225        #[cfg(feature = "tls")]
226        if let Some(tls) = &self.config.tls {
227            if let Some(ca_bytes) = &tls.ca_cert_bytes {
228                let cert = reqwest::Certificate::from_pem(&ca_bytes)
229                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
230
231                builder = builder.add_root_certificate(cert);
232            }
233
234            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
235                let mut identity_bytes = cert.clone();
236                identity_bytes.extend_from_slice(key);
237                let identity = reqwest::Identity::from_pem(&identity_bytes)
238                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
239                builder = builder.identity(identity);
240            }
241
242            if tls.danger_accept_invalid_certs {
243                builder = builder.danger_accept_invalid_certs(true);
244            }
245        }
246
247        builder
248            .build()
249            .map_err(|e| ConfigError::Foreign(Box::new(e)))
250    }
251}
252
253impl Source for VaultSource {
254    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
255        Box::new(self.clone())
256    }
257
258    fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
259        let client = self.build_blocking_client()?;
260        let url = self.build_read_url()?;
261        let resp = client
262            .get(url)
263            .header("X-Vault-Token", &self.config.token)
264            .send()
265            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
266
267        if !resp.status().is_success() {
268            return Err(ConfigError::Message("Vault request failed".into()));
269        }
270
271        let raw: serde_json::Value = resp.json().map_err(|e| ConfigError::Foreign(Box::new(e)))?;
272        let json_obj = raw
273            .get("data")
274            .and_then(|x| {
275                if self.kv_version == KvVersion::V2 {
276                    x.get("data")
277                } else {
278                    Some(x)
279                }
280            })
281            .and_then(|x| x.as_object())
282            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
283
284        let mut secret = std::collections::HashMap::new();
285        flatten_json(
286            "",
287            &serde_json::Value::Object(json_obj.clone()),
288            &mut secret,
289        );
290        Ok(secret)
291    }
292}
293
294#[cfg(feature = "async")]
295#[async_trait]
296impl AsyncSource for VaultSource {
297    async fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
298        let client = self.build_async_client()?;
299        let url = self.build_read_url()?;
300        let resp = client
301            .get(url)
302            .header("X-Vault-Token", &self.config.token)
303            .send()
304            .await
305            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
306
307        if !resp.status().is_success() {
308            return Err(ConfigError::Message("Vault request failed".into()));
309        }
310
311        let raw: serde_json::Value = resp
312            .json()
313            .await
314            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
315        let json_obj = raw
316            .get("data")
317            .and_then(|x| {
318                if self.kv_version == KvVersion::V2 {
319                    x.get("data")
320                } else {
321                    Some(x)
322                }
323            })
324            .and_then(|x| x.as_object())
325            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
326
327        let mut secret = std::collections::HashMap::new();
328        flatten_json(
329            "",
330            &serde_json::Value::Object(json_obj.clone()),
331            &mut secret,
332        );
333        Ok(secret)
334    }
335}