Skip to main content

cuenv_vault/
secrets.rs

1//! `HashiCorp` Vault secret resolver with auto-negotiating dual-mode (HTTP + CLI)
2
3use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7use vaultrs::client::VaultClient;
8
9/// Configuration for `HashiCorp` Vault resolution
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct VaultSecretConfig {
13    /// Path to the secret (e.g., "secret/data/myapp/config")
14    pub path: String,
15
16    /// Key within the secret to extract
17    pub key: String,
18
19    /// Secret engine mount point (defaults to "secret")
20    #[serde(default = "default_mount")]
21    pub mount: String,
22}
23
24fn default_mount() -> String {
25    "secret".to_string()
26}
27
28impl VaultSecretConfig {
29    /// Create a new Vault secret config
30    #[must_use]
31    pub fn new(path: impl Into<String>, key: impl Into<String>) -> Self {
32        Self {
33            path: path.into(),
34            key: key.into(),
35            mount: "secret".to_string(),
36        }
37    }
38
39    /// Get the full path including mount point
40    #[must_use]
41    pub fn full_path(&self) -> String {
42        // KV v2 uses /data/ in the path
43        if self.path.starts_with(&self.mount) {
44            self.path.clone()
45        } else {
46            format!("{}/data/{}", self.mount, self.path)
47        }
48    }
49}
50
51/// Resolves secrets from `HashiCorp` Vault
52///
53/// Mode is auto-negotiated based on environment:
54/// - If `VAULT_TOKEN` and `VAULT_ADDR` are set → HTTP mode
55/// - Otherwise → CLI mode (uses `vault` CLI)
56///
57/// The `source` field in [`SecretSpec`] can be:
58/// - A JSON-encoded [`VaultSecretConfig`]
59pub struct VaultResolver {
60    client: Option<VaultClient>,
61}
62
63impl std::fmt::Debug for VaultResolver {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("VaultResolver")
66            .field("mode", &if self.can_use_http() { "http" } else { "cli" })
67            .finish()
68    }
69}
70
71impl VaultResolver {
72    /// Create a new Vault resolver with auto-detected mode
73    ///
74    /// If Vault credentials are available in environment, uses HTTP mode.
75    /// Otherwise, CLI mode will be used.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the Vault HTTP client cannot be initialized.
80    pub fn new() -> Result<Self, SecretError> {
81        let client = if Self::http_credentials_available() {
82            let addr =
83                std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
84            let token =
85                std::env::var("VAULT_TOKEN").map_err(|_| SecretError::ResolutionFailed {
86                    name: "vault".to_string(),
87                    message: "VAULT_TOKEN environment variable not set".to_string(),
88                })?;
89
90            Some(
91                VaultClient::new(
92                    vaultrs::client::VaultClientSettingsBuilder::default()
93                        .address(addr)
94                        .token(token)
95                        .build()
96                        .map_err(|e| SecretError::ResolutionFailed {
97                            name: "vault".to_string(),
98                            message: format!("Failed to build Vault client: {e}"),
99                        })?,
100                )
101                .map_err(|e| SecretError::ResolutionFailed {
102                    name: "vault".to_string(),
103                    message: format!("Failed to create Vault client: {e}"),
104                })?,
105            )
106        } else {
107            None
108        };
109
110        Ok(Self { client })
111    }
112
113    /// Check if HTTP credentials are available in environment
114    fn http_credentials_available() -> bool {
115        std::env::var("VAULT_TOKEN").is_ok() && std::env::var("VAULT_ADDR").is_ok()
116    }
117
118    /// Check if this resolver can use HTTP mode
119    const fn can_use_http(&self) -> bool {
120        self.client.is_some()
121    }
122
123    /// Resolve using the Vault HTTP API
124    async fn resolve_http(
125        &self,
126        name: &str,
127        config: &VaultSecretConfig,
128    ) -> Result<String, SecretError> {
129        let client = self
130            .client
131            .as_ref()
132            .ok_or_else(|| SecretError::ResolutionFailed {
133                name: name.to_string(),
134                message: "Vault HTTP client not initialized".to_string(),
135            })?;
136
137        // Read secret from KV v2
138        let secret: std::collections::HashMap<String, String> =
139            vaultrs::kv2::read(client, &config.mount, &config.path)
140                .await
141                .map_err(|e| SecretError::ResolutionFailed {
142                    name: name.to_string(),
143                    message: format!("Vault read error: {e}"),
144                })?;
145
146        secret
147            .get(&config.key)
148            .cloned()
149            .ok_or_else(|| SecretError::ResolutionFailed {
150                name: name.to_string(),
151                message: format!("Key '{}' not found in secret", config.key),
152            })
153    }
154
155    /// Resolve using the vault CLI
156    async fn resolve_cli(
157        &self,
158        name: &str,
159        config: &VaultSecretConfig,
160    ) -> Result<String, SecretError> {
161        let output = Command::new("vault")
162            .args([
163                "kv",
164                "get",
165                "-mount",
166                &config.mount,
167                "-field",
168                &config.key,
169                &config.path,
170            ])
171            .output()
172            .await
173            .map_err(|e| SecretError::ResolutionFailed {
174                name: name.to_string(),
175                message: format!("Failed to execute vault CLI: {e}"),
176            })?;
177
178        if !output.status.success() {
179            let stderr = String::from_utf8_lossy(&output.stderr);
180            return Err(SecretError::ResolutionFailed {
181                name: name.to_string(),
182                message: format!("vault CLI failed: {stderr}"),
183            });
184        }
185
186        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
187    }
188
189    /// Resolve a secret - tries HTTP first if available, falls back to CLI
190    async fn resolve_with_config(
191        &self,
192        name: &str,
193        config: &VaultSecretConfig,
194    ) -> Result<String, SecretError> {
195        // Try HTTP mode if available
196        if self.client.is_some() {
197            return self.resolve_http(name, config).await;
198        }
199
200        // Fallback to CLI
201        self.resolve_cli(name, config).await
202    }
203}
204
205#[async_trait]
206impl SecretResolver for VaultResolver {
207    fn provider_name(&self) -> &'static str {
208        "vault"
209    }
210
211    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
212        // Parse source as JSON VaultSecretConfig
213        let config: VaultSecretConfig =
214            serde_json::from_str(&spec.source).map_err(|e| SecretError::ResolutionFailed {
215                name: name.to_string(),
216                message: format!("Invalid Vault secret config: {e}"),
217            })?;
218
219        self.resolve_with_config(name, &config).await
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_vault_config_serialization() {
229        let config = VaultSecretConfig {
230            path: "myapp/config".to_string(),
231            key: "password".to_string(),
232            mount: "secret".to_string(),
233        };
234
235        let json = serde_json::to_string(&config).unwrap();
236        let parsed: VaultSecretConfig = serde_json::from_str(&json).unwrap();
237        assert_eq!(config, parsed);
238    }
239
240    #[test]
241    fn test_full_path() {
242        let config = VaultSecretConfig::new("myapp/config", "password");
243        assert_eq!(config.full_path(), "secret/data/myapp/config");
244    }
245
246    #[test]
247    fn test_full_path_with_mount() {
248        let config = VaultSecretConfig {
249            path: "myapp/config".to_string(),
250            key: "password".to_string(),
251            mount: "kv".to_string(),
252        };
253        assert_eq!(config.full_path(), "kv/data/myapp/config");
254    }
255
256    #[test]
257    fn test_http_credentials_check() {
258        // This test just ensures the function exists and doesn't panic
259        let _ = VaultResolver::http_credentials_available();
260    }
261
262    #[test]
263    fn test_vault_config_new() {
264        let config = VaultSecretConfig::new("path/to/secret", "api_key");
265        assert_eq!(config.path, "path/to/secret");
266        assert_eq!(config.key, "api_key");
267        assert_eq!(config.mount, "secret"); // Default mount
268    }
269
270    #[test]
271    fn test_vault_config_new_with_string_types() {
272        let config = VaultSecretConfig::new(String::from("my/path"), String::from("mykey"));
273        assert_eq!(config.path, "my/path");
274        assert_eq!(config.key, "mykey");
275    }
276
277    #[test]
278    fn test_full_path_already_includes_mount() {
279        // When path already starts with mount, don't add data prefix
280        let config = VaultSecretConfig {
281            path: "secret/myapp/config".to_string(),
282            key: "password".to_string(),
283            mount: "secret".to_string(),
284        };
285        assert_eq!(config.full_path(), "secret/myapp/config");
286    }
287
288    #[test]
289    fn test_full_path_custom_mount_already_included() {
290        let config = VaultSecretConfig {
291            path: "kv/production/db".to_string(),
292            key: "password".to_string(),
293            mount: "kv".to_string(),
294        };
295        assert_eq!(config.full_path(), "kv/production/db");
296    }
297
298    #[test]
299    fn test_default_mount_function() {
300        assert_eq!(default_mount(), "secret");
301    }
302
303    #[test]
304    fn test_vault_config_clone() {
305        let config = VaultSecretConfig {
306            path: "myapp/config".to_string(),
307            key: "password".to_string(),
308            mount: "kv".to_string(),
309        };
310        let cloned = config.clone();
311        assert_eq!(config, cloned);
312    }
313
314    #[test]
315    fn test_vault_config_debug() {
316        let config = VaultSecretConfig::new("test/path", "test_key");
317        let debug_str = format!("{config:?}");
318        assert!(debug_str.contains("VaultSecretConfig"));
319        assert!(debug_str.contains("test/path"));
320        assert!(debug_str.contains("test_key"));
321    }
322
323    #[test]
324    fn test_vault_config_deserialization_with_defaults() {
325        // When mount is missing in JSON, it should use the default
326        let json = r#"{"path": "myapp", "key": "secret"}"#;
327        let config: VaultSecretConfig = serde_json::from_str(json).unwrap();
328        assert_eq!(config.path, "myapp");
329        assert_eq!(config.key, "secret");
330        assert_eq!(config.mount, "secret"); // Default value
331    }
332
333    #[test]
334    fn test_vault_config_full_deserialization() {
335        let json = r#"{"path": "prod/db", "key": "password", "mount": "kv"}"#;
336        let config: VaultSecretConfig = serde_json::from_str(json).unwrap();
337        assert_eq!(config.path, "prod/db");
338        assert_eq!(config.key, "password");
339        assert_eq!(config.mount, "kv");
340    }
341
342    #[test]
343    fn test_vault_config_camel_case_serialization() {
344        let config = VaultSecretConfig::new("mypath", "mykey");
345        let json = serde_json::to_string(&config).unwrap();
346        // Verify camelCase is used (not snake_case)
347        assert!(json.contains("\"path\""));
348        assert!(json.contains("\"key\""));
349        assert!(json.contains("\"mount\""));
350    }
351
352    #[test]
353    fn test_full_path_deep_nesting() {
354        let config = VaultSecretConfig {
355            path: "environments/production/services/api/database".to_string(),
356            key: "connection_string".to_string(),
357            mount: "secret".to_string(),
358        };
359        assert_eq!(
360            config.full_path(),
361            "secret/data/environments/production/services/api/database"
362        );
363    }
364
365    #[test]
366    fn test_vault_config_equality() {
367        let config1 = VaultSecretConfig::new("path", "key");
368        let config2 = VaultSecretConfig::new("path", "key");
369        let config3 = VaultSecretConfig::new("path", "different_key");
370
371        assert_eq!(config1, config2);
372        assert_ne!(config1, config3);
373    }
374
375    #[test]
376    fn test_vault_config_with_different_mounts() {
377        let mut config1 = VaultSecretConfig::new("path", "key");
378        config1.mount = "kv".to_string();
379        let mut config2 = VaultSecretConfig::new("path", "key");
380        config2.mount = "secrets".to_string();
381
382        assert_ne!(config1, config2);
383    }
384
385    #[test]
386    fn test_vault_config_roundtrip() {
387        let original = VaultSecretConfig {
388            path: "deep/nested/path".to_string(),
389            key: "complex_key".to_string(),
390            mount: "custom_mount".to_string(),
391        };
392        let json = serde_json::to_string(&original).unwrap();
393        let parsed: VaultSecretConfig = serde_json::from_str(&json).unwrap();
394        assert_eq!(original, parsed);
395    }
396
397    #[test]
398    fn test_full_path_with_special_characters() {
399        let config = VaultSecretConfig {
400            path: "my-app/config_v1".to_string(),
401            key: "api-key".to_string(),
402            mount: "secret".to_string(),
403        };
404        assert_eq!(config.full_path(), "secret/data/my-app/config_v1");
405    }
406
407    #[test]
408    fn test_full_path_empty_path() {
409        let config = VaultSecretConfig {
410            path: String::new(),
411            key: "key".to_string(),
412            mount: "secret".to_string(),
413        };
414        assert_eq!(config.full_path(), "secret/data/");
415    }
416
417    #[test]
418    fn test_vault_config_deserialization_missing_path() {
419        let json = r#"{"key": "mykey"}"#;
420        let result = serde_json::from_str::<VaultSecretConfig>(json);
421        assert!(result.is_err());
422    }
423
424    #[test]
425    fn test_vault_config_deserialization_missing_key() {
426        let json = r#"{"path": "mypath"}"#;
427        let result = serde_json::from_str::<VaultSecretConfig>(json);
428        assert!(result.is_err());
429    }
430
431    #[test]
432    fn test_resolver_new_without_credentials() {
433        // Without VAULT_TOKEN and VAULT_ADDR, should use CLI mode
434        if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
435            let resolver = VaultResolver::new();
436            assert!(resolver.is_ok());
437            let resolver = resolver.unwrap();
438            assert!(!resolver.can_use_http());
439        }
440    }
441
442    #[test]
443    fn test_resolver_provider_name() {
444        if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
445            let resolver = VaultResolver::new().unwrap();
446            assert_eq!(resolver.provider_name(), "vault");
447        }
448    }
449
450    #[test]
451    fn test_resolver_debug_output() {
452        if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
453            let resolver = VaultResolver::new().unwrap();
454            let debug = format!("{resolver:?}");
455            assert!(debug.contains("VaultResolver"));
456            assert!(debug.contains("cli") || debug.contains("http"));
457        }
458    }
459
460    #[test]
461    fn test_http_credentials_available_logic() {
462        let token = std::env::var("VAULT_TOKEN").is_ok();
463        let addr = std::env::var("VAULT_ADDR").is_ok();
464        let expected = token && addr;
465        assert_eq!(VaultResolver::http_credentials_available(), expected);
466    }
467
468    #[test]
469    fn test_vault_config_unicode_path() {
470        let config = VaultSecretConfig::new("路径/配置", "密钥");
471        assert_eq!(config.path, "路径/配置");
472        assert_eq!(config.key, "密钥");
473        assert_eq!(config.full_path(), "secret/data/路径/配置");
474    }
475
476    #[test]
477    fn test_vault_config_with_slashes_in_key() {
478        let config = VaultSecretConfig {
479            path: "myapp/config".to_string(),
480            key: "nested/key/path".to_string(),
481            mount: "secret".to_string(),
482        };
483        assert_eq!(config.key, "nested/key/path");
484    }
485
486    #[test]
487    fn test_vault_config_empty_key() {
488        let config = VaultSecretConfig {
489            path: "myapp/config".to_string(),
490            key: String::new(),
491            mount: "secret".to_string(),
492        };
493        assert_eq!(config.key, "");
494    }
495
496    #[test]
497    fn test_full_path_mount_partial_match() {
498        // Path starts with "secret" but not as mount
499        let config = VaultSecretConfig {
500            path: "secretive/path".to_string(),
501            key: "key".to_string(),
502            mount: "secret".to_string(),
503        };
504        // "secretive" starts with "secret", so it matches the starts_with check
505        assert_eq!(config.full_path(), "secretive/path");
506    }
507
508    #[test]
509    fn test_full_path_different_mount_prefix() {
510        // Path doesn't start with mount
511        let config = VaultSecretConfig {
512            path: "other/path".to_string(),
513            key: "key".to_string(),
514            mount: "kv".to_string(),
515        };
516        assert_eq!(config.full_path(), "kv/data/other/path");
517    }
518}