use serde::{Deserialize, Serialize};
use tracing::debug;
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use vaultrs::kv2;
use super::error::{SecretsError, SecretsResult};
use super::provider::SecretProvider;
use super::types::{SecretMetadata, SecretValue};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenBaoConfig {
pub address: String,
pub auth: OpenBaoAuth,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub ca_cert: Option<String>,
#[serde(default)]
pub skip_verify: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum OpenBaoAuth {
Token {
token: String,
},
AppRole {
role_id: String,
secret_id: String,
#[serde(default = "default_approle_mount")]
mount: String,
},
Kubernetes {
role: String,
#[serde(default = "default_k8s_token_path")]
token_path: String,
#[serde(default = "default_k8s_mount")]
mount: String,
},
}
fn default_approle_mount() -> String {
"approle".to_string()
}
fn default_k8s_token_path() -> String {
"/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
}
fn default_k8s_mount() -> String {
"kubernetes".to_string()
}
impl OpenBaoConfig {
#[must_use]
pub fn from_env() -> Option<Self> {
use crate::config::env_compat::vault;
let address = vault::addr().get()?;
let auth = if let Some(token) = vault::token().get() {
OpenBaoAuth::Token { token }
} else if let (Some(role_id), Some(secret_id)) = (
vault::approle_role_id().get(),
vault::approle_secret_id().get(),
) {
OpenBaoAuth::AppRole {
role_id,
secret_id,
mount: default_approle_mount(),
}
} else if let Some(role) = vault::k8s_role().get() {
OpenBaoAuth::Kubernetes {
role,
token_path: default_k8s_token_path(),
mount: default_k8s_mount(),
}
} else {
return None;
};
Some(Self {
address,
auth,
namespace: vault::namespace().get(),
ca_cert: vault::ca_cert().get(),
skip_verify: vault::skip_verify().get_bool().unwrap_or(false),
})
}
#[must_use]
pub fn with_token(address: &str, token: &str) -> Self {
Self {
address: address.to_string(),
auth: OpenBaoAuth::Token {
token: token.to_string(),
},
namespace: None,
ca_cert: None,
skip_verify: false,
}
}
#[must_use]
pub fn with_approle(address: &str, role_id: &str, secret_id: &str) -> Self {
Self {
address: address.to_string(),
auth: OpenBaoAuth::AppRole {
role_id: role_id.to_string(),
secret_id: secret_id.to_string(),
mount: default_approle_mount(),
},
namespace: None,
ca_cert: None,
skip_verify: false,
}
}
#[must_use]
pub fn with_namespace(mut self, namespace: &str) -> Self {
self.namespace = Some(namespace.to_string());
self
}
#[must_use]
pub fn with_ca_cert(mut self, path: &str) -> Self {
self.ca_cert = Some(path.to_string());
self
}
#[must_use]
pub fn with_skip_verify(mut self) -> Self {
self.skip_verify = true;
self
}
}
pub struct OpenBaoProvider {
config: OpenBaoConfig,
}
impl OpenBaoProvider {
pub fn new(config: &OpenBaoConfig) -> SecretsResult<Self> {
Ok(Self {
config: config.clone(),
})
}
async fn get_client(&self) -> SecretsResult<VaultClient> {
self.create_client().await
}
async fn create_client(&self) -> SecretsResult<VaultClient> {
let mut settings = VaultClientSettingsBuilder::default();
settings.address(&self.config.address);
if let Some(ref ns) = self.config.namespace {
settings.namespace(Some(ns.clone()));
}
let settings = settings.build().map_err(|e| {
SecretsError::ConfigError(format!("failed to build Vault client settings: {e}"))
})?;
let mut client = VaultClient::new(settings).map_err(|e| {
SecretsError::ProviderError(format!("failed to create Vault client: {e}"))
})?;
match &self.config.auth {
OpenBaoAuth::Token { token } => {
client.set_token(token);
}
OpenBaoAuth::AppRole {
role_id,
secret_id,
mount,
} => {
self.auth_approle(&mut client, role_id, secret_id, mount)
.await?;
}
OpenBaoAuth::Kubernetes {
role,
token_path,
mount,
} => {
self.auth_kubernetes(&mut client, role, token_path, mount)
.await?;
}
}
Ok(client)
}
async fn auth_approle(
&self,
client: &mut VaultClient,
role_id: &str,
secret_id: &str,
mount: &str,
) -> SecretsResult<()> {
let auth_info = vaultrs::auth::approle::login(client, mount, role_id, secret_id)
.await
.map_err(|e| SecretsError::AuthError(format!("AppRole login failed: {e}")))?;
client.set_token(&auth_info.client_token);
debug!("AppRole authentication successful");
Ok(())
}
async fn auth_kubernetes(
&self,
client: &mut VaultClient,
role: &str,
token_path: &str,
mount: &str,
) -> SecretsResult<()> {
let jwt = tokio::fs::read_to_string(token_path).await.map_err(|e| {
SecretsError::AuthError(format!(
"failed to read K8s service account token from {token_path}: {e}"
))
})?;
let auth_info = vaultrs::auth::kubernetes::login(client, mount, role, jwt.trim())
.await
.map_err(|e| SecretsError::AuthError(format!("Kubernetes login failed: {e}")))?;
client.set_token(&auth_info.client_token);
debug!("Kubernetes authentication successful");
Ok(())
}
pub async fn get(&self, path: &str, key: &str) -> SecretsResult<SecretValue> {
let client = self.get_client().await?;
let (mount, secret_path) = Self::parse_path(path);
let secret: std::collections::HashMap<String, String> =
kv2::read(&client, &mount, &secret_path)
.await
.map_err(|e| {
if e.to_string().contains("403") || e.to_string().contains("permission denied")
{
SecretsError::AuthError("Vault token expired or invalid".into())
} else {
SecretsError::ProviderError(format!("failed to read secret {path}: {e}"))
}
})?;
let value = secret.get(key).ok_or_else(|| {
SecretsError::NotFound(format!("key '{key}' not found in secret '{path}'"))
})?;
let metadata = SecretMetadata {
version: None, source_path: Some(path.to_string()),
provider: Some("openbao".into()),
};
Ok(SecretValue::with_metadata(
value.as_bytes().to_vec(),
metadata,
))
}
fn parse_path(path: &str) -> (String, String) {
if let Some(rest) = path.strip_prefix("secret/data/") {
return ("secret".into(), rest.into());
}
let parts: Vec<&str> = path.splitn(3, '/').collect();
if parts.len() >= 3 && parts[1] == "data" {
return (parts[0].into(), parts[2..].join("/"));
}
("secret".into(), path.into())
}
}
impl SecretProvider for OpenBaoProvider {
async fn get(&self, path: &str, key: Option<&str>) -> SecretsResult<SecretValue> {
let key = key.ok_or_else(|| {
SecretsError::ConfigError("key is required for OpenBao secrets".into())
})?;
self.get(path, key).await
}
async fn health_check(&self) -> SecretsResult<()> {
let client = self.get_client().await?;
vaultrs::sys::health(&client)
.await
.map_err(|e| SecretsError::ProviderError(format!("Vault health check failed: {e}")))?;
Ok(())
}
fn name(&self) -> &'static str {
"openbao"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_path_with_mount() {
let (mount, path) = OpenBaoProvider::parse_path("secret/data/myapp/tls");
assert_eq!(mount, "secret");
assert_eq!(path, "myapp/tls");
}
#[test]
fn test_parse_path_custom_mount() {
let (mount, path) = OpenBaoProvider::parse_path("kv/data/myapp/creds");
assert_eq!(mount, "kv");
assert_eq!(path, "myapp/creds");
}
#[test]
fn test_parse_path_default_mount() {
let (mount, path) = OpenBaoProvider::parse_path("myapp/tls");
assert_eq!(mount, "secret");
assert_eq!(path, "myapp/tls");
}
#[test]
fn test_openbao_auth_token_serialization() {
let auth = OpenBaoAuth::Token {
token: "test-token".into(),
};
let json = serde_json::to_string(&auth).unwrap();
assert!(json.contains("\"method\":\"token\""));
}
#[test]
fn test_openbao_auth_approle_serialization() {
let auth = OpenBaoAuth::AppRole {
role_id: "role123".into(),
secret_id: "secret456".into(),
mount: "approle".into(),
};
let json = serde_json::to_string(&auth).unwrap();
assert!(json.contains("\"method\":\"app_role\""));
assert!(json.contains("role_id"));
}
#[test]
fn test_openbao_config_serialization() {
let config = OpenBaoConfig {
address: "https://vault.example.com:8200".into(),
auth: OpenBaoAuth::Token {
token: "test".into(),
},
namespace: Some("hypersec".into()),
ca_cert: None,
skip_verify: false,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("vault.example.com"));
}
}