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 async and nested configuration keys.
8//!
9//! TODO: implement TLS connection.
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
49mod utils;
50
51use config::Source;
52use config::{ConfigError, Map, Value};
53
54#[cfg(feature = "async")]
55use async_trait::async_trait;
56#[cfg(feature = "async")]
57use config::AsyncSource;
58
59use crate::utils::flatten_json;
60
61/// A `Source` for the `config` library that loads configurations from HashiCorp Vault.
62///
63/// This source connects to a HashiCorp Vault server and loads a secret from
64/// the version 2 of the KV (Key-Value) engine. The values from the secret are included
65/// in the configuration as flat key-value pairs.
66///
67/// # Example
68///
69/// ```
70/// use config_vault::VaultSource;
71///
72/// let vault = VaultSource::new(
73///     "http://vault.example.com:8200".to_string(),
74///     "my-token".to_string(),
75///     "secret".to_string(),
76///     "dev".to_string(),
77/// );
78/// ```
79#[derive(Debug, Clone, PartialEq)]
80pub struct VaultSource {
81    vault_addr: String,
82    vault_token: String,
83    vault_mount: String,
84    vault_path: String,
85    kv_version: KvVersion,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub enum KvVersion {
90    V1 = 1,
91    V2,
92}
93
94impl KvVersion {
95    pub fn get_api_path(&self, mount: &str, path: &str) -> String {
96        match self {
97            KvVersion::V1 => format!("v1/{}/{}", mount, path),
98            KvVersion::V2 => format!("v1/{}/data/{}", mount, path),
99        }
100    }
101}
102
103impl VaultSource {
104    /// Creates a new instance of `VaultSource`.
105    ///
106    /// # Parameters
107    ///
108    /// * `vault_addr` - Complete URL of the Vault server (e.g. "http://127.0.0.1:8200")
109    /// * `vault_token` - Authentication token for Vault
110    /// * `vault_mount` - Name of the KV engine mount (e.g. "secret")
111    /// * `vault_path` - Path to the secret within the mount (e.g. "dev")
112    ///
113    /// # Example
114    ///
115    /// ```
116    /// use config_vault::VaultSource;
117    ///
118    /// let source = VaultSource::new(
119    ///     "http://127.0.0.1:8200".to_string(),
120    ///     "hvs.EXAMPLE_TOKEN".to_string(),
121    ///     "secret".to_string(),
122    ///     "dev".to_string(),
123    /// );
124    /// ```
125    pub fn new(
126        vault_addr: String,
127        vault_token: String,
128        vault_mount: String,
129        vault_path: String,
130    ) -> Self {
131        Self {
132            vault_addr,
133            vault_token,
134            vault_mount,
135            vault_path,
136            kv_version: KvVersion::V2,
137        }
138    }
139
140    /// Creates a new instance of `VaultSource` with kv_version V1
141    ///
142    /// # Parameters
143    ///
144    /// * `vault_addr` - Complete URL of the Vault server (e.g. "http://127.0.0.1:8200")
145    /// * `vault_token` - Authentication token for Vault
146    /// * `vault_mount` - Name of the KV engine mount (e.g. "secret")
147    /// * `vault_path` - Path to the secret within the mount (e.g. "dev")
148    ///
149    /// # Example
150    ///
151    /// ```
152    /// use config_vault::VaultSource;
153    ///
154    /// let source = VaultSource::new_v1(
155    ///     "http://127.0.0.1:8200".to_string(),
156    ///     "hvs.EXAMPLE_TOKEN".to_string(),
157    ///     "secret".to_string(),
158    ///     "dev".to_string(),
159    /// );
160    pub fn new_v1(
161        vault_addr: String,
162        vault_token: String,
163        vault_mount: String,
164        vault_path: String,
165    ) -> Self {
166        Self {
167            vault_addr,
168            vault_token,
169            vault_mount,
170            vault_path,
171            kv_version: KvVersion::V1,
172        }
173    }
174
175    fn build_kv_read_url(&self) -> Result<url::Url, ConfigError> {
176        let api_path = match self.kv_version {
177            KvVersion::V1 => format!("v1/{}/{}", self.vault_mount, self.vault_path),
178            KvVersion::V2 => format!("v1/{}/data/{}", self.vault_mount, self.vault_path),
179        };
180
181        let mut url = url::Url::parse(&self.vault_addr).map_err(|_| {
182            ConfigError::Message(format!("Invalid Vault address: {}", self.vault_addr))
183        })?;
184
185        url.path_segments_mut()
186            .map_err(|_| ConfigError::Message("Vault base URL cannot be a base".into()))?
187            .pop_if_empty()
188            .extend(api_path.split("/"));
189
190        Ok(url)
191    }
192}
193
194impl Source for VaultSource {
195    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
196        Box::new(self.clone())
197    }
198
199    fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
200        let client = reqwest::blocking::Client::new();
201        let url = self.build_kv_read_url()?;
202        let resp = client
203            .get(url)
204            .header("X-Vault-Token", &self.vault_token)
205            .send()
206            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
207
208        if !resp.status().is_success() {
209            return Err(ConfigError::Message("Vault request failed".into()));
210        }
211
212        let raw: serde_json::Value = resp.json().map_err(|e| ConfigError::Foreign(Box::new(e)))?;
213        let json_obj = raw
214            .get("data")
215            .and_then(|x| {
216                if self.kv_version == KvVersion::V2 {
217                    x.get("data")
218                } else {
219                    Some(x)
220                }
221            })
222            .and_then(|x| x.as_object())
223            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
224
225        let mut secret = std::collections::HashMap::new();
226        flatten_json(
227            "",
228            &serde_json::Value::Object(json_obj.clone()),
229            &mut secret,
230        );
231        Ok(secret)
232    }
233}
234
235#[cfg(feature = "async")]
236#[async_trait]
237impl AsyncSource for VaultSource {
238    async fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
239        let client_builder = reqwest::Client::builder();
240
241        let client = client_builder
242            .build()
243            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
244
245        let url = self.build_kv_read_url()?;
246        let resp = client
247            .get(url)
248            .header("X-Vault-Token", &self.vault_token)
249            .send()
250            .await
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
258            .json()
259            .await
260            .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
261        let json_obj = raw
262            .get("data")
263            .and_then(|x| {
264                if self.kv_version == KvVersion::V2 {
265                    x.get("data")
266                } else {
267                    Some(x)
268                }
269            })
270            .and_then(|x| x.as_object())
271            .ok_or_else(|| ConfigError::Message("Vault response missing data".into()))?;
272
273        let mut secret = std::collections::HashMap::new();
274        flatten_json(
275            "",
276            &serde_json::Value::Object(json_obj.clone()),
277            &mut secret,
278        );
279        Ok(secret)
280    }
281}