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::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 KvVersion {
171    pub fn api_path(&self, mount: &str, path: &str) -> String {
172        match self {
173            KvVersion::V1 => format!("v1/{}/{}", mount, path),
174            KvVersion::V2 => format!("v1/{}/data/{}", mount, path),
175        }
176    }
177}
178
179impl VaultSource {
180    pub fn builder() -> VaultSourceBuilder {
181        VaultSourceBuilder::new()
182    }
183
184    fn build_blocking_client(&self) -> Result<reqwest::blocking::Client, ConfigError> {
185        let mut builder = reqwest::blocking::Client::builder();
186
187        #[cfg(feature = "tls")]
188        if let Some(tls) = &self.config.tls {
189            if let Some(ca_bytes) = &tls.ca_cert_bytes {
190                let cert = reqwest::Certificate::from_pem(&ca_bytes)
191                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
192
193                builder = builder.add_root_certificate(cert);
194            }
195
196            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
197                let mut identity_bytes = cert.clone();
198                identity_bytes.extend_from_slice(key);
199                let identity = reqwest::Identity::from_pem(&identity_bytes)
200                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
201                builder = builder.identity(identity);
202            }
203
204            if tls.danger_accept_invalid_certs {
205                builder = builder.danger_accept_invalid_certs(true);
206            }
207        }
208
209        builder
210            .build()
211            .map_err(|e| ConfigError::Foreign(Box::new(e)))
212    }
213
214    #[cfg(feature = "async")]
215    fn build_async_client(&self) -> Result<reqwest::Client, ConfigError> {
216        let mut builder = reqwest::Client::builder();
217
218        #[cfg(feature = "tls")]
219        if let Some(tls) = &self.config.tls {
220            if let Some(ca_bytes) = &tls.ca_cert_bytes {
221                let cert = reqwest::Certificate::from_pem(&ca_bytes)
222                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
223
224                builder = builder.add_root_certificate(cert);
225            }
226
227            if let (Some(cert), Some(key)) = (&tls.client_cert, &tls.client_key) {
228                let mut identity_bytes = cert.clone();
229                identity_bytes.extend_from_slice(key);
230                let identity = reqwest::Identity::from_pem(&identity_bytes)
231                    .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
232                builder = builder.identity(identity);
233            }
234
235            if tls.danger_accept_invalid_certs {
236                builder = builder.danger_accept_invalid_certs(true);
237            }
238        }
239
240        builder
241            .build()
242            .map_err(|e| ConfigError::Foreign(Box::new(e)))
243    }
244}
245
246impl Source for VaultSource {
247    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
248        Box::new(self.clone())
249    }
250
251    fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
252        let client = self.build_blocking_client()?;
253        let resp = client
254            .get(self.config.address.as_str())
255            .header("X-Vault-Token", &self.config.token)
256            .send()
257            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
258
259        if !resp.status().is_success() {
260            return Err(ConfigError::Message("Vault request failed".into()));
261        }
262
263        let raw: serde_json::Value = resp.json().map_err(|e| ConfigError::Foreign(Box::new(e)))?;
264        let json_obj = raw
265            .get("data")
266            .and_then(|x| {
267                if self.kv_version == KvVersion::V2 {
268                    x.get("data")
269                } else {
270                    Some(x)
271                }
272            })
273            .and_then(|x| x.as_object())
274            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
275
276        let mut secret = std::collections::HashMap::new();
277        flatten_json(
278            "",
279            &serde_json::Value::Object(json_obj.clone()),
280            &mut secret,
281        );
282        Ok(secret)
283    }
284}
285
286#[cfg(feature = "async")]
287#[async_trait]
288impl AsyncSource for VaultSource {
289    async fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
290        let client = self.build_async_client()?;
291
292        let resp = client
293            .get(self.config.address.as_str())
294            .header("X-Vault-Token", &self.config.token)
295            .send()
296            .await
297            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
298
299        if !resp.status().is_success() {
300            return Err(ConfigError::Message("Vault request failed".into()));
301        }
302
303        let raw: serde_json::Value = resp
304            .json()
305            .await
306            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
307        let json_obj = raw
308            .get("data")
309            .and_then(|x| {
310                if self.kv_version == KvVersion::V2 {
311                    x.get("data")
312                } else {
313                    Some(x)
314                }
315            })
316            .and_then(|x| x.as_object())
317            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
318
319        let mut secret = std::collections::HashMap::new();
320        flatten_json(
321            "",
322            &serde_json::Value::Object(json_obj.clone()),
323            &mut secret,
324        );
325        Ok(secret)
326    }
327}