/home/jgrusewski/Work/foxhunt/config/src/vault.rs
Line | Count | Source |
1 | | //! HashiCorp Vault configuration for secure secret management. |
2 | | //! |
3 | | //! This module provides configuration structures for integrating with HashiCorp Vault |
4 | | //! to securely manage secrets, API keys, and sensitive configuration data in the |
5 | | //! Foxhunt trading system. Supports token-based authentication and namespace isolation. |
6 | | |
7 | | use serde::{Deserialize, Serialize}; |
8 | | use secrecy::{ExposeSecret, SecretString}; |
9 | | use std::fmt; |
10 | | |
11 | | /// HashiCorp Vault configuration for secure secret storage. |
12 | | /// |
13 | | /// Configures connection to HashiCorp Vault for retrieving sensitive |
14 | | /// configuration data such as API keys, database passwords, and other |
15 | | /// secrets. Supports Vault Enterprise features like namespaces. |
16 | | /// |
17 | | /// # Security |
18 | | /// |
19 | | /// The Vault token is wrapped in `SecretString` to prevent accidental |
20 | | /// exposure in logs, debug output, or memory dumps. The token is automatically |
21 | | /// zeroized when the config is dropped. |
22 | | #[derive(Clone, Serialize, Deserialize)] |
23 | | pub struct VaultConfig { |
24 | | /// Vault server URL (e.g., "<https://vault.example.com:8200>") |
25 | | pub url: String, |
26 | | /// Vault authentication token for API access (securely stored) |
27 | | #[serde(serialize_with = "serialize_secret", deserialize_with = "deserialize_secret")] |
28 | | pub token: SecretString, |
29 | | /// Mount path for the secrets engine (e.g., "secret/") |
30 | | pub mount_path: String, |
31 | | /// Vault namespace for multi-tenant deployments (Enterprise feature) |
32 | | pub namespace: Option<String>, |
33 | | } |
34 | | |
35 | | /// Custom serializer for SecretString that prevents token exposure |
36 | 2 | fn serialize_secret<S>(_secret: &SecretString, serializer: S) -> Result<S::Ok, S::Error> |
37 | 2 | where |
38 | 2 | S: serde::Serializer, |
39 | | { |
40 | | // Serialize as redacted placeholder to prevent token exposure |
41 | 2 | serializer.serialize_str("***REDACTED***") |
42 | 2 | } |
43 | | |
44 | | /// Custom deserializer for SecretString |
45 | 1 | fn deserialize_secret<'de, D>(deserializer: D) -> Result<SecretString, D::Error> |
46 | 1 | where |
47 | 1 | D: serde::Deserializer<'de>, |
48 | | { |
49 | 1 | let s = String::deserialize(deserializer)?0 ; |
50 | 1 | Ok(SecretString::from(s)) |
51 | 1 | } |
52 | | |
53 | | impl fmt::Debug for VaultConfig { |
54 | 2 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
55 | 2 | f.debug_struct("VaultConfig") |
56 | 2 | .field("url", &self.url) |
57 | 2 | .field("token", &"***REDACTED***") |
58 | 2 | .field("mount_path", &self.mount_path) |
59 | 2 | .field("namespace", &self.namespace) |
60 | 2 | .finish() |
61 | 2 | } |
62 | | } |
63 | | |
64 | | impl Drop for VaultConfig { |
65 | 16 | fn drop(&mut self) { |
66 | | // Explicitly zeroize the token when VaultConfig is dropped |
67 | | // This ensures the secret is cleared from memory |
68 | | // Note: SecretString already implements ZeroizeOnDrop, but we make it explicit |
69 | | // for documentation purposes |
70 | 16 | } |
71 | | } |
72 | | |
73 | | impl VaultConfig { |
74 | | /// Creates a new VaultConfig with the specified parameters. |
75 | | /// |
76 | | /// # Security |
77 | | /// |
78 | | /// The token is immediately wrapped in a `SecretString` to prevent exposure. |
79 | | /// Consider using `from_env()` or loading from secure configuration |
80 | | /// sources instead of passing plain strings. |
81 | 14 | pub fn new(url: String, token: String, mount_path: String) -> Self { |
82 | 14 | Self { |
83 | 14 | url, |
84 | 14 | token: SecretString::from(token), |
85 | 14 | mount_path, |
86 | 14 | namespace: None, |
87 | 14 | } |
88 | 14 | } |
89 | | |
90 | | /// Sets the namespace for multi-tenant Vault deployments. |
91 | 2 | pub fn with_namespace(mut self, namespace: String) -> Self { |
92 | 2 | self.namespace = Some(namespace); |
93 | 2 | self |
94 | 2 | } |
95 | | |
96 | | /// Gets a reference to the secret token (requires explicit exposure) |
97 | | /// |
98 | | /// # Security |
99 | | /// |
100 | | /// This method requires the caller to explicitly acknowledge they are |
101 | | /// exposing the secret. Use only when necessary (e.g., when making |
102 | | /// API calls to Vault) and ensure the exposed value is not logged |
103 | | /// or stored in insecure locations. |
104 | 1 | pub fn token(&self) -> &SecretString { |
105 | 1 | &self.token |
106 | 1 | } |
107 | | |
108 | | /// Validates the vault configuration. |
109 | | /// |
110 | | /// # Security |
111 | | /// |
112 | | /// Validation checks length without exposing the token value. |
113 | 7 | pub fn validate(&self) -> Result<(), String> { |
114 | 7 | if self.url.is_empty() { |
115 | 2 | return Err("Vault URL cannot be empty".to_string()); |
116 | 5 | } |
117 | 5 | if self.token.expose_secret().is_empty() { |
118 | 2 | return Err("Vault token cannot be empty".to_string()); |
119 | 3 | } |
120 | 3 | if self.mount_path.is_empty() { |
121 | 2 | return Err("Vault mount path cannot be empty".to_string()); |
122 | 1 | } |
123 | 1 | Ok(()) |
124 | 7 | } |
125 | | } |
126 | | |
127 | | #[cfg(test)] |
128 | | mod tests { |
129 | | use super::*; |
130 | | |
131 | 14 | fn create_test_config() -> VaultConfig { |
132 | 14 | VaultConfig::new( |
133 | 14 | "https://vault.example.com:8200".to_string(), |
134 | 14 | "test-token-12345".to_string(), |
135 | 14 | "secret/".to_string(), |
136 | | ) |
137 | 14 | } |
138 | | |
139 | | #[test] |
140 | 1 | fn test_vault_config_creation() { |
141 | 1 | let config = create_test_config(); |
142 | 1 | assert_eq!(config.url, "https://vault.example.com:8200"); |
143 | 1 | assert_eq!(config.mount_path, "secret/"); |
144 | 1 | assert!(config.namespace.is_none()); |
145 | 1 | } |
146 | | |
147 | | #[test] |
148 | 1 | fn test_vault_config_with_namespace() { |
149 | 1 | let config = create_test_config().with_namespace("production".to_string()); |
150 | 1 | assert_eq!(config.namespace.as_deref(), Some("production")); |
151 | 1 | } |
152 | | |
153 | | #[test] |
154 | 1 | fn test_vault_config_validation_success() { |
155 | 1 | let config = create_test_config(); |
156 | 1 | assert!(config.validate().is_ok()); |
157 | 1 | } |
158 | | |
159 | | #[test] |
160 | 1 | fn test_vault_config_validation_empty_url() { |
161 | 1 | let mut config = create_test_config(); |
162 | 1 | config.url = String::new(); |
163 | 1 | assert!(config.validate().is_err()); |
164 | 1 | assert_eq!(config.validate().unwrap_err(), "Vault URL cannot be empty"); |
165 | 1 | } |
166 | | |
167 | | #[test] |
168 | 1 | fn test_vault_config_validation_empty_token() { |
169 | 1 | let mut config = create_test_config(); |
170 | 1 | config.token = SecretString::from(String::new()); |
171 | 1 | assert!(config.validate().is_err()); |
172 | 1 | assert_eq!( |
173 | 1 | config.validate().unwrap_err(), |
174 | | "Vault token cannot be empty" |
175 | | ); |
176 | 1 | } |
177 | | |
178 | | #[test] |
179 | 1 | fn test_vault_config_validation_empty_mount_path() { |
180 | 1 | let mut config = create_test_config(); |
181 | 1 | config.mount_path = String::new(); |
182 | 1 | assert!(config.validate().is_err()); |
183 | 1 | assert_eq!( |
184 | 1 | config.validate().unwrap_err(), |
185 | | "Vault mount path cannot be empty" |
186 | | ); |
187 | 1 | } |
188 | | |
189 | | #[test] |
190 | 1 | fn test_vault_config_serialization() { |
191 | 1 | let config = create_test_config(); |
192 | 1 | let serialized = serde_json::to_string(&config).unwrap(); |
193 | | // Token should be redacted in serialization |
194 | 1 | assert!(serialized.contains("***REDACTED***")); |
195 | 1 | assert!(!serialized.contains("test-token-12345")); |
196 | 1 | } |
197 | | |
198 | | #[test] |
199 | 1 | fn test_vault_config_deserialization() { |
200 | 1 | let config = create_test_config(); |
201 | 1 | let serialized = serde_json::to_string(&config).unwrap(); |
202 | 1 | let deserialized: VaultConfig = serde_json::from_str(&serialized).unwrap(); |
203 | 1 | assert_eq!(config.url, deserialized.url); |
204 | 1 | assert_eq!(config.mount_path, deserialized.mount_path); |
205 | 1 | } |
206 | | |
207 | | #[test] |
208 | 1 | fn test_vault_config_clone() { |
209 | 1 | let config1 = create_test_config(); |
210 | 1 | let config2 = config1.clone(); |
211 | 1 | assert_eq!(config1.url, config2.url); |
212 | 1 | } |
213 | | |
214 | | #[test] |
215 | 1 | fn test_vault_config_debug() { |
216 | 1 | let config = create_test_config(); |
217 | 1 | let debug_output = format!("{:?}", config); |
218 | 1 | assert!(debug_output.contains("VaultConfig")); |
219 | 1 | assert!(debug_output.contains("***REDACTED***")); |
220 | 1 | assert!(!debug_output.contains("test-token-12345")); |
221 | 1 | } |
222 | | |
223 | | #[test] |
224 | 1 | fn test_vault_config_namespace_none() { |
225 | 1 | let config = create_test_config(); |
226 | 1 | assert!(config.namespace.is_none()); |
227 | 1 | } |
228 | | |
229 | | #[test] |
230 | 1 | fn test_vault_config_namespace_some() { |
231 | 1 | let config = create_test_config().with_namespace("dev".to_string()); |
232 | 1 | assert!(config.namespace.is_some()); |
233 | 1 | assert_eq!(config.namespace.as_deref(), Some("dev")); |
234 | 1 | } |
235 | | |
236 | | #[test] |
237 | 1 | fn test_vault_config_token_not_exposed() { |
238 | 1 | let config = create_test_config(); |
239 | | // Verify token accessor works |
240 | 1 | assert_eq!(config.token().expose_secret(), "test-token-12345"); |
241 | 1 | } |
242 | | |
243 | | #[test] |
244 | 1 | fn test_vault_config_token_redacted_in_display() { |
245 | 1 | let config = create_test_config(); |
246 | 1 | let debug_str = format!("{:?}", config); |
247 | 1 | assert!(!debug_str.contains("test-token-12345")); |
248 | 1 | } |
249 | | } |