1use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7use vaultrs::client::VaultClient;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "camelCase")]
12pub struct VaultSecretConfig {
13 pub path: String,
15
16 pub key: String,
18
19 #[serde(default = "default_mount")]
21 pub mount: String,
22}
23
24fn default_mount() -> String {
25 "secret".to_string()
26}
27
28impl VaultSecretConfig {
29 #[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 #[must_use]
41 pub fn full_path(&self) -> String {
42 if self.path.starts_with(&self.mount) {
44 self.path.clone()
45 } else {
46 format!("{}/data/{}", self.mount, self.path)
47 }
48 }
49}
50
51pub 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 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 fn http_credentials_available() -> bool {
115 std::env::var("VAULT_TOKEN").is_ok() && std::env::var("VAULT_ADDR").is_ok()
116 }
117
118 fn can_use_http(&self) -> bool {
120 self.client.is_some()
121 }
122
123 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 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 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 async fn resolve_with_config(
191 &self,
192 name: &str,
193 config: &VaultSecretConfig,
194 ) -> Result<String, SecretError> {
195 if self.client.is_some() {
197 return self.resolve_http(name, config).await;
198 }
199
200 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 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 let _ = VaultResolver::http_credentials_available();
260 }
261}