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}