use super::{Address, 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 trimmed = path.trim_start_matches('/').trim_end_matches('/');
let mount = if trimmed.is_empty() {
"secret".to_string()
} else {
trimmed.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();
if let Some(field) = url.query_value("field") {
let hint = crate::config::ref_table_hint(None, "<kv-path>", None, Some(&field));
return Err(SecretSpecError::ProviderOperationFailed(format!(
"vault URIs take no `field` query: address the KV entry with \
{hint} on the secret instead"
)));
}
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_field_async(
&self,
secret_path: &str,
field: &str,
) -> Result<Option<SecretString>> {
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(field))
.and_then(|v| v.as_str()),
KvVersion::V1 => body
.get("data")
.and_then(|d| d.get(field))
.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, secret_path: &str, value: &SecretString) -> Result<()> {
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 convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: Self::format_secret_path(project, profile, key)?,
field: Some("value".to_string()),
..Default::default()
})
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let host = self
.config
.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let mut uri = format!("vault://{}", host);
if self.config.mount != "secret" {
uri.push('/');
uri.push_str(&self.config.mount);
}
uri
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field"]
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.resolve_coords(addr)?;
let Some(field) = &coords.field else {
return Err(SecretSpecError::ProviderOperationFailed(
"vault references need a `field`: KV entries are maps, e.g. \
ref = { item = \"myapp/config\", field = \"db_password\" }"
.to_string(),
));
};
super::block_on(self.get_field_async(&coords.item, field))
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
self.check_writable(addr)?;
let coords = self.resolve_coords(addr)?;
super::block_on(self.set_secret_async(&coords.item, value))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Convention { .. } => Ok(()),
Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed(
"vault secret references are read-only: writing a single field would \
clobber the other fields at the same KV path"
.to_string(),
)),
}
}
}
#[cfg(test)]
mod reference_tests {
use super::*;
use url::Url;
fn config(s: &str) -> VaultConfig {
VaultConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap()
}
#[test]
fn field_query_is_rejected_with_ref_hint() {
let err = VaultConfig::try_from(&ProviderUrl::new(
Url::parse("vault://vault.example.com:8200/secret?field=x").unwrap(),
))
.unwrap_err();
assert!(err.to_string().contains("field = \"x\""), "{err}");
}
#[test]
fn native_address_requires_a_field() {
let p = VaultProvider::new(config("vault://vault.example.com:8200/secret"));
let addr = crate::config::NativeAddress {
item: "myapp/config".into(),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("need a `field`"), "{err}");
}
#[test]
fn native_address_is_read_only() {
let p = VaultProvider::new(config("vault://vault.example.com:8200/secret"));
let addr = crate::config::NativeAddress {
item: "myapp/config".into(),
field: Some("db_password".into()),
..Default::default()
};
let refusal = p.check_writable(Address::Native(&addr)).unwrap_err();
assert!(refusal.to_string().contains("read-only"), "{refusal}");
let err = p
.set(
Address::Native(&addr),
&secrecy::SecretString::new("v".into()),
)
.unwrap_err();
assert_eq!(err.to_string(), refusal.to_string());
}
#[test]
fn native_address_rejects_version() {
let p = VaultProvider::new(config("vault://vault.example.com:8200/secret"));
let addr = crate::config::NativeAddress {
item: "myapp/config".into(),
field: Some("db_password".into()),
version: Some("3".into()),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`version`"), "{err}");
}
}