use super::{SecretResolver, extract_field, split_field};
use crate::error::{CliError, CliResult};
use async_trait::async_trait;
use serde_json::Value;
pub struct VaultResolver {
addr: String,
token: String,
namespace: Option<String>,
client: reqwest::Client,
}
impl VaultResolver {
pub fn from_env() -> CliResult<Self> {
let addr = std::env::var("VAULT_ADDR").map_err(|_| CliError::SecretAuthFailed {
scheme: "vault".into(),
hint: "set VAULT_ADDR (e.g. https://vault.example.com:8200)".into(),
})?;
let token = std::env::var("VAULT_TOKEN").map_err(|_| CliError::SecretAuthFailed {
scheme: "vault".into(),
hint: "set VAULT_TOKEN".into(),
})?;
Ok(Self {
addr: addr.trim_end_matches('/').to_owned(),
token,
namespace: std::env::var("VAULT_NAMESPACE").ok(),
client: reqwest::Client::new(),
})
}
}
#[async_trait]
impl SecretResolver for VaultResolver {
fn scheme(&self) -> &'static str {
"vault"
}
async fn resolve(&self, reference: &str) -> CliResult<String> {
let (path, field) = split_field(reference);
let url = format!("{}/v1/{}", self.addr, path);
let mut req = self.client.get(&url).header("X-Vault-Token", &self.token);
if let Some(ns) = &self.namespace {
req = req.header("X-Vault-Namespace", ns);
}
let resp = req
.send()
.await
.map_err(|source| CliError::SecretFetchFailed {
scheme: "vault".into(),
reference: reference.into(),
source: Box::new(source),
})?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(CliError::SecretNotFound {
scheme: "vault".into(),
reference: reference.into(),
});
}
if resp.status() == reqwest::StatusCode::FORBIDDEN
|| resp.status() == reqwest::StatusCode::UNAUTHORIZED
{
return Err(CliError::SecretAuthFailed {
scheme: "vault".into(),
hint: "VAULT_TOKEN was rejected (403/401) — check the token and its policy".into(),
});
}
let resp = resp
.error_for_status()
.map_err(|source| CliError::SecretFetchFailed {
scheme: "vault".into(),
reference: reference.into(),
source: Box::new(source),
})?;
let body: Value = resp
.json()
.await
.map_err(|source| CliError::SecretFetchFailed {
scheme: "vault".into(),
reference: reference.into(),
source: Box::new(source),
})?;
let data = &body["data"]["data"];
match field {
Some(f) => extract_field("vault", reference, &data.to_string(), f),
None => Ok(data.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn reads_kv_v2_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/app"))
.and(header("X-Vault-Token", "test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "data": { "token": "s3cr3t-value", "other": "x" } }
})))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "test-token".into(),
namespace: None,
client: reqwest::Client::new(),
};
let v = resolver.resolve("secret/data/app#token").await.unwrap();
assert_eq!(v, "s3cr3t-value");
}
#[tokio::test]
async fn missing_secret_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/nope"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "t".into(),
namespace: None,
client: reqwest::Client::new(),
};
match resolver.resolve("secret/data/nope#x").await.unwrap_err() {
CliError::SecretNotFound { .. } => {}
other => panic!("expected SecretNotFound, got {other:?}"),
}
}
#[test]
fn scheme_is_vault() {
let r = VaultResolver {
addr: "http://x".into(),
token: "t".into(),
namespace: None,
client: reqwest::Client::new(),
};
assert_eq!(r.scheme(), "vault");
}
#[tokio::test]
async fn forbidden_status_maps_to_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/secured"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "bad".into(),
namespace: None,
client: reqwest::Client::new(),
};
match resolver.resolve("secret/data/secured#x").await.unwrap_err() {
CliError::SecretAuthFailed { scheme, .. } => assert_eq!(scheme, "vault"),
other => panic!("expected SecretAuthFailed, got {other:?}"),
}
}
#[tokio::test]
async fn unauthorized_status_maps_to_auth_failed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/secured"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "bad".into(),
namespace: None,
client: reqwest::Client::new(),
};
match resolver.resolve("secret/data/secured").await.unwrap_err() {
CliError::SecretAuthFailed { .. } => {}
other => panic!("expected SecretAuthFailed, got {other:?}"),
}
}
#[tokio::test]
async fn no_field_returns_whole_kv_v2_map_as_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/all"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "data": { "a": "1", "b": "2" } }
})))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "t".into(),
namespace: None,
client: reqwest::Client::new(),
};
let v = resolver.resolve("secret/data/all").await.unwrap();
let parsed: Value = serde_json::from_str(&v).unwrap();
assert_eq!(parsed["a"], "1");
assert_eq!(parsed["b"], "2");
}
#[tokio::test]
async fn namespace_header_is_sent_when_configured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/ns"))
.and(header("X-Vault-Namespace", "team-a"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": { "data": { "k": "v" } }
})))
.mount(&server)
.await;
let resolver = VaultResolver {
addr: server.uri(),
token: "t".into(),
namespace: Some("team-a".into()),
client: reqwest::Client::new(),
};
let v = resolver.resolve("secret/data/ns#k").await.unwrap();
assert_eq!(v, "v");
}
#[test]
#[serial_test::serial(vault_env)]
fn from_env_reads_addr_token_namespace() {
unsafe {
std::env::set_var("VAULT_ADDR", "https://vault.example.com:8200/");
std::env::set_var("VAULT_TOKEN", "tok-123");
std::env::set_var("VAULT_NAMESPACE", "ns1");
}
let r = VaultResolver::from_env().unwrap();
assert_eq!(r.addr, "https://vault.example.com:8200");
assert_eq!(r.token, "tok-123");
assert_eq!(r.namespace.as_deref(), Some("ns1"));
unsafe {
std::env::remove_var("VAULT_ADDR");
std::env::remove_var("VAULT_TOKEN");
std::env::remove_var("VAULT_NAMESPACE");
}
}
#[test]
#[serial_test::serial(vault_env)]
fn from_env_errors_without_addr() {
unsafe {
std::env::remove_var("VAULT_ADDR");
std::env::remove_var("VAULT_TOKEN");
}
match VaultResolver::from_env() {
Err(CliError::SecretAuthFailed { scheme, hint }) => {
assert_eq!(scheme, "vault");
assert!(hint.contains("VAULT_ADDR"), "{hint}");
}
Err(other) => panic!("expected SecretAuthFailed, got {other:?}"),
Ok(_) => panic!("expected an error when VAULT_ADDR is unset"),
}
}
#[test]
#[serial_test::serial(vault_env)]
fn from_env_errors_without_token() {
unsafe {
std::env::set_var("VAULT_ADDR", "https://v");
std::env::remove_var("VAULT_TOKEN");
}
match VaultResolver::from_env() {
Err(CliError::SecretAuthFailed { scheme, hint }) => {
assert_eq!(scheme, "vault");
assert!(hint.contains("VAULT_TOKEN"), "{hint}");
}
Err(other) => panic!("expected SecretAuthFailed, got {other:?}"),
Ok(_) => panic!("expected an error when VAULT_TOKEN is unset"),
}
unsafe {
std::env::remove_var("VAULT_ADDR");
}
}
}