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};
24use crate::sensitive::SensitiveString;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct OpenBaoConfig {
29 pub address: String,
31
32 pub auth: OpenBaoAuth,
34
35 #[serde(default)]
37 pub namespace: Option<String>,
38
39 #[serde(default)]
41 pub ca_cert: Option<String>,
42
43 #[serde(default)]
45 pub skip_verify: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(tag = "method", rename_all = "snake_case")]
51pub enum OpenBaoAuth {
52 Token {
54 token: SensitiveString,
57 },
58
59 AppRole {
61 role_id: String,
63 secret_id: SensitiveString,
66 #[serde(default = "default_approle_mount")]
68 mount: String,
69 },
70
71 Kubernetes {
73 role: String,
75 #[serde(default = "default_k8s_token_path")]
77 token_path: String,
78 #[serde(default = "default_k8s_mount")]
80 mount: String,
81 },
82}
83
84fn default_approle_mount() -> String {
85 "approle".to_string()
86}
87
88fn default_k8s_token_path() -> String {
89 "/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
90}
91
92fn default_k8s_mount() -> String {
93 "kubernetes".to_string()
94}
95
96impl OpenBaoConfig {
97 #[cfg(feature = "config")]
124 #[must_use]
125 pub fn from_env() -> Option<Self> {
126 use crate::config::env_compat::vault;
127
128 let address = vault::addr().get()?;
129
130 let auth = if let Some(token) = vault::token().get() {
132 OpenBaoAuth::Token {
133 token: token.into(),
134 }
135 } else if let (Some(role_id), Some(secret_id)) = (
136 vault::approle_role_id().get(),
137 vault::approle_secret_id().get(),
138 ) {
139 OpenBaoAuth::AppRole {
140 role_id,
141 secret_id: secret_id.into(),
142 mount: default_approle_mount(),
143 }
144 } else if let Some(role) = vault::k8s_role().get() {
145 OpenBaoAuth::Kubernetes {
146 role,
147 token_path: default_k8s_token_path(),
148 mount: default_k8s_mount(),
149 }
150 } else {
151 return None;
153 };
154
155 Some(Self {
156 address,
157 auth,
158 namespace: vault::namespace().get(),
159 ca_cert: vault::ca_cert().get(),
160 skip_verify: vault::skip_verify().get_bool().unwrap_or(false),
161 })
162 }
163
164 #[must_use]
166 pub fn with_token(address: &str, token: &str) -> Self {
167 Self {
168 address: address.to_string(),
169 auth: OpenBaoAuth::Token {
170 token: token.into(),
171 },
172 namespace: None,
173 ca_cert: None,
174 skip_verify: false,
175 }
176 }
177
178 #[must_use]
180 pub fn with_approle(address: &str, role_id: &str, secret_id: &str) -> Self {
181 Self {
182 address: address.to_string(),
183 auth: OpenBaoAuth::AppRole {
184 role_id: role_id.to_string(),
185 secret_id: secret_id.into(),
186 mount: default_approle_mount(),
187 },
188 namespace: None,
189 ca_cert: None,
190 skip_verify: false,
191 }
192 }
193
194 #[must_use]
196 pub fn with_namespace(mut self, namespace: &str) -> Self {
197 self.namespace = Some(namespace.to_string());
198 self
199 }
200
201 #[must_use]
203 pub fn with_ca_cert(mut self, path: &str) -> Self {
204 self.ca_cert = Some(path.to_string());
205 self
206 }
207
208 #[must_use]
211 pub fn with_skip_verify(mut self) -> Self {
212 self.skip_verify = true;
213 self
214 }
215
216 pub fn validate(&self, is_production: bool) -> Result<(), String> {
230 if is_production && self.skip_verify {
231 return Err(
232 "vault: skip_verify (TLS verification disabled) is not permitted in \
233 production -- configure ca_cert for private-CA trust instead"
234 .to_string(),
235 );
236 }
237 Ok(())
238 }
239}
240
241pub struct OpenBaoProvider {
243 config: OpenBaoConfig,
244}
245
246impl OpenBaoProvider {
247 pub fn new(config: &OpenBaoConfig) -> SecretsResult<Self> {
253 config
257 .validate(crate::env::is_production())
258 .map_err(SecretsError::ConfigError)?;
259 Ok(Self {
260 config: config.clone(),
261 })
262 }
263
264 async fn get_client(&self) -> SecretsResult<VaultClient> {
269 self.create_client().await
270 }
271
272 async fn create_client(&self) -> SecretsResult<VaultClient> {
274 let mut settings = VaultClientSettingsBuilder::default();
275 settings.address(&self.config.address);
276
277 if let Some(ref ns) = self.config.namespace {
278 settings.namespace(Some(ns.clone()));
279 }
280
281 if let Some(ref ca) = self.config.ca_cert {
287 settings.ca_certs(vec![ca.clone()]);
288 }
289 settings.verify(!self.config.skip_verify);
290
291 let settings = settings.build().map_err(|e| {
292 SecretsError::ConfigError(format!("failed to build Vault client settings: {e}"))
293 })?;
294
295 let mut client = VaultClient::new(settings).map_err(|e| {
296 SecretsError::ProviderError(format!("failed to create Vault client: {e}"))
297 })?;
298
299 match &self.config.auth {
301 OpenBaoAuth::Token { token } => {
302 client.set_token(token.expose());
303 }
304 OpenBaoAuth::AppRole {
305 role_id,
306 secret_id,
307 mount,
308 } => {
309 self.auth_approle(&mut client, role_id, secret_id.expose(), mount)
310 .await?;
311 }
312 OpenBaoAuth::Kubernetes {
313 role,
314 token_path,
315 mount,
316 } => {
317 self.auth_kubernetes(&mut client, role, token_path, mount)
318 .await?;
319 }
320 }
321
322 Ok(client)
323 }
324
325 async fn auth_approle(
327 &self,
328 client: &mut VaultClient,
329 role_id: &str,
330 secret_id: &str,
331 mount: &str,
332 ) -> SecretsResult<()> {
333 let auth_info = vaultrs::auth::approle::login(client, mount, role_id, secret_id)
334 .await
335 .map_err(|e| SecretsError::AuthError(format!("AppRole login failed: {e}")))?;
336
337 client.set_token(&auth_info.client_token);
338 debug!("AppRole authentication successful");
339 Ok(())
340 }
341
342 async fn auth_kubernetes(
344 &self,
345 client: &mut VaultClient,
346 role: &str,
347 token_path: &str,
348 mount: &str,
349 ) -> SecretsResult<()> {
350 let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
351 SecretsError::AuthError(format!(
352 "failed to read K8s service account token from {token_path}: {e}"
353 ))
354 })?;
355
356 let auth_info = vaultrs::auth::kubernetes::login(client, mount, role, jwt.trim())
357 .await
358 .map_err(|e| SecretsError::AuthError(format!("Kubernetes login failed: {e}")))?;
359
360 client.set_token(&auth_info.client_token);
361 debug!("Kubernetes authentication successful");
362 Ok(())
363 }
364
365 pub async fn get(&self, path: &str, key: &str) -> SecretsResult<SecretValue> {
371 let client = self.get_client().await?;
372
373 let (mount, secret_path) = Self::parse_path(path);
376
377 let secret: std::collections::HashMap<String, String> =
379 kv2::read(&client, &mount, &secret_path)
380 .await
381 .map_err(|e| {
382 if e.to_string().contains("403") || e.to_string().contains("permission denied")
384 {
385 SecretsError::AuthError("Vault token expired or invalid".into())
386 } else {
387 SecretsError::ProviderError(format!("failed to read secret {path}: {e}"))
388 }
389 })?;
390
391 let value = secret.get(key).ok_or_else(|| {
393 SecretsError::NotFound(format!("key '{key}' not found in secret '{path}'"))
394 })?;
395
396 let metadata = SecretMetadata {
397 version: None, source_path: Some(path.to_string()),
399 provider: Some("openbao".into()),
400 };
401
402 Ok(SecretValue::with_metadata(
403 value.as_bytes().to_vec(),
404 metadata,
405 ))
406 }
407
408 fn parse_path(path: &str) -> (String, String) {
414 if let Some(rest) = path.strip_prefix("secret/data/") {
416 return ("secret".into(), rest.into());
417 }
418
419 let parts: Vec<&str> = path.splitn(3, '/').collect();
421 if parts.len() >= 3 && parts[1] == "data" {
422 return (parts[0].into(), parts[2..].join("/"));
423 }
424
425 ("secret".into(), path.into())
427 }
428}
429
430impl SecretProvider for OpenBaoProvider {
431 async fn get(&self, path: &str, key: Option<&str>) -> SecretsResult<SecretValue> {
432 let key = key.ok_or_else(|| {
433 SecretsError::ConfigError("key is required for OpenBao secrets".into())
434 })?;
435 self.get(path, key).await
436 }
437
438 async fn health_check(&self) -> SecretsResult<()> {
439 let client = self.get_client().await?;
440
441 vaultrs::sys::health(&client)
443 .await
444 .map_err(|e| SecretsError::ProviderError(format!("Vault health check failed: {e}")))?;
445
446 Ok(())
447 }
448
449 fn name(&self) -> &'static str {
450 "openbao"
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn test_parse_path_with_mount() {
460 let (mount, path) = OpenBaoProvider::parse_path("secret/data/myapp/tls");
461 assert_eq!(mount, "secret");
462 assert_eq!(path, "myapp/tls");
463 }
464
465 #[test]
466 fn test_parse_path_custom_mount() {
467 let (mount, path) = OpenBaoProvider::parse_path("kv/data/myapp/creds");
468 assert_eq!(mount, "kv");
469 assert_eq!(path, "myapp/creds");
470 }
471
472 #[test]
473 fn test_parse_path_default_mount() {
474 let (mount, path) = OpenBaoProvider::parse_path("myapp/tls");
475 assert_eq!(mount, "secret");
476 assert_eq!(path, "myapp/tls");
477 }
478
479 #[test]
480 fn test_openbao_auth_token_serialization() {
481 let auth = OpenBaoAuth::Token {
482 token: "test-token".into(),
483 };
484 let json = serde_json::to_string(&auth).unwrap();
485 assert!(json.contains("\"method\":\"token\""));
486 }
487
488 #[test]
489 fn test_openbao_auth_approle_serialization() {
490 let auth = OpenBaoAuth::AppRole {
491 role_id: "role123".into(),
492 secret_id: "secret456".into(),
493 mount: "approle".into(),
494 };
495 let json = serde_json::to_string(&auth).unwrap();
496 assert!(json.contains("\"method\":\"app_role\""));
497 assert!(json.contains("role_id"));
498 }
499
500 #[test]
501 fn test_openbao_auth_redacts_secrets() {
502 let token_auth = OpenBaoAuth::Token {
505 token: "super-secret-token".into(),
506 };
507 let dbg = format!("{token_auth:?}");
508 let json = serde_json::to_string(&token_auth).unwrap();
509 assert!(!dbg.contains("super-secret-token"), "token leaked in Debug");
510 assert!(!json.contains("super-secret-token"), "token leaked in JSON");
511
512 let approle = OpenBaoAuth::AppRole {
513 role_id: "role-abc".into(),
514 secret_id: "super-secret-id".into(),
515 mount: "approle".into(),
516 };
517 let dbg = format!("{approle:?}");
518 let json = serde_json::to_string(&approle).unwrap();
519 assert!(
520 !dbg.contains("super-secret-id"),
521 "secret_id leaked in Debug"
522 );
523 assert!(
524 !json.contains("super-secret-id"),
525 "secret_id leaked in JSON"
526 );
527 assert!(json.contains("role-abc"));
529 }
530
531 #[test]
532 fn validate_rejects_skip_verify_in_production() {
533 let insecure = OpenBaoConfig::with_token("https://vault:8200", "t").with_skip_verify();
534 assert!(insecure.skip_verify);
535 assert!(insecure.validate(false).is_ok(), "dev allows skip_verify");
536 assert!(
537 insecure.validate(true).is_err(),
538 "production must reject skip_verify"
539 );
540
541 let secure = OpenBaoConfig::with_token("https://vault:8200", "t");
542 assert!(!secure.skip_verify);
543 assert!(secure.validate(true).is_ok());
544 }
545
546 #[test]
547 fn test_openbao_config_serialization() {
548 let config = OpenBaoConfig {
549 address: "https://vault.example.com:8200".into(),
550 auth: OpenBaoAuth::Token {
551 token: "test".into(),
552 },
553 namespace: Some("myorg".into()),
554 ca_cert: None,
555 skip_verify: false,
556 };
557 let json = serde_json::to_string(&config).unwrap();
558 assert!(json.contains("vault.example.com"));
559 }
560}