use super::{Address, ProviderCredentials, ProviderUrl, credential_or_envs, preferred_env};
use crate::config::NativeAddress;
use crate::{Result, SecretSpecError};
use reqwest::header::{HeaderMap, HeaderValue};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::OnceLock;
use url::Url;
pub(crate) const ROLE_ID: &str = "role_id";
pub(crate) const SECRET_ID: &str = "secret_id";
pub(crate) const TOKEN: &str = "token";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub(crate) enum KvVersion {
V1,
#[default]
V2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub(crate) enum AuthMethod {
#[default]
Token,
AppRole,
Jwt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] pub(crate) enum Product {
Vault,
OpenBao,
}
impl Product {
pub(crate) fn scheme(self) -> &'static str {
match self {
Self::Vault => "vault",
Self::OpenBao => "openbao",
}
}
fn display_name(self) -> &'static str {
match self {
Self::Vault => "Vault",
Self::OpenBao => "OpenBao",
}
}
fn address_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_ADDR"],
Self::OpenBao => &["BAO_ADDR", "VAULT_ADDR"],
}
}
fn namespace_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_NAMESPACE"],
Self::OpenBao => &["BAO_NAMESPACE", "VAULT_NAMESPACE"],
}
}
fn token_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_TOKEN"],
Self::OpenBao => &["BAO_TOKEN", "VAULT_TOKEN"],
}
}
fn token_path_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &[],
Self::OpenBao => &["BAO_TOKEN_PATH", "VAULT_TOKEN_PATH"],
}
}
fn role_id_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_ROLE_ID"],
Self::OpenBao => &["BAO_ROLE_ID", "VAULT_ROLE_ID"],
}
}
fn secret_id_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_SECRET_ID"],
Self::OpenBao => &["BAO_SECRET_ID", "VAULT_SECRET_ID"],
}
}
fn jwt_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_JWT"],
Self::OpenBao => &["BAO_JWT", "VAULT_JWT"],
}
}
fn jwt_role_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_JWT_ROLE"],
Self::OpenBao => &["BAO_JWT_ROLE", "VAULT_JWT_ROLE"],
}
}
fn jwt_audience_envs(self) -> &'static [&'static str] {
match self {
Self::Vault => &["VAULT_JWT_AUDIENCE"],
Self::OpenBao => &["BAO_JWT_AUDIENCE", "VAULT_JWT_AUDIENCE"],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct KvConfig {
pub(crate) endpoint: String,
pub(crate) mount: String,
pub(crate) kv_version: KvVersion,
pub(crate) namespace: Option<String>,
pub(crate) auth: AuthMethod,
pub(crate) role: Option<String>,
pub(crate) audience: Option<String>,
}
impl Default for KvConfig {
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(),
role: None,
audience: None,
}
}
}
impl KvConfig {
fn normalize_endpoint(endpoint: &str, product: Product) -> Result<String> {
let mut endpoint = Url::parse(endpoint).map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid {} address: {error}",
product.display_name()
))
})?;
if !matches!(endpoint.scheme(), "http" | "https") || endpoint.host().is_none() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid {} address: expected an http:// or https:// URL with a host",
product.display_name()
)));
}
endpoint.set_password(None).map_err(|_| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid {} address password",
product.display_name()
))
})?;
endpoint.set_username("").map_err(|_| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid {} address username",
product.display_name()
))
})?;
endpoint.set_path("");
endpoint.set_query(None);
endpoint.set_fragment(None);
Ok(endpoint.as_str().trim_end_matches('/').to_string())
}
pub(crate) fn parse(url: &ProviderUrl, product: Product) -> Result<Self> {
if url.scheme() != product.scheme() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for {} provider. Expected '{}'.",
url.scheme(),
product.display_name(),
product.scheme()
)));
}
let use_tls = url
.query_pairs()
.find(|(key, _)| key == "tls")
.map(|(_, value)| value != "false" && value != "0")
.unwrap_or(true);
let http_scheme = if use_tls { "https" } else { "http" };
let endpoint = match url.host().filter(|host| !host.is_empty()) {
Some(host) => match url.port() {
Some(port) => format!("{http_scheme}://{host}:{port}"),
None => format!("{http_scheme}://{host}"),
},
None => preferred_env(product.address_envs()).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"No {} address provided. Specify a host in the URI (for example, \
{}://127.0.0.1:8200) or set {}.",
product.display_name(),
product.scheme(),
product.address_envs().join(" or ")
))
})?,
};
let endpoint = Self::normalize_endpoint(&endpoint, product)?;
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(|(key, _)| key == "kv")
.map(|(_, value)| match value.as_ref() {
"1" | "v1" => KvVersion::V1,
_ => KvVersion::V2,
})
.unwrap_or_default();
let namespace = match url.username() {
username if !username.is_empty() => Some(username),
_ => preferred_env(product.namespace_envs()),
};
let auth = url
.query_pairs()
.find(|(key, _)| key == "auth")
.map(|(_, value)| match value.as_ref() {
"approle" => Ok(AuthMethod::AppRole),
"jwt" => Ok(AuthMethod::Jwt),
"token" => Ok(AuthMethod::Token),
other => Err(SecretSpecError::ProviderOperationFailed(format!(
"Unknown auth method '{other}'. Expected 'token', 'approle', or 'jwt'."
))),
})
.transpose()?
.unwrap_or_default();
let role = url
.query_pairs()
.find(|(key, _)| key == "role")
.map(|(_, value)| value.to_string())
.or_else(|| preferred_env(product.jwt_role_envs()))
.filter(|value| !value.is_empty());
let audience = url
.query_pairs()
.find(|(key, _)| key == "audience")
.map(|(_, value)| value.to_string())
.or_else(|| preferred_env(product.jwt_audience_envs()))
.filter(|value| !value.is_empty());
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!(
"{} URIs take no `field` query: address the KV entry with {hint} on the \
secret instead",
product.scheme()
)));
}
Ok(Self {
endpoint,
mount,
kv_version,
namespace,
auth,
role,
audience,
})
}
}
pub(crate) struct KvProvider {
config: KvConfig,
credentials: ProviderCredentials,
product: Product,
http: OnceLock<reqwest::Client>,
}
impl KvProvider {
pub(crate) fn new(config: KvConfig, product: Product) -> Self {
Self {
config,
credentials: ProviderCredentials::new(),
product,
http: OnceLock::new(),
}
}
fn http(&self) -> &reqwest::Client {
self.http.get_or_init(reqwest::Client::new)
}
pub(crate) fn with_credentials(&mut self, credentials: ProviderCredentials) {
self.credentials = credentials;
}
pub(crate) fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<NativeAddress> {
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(NativeAddress {
item: format!("secretspec/{project}/{profile}/{key}"),
field: Some("value".to_string()),
..Default::default()
})
}
pub(crate) fn uri(&self) -> String {
let authority = self
.config
.endpoint
.strip_prefix("https://")
.or_else(|| self.config.endpoint.strip_prefix("http://"))
.expect("KvConfig endpoints are normalized HTTP origins");
let mut uri = Url::parse(&format!("{}://{authority}", self.product.scheme()))
.expect("a normalized endpoint forms a provider URI");
if let Some(namespace) = &self.config.namespace {
uri.set_username(namespace)
.expect("a provider URI supports namespace userinfo");
}
uri.set_path(&format!("/{}", self.config.mount));
if self.config.endpoint.starts_with("http://") {
uri.query_pairs_mut().append_pair("tls", "false");
}
if self.config.kv_version == KvVersion::V1 {
uri.query_pairs_mut().append_pair("kv", "1");
}
match self.config.auth {
AuthMethod::Token => {}
AuthMethod::AppRole => {
uri.query_pairs_mut().append_pair("auth", "approle");
}
AuthMethod::Jwt => {
uri.query_pairs_mut().append_pair("auth", "jwt");
if let Some(role) = &self.config.role {
uri.query_pairs_mut().append_pair("role", role);
}
if let Some(audience) = &self.config.audience {
uri.query_pairs_mut().append_pair("audience", audience);
}
}
}
uri.into()
}
fn require_field<'a>(&self, coords: &'a NativeAddress) -> Result<&'a str> {
coords.field.as_deref().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} references need a `field`: KV entries are maps, e.g. \
ref = {{ item = \"myapp/config\", field = \"db_password\" }}",
self.product.scheme()
))
})
}
pub(crate) fn get(&self, coords: &NativeAddress) -> Result<Option<SecretString>> {
let field = self.require_field(coords)?;
super::block_on(self.get_field_async(&coords.item, field))
}
pub(crate) fn set(&self, coords: &NativeAddress, value: &SecretString) -> Result<()> {
super::block_on(self.set_secret_async(&coords.item, value))
}
pub(crate) fn set_expiring(
&self,
coords: &NativeAddress,
value: &SecretString,
max_age: std::time::Duration,
) -> Result<()> {
if self.config.kv_version == KvVersion::V1 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} KV v1 cannot expire a secret; use a KV v2 mount to hold values with a \
maximum age",
self.product.scheme()
)));
}
super::block_on(async {
self.set_version_ttl_async(&coords.item, max_age).await?;
self.set_secret_async(&coords.item, value).await
})
}
pub(crate) fn delete(&self, coords: &NativeAddress) -> Result<bool> {
let field = self.require_field(coords)?;
super::block_on(async {
match self.config.kv_version {
KvVersion::V2 => {
if !self.metadata_exists_async(&coords.item).await? {
return Ok(false);
}
}
KvVersion::V1 => {
if self.get_field_async(&coords.item, field).await?.is_none() {
return Ok(false);
}
}
}
self.delete_path_async(&coords.item).await?;
Ok(true)
})
}
pub(crate) fn check_writable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Convention { .. } => Ok(()),
Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} secret references are read-only: writing a single field would clobber the \
other fields at the same KV path",
self.product.scheme()
))),
}
}
pub(crate) fn check_deletable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Convention { .. } => Ok(()),
Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} secret references cannot be deleted: the KV path they name is managed outside \
SecretSpec, and deleting it would destroy every field in it",
self.product.scheme()
))),
}
}
async fn resolve_token(&self) -> Result<SecretString> {
match self.config.auth {
AuthMethod::Token => self.resolve_token_auth(),
AuthMethod::AppRole => self.resolve_approle_auth().await,
AuthMethod::Jwt => self.resolve_jwt_auth().await,
}
}
fn resolve_token_auth(&self) -> Result<SecretString> {
if let Some(token) = credential_or_envs(&self.credentials, TOKEN, self.product.token_envs())
{
return Ok(SecretString::new(token.into()));
}
let token_path = preferred_env(self.product.token_path_envs())
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".vault-token"))
});
if let Some(path) = token_path
&& 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()));
}
}
let token_path_hint = match self.product {
Product::Vault => "create a ~/.vault-token file".to_string(),
Product::OpenBao => {
"set BAO_TOKEN_PATH (VAULT_TOKEN_PATH is also accepted), or create a \
~/.vault-token file"
.to_string()
}
};
Err(SecretSpecError::ProviderOperationFailed(format!(
"No {} token found. Configure the token provider credential, set {}, {}, or {}.",
self.product.display_name(),
self.product.token_envs().join(" or "),
token_path_hint,
"authenticate with another supported method"
)))
}
async fn resolve_approle_auth(&self) -> Result<SecretString> {
let role_id = credential_or_envs(&self.credentials, ROLE_ID, self.product.role_id_envs())
.ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} role_id credential is required for AppRole authentication; configure \
credentials.role_id or set {}.",
self.product.display_name(),
self.product.role_id_envs().join(" or ")
))
})?;
let secret_id =
credential_or_envs(&self.credentials, SECRET_ID, self.product.secret_id_envs())
.ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} secret_id credential is required for AppRole authentication; configure \
credentials.secret_id or set {}.",
self.product.display_name(),
self.product.secret_id_envs().join(" or ")
))
})?;
let url = format!("{}/v1/auth/approle/login", self.config.endpoint);
let body = serde_json::json!({
"role_id": role_id,
"secret_id": secret_id,
});
let response = self
.build_login_request(&url, &body)?
.send()
.await
.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"{} AppRole login failed: {error}",
self.product.display_name()
))
})?;
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}",
self.product.display_name()
)));
}
let response: serde_json::Value = response.json().await.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse {} AppRole login response: {error}",
self.product.display_name()
))
})?;
let token = response["auth"]["client_token"].as_str().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} AppRole login response missing auth.client_token",
self.product.display_name()
))
})?;
Ok(SecretString::new(token.to_string().into()))
}
async fn resolve_jwt_auth(&self) -> Result<SecretString> {
let role = self.config.role.clone().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} JWT authentication requires a role. Set `?role=` in the provider URI or {}.",
self.product.display_name(),
self.product.jwt_role_envs().join(" or ")
))
})?;
let jwt = self.resolve_jwt().await?;
let url = format!("{}/v1/auth/jwt/login", self.config.endpoint);
let body = serde_json::json!({
"role": role,
"jwt": jwt.expose_secret(),
});
let response = self
.build_login_request(&url, &body)?
.send()
.await
.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"{} JWT login failed: {error}",
self.product.display_name()
))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} JWT login returned HTTP {status}: {body}",
self.product.display_name()
)));
}
let response: serde_json::Value = response.json().await.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse {} JWT login response: {error}",
self.product.display_name()
))
})?;
let token = response["auth"]["client_token"].as_str().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"{} JWT login response missing auth.client_token",
self.product.display_name()
))
})?;
Ok(SecretString::new(token.to_string().into()))
}
async fn resolve_jwt(&self) -> Result<SecretString> {
if let Some(jwt) = preferred_env(self.product.jwt_envs()) {
return Ok(SecretString::new(jwt.into()));
}
let request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL")
.ok()
.filter(|value| !value.is_empty());
let request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
.ok()
.filter(|value| !value.is_empty());
let (request_url, request_token) = match (request_url, request_token) {
(Some(url), Some(token)) => (url, token),
_ => {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"No JWT available for {} JWT auth. Set {}, or run under a GitHub Actions / \
Forgejo job with `id-token` write permission.",
self.product.display_name(),
self.product.jwt_envs().join(" or ")
)));
}
};
let mut request = self.http().get(&request_url).bearer_auth(&request_token);
if let Some(audience) = &self.config.audience {
request = request.query(&[("audience", audience.as_str())]);
}
let response = request.send().await.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to request CI OIDC token: {error}"
))
})?;
if !response.status().is_success() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"CI OIDC token request returned HTTP {}",
response.status()
)));
}
let response: serde_json::Value = response.json().await.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse CI OIDC token response: {error}"
))
})?;
let jwt = response["value"].as_str().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"CI OIDC token response missing `value`".to_string(),
)
})?;
Ok(SecretString::new(jwt.to_string().into()))
}
fn build_login_request(
&self,
url: &str,
body: &serde_json::Value,
) -> Result<reqwest::RequestBuilder> {
Ok(self
.http()
.post(url)
.headers(self.build_namespace_headers()?)
.json(body))
}
fn build_headers(&self, token: &SecretString) -> Result<HeaderMap> {
let mut headers = self.build_namespace_headers()?;
headers.insert(
"X-Vault-Token",
HeaderValue::from_str(token.expose_secret()).map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!("Invalid token value: {error}"))
})?,
);
Ok(headers)
}
fn build_namespace_headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
if let Some(namespace) = &self.config.namespace {
headers.insert(
"X-Vault-Namespace",
HeaderValue::from_str(namespace).map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid namespace value: {error}"
))
})?,
);
}
Ok(headers)
}
fn build_url(&self, secret_path: &str) -> String {
match self.config.kv_version {
KvVersion::V2 => format!(
"{}/v1/{}/data/{secret_path}",
self.config.endpoint, self.config.mount
),
KvVersion::V1 => format!(
"{}/v1/{}/{secret_path}",
self.config.endpoint, self.config.mount
),
}
}
async fn send_with_connect_retry(
&self,
mut build: impl FnMut() -> Result<reqwest::RequestBuilder>,
) -> Result<reqwest::Response> {
const ATTEMPTS: usize = 3;
let mut last_error = None;
for attempt in 1..=ATTEMPTS {
let response = build()?.send().await;
match response {
Ok(response) => return Ok(response),
Err(error) if attempt < ATTEMPTS && (error.is_connect() || error.is_timeout()) => {
last_error = Some(error);
std::thread::sleep(std::time::Duration::from_millis(25 * attempt as u64));
}
Err(error) => {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to {} at {}: {error}",
self.product.display_name(),
self.config.endpoint
)));
}
}
}
Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to {} at {}: {}",
self.product.display_name(),
self.config.endpoint,
last_error.expect("connect retry exhausted with an error")
)))
}
fn metadata_url(&self, secret_path: &str) -> String {
format!(
"{}/v1/{}/metadata/{secret_path}",
self.config.endpoint, self.config.mount
)
}
async fn metadata_exists_async(&self, secret_path: &str) -> Result<bool> {
let url = self.metadata_url(secret_path);
let token = self.resolve_token().await?;
let headers = self.build_headers(&token)?;
let response = self
.send_with_connect_retry(|| Ok(self.http().get(&url).headers(headers.clone())))
.await?;
match response.status().as_u16() {
200 => Ok(true),
404 => Ok(false),
403 => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} authentication failed (403 Forbidden) reading version metadata. Check {} and \
ensure it has read access to metadata as well as delete permissions.",
self.product.display_name(),
self.product.token_envs().join(" or ")
))),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"{} returned HTTP {status} while reading version metadata: {body}",
self.product.display_name()
)))
}
}
}
async fn set_version_ttl_async(
&self,
secret_path: &str,
max_age: std::time::Duration,
) -> Result<()> {
let url = self.metadata_url(secret_path);
let token = self.resolve_token().await?;
let body = serde_json::json!({ "delete_version_after": format!("{}s", max_age.as_secs()) });
let headers = self.build_headers(&token)?;
let response = self
.send_with_connect_retry(|| {
Ok(self.http().post(&url).headers(headers.clone()).json(&body))
})
.await?;
match response.status().as_u16() {
200 | 204 => Ok(()),
403 => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} authentication failed (403 Forbidden) writing version metadata. A value with \
a maximum age needs write access to the path's metadata as well as its data.",
self.product.display_name()
))),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"{} returned HTTP {status} while setting version expiry: {body}",
self.product.display_name()
)))
}
}
}
async fn delete_path_async(&self, secret_path: &str) -> Result<()> {
let url = match self.config.kv_version {
KvVersion::V2 => self.metadata_url(secret_path),
KvVersion::V1 => self.build_url(secret_path),
};
let token = self.resolve_token().await?;
let headers = self.build_headers(&token)?;
let response = self
.send_with_connect_retry(|| Ok(self.http().delete(&url).headers(headers.clone())))
.await?;
match response.status().as_u16() {
200 | 204 | 404 => Ok(()),
403 => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} authentication failed (403 Forbidden). Check {} and ensure it has delete \
permissions.",
self.product.display_name(),
self.product.token_envs().join(" or ")
))),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"{} returned HTTP {status} while deleting secret: {body}",
self.product.display_name()
)))
}
}
}
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().await?;
let headers = self.build_headers(&token)?;
let response = self
.send_with_connect_retry(|| Ok(self.http().get(&url).headers(headers.clone())))
.await?;
match response.status().as_u16() {
200 => {
let body: serde_json::Value = response.json().await.map_err(|error| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse {} response: {error}",
self.product.display_name()
))
})?;
let value = match self.config.kv_version {
KvVersion::V2 => body
.get("data")
.and_then(|data| data.get("data"))
.and_then(|data| data.get(field))
.and_then(|value| value.as_str()),
KvVersion::V1 => body
.get("data")
.and_then(|data| data.get(field))
.and_then(|value| value.as_str()),
};
Ok(value.map(|value| SecretString::new(value.to_string().into())))
}
404 => Ok(None),
403 => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} authentication failed (403 Forbidden). Check {} and ensure it has the \
required permissions.",
self.product.display_name(),
self.product.token_envs().join(" or ")
))),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"{} returned HTTP {status}: {body}",
self.product.display_name()
)))
}
}
}
async fn set_secret_async(&self, secret_path: &str, value: &SecretString) -> Result<()> {
let url = self.build_url(secret_path);
let token = self.resolve_token().await?;
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 headers = self.build_headers(&token)?;
let response = self
.send_with_connect_retry(|| {
Ok(self.http().post(&url).headers(headers.clone()).json(&body))
})
.await?;
match response.status().as_u16() {
200 | 204 => Ok(()),
403 => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} authentication failed (403 Forbidden). Check {} and ensure it has write \
permissions.",
self.product.display_name(),
self.product.token_envs().join(" or ")
))),
status => {
let body = response.text().await.unwrap_or_default();
Err(SecretSpecError::ProviderOperationFailed(format!(
"{} returned HTTP {status} while writing secret: {body}",
self.product.display_name()
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::EnvVarGuard;
fn provider_url(spec: &str) -> ProviderUrl {
ProviderUrl::new(Url::parse(spec).unwrap())
}
#[test]
fn openbao_environment_names_separate_cli_and_secretspec_conventions() {
assert_eq!(Product::OpenBao.address_envs(), &["BAO_ADDR", "VAULT_ADDR"]);
assert_eq!(
Product::OpenBao.namespace_envs(),
&["BAO_NAMESPACE", "VAULT_NAMESPACE"]
);
assert_eq!(Product::OpenBao.token_envs(), &["BAO_TOKEN", "VAULT_TOKEN"]);
assert_eq!(
Product::OpenBao.token_path_envs(),
&["BAO_TOKEN_PATH", "VAULT_TOKEN_PATH"]
);
assert_eq!(
Product::OpenBao.role_id_envs(),
&["BAO_ROLE_ID", "VAULT_ROLE_ID"]
);
assert_eq!(
Product::OpenBao.secret_id_envs(),
&["BAO_SECRET_ID", "VAULT_SECRET_ID"]
);
assert_eq!(Product::OpenBao.jwt_envs(), &["BAO_JWT", "VAULT_JWT"]);
assert_eq!(
Product::OpenBao.jwt_role_envs(),
&["BAO_JWT_ROLE", "VAULT_JWT_ROLE"]
);
assert_eq!(
Product::OpenBao.jwt_audience_envs(),
&["BAO_JWT_AUDIENCE", "VAULT_JWT_AUDIENCE"]
);
}
#[test]
fn environment_addresses_drop_trailing_slashes_before_request_paths_are_appended() {
let _lock = crate::tests::scrub_resolution_env();
{
let _bao_addr = EnvVarGuard::set("BAO_ADDR", "http://127.0.0.1:8200/");
let _vault_addr = EnvVarGuard::remove("VAULT_ADDR");
let config = KvConfig::parse(&provider_url("openbao://"), Product::OpenBao).unwrap();
let provider = KvProvider::new(config, Product::OpenBao);
assert_eq!(
provider.build_url("app/config"),
"http://127.0.0.1:8200/v1/secret/data/app/config"
);
}
{
let _vault_addr = EnvVarGuard::set("VAULT_ADDR", "http://127.0.0.1:8200///");
let config = KvConfig::parse(&provider_url("vault://"), Product::Vault).unwrap();
let provider = KvProvider::new(config, Product::Vault);
assert_eq!(
provider.build_url("app/config"),
"http://127.0.0.1:8200/v1/secret/data/app/config"
);
}
}
#[test]
fn version_policy_and_deletion_address_the_metadata_path() {
let _lock = crate::tests::scrub_resolution_env();
let _vault_addr = EnvVarGuard::set("VAULT_ADDR", "http://127.0.0.1:8200");
let config = KvConfig::parse(&provider_url("vault://"), Product::Vault).unwrap();
let provider = KvProvider::new(config, Product::Vault);
assert_eq!(
provider.metadata_url("app/config"),
"http://127.0.0.1:8200/v1/secret/metadata/app/config"
);
assert_eq!(
provider.build_url("app/config"),
"http://127.0.0.1:8200/v1/secret/data/app/config"
);
}
#[test]
fn kv_v2_delete_destroys_metadata_when_the_current_version_is_unreadable() {
use std::io::{Read, Write};
use std::net::TcpListener;
let _lock = crate::tests::scrub_resolution_env();
let _token = EnvVarGuard::set("VAULT_TOKEN", "test-token");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let mut request_lines = Vec::new();
for status in ["200 OK", "204 No Content"] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 8192];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]);
request_lines.push(request.lines().next().unwrap_or_default().to_string());
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
request_lines
});
let config = KvConfig::parse(
&provider_url(&format!("vault://{endpoint}/secret?tls=false&kv=2")),
Product::Vault,
)
.unwrap();
let provider = KvProvider::new(config, Product::Vault);
let coords = NativeAddress {
item: "cache/API_KEY".to_string(),
field: Some("value".to_string()),
..Default::default()
};
assert!(provider.delete(&coords).unwrap());
assert_eq!(
server.join().unwrap(),
[
"GET /v1/secret/metadata/cache/API_KEY HTTP/1.1",
"DELETE /v1/secret/metadata/cache/API_KEY HTTP/1.1",
]
);
}
#[test]
fn kv_v1_refuses_to_hold_an_expiring_value() {
let _lock = crate::tests::scrub_resolution_env();
let _vault_addr = EnvVarGuard::remove("VAULT_ADDR");
let config = KvConfig::parse(
&provider_url("vault://127.0.0.1:8200/kv1?tls=false&kv=1"),
Product::Vault,
)
.unwrap();
let provider = KvProvider::new(config, Product::Vault);
let coords = NativeAddress {
item: "app/config".to_string(),
field: Some("value".to_string()),
..Default::default()
};
let error = provider
.set_expiring(
&coords,
&SecretString::new("value".to_string().into()),
std::time::Duration::from_secs(3600),
)
.unwrap_err();
assert!(error.to_string().contains("KV v1 cannot expire"), "{error}");
}
#[test]
fn a_reference_is_never_deleted() {
let _lock = crate::tests::scrub_resolution_env();
let _vault_addr = EnvVarGuard::set("VAULT_ADDR", "http://127.0.0.1:8200");
let config = KvConfig::parse(&provider_url("vault://"), Product::Vault).unwrap();
let provider = KvProvider::new(config, Product::Vault);
let reference = NativeAddress {
item: "team/shared".to_string(),
field: Some("db_password".to_string()),
..Default::default()
};
let error = provider
.check_deletable(Address::Native(&reference))
.unwrap_err();
assert!(error.to_string().contains("cannot be deleted"), "{error}");
assert!(
provider
.check_deletable(Address::convention("proj", "default", "API_KEY"))
.is_ok()
);
}
#[test]
fn environment_endpoints_drop_credentials_and_unsupported_url_components() {
let _lock = crate::tests::scrub_resolution_env();
let _bao_addr = EnvVarGuard::set(
"BAO_ADDR",
"https://alice:leaked-password@bao.example.com:8200/prefix?token=leaked-query#fragment",
);
let _vault_addr = EnvVarGuard::remove("VAULT_ADDR");
let config = KvConfig::parse(&provider_url("openbao://"), Product::OpenBao).unwrap();
assert_eq!(config.endpoint, "https://bao.example.com:8200");
let provider = KvProvider::new(config, Product::OpenBao);
assert_eq!(provider.uri(), "openbao://bao.example.com:8200/secret");
assert_eq!(
provider.build_url("app/config"),
"https://bao.example.com:8200/v1/secret/data/app/config"
);
assert!(!provider.uri().contains("alice"));
assert!(!provider.uri().contains("leaked-password"));
assert!(!provider.uri().contains("leaked-query"));
}
#[test]
fn uri_retains_effective_non_secret_attribution() {
let config = KvConfig::parse(
&provider_url(
"openbao://team-a@bao.example.com:8200/team/secret?tls=false&kv=1&auth=jwt&role=ci-role&audience=deploy",
),
Product::OpenBao,
)
.unwrap();
let provider = KvProvider::new(config, Product::OpenBao);
assert_eq!(
provider.uri(),
"openbao://team-a@bao.example.com:8200/team/secret?tls=false&kv=1&auth=jwt&role=ci-role&audience=deploy"
);
let approle = KvConfig::parse(
&provider_url("openbao://team-a@bao.example.com:8200/secret?auth=approle"),
Product::OpenBao,
)
.unwrap();
assert_eq!(
KvProvider::new(approle, Product::OpenBao).uri(),
"openbao://team-a@bao.example.com:8200/secret?auth=approle"
);
}
#[test]
fn login_requests_include_the_configured_namespace() {
let provider = KvProvider::new(
KvConfig {
endpoint: "https://bao.example.com:8200".to_string(),
namespace: Some("team-a".to_string()),
..Default::default()
},
Product::OpenBao,
);
let request = provider
.build_login_request(
"https://bao.example.com:8200/v1/auth/approle/login",
&serde_json::json!({ "role_id": "role", "secret_id": "secret" }),
)
.unwrap()
.build()
.unwrap();
assert_eq!(
request
.headers()
.get("X-Vault-Namespace")
.unwrap()
.to_str()
.unwrap(),
"team-a"
);
}
}