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)]
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    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}