Coverage Report

Created: 2025-10-06 12:43

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}