use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use super::Provider;
use super::ProviderUrl;
use crate::MonosecretError;
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum KvVersion {
V1,
#[default]
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 = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
if scheme != "vault" && scheme != "openbao" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{scheme}' for vault provider. Expected 'vault' or 'openbao'."
)));
}
let use_tls = url
.query_pairs()
.find(|(k, _)| k == "tls")
.is_none_or(|(_, v)| v != "false" && v != "0");
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(|| {
MonosecretError::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() {
std::env::var("VAULT_NAMESPACE")
.ok()
.filter(|s| !s.is_empty())
} else {
Some(username)
}
};
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(MonosecretError::ProviderOperationFailed(format!(
"Unknown auth method '{other}'. Expected 'token' or 'approle'."
)))
}
}
})
.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(MonosecretError::ProviderOperationFailed(
"project cannot be empty".to_string(),
));
}
if profile.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"profile cannot be empty".to_string(),
));
}
if key.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"key cannot be empty".to_string(),
));
}
Ok(format!("monosecret/{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()),
}
}
#[allow(clippy::collapsible_if)]
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(MonosecretError::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(|_| {
MonosecretError::ProviderOperationFailed(
"VAULT_ROLE_ID environment variable is required for AppRole authentication."
.to_string(),
)
})?;
let secret_id = std::env::var("VAULT_SECRET_ID").map_err(|_| {
MonosecretError::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| {
MonosecretError::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(MonosecretError::ProviderOperationFailed(format!(
"AppRole login returned HTTP {status}: {body}"
)));
}
let resp: serde_json::Value = response.json().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse AppRole login response: {e}"
))
})?;
let token = resp["auth"]["client_token"].as_str().ok_or_else(|| {
MonosecretError::ProviderOperationFailed(
"AppRole login response missing auth.client_token".to_string(),
)
})?;
Ok(SecretString::new(token.to_string().into()))
}
fn build_headers(token: &SecretString, namespace: Option<&str>) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
"X-Vault-Token",
HeaderValue::from_str(token.expose_secret()).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!("Invalid token value: {e}"))
})?,
);
if let Some(ns) = namespace {
headers.insert(
"X-Vault-Namespace",
HeaderValue::from_str(ns).map_err(|e| {
MonosecretError::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.as_deref())?;
let client = reqwest::Client::new();
let response = client
.get(&url)
.headers(headers)
.send()
.await
.map_err(|e| {
MonosecretError::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| {
MonosecretError::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(MonosecretError::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(MonosecretError::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.as_deref())?;
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| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to connect to Vault at {}: {}",
self.config.endpoint, e
))
})?;
match response.status().as_u16() {
200 | 204 => Ok(()),
403 => {
Err(MonosecretError::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(MonosecretError::ProviderOperationFailed(format!(
"Vault returned HTTP {status} while writing secret: {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
}
}
#[cfg(test)]
mod tests {
use super::*;
static VAULT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn build_headers_includes_token() {
let token = SecretString::new("my-token".into());
let headers = VaultProvider::build_headers(&token, None).unwrap();
let snapshot = format!(
"X-Vault-Token: {}\nX-Vault-Namespace: {:?}",
headers.get("X-Vault-Token").unwrap().to_str().unwrap(),
headers.get("X-Vault-Namespace")
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn build_headers_includes_namespace_when_set() {
let token = SecretString::new("my-token".into());
let headers = VaultProvider::build_headers(&token, Some("ns1")).unwrap();
let snapshot = format!(
"X-Vault-Token: {}\nX-Vault-Namespace: {}",
headers.get("X-Vault-Token").unwrap().to_str().unwrap(),
headers.get("X-Vault-Namespace").unwrap().to_str().unwrap()
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn build_headers_rejects_invalid_token() {
let token = SecretString::new("bad\ntoken".into());
let err = VaultProvider::build_headers(&token, None).unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn build_headers_rejects_invalid_namespace() {
let token = SecretString::new("my-token".into());
let err = VaultProvider::build_headers(&token, Some("bad\nns")).unwrap_err();
insta::assert_snapshot!(err.to_string());
}
#[test]
fn get_errors_on_invalid_namespace_before_network() {
let _guard = VAULT_TEST_LOCK.lock().unwrap();
unsafe {
std::env::set_var("VAULT_TOKEN", "test-token");
}
let provider = VaultProvider::new(VaultConfig {
endpoint: "http://127.0.0.1:1".to_string(),
mount: "secret".to_string(),
kv_version: KvVersion::V2,
namespace: Some("bad\nnamespace".to_string()),
auth: AuthMethod::Token,
});
let err = provider.get("project", "KEY", "default").unwrap_err();
insta::assert_snapshot!(err.to_string());
unsafe {
std::env::remove_var("VAULT_TOKEN");
}
}
#[test]
fn set_errors_on_invalid_namespace_before_network() {
let _guard = VAULT_TEST_LOCK.lock().unwrap();
unsafe {
std::env::set_var("VAULT_TOKEN", "test-token");
}
let provider = VaultProvider::new(VaultConfig {
endpoint: "http://127.0.0.1:1".to_string(),
mount: "secret".to_string(),
kv_version: KvVersion::V2,
namespace: Some("bad\nnamespace".to_string()),
auth: AuthMethod::Token,
});
let err = provider
.set("project", "KEY", &SecretString::new("v".into()), "default")
.unwrap_err();
insta::assert_snapshot!(err.to_string());
unsafe {
std::env::remove_var("VAULT_TOKEN");
}
}
}