1use serde::{Deserialize, Serialize};
17use tracing::debug;
18use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
19use vaultrs::kv2;
20
21use super::error::{SecretsError, SecretsResult};
22use super::provider::SecretProvider;
23use super::types::{SecretMetadata, SecretValue};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct OpenBaoConfig {
28 pub address: String,
30
31 pub auth: OpenBaoAuth,
33
34 #[serde(default)]
36 pub namespace: Option<String>,
37
38 #[serde(default)]
40 pub ca_cert: Option<String>,
41
42 #[serde(default)]
44 pub skip_verify: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "method", rename_all = "snake_case")]
50pub enum OpenBaoAuth {
51 Token {
53 token: String,
55 },
56
57 AppRole {
59 role_id: String,
61 secret_id: String,
63 #[serde(default = "default_approle_mount")]
65 mount: String,
66 },
67
68 Kubernetes {
70 role: String,
72 #[serde(default = "default_k8s_token_path")]
74 token_path: String,
75 #[serde(default = "default_k8s_mount")]
77 mount: String,
78 },
79}
80
81fn default_approle_mount() -> String {
82 "approle".to_string()
83}
84
85fn default_k8s_token_path() -> String {
86 "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
87}
88
89fn default_k8s_mount() -> String {
90 "kubernetes".to_string()
91}
92
93impl OpenBaoConfig {
94 #[cfg(feature = "config")]
121 #[must_use]
122 pub fn from_env() -> Option<Self> {
123 use crate::config::env_compat::vault;
124
125 let address = vault::addr().get()?;
126
127 let auth = if let Some(token) = vault::token().get() {
129 OpenBaoAuth::Token { token }
130 } else if let (Some(role_id), Some(secret_id)) = (
131 vault::approle_role_id().get(),
132 vault::approle_secret_id().get(),
133 ) {
134 OpenBaoAuth::AppRole {
135 role_id,
136 secret_id,
137 mount: default_approle_mount(),
138 }
139 } else if let Some(role) = vault::k8s_role().get() {
140 OpenBaoAuth::Kubernetes {
141 role,
142 token_path: default_k8s_token_path(),
143 mount: default_k8s_mount(),
144 }
145 } else {
146 return None;
148 };
149
150 Some(Self {
151 address,
152 auth,
153 namespace: vault::namespace().get(),
154 ca_cert: vault::ca_cert().get(),
155 skip_verify: vault::skip_verify().get_bool().unwrap_or(false),
156 })
157 }
158
159 #[must_use]
161 pub fn with_token(address: &str, token: &str) -> Self {
162 Self {
163 address: address.to_string(),
164 auth: OpenBaoAuth::Token {
165 token: token.to_string(),
166 },
167 namespace: None,
168 ca_cert: None,
169 skip_verify: false,
170 }
171 }
172
173 #[must_use]
175 pub fn with_approle(address: &str, role_id: &str, secret_id: &str) -> Self {
176 Self {
177 address: address.to_string(),
178 auth: OpenBaoAuth::AppRole {
179 role_id: role_id.to_string(),
180 secret_id: secret_id.to_string(),
181 mount: default_approle_mount(),
182 },
183 namespace: None,
184 ca_cert: None,
185 skip_verify: false,
186 }
187 }
188
189 #[must_use]
191 pub fn with_namespace(mut self, namespace: &str) -> Self {
192 self.namespace = Some(namespace.to_string());
193 self
194 }
195
196 #[must_use]
198 pub fn with_ca_cert(mut self, path: &str) -> Self {
199 self.ca_cert = Some(path.to_string());
200 self
201 }
202
203 #[must_use]
205 pub fn with_skip_verify(mut self) -> Self {
206 self.skip_verify = true;
207 self
208 }
209}
210
211pub struct OpenBaoProvider {
213 config: OpenBaoConfig,
214}
215
216impl OpenBaoProvider {
217 pub fn new(config: &OpenBaoConfig) -> SecretsResult<Self> {
223 Ok(Self {
224 config: config.clone(),
225 })
226 }
227
228 async fn get_client(&self) -> SecretsResult<VaultClient> {
233 self.create_client().await
234 }
235
236 async fn create_client(&self) -> SecretsResult<VaultClient> {
238 let mut settings = VaultClientSettingsBuilder::default();
239 settings.address(&self.config.address);
240
241 if let Some(ref ns) = self.config.namespace {
242 settings.namespace(Some(ns.clone()));
243 }
244
245 let settings = settings.build().map_err(|e| {
249 SecretsError::ConfigError(format!("failed to build Vault client settings: {e}"))
250 })?;
251
252 let mut client = VaultClient::new(settings).map_err(|e| {
253 SecretsError::ProviderError(format!("failed to create Vault client: {e}"))
254 })?;
255
256 match &self.config.auth {
258 OpenBaoAuth::Token { token } => {
259 client.set_token(token);
260 }
261 OpenBaoAuth::AppRole {
262 role_id,
263 secret_id,
264 mount,
265 } => {
266 self.auth_approle(&mut client, role_id, secret_id, mount)
267 .await?;
268 }
269 OpenBaoAuth::Kubernetes {
270 role,
271 token_path,
272 mount,
273 } => {
274 self.auth_kubernetes(&mut client, role, token_path, mount)
275 .await?;
276 }
277 }
278
279 Ok(client)
280 }
281
282 async fn auth_approle(
284 &self,
285 client: &mut VaultClient,
286 role_id: &str,
287 secret_id: &str,
288 mount: &str,
289 ) -> SecretsResult<()> {
290 let auth_info = vaultrs::auth::approle::login(client, mount, role_id, secret_id)
291 .await
292 .map_err(|e| SecretsError::AuthError(format!("AppRole login failed: {e}")))?;
293
294 client.set_token(&auth_info.client_token);
295 debug!("AppRole authentication successful");
296 Ok(())
297 }
298
299 async fn auth_kubernetes(
301 &self,
302 client: &mut VaultClient,
303 role: &str,
304 token_path: &str,
305 mount: &str,
306 ) -> SecretsResult<()> {
307 let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
308 SecretsError::AuthError(format!(
309 "failed to read K8s service account token from {token_path}: {e}"
310 ))
311 })?;
312
313 let auth_info = vaultrs::auth::kubernetes::login(client, mount, role, jwt.trim())
314 .await
315 .map_err(|e| SecretsError::AuthError(format!("Kubernetes login failed: {e}")))?;
316
317 client.set_token(&auth_info.client_token);
318 debug!("Kubernetes authentication successful");
319 Ok(())
320 }
321
322 pub async fn get(&self, path: &str, key: &str) -> SecretsResult<SecretValue> {
328 let client = self.get_client().await?;
329
330 let (mount, secret_path) = Self::parse_path(path);
333
334 let secret: std::collections::HashMap<String, String> =
336 kv2::read(&client, &mount, &secret_path)
337 .await
338 .map_err(|e| {
339 if e.to_string().contains("403") || e.to_string().contains("permission denied")
341 {
342 SecretsError::AuthError("Vault token expired or invalid".into())
343 } else {
344 SecretsError::ProviderError(format!("failed to read secret {path}: {e}"))
345 }
346 })?;
347
348 let value = secret.get(key).ok_or_else(|| {
350 SecretsError::NotFound(format!("key '{key}' not found in secret '{path}'"))
351 })?;
352
353 let metadata = SecretMetadata {
354 version: None, source_path: Some(path.to_string()),
356 provider: Some("openbao".into()),
357 };
358
359 Ok(SecretValue::with_metadata(
360 value.as_bytes().to_vec(),
361 metadata,
362 ))
363 }
364
365 fn parse_path(path: &str) -> (String, String) {
371 if let Some(rest) = path.strip_prefix("secret/data/") {
373 return ("secret".into(), rest.into());
374 }
375
376 let parts: Vec<&str> = path.splitn(3, '/').collect();
378 if parts.len() >= 3 && parts[1] == "data" {
379 return (parts[0].into(), parts[2..].join("/"));
380 }
381
382 ("secret".into(), path.into())
384 }
385}
386
387impl SecretProvider for OpenBaoProvider {
388 async fn get(&self, path: &str, key: Option<&str>) -> SecretsResult<SecretValue> {
389 let key = key.ok_or_else(|| {
390 SecretsError::ConfigError("key is required for OpenBao secrets".into())
391 })?;
392 self.get(path, key).await
393 }
394
395 async fn health_check(&self) -> SecretsResult<()> {
396 let client = self.get_client().await?;
397
398 vaultrs::sys::health(&client)
400 .await
401 .map_err(|e| SecretsError::ProviderError(format!("Vault health check failed: {e}")))?;
402
403 Ok(())
404 }
405
406 fn name(&self) -> &'static str {
407 "openbao"
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_parse_path_with_mount() {
417 let (mount, path) = OpenBaoProvider::parse_path("secret/data/myapp/tls");
418 assert_eq!(mount, "secret");
419 assert_eq!(path, "myapp/tls");
420 }
421
422 #[test]
423 fn test_parse_path_custom_mount() {
424 let (mount, path) = OpenBaoProvider::parse_path("kv/data/myapp/creds");
425 assert_eq!(mount, "kv");
426 assert_eq!(path, "myapp/creds");
427 }
428
429 #[test]
430 fn test_parse_path_default_mount() {
431 let (mount, path) = OpenBaoProvider::parse_path("myapp/tls");
432 assert_eq!(mount, "secret");
433 assert_eq!(path, "myapp/tls");
434 }
435
436 #[test]
437 fn test_openbao_auth_token_serialization() {
438 let auth = OpenBaoAuth::Token {
439 token: "test-token".into(),
440 };
441 let json = serde_json::to_string(&auth).unwrap();
442 assert!(json.contains("\"method\":\"token\""));
443 }
444
445 #[test]
446 fn test_openbao_auth_approle_serialization() {
447 let auth = OpenBaoAuth::AppRole {
448 role_id: "role123".into(),
449 secret_id: "secret456".into(),
450 mount: "approle".into(),
451 };
452 let json = serde_json::to_string(&auth).unwrap();
453 assert!(json.contains("\"method\":\"app_role\""));
454 assert!(json.contains("role_id"));
455 }
456
457 #[test]
458 fn test_openbao_config_serialization() {
459 let config = OpenBaoConfig {
460 address: "https://vault.example.com:8200".into(),
461 auth: OpenBaoAuth::Token {
462 token: "test".into(),
463 },
464 namespace: Some("myorg".into()),
465 ca_cert: None,
466 skip_verify: false,
467 };
468 let json = serde_json::to_string(&config).unwrap();
469 assert!(json.contains("vault.example.com"));
470 }
471}