use super::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use reqwest::header::{HeaderMap, HeaderValue};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum KvVersion {
V1,
V2,
}
impl Default for KvVersion {
fn default() -> Self {
KvVersion::V2
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AuthMethod {
#[default]
Token,
AppRole,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultConfig {
pub endpoint: String,
pub mount: String,
pub kv_version: KvVersion,
pub namespace: Option<String>,
pub auth: AuthMethod,
}
impl Default for VaultConfig {
fn default() -> Self {
Self {
endpoint: "https://127.0.0.1:8200".to_string(),
mount: "secret".to_string(),
kv_version: KvVersion::default(),
namespace: None,
auth: AuthMethod::default(),
}
}
}
impl TryFrom<&ProviderUrl> for VaultConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
if scheme != "vault" && scheme != "openbao" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for vault provider. Expected 'vault' or 'openbao'.",
scheme
)));
}
let use_tls = url
.query_pairs()
.find(|(k, _)| k == "tls")
.map(|(_, v)| v != "false" && v != "0")
.unwrap_or(true);
let http_scheme = if use_tls { "https" } else { "http" };
let endpoint = match url.host().filter(|s| !s.is_empty()) {
Some(host) => {
if let Some(port) = url.port() {
format!("{}://{}:{}", http_scheme, host, port)
} else {
format!("{}://{}", http_scheme, host)
}
}
None => std::env::var("VAULT_ADDR")
.ok()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"No Vault address provided. Either specify a host in the URI \
(e.g., vault://vault.example.com:8200) or set the VAULT_ADDR \
environment variable."
.to_string(),
)
})?,
};
let path = url.path();
let mount = path
.trim_start_matches('/')
.split('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("secret")
.to_string();
let kv_version = url
.query_pairs()
.find(|(k, _)| k == "kv")
.map(|(_, v)| match v.as_ref() {
"1" | "v1" => KvVersion::V1,
_ => KvVersion::V2,
})
.unwrap_or_default();
let namespace = {
let username = url.username();
if !username.is_empty() {
Some(username)
} else {
std::env::var("VAULT_NAMESPACE")
.ok()
.filter(|s| !s.is_empty())
}
};
let auth = url
.query_pairs()
.find(|(k, _)| k == "auth")
.map(|(_, v)| match v.as_ref() {
"approle" => Ok(AuthMethod::AppRole),
"token" => Ok(AuthMethod::Token),
other => Err(SecretSpecError::ProviderOperationFailed(format!(
"Unknown auth method '{}'. Expected 'token' or 'approle'.",
other
))),
})
.transpose()?
.unwrap_or_default();
Ok(Self {
endpoint,
mount,
kv_version,
namespace,
auth,
})
}
}
pub struct VaultProvider {
config: VaultConfig,
}
crate::register_provider! {
struct: VaultProvider,
config: VaultConfig,
name: "vault",
description: "HashiCorp Vault / OpenBao secret management",
schemes: ["vault", "openbao"],
examples: ["vault://vault.example.com:8200/secret", "openbao://bao.internal:8200/secret"],
}
impl VaultProvider {
pub fn new(config: VaultConfig) -> Self {
Self { config }
}
fn format_secret_path(project: &str, profile: &str, key: &str) -> Result<String> {
if project.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(
"project cannot be empty".to_string(),
));
}
if profile.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(
"profile cannot be empty".to_string(),
));
}
if key.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(
"key cannot be empty".to_string(),
));
}
Ok(format!("secretspec/{}/{}/{}", project, profile, key))
}
fn resolve_token(&self) -> Result<SecretString> {
match self.config.auth {
AuthMethod::Token => Self::resolve_token_auth(),
AuthMethod::AppRole => super::block_on(self.resolve_approle_auth()),
}
}
fn resolve_token_auth() -> Result<SecretString> {
if let Ok(token) = std::env::var("VAULT_TOKEN") {
if !token.is_empty() {
return Ok(SecretString::new(token.into()));
}
}
let token_path = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| std::path::PathBuf::from(home).join(".vault-token"));
if let Some(path) = token_path {
if let Ok(token) = std::fs::read_to_string(&path) {
let token = token.trim();
if !token.is_empty() {
return Ok(SecretString::new(token.to_string().into()));
}
}
}
Err(SecretSpecError::ProviderOperationFailed(
"No Vault token found. Set the VAULT_TOKEN environment variable \
or create a ~/.vault-token file."
.to_string(),
))
}
async fn resolve_approle_auth(&self) -> Result<SecretString> {
let role_id = std::env::var("VAULT_ROLE_ID").map_err(|_| {
SecretSpecError::ProviderOperationFailed(
"VAULT_ROLE_ID environment variable is required for AppRole authentication."
.to_string(),
)
})?;
let secret_id = std::env::var("VAULT_SECRET_ID").map_err(|_| {
SecretSpecError::ProviderOperationFailed(
"VAULT_SECRET_ID environment variable is required for AppRole authentication."
.to_string(),
)
})?;
let url = format!("{}/v1/auth/approle/login", self.config.endpoint);
let body = serde_json::json!({
"role_id": role_id,
"secret_id": secret_id,
});
let client = reqwest::Client::new();
let response = client.post(&url).json(&body).send().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!("AppRole login failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SecretSpecError::ProviderOperationFailed(format!(
"AppRole login returned HTTP {}: {}",
status, body
)));
}
let resp: serde_json::Value = response.json().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse AppRole login response: {}",
e
))
})?;
let token = resp["auth"]["client_token"].as_str().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"AppRole login response missing auth.client_token".to_string(),
)
})?;
Ok(SecretString::new(token.to_string().into()))
}
fn build_headers(token: &SecretString, namespace: &Option<String>) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
"X-Vault-Token",
HeaderValue::from_str(token.expose_secret()).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!("Invalid token value: {}", e))
})?,
);
if let Some(ns) = namespace {
headers.insert(
"X-Vault-Namespace",
HeaderValue::from_str(ns).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid namespace value: {}",
e
))
})?,
);
}
Ok(headers)
}
fn build_url(&self, secret_path: &str) -> String {
match self.config.kv_version {
KvVersion::V2 => format!(
"{}/v1/{}/data/{}",
self.config.endpoint, self.config.mount, secret_path
),
KvVersion::V1 => format!(
"{}/v1/{}/{}",
self.config.endpoint, self.config.mount, secret_path
),
}
}
async fn get_secret_async(
&self,
project: &str,
key: &str,
profile: &str,
) -> Result<Option<SecretString>> {
let secret_path = Self::format_secret_path(project, profile, key)?;
let url = self.build_url(&secret_path);
let token = self.resolve_token()?;
let headers = Self::build_headers(&token, &self.config.namespace)?;
let client = reqwest::Client::new();
let response = client
.get(&url)
.headers(headers)
.send()
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to Vault at {}: {}",
self.config.endpoint, e
))
})?;
match response.status().as_u16() {
200 => {
let body: serde_json::Value = response.json().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse Vault response: {}",
e
))
})?;
let value = match self.config.kv_version {
KvVersion::V2 => body
.get("data")
.and_then(|d| d.get("data"))
.and_then(|d| d.get("value"))
.and_then(|v| v.as_str()),
KvVersion::V1 => body
.get("data")
.and_then(|d| d.get("value"))
.and_then(|v| v.as_str()),
};
Ok(value.map(|v| SecretString::new(v.to_string().into())))
}
404 => Ok(None),
403 => Err(SecretSpecError::ProviderOperationFailed(
"Vault authentication failed (403 Forbidden). \
Check your VAULT_TOKEN and ensure it has the required permissions."
.to_string(),
)),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"Vault returned HTTP {}: {}",
status, body
)))
}
}
}
async fn set_secret_async(
&self,
project: &str,
key: &str,
value: &SecretString,
profile: &str,
) -> Result<()> {
let secret_path = Self::format_secret_path(project, profile, key)?;
let url = self.build_url(&secret_path);
let token = self.resolve_token()?;
let headers = Self::build_headers(&token, &self.config.namespace)?;
let body = match self.config.kv_version {
KvVersion::V2 => {
serde_json::json!({ "data": { "value": value.expose_secret() } })
}
KvVersion::V1 => {
serde_json::json!({ "value": value.expose_secret() })
}
};
let client = reqwest::Client::new();
let response = client
.post(&url)
.headers(headers)
.json(&body)
.send()
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to Vault at {}: {}",
self.config.endpoint, e
))
})?;
match response.status().as_u16() {
200 | 204 => Ok(()),
403 => Err(SecretSpecError::ProviderOperationFailed(
"Vault authentication failed (403 Forbidden). \
Check your VAULT_TOKEN and ensure it has write permissions."
.to_string(),
)),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"Vault returned HTTP {} while writing secret: {}",
status, body
)))
}
}
}
}
impl Provider for VaultProvider {
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let mut uri = format!(
"vault://{}",
self.config
.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://")
);
if self.config.mount != "secret" {
uri.push('/');
uri.push_str(&self.config.mount);
}
uri
}
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
super::block_on(self.get_secret_async(project, key, profile))
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
super::block_on(self.set_secret_async(project, key, value, profile))
}
fn allows_set(&self) -> bool {
true
}
}