config_vault/lib.rs
1//! # config-vault
2//!
3//! `config-vault` is an extension for the `config` crate that allows loading configurations
4//! directly from HashiCorp Vault.
5//!
6//! This library implements a custom `Source` for the `config` crate that can
7//! connect to a HashiCorp Vault server and load secrets from the KV2 engine as
8//! configuration values.
9//!
10//! ## Example
11//!
12//! ```
13//! use config::{Config, ConfigError};
14//! use config_vault::VaultSource;
15//!
16//! fn load_config() -> Result<Config, ConfigError> {
17//! let vault_source = VaultSource::new(
18//! "http://127.0.0.1:8200".to_string(), // Vault address
19//! "hvs.EXAMPLE_TOKEN".to_string(), // Vault token
20//! "secret".to_string(), // KV mount name
21//! "dev".to_string(), // Secret path
22//! );
23//!
24//! Config::builder()
25//! .add_source(vault_source)
26//! // You can add other sources
27//! .build()
28//! }
29//! ```
30//!
31//! If you want to use the KV1 engine, you can use the `new_v1` method instead of `new`:
32//!
33//! ```
34//! use config_vault::VaultSource;
35//!
36//! let vault_source = VaultSource::new_v1(
37//! "http://127.0.0.1:8200".to_string(), // Vault address
38//! "hvs.EXAMPLE_TOKEN".to_string(), // Vault token
39//! "secret".to_string(), // KV mount name
40//! "dev".to_string(), // Secret path
41//! );
42//! ```
43
44use std::collections::HashMap;
45
46use config::{ConfigError, Map, Source, Value};
47use reqwest::blocking::Client;
48use serde_json::Value as JsonValue;
49use url::Url;
50
51/// A `Source` for the `config` library that loads configurations from HashiCorp Vault.
52///
53/// This source connects to a HashiCorp Vault server and loads a secret from
54/// the version 2 of the KV (Key-Value) engine. The values from the secret are included
55/// in the configuration as flat key-value pairs.
56///
57/// # Example
58///
59/// ```
60/// use config_vault::VaultSource;
61///
62/// let vault = VaultSource::new(
63/// "http://vault.example.com:8200".to_string(),
64/// "my-token".to_string(),
65/// "secret".to_string(),
66/// "dev".to_string(),
67/// );
68/// ```
69#[derive(Debug, Clone)]
70pub struct VaultSource {
71 vault_addr: String,
72 vault_token: String,
73 vault_mount: String,
74 vault_path: String,
75 kv_version: KvVersion,
76}
77
78#[derive(Debug, Clone, PartialEq)]
79pub enum KvVersion {
80 V1 = 1,
81 V2,
82}
83
84impl KvVersion {
85 fn get_api_path(&self, mount: &str, path: &str) -> String {
86 match self {
87 KvVersion::V1 => format!("v1/{}/{}", mount, path),
88 _ => format!("v1/{}/data/{}", mount, path),
89 }
90 }
91}
92
93impl VaultSource {
94 /// Creates a new instance of `VaultSource`.
95 ///
96 /// # Parameters
97 ///
98 /// * `vault_addr` - Complete URL of the Vault server (e.g. "http://127.0.0.1:8200")
99 /// * `vault_token` - Authentication token for Vault
100 /// * `vault_mount` - Name of the KV engine mount (e.g. "secret")
101 /// * `vault_path` - Path to the secret within the mount (e.g. "dev")
102 ///
103 /// # Example
104 ///
105 /// ```
106 /// use config_vault::VaultSource;
107 ///
108 /// let source = VaultSource::new(
109 /// "http://127.0.0.1:8200".to_string(),
110 /// "hvs.EXAMPLE_TOKEN".to_string(),
111 /// "secret".to_string(),
112 /// "dev".to_string(),
113 /// );
114 /// ```
115 pub fn new(
116 vault_addr: String,
117 vault_token: String,
118 vault_mount: String,
119 vault_path: String,
120 ) -> Self {
121 Self {
122 vault_addr,
123 vault_token,
124 vault_mount,
125 vault_path,
126 kv_version: KvVersion::V2,
127 }
128 }
129
130 /// Creates a new instance of `VaultSource` with kv_version V1
131 ///
132 /// # Parameters
133 ///
134 /// * `vault_addr` - Complete URL of the Vault server (e.g. "http://127.0.0.1:8200")
135 /// * `vault_token` - Authentication token for Vault
136 /// * `vault_mount` - Name of the KV engine mount (e.g. "secret")
137 /// * `vault_path` - Path to the secret within the mount (e.g. "dev")
138 ///
139 /// # Example
140 ///
141 /// ```
142 /// use config_vault::VaultSource;
143 ///
144 /// let source = VaultSource::new_v1(
145 /// "http://127.0.0.1:8200".to_string(),
146 /// "hvs.EXAMPLE_TOKEN".to_string(),
147 /// "secret".to_string(),
148 /// "dev".to_string(),
149 /// );
150 /// ```
151 pub fn new_v1(
152 vault_addr: String,
153 vault_token: String,
154 vault_mount: String,
155 vault_path: String,
156 ) -> Self {
157 Self {
158 vault_addr,
159 vault_token,
160 vault_mount,
161 vault_path,
162 kv_version: KvVersion::V1,
163 }
164 }
165
166 /// Changes the KvVersion
167 ///
168 /// This function takes the target KvVersion and replaces the existing one.
169 ///
170 pub fn set_kv_version(&mut self, kv_version: KvVersion) {
171 self.kv_version = kv_version;
172 }
173
174 /// Builds the URL for Vault's KV1/KV2 engine read API.
175 ///
176 /// This function takes the base address of Vault and builds the complete URL
177 /// to access the read API of the KV1 engine with the specified path.
178 ///
179 /// # Returns
180 ///
181 /// * `Result<Url, ConfigError>` - The constructed URL or an error if the address is invalid
182 fn build_kv_read_url(&self) -> Result<Url, ConfigError> {
183 let api_path = self
184 .kv_version
185 .get_api_path(&self.vault_mount, &self.vault_path);
186
187 let mut url = Url::parse(&self.vault_addr)
188 .map_err(|e| ConfigError::Message(format!("Invalid Vault address URL: {}", e)))?;
189
190 url.path_segments_mut()
191 .map_err(|_| ConfigError::Message("Vault address URL cannot be a base".into()))?
192 .pop_if_empty() // Remove trailing slash if any
193 .extend(api_path.split('/')); // Add the API path segments
194
195 Ok(url)
196 }
197}
198
199impl Source for VaultSource {
200 fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
201 Box::new(self.clone())
202 }
203
204 /// Implementation of the `collect` method from `Source`.
205 ///
206 /// This method makes an HTTP request to the Vault API to obtain
207 /// configuration values stored in the specified secret.
208 ///
209 /// # Returns
210 ///
211 /// * `Result<Map<String, Value>, ConfigError>` - A map with configuration values
212 /// or an error if the request fails or the response format is not as expected.
213 fn collect(&self) -> Result<Map<String, Value>, ConfigError> {
214 let url = self.build_kv_read_url()?;
215
216 let client = Client::new();
217 let response = client
218 .get(url)
219 .header("X-Vault-Token", &self.vault_token)
220 .send()
221 .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
222
223 if response.status().is_success() {
224 let raw = response
225 .json::<JsonValue>()
226 .map_err(|e| ConfigError::Foreign(Box::new(e)))?;
227
228 let json_obj = raw
229 .get("data")
230 .and_then(|x| {
231 if self.kv_version == KvVersion::V2 {
232 x.get("data")
233 } else {
234 Some(x)
235 }
236 })
237 .and_then(|x| x.as_object())
238 .unwrap();
239
240 let mut secret = HashMap::new();
241 for (k, v) in json_obj {
242 secret.insert(k.clone(), Value::from(v.as_str().unwrap()));
243 }
244
245 Ok(secret)
246 } else {
247 Err(ConfigError::Message(format!(
248 "Failed to fetch secret from Vault (wrong kv version?): {}",
249 response.status()
250 )))
251 }
252 }
253}