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