feat(rust): add HashiCorp Vault secret manager crate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yujong Lee 2026-09-21 20:26:28 +00:00
parent 5216844c40
commit 3ba4a60d5e
18 changed files with 1314 additions and 8 deletions

View file

@ -130,7 +130,7 @@ jobs:
- name: Test secret manager feature combinations
run: |
cargo test -p litellm-auth-gcp --locked --no-default-features
for features in '' aws google aws,google; do
for features in '' aws google hashicorp aws,google,hashicorp; do
cargo test -p litellm-secrets --locked --no-default-features --features "$features"
done

View file

@ -2698,6 +2698,7 @@ dependencies = [
"litellm-core-utils",
"litellm-secrets-aws",
"litellm-secrets-google",
"litellm-secrets-hashicorp",
"litellm-secrets-types",
"moka",
"reqwest 0.12.28",
@ -2755,6 +2756,24 @@ dependencies = [
"wiremock",
]
[[package]]
name = "litellm-secrets-hashicorp"
version = "0.1.0"
dependencies = [
"litellm-core-utils",
"litellm-secrets-types",
"moka",
"reqwest 0.12.28",
"rstest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.19",
"tokio",
"veil",
"wiremock",
]
[[package]]
name = "litellm-secrets-types"
version = "0.1.0"

View file

@ -22,6 +22,7 @@ litellm-secrets = { path = "crates/secrets" }
litellm-secrets-types = { path = "crates/secrets-types" }
litellm-secrets-aws = { path = "crates/secrets-aws" }
litellm-secrets-google = { path = "crates/secrets-google" }
litellm-secrets-hashicorp = { path = "crates/secrets-hashicorp" }
litellm-http = { path = "crates/http" }
litellm-llms = { path = "crates/llms" }
litellm-types = { path = "crates/types" }

View file

@ -0,0 +1,23 @@
[package]
name = "litellm-secrets-hashicorp"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
litellm-core-utils.workspace = true
litellm-secrets-types.workspace = true
moka.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
veil.workspace = true
[dev-dependencies]
rstest.workspace = true
tempfile = "3"
tokio.workspace = true
wiremock = "0.6.5"

View file

@ -0,0 +1,169 @@
use std::{path::PathBuf, time::Duration};
use litellm_core_utils::settings::Lookup;
use litellm_secrets_types::KeyManagementSettings;
use litellm_secrets_types::SecretValue;
use crate::Error;
const DEFAULT_ADDRESS: &str = "http://127.0.0.1:8200";
const DEFAULT_MOUNT: &str = "secret";
const DEFAULT_APPROLE_MOUNT_PATH: &str = "approle";
const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(86400);
const HCP_VAULT_ADDR: &str = "HCP_VAULT_ADDR";
const HCP_VAULT_TOKEN: &str = "HCP_VAULT_TOKEN";
const HCP_VAULT_NAMESPACE: &str = "HCP_VAULT_NAMESPACE";
const HCP_VAULT_LOGIN_NAMESPACE: &str = "HCP_VAULT_LOGIN_NAMESPACE";
const HCP_VAULT_SECRET_NAMESPACE: &str = "HCP_VAULT_SECRET_NAMESPACE";
const HCP_VAULT_MOUNT_NAME: &str = "HCP_VAULT_MOUNT_NAME";
const HCP_VAULT_PATH_PREFIX: &str = "HCP_VAULT_PATH_PREFIX";
const HCP_VAULT_APPROLE_ROLE_ID: &str = "HCP_VAULT_APPROLE_ROLE_ID";
const HCP_VAULT_APPROLE_SECRET_ID: &str = "HCP_VAULT_APPROLE_SECRET_ID";
const HCP_VAULT_APPROLE_MOUNT_PATH: &str = "HCP_VAULT_APPROLE_MOUNT_PATH";
const HCP_VAULT_CLIENT_CERT: &str = "HCP_VAULT_CLIENT_CERT";
const HCP_VAULT_CLIENT_KEY: &str = "HCP_VAULT_CLIENT_KEY";
const HCP_VAULT_CERT_ROLE: &str = "HCP_VAULT_CERT_ROLE";
const HCP_VAULT_REFRESH_INTERVAL: &str = "HCP_VAULT_REFRESH_INTERVAL";
const SECRET_MANAGER_REFRESH_INTERVAL: &str = "SECRET_MANAGER_REFRESH_INTERVAL";
#[derive(Clone, Debug)]
pub struct AppRoleAuth {
pub role_id: String,
pub secret_id: SecretValue,
pub mount_path: String,
}
#[derive(Clone, Debug)]
pub struct TlsCertAuth {
pub cert_path: PathBuf,
pub key_path: PathBuf,
pub role: Option<String>,
}
#[derive(Clone, Debug)]
pub struct HashicorpVaultConfig {
pub address: String,
pub token: Option<SecretValue>,
pub namespace: Option<String>,
pub login_namespace: Option<String>,
pub secret_namespace: Option<String>,
pub mount: String,
pub path_prefix: Option<String>,
pub approle: Option<AppRoleAuth>,
pub tls_cert: Option<TlsCertAuth>,
pub refresh_interval: Duration,
}
impl HashicorpVaultConfig {
pub fn from_environment(environment: &dyn Lookup) -> Result<Self, Error> {
let address: String = environment
.get(HCP_VAULT_ADDR)
.and_then(|value| nonempty(value.trim()))
.map(|value| value.trim_end_matches('/').to_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_ADDRESS.to_owned());
let token: Option<SecretValue> = environment
.get(HCP_VAULT_TOKEN)
.and_then(nonempty)
.map(SecretValue::new);
let namespace: Option<String> = path_component(environment.get(HCP_VAULT_NAMESPACE));
let login_namespace: Option<String> =
path_component(environment.get(HCP_VAULT_LOGIN_NAMESPACE));
let secret_namespace: Option<String> =
path_component(environment.get(HCP_VAULT_SECRET_NAMESPACE));
let mount: String = path_component(environment.get(HCP_VAULT_MOUNT_NAME))
.unwrap_or_else(|| DEFAULT_MOUNT.to_owned());
let path_prefix: Option<String> = path_component(environment.get(HCP_VAULT_PATH_PREFIX));
let approle: Option<AppRoleAuth> = match (
environment
.get(HCP_VAULT_APPROLE_ROLE_ID)
.and_then(nonempty),
environment
.get(HCP_VAULT_APPROLE_SECRET_ID)
.and_then(nonempty)
.map(SecretValue::new),
) {
(Some(role_id), Some(secret_id)) => Some(AppRoleAuth {
role_id,
secret_id,
mount_path: path_component(environment.get(HCP_VAULT_APPROLE_MOUNT_PATH))
.unwrap_or_else(|| DEFAULT_APPROLE_MOUNT_PATH.to_owned()),
}),
_ => None,
};
let tls_cert: Option<TlsCertAuth> = match (
environment.get(HCP_VAULT_CLIENT_CERT).and_then(nonempty),
environment.get(HCP_VAULT_CLIENT_KEY).and_then(nonempty),
) {
(Some(cert_path), Some(key_path)) => Some(TlsCertAuth {
cert_path: PathBuf::from(cert_path),
key_path: PathBuf::from(key_path),
role: environment.get(HCP_VAULT_CERT_ROLE).and_then(nonempty),
}),
_ => None,
};
let refresh_interval: Duration = refresh_interval(environment)?;
Ok(Self {
address,
token,
namespace,
login_namespace,
secret_namespace,
mount,
path_prefix,
approle,
tls_cert,
refresh_interval,
})
}
pub fn from_settings(
_settings: &KeyManagementSettings,
environment: &dyn Lookup,
) -> Result<Self, Error> {
Self::from_environment(environment)
}
pub fn login_namespace(&self) -> Option<&str> {
self.login_namespace
.as_deref()
.or(self.namespace.as_deref())
}
pub fn secret_namespace(&self) -> Option<&str> {
self.secret_namespace
.as_deref()
.or(self.namespace.as_deref())
}
}
fn nonempty(value: impl AsRef<str>) -> Option<String> {
let value: &str = value.as_ref();
(!value.is_empty()).then(|| value.to_owned())
}
fn path_component(value: Option<String>) -> Option<String> {
value
.and_then(|value| nonempty(value.trim()))
.map(|value| value.trim_matches('/').to_owned())
.filter(|value| !value.is_empty())
}
fn refresh_interval(environment: &dyn Lookup) -> Result<Duration, Error> {
let value: Option<String> = environment
.get(HCP_VAULT_REFRESH_INTERVAL)
.and_then(nonempty)
.or_else(|| {
environment
.get(SECRET_MANAGER_REFRESH_INTERVAL)
.and_then(nonempty)
});
let Some(value) = value else {
return Ok(DEFAULT_REFRESH_INTERVAL);
};
let seconds: i64 = value.parse().map_err(|_| Error::RefreshInterval)?;
if seconds < 0 {
return Ok(Duration::from_nanos(1));
}
Ok(Duration::from_secs(seconds as u64))
}

View file

@ -0,0 +1,32 @@
#[derive(thiserror::Error, veil::Redact)]
pub enum Error {
#[error("HashiCorp Vault requires an enterprise license")]
EnterpriseRequired,
#[error("invalid secret name")]
InvalidSecretName(#[from] litellm_secrets_types::Error),
#[error("HashiCorp Vault request failed")]
Request(
#[from]
#[redact]
reqwest::Error,
),
#[error("HashiCorp Vault TLS identity could not be configured for {path}: {message}")]
TlsIdentity {
path: std::path::PathBuf,
message: String,
},
#[error("HashiCorp Vault login returned HTTP {status}")]
LoginStatus { status: u16 },
#[error("HashiCorp Vault login response is malformed")]
MalformedLogin,
#[error("HashiCorp Vault authentication is not configured")]
NoAuthConfigured,
#[error("HashiCorp Vault returned HTTP {status}")]
Status { status: u16 },
#[error("HashiCorp Vault response payload is malformed")]
MalformedPayload,
#[error("HashiCorp Vault secret value is not a string")]
NonStringValue,
#[error("invalid HashiCorp Vault refresh interval")]
RefreshInterval,
}

View file

@ -0,0 +1,9 @@
#![forbid(unsafe_code)]
mod config;
mod error;
pub mod secret_manager;
pub use config::{AppRoleAuth, HashicorpVaultConfig, TlsCertAuth};
pub use error::Error;
pub use secret_manager::HashicorpVault;

View file

@ -0,0 +1,333 @@
use std::{
fmt,
sync::Arc,
time::{Duration, Instant},
};
use litellm_core_utils::settings::Lookup;
use litellm_secrets_types::{
BaseSecretManager, SecretValue, async_rotate_secret, validate_secret_name,
};
use moka::future::Cache;
use reqwest::Client;
use serde_json::{Value, json};
use tokio::sync::Mutex;
use crate::{Error, HashicorpVaultConfig};
const CACHE_CAPACITY: u64 = 200;
#[derive(Clone)]
struct CachedToken {
token: SecretValue,
expires_at: Option<Instant>,
}
#[derive(Clone)]
pub struct HashicorpVault {
client: Arc<Client>,
config: HashicorpVaultConfig,
cache: Cache<String, SecretValue>,
auth_token: Arc<Mutex<Option<CachedToken>>>,
}
impl fmt::Debug for HashicorpVault {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HashicorpVault")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl HashicorpVault {
pub fn new(
environment: Arc<dyn Lookup + Send + Sync>,
enterprise_enabled: bool,
) -> Result<Self, Error> {
if !enterprise_enabled {
return Err(Error::EnterpriseRequired);
}
let config: HashicorpVaultConfig =
HashicorpVaultConfig::from_environment(environment.as_ref())?;
let client: Client = client_for_config(&config)?;
Self::with_client(client, config, enterprise_enabled)
}
pub fn with_client(
client: Client,
config: HashicorpVaultConfig,
enterprise_enabled: bool,
) -> Result<Self, Error> {
if !enterprise_enabled {
return Err(Error::EnterpriseRequired);
}
let cache: Cache<String, SecretValue> = Cache::builder()
.max_capacity(CACHE_CAPACITY)
.time_to_live(config.refresh_interval)
.build();
Ok(Self {
client: Arc::new(client),
config,
cache,
auth_token: Arc::new(Mutex::new(None)),
})
}
pub fn secret_url(&self, secret_name: &str) -> Result<String, Error> {
validate_secret_name(secret_name).map_err(Error::InvalidSecretName)?;
let namespace: String = self
.config
.secret_namespace()
.map(|value| format!("{value}/"))
.unwrap_or_default();
let path_prefix: String = self
.config
.path_prefix
.as_deref()
.map(|value| format!("{value}/"))
.unwrap_or_default();
Ok(format!(
"{}/v1/{}{}/data/{}{}",
self.config.address, namespace, self.config.mount, path_prefix, secret_name
))
}
pub fn login_url(&self) -> Option<String> {
self.config.approle.as_ref().map_or_else(
|| {
self.config
.tls_cert
.as_ref()
.map(|_| format!("{}/v1/auth/cert/login", self.config.address))
},
|approle| {
Some(format!(
"{}/v1/auth/{}/login",
self.config.address, approle.mount_path
))
},
)
}
pub fn config(&self) -> &HashicorpVaultConfig {
&self.config
}
pub async fn async_read_secret(&self, secret_name: &str) -> Result<Option<SecretValue>, Error> {
let url: String = self.secret_url(secret_name)?;
if let Some(value) = self.cache.get(&url).await {
return Ok(Some(value));
}
let token: SecretValue = self.vault_token().await?;
let mut request = self
.client
.get(&url)
.header("X-Vault-Token", token.expose());
if let Some(namespace) = self.config.secret_namespace() {
request = request.header("X-Vault-Namespace", namespace);
}
let response: reqwest::Response = request.send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !response.status().is_success() {
return Err(Error::Status {
status: response.status().as_u16(),
});
}
let body = response.bytes().await?;
let body: Value = serde_json::from_slice(&body).map_err(|_| Error::MalformedPayload)?;
let data: &Value = body
.get("data")
.and_then(Value::as_object)
.and_then(|value| value.get("data"))
.ok_or(Error::MalformedPayload)?;
let data: &serde_json::Map<String, Value> =
data.as_object().ok_or(Error::MalformedPayload)?;
let Some(value) = data.get("key") else {
return Ok(None);
};
let value: &str = value.as_str().ok_or(Error::NonStringValue)?;
let value: SecretValue = SecretValue::new(value);
self.cache.insert(url, value.clone()).await;
Ok(Some(value))
}
pub async fn async_write_secret(
&self,
secret_name: &str,
value: SecretValue,
description: Option<&str>,
) -> Result<Value, Error> {
let url: String = self.secret_url(secret_name)?;
let data: Value = match description {
Some(description) => json!({"key": value.expose(), "description": description}),
None => json!({"key": value.expose()}),
};
let token: SecretValue = self.vault_token().await?;
let mut request = self
.client
.post(&url)
.header("X-Vault-Token", token.expose());
if let Some(namespace) = self.config.secret_namespace() {
request = request.header("X-Vault-Namespace", namespace);
}
let response: reqwest::Response = request.json(&json!({"data": data})).send().await?;
if !response.status().is_success() {
return Err(Error::Status {
status: response.status().as_u16(),
});
}
self.cache.invalidate(&url).await;
let body = response.bytes().await?;
if body.is_empty() {
return Ok(Value::Null);
}
serde_json::from_slice(&body).map_err(|_| Error::MalformedPayload)
}
pub async fn async_delete_secret(&self, secret_name: &str) -> Result<(), Error> {
let url: String = self.secret_url(secret_name)?;
let token: SecretValue = self.vault_token().await?;
let mut request = self
.client
.delete(&url)
.header("X-Vault-Token", token.expose());
if let Some(namespace) = self.config.secret_namespace() {
request = request.header("X-Vault-Namespace", namespace);
}
let response: reqwest::Response = request.send().await?;
if !response.status().is_success() {
return Err(Error::Status {
status: response.status().as_u16(),
});
}
self.cache.invalidate(&url).await;
Ok(())
}
pub async fn async_rotate_secret(
&self,
current_name: &str,
new_name: &str,
value: &SecretValue,
) -> Result<Value, Error> {
async_rotate_secret(self, current_name, new_name, value).await
}
async fn vault_token(&self) -> Result<SecretValue, Error> {
let mut cached: tokio::sync::MutexGuard<'_, Option<CachedToken>> =
self.auth_token.lock().await;
if let Some(entry) = cached.as_ref()
&& entry
.expires_at
.is_none_or(|expires_at| expires_at > Instant::now())
{
return Ok(entry.token.clone());
}
let Some(login_url) = self.login_url() else {
let Some(token) = self.config.token.clone() else {
return Err(Error::NoAuthConfigured);
};
return Ok(token);
};
let body: Value = match (self.config.approle.as_ref(), self.config.tls_cert.as_ref()) {
(Some(approle), _) => {
json!({"role_id": approle.role_id, "secret_id": approle.secret_id.expose()})
}
(None, Some(tls)) => tls
.role
.as_deref()
.map_or_else(|| json!({}), |role| json!({"name": role})),
(None, None) => {
let Some(token) = self.config.token.clone() else {
return Err(Error::NoAuthConfigured);
};
return Ok(token);
}
};
let mut request = self.client.post(login_url).json(&body);
if let Some(namespace) = self.config.login_namespace() {
request = request.header("X-Vault-Namespace", namespace);
}
let response: reqwest::Response = request.send().await?;
if !response.status().is_success() {
return Err(Error::LoginStatus {
status: response.status().as_u16(),
});
}
let body = response.bytes().await?;
let payload: Value = serde_json::from_slice(&body).map_err(|_| Error::MalformedLogin)?;
let auth: &serde_json::Map<String, Value> = payload
.get("auth")
.and_then(Value::as_object)
.ok_or(Error::MalformedLogin)?;
let token: SecretValue = SecretValue::new(
auth.get("client_token")
.and_then(Value::as_str)
.ok_or(Error::MalformedLogin)?,
);
let lease_duration: u64 = auth
.get("lease_duration")
.and_then(Value::as_u64)
.ok_or(Error::MalformedLogin)?;
let expires_at: Option<Instant> =
(lease_duration > 0).then(|| Instant::now() + Duration::from_secs(lease_duration));
*cached = Some(CachedToken {
token: token.clone(),
expires_at,
});
Ok(token)
}
}
impl BaseSecretManager for HashicorpVault {
type Error = Error;
type WriteResponse = Value;
type DeleteResponse = ();
async fn async_read_secret(&self, name: &str) -> Result<Option<SecretValue>, Error> {
HashicorpVault::async_read_secret(self, name).await
}
async fn async_write_secret(
&self,
name: &str,
value: &SecretValue,
description: Option<&str>,
) -> Result<Value, Error> {
HashicorpVault::async_write_secret(self, name, value.clone(), description).await
}
async fn async_delete_secret(
&self,
name: &str,
_recovery_window_in_days: i64,
) -> Result<(), Error> {
HashicorpVault::async_delete_secret(self, name).await
}
}
fn client_for_config(config: &HashicorpVaultConfig) -> Result<Client, Error> {
let mut builder: reqwest::ClientBuilder = Client::builder();
if let Some(tls) = config.tls_cert.as_ref() {
let cert: Vec<u8> = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity {
path: tls.cert_path.clone(),
message: source.to_string(),
})?;
let key: Vec<u8> = std::fs::read(&tls.key_path).map_err(|source| Error::TlsIdentity {
path: tls.key_path.clone(),
message: source.to_string(),
})?;
let identity: reqwest::Identity = reqwest::Identity::from_pem(
&[cert.as_slice(), key.as_slice()].concat(),
)
.map_err(|source| Error::TlsIdentity {
path: tls.cert_path.clone(),
message: source.to_string(),
})?;
builder = builder.identity(identity);
}
builder.build().map_err(Error::Request)
}

View file

@ -0,0 +1,424 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use litellm_core_utils::settings::Lookup;
use litellm_secrets_hashicorp::{Error, HashicorpVault, HashicorpVaultConfig};
use litellm_secrets_types::SecretValue;
use serde::Deserialize;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, header, method, path},
};
fn config(server: &MockServer, values: &[(&str, &str)]) -> HashicorpVaultConfig {
let mut environment_values: HashMap<String, String> = values
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect();
environment_values.insert("HCP_VAULT_ADDR".to_owned(), server.uri());
let environment: Arc<dyn Lookup + Send + Sync> =
Arc::new(move |name: &str| environment_values.get(name).cloned());
HashicorpVaultConfig::from_environment(environment.as_ref()).unwrap()
}
fn manager(server: &MockServer, values: &[(&str, &str)]) -> HashicorpVault {
HashicorpVault::with_client(reqwest::Client::new(), config(server, values), true).unwrap()
}
#[tokio::test]
async fn token_reads_use_vault_headers_and_cache_values() {
let server: MockServer = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/name"))
.and(header("X-Vault-Token", "token"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.expect(1)
.mount(&server)
.await;
let manager: HashicorpVault = manager(&server, &[("HCP_VAULT_TOKEN", "token")]);
assert_eq!(
manager
.async_read_secret("name")
.await
.unwrap()
.unwrap()
.expose(),
"value"
);
assert_eq!(
manager
.async_read_secret("name")
.await
.unwrap()
.unwrap()
.expose(),
"value"
);
}
#[tokio::test]
async fn namespace_mount_and_prefix_are_sanitized_in_the_url() {
let server: MockServer = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/team-a/kv-prod/data/virtual-keys/name"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.expect(1)
.mount(&server)
.await;
let manager: HashicorpVault = manager(
&server,
&[
("HCP_VAULT_TOKEN", "token"),
("HCP_VAULT_SECRET_NAMESPACE", " /team-a/ "),
("HCP_VAULT_MOUNT_NAME", " /kv-prod/ "),
("HCP_VAULT_PATH_PREFIX", " /virtual-keys/ "),
],
);
assert_eq!(
manager.secret_url("name").unwrap(),
format!("{}/v1/team-a/kv-prod/data/virtual-keys/name", server.uri())
);
assert!(manager.async_read_secret("name").await.unwrap().is_some());
}
#[tokio::test]
async fn approle_login_uses_namespace_and_reuses_the_token() {
let server: MockServer = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/custom-approle/login"))
.and(header("X-Vault-Namespace", "login-root"))
.and(body_json(json!({"role_id": "role", "secret_id": "secret"})))
.respond_with(ResponseTemplate::new(200).set_body_json(
json!({"auth": {"client_token": "login-token", "lease_duration": 3600}}),
))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/secret-root/secret/data/name"))
.and(header("X-Vault-Token", "login-token"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.expect(1)
.mount(&server)
.await;
let manager: HashicorpVault = manager(
&server,
&[
("HCP_VAULT_APPROLE_ROLE_ID", "role"),
("HCP_VAULT_APPROLE_SECRET_ID", "secret"),
("HCP_VAULT_APPROLE_MOUNT_PATH", "custom-approle"),
("HCP_VAULT_NAMESPACE", "secret-root"),
("HCP_VAULT_LOGIN_NAMESPACE", "login-root"),
],
);
assert!(manager.async_read_secret("name").await.unwrap().is_some());
assert!(manager.async_read_secret("name-2").await.unwrap().is_none());
}
#[tokio::test]
async fn approle_tokens_expire_after_the_vault_lease() {
let server: MockServer = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/auth/approle/login"))
.respond_with(
ResponseTemplate::new(200).set_body_json(
json!({"auth": {"client_token": "login-token", "lease_duration": 1}}),
),
)
.expect(2)
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.expect(2)
.mount(&server)
.await;
let manager: HashicorpVault = manager(
&server,
&[
("HCP_VAULT_APPROLE_ROLE_ID", "role"),
("HCP_VAULT_APPROLE_SECRET_ID", "secret"),
("HCP_VAULT_REFRESH_INTERVAL", "0"),
],
);
assert!(manager.async_read_secret("first").await.unwrap().is_some());
tokio::time::sleep(Duration::from_secs(1) + Duration::from_millis(50)).await;
assert!(manager.async_read_secret("second").await.unwrap().is_some());
}
#[tokio::test]
async fn tls_login_posts_the_role_and_uses_the_client_identity() {
let server: MockServer = MockServer::start().await;
let directory: tempfile::TempDir = tempfile::tempdir().unwrap();
let cert_path = directory.path().join("client.crt");
let key_path = directory.path().join("client.key");
std::fs::write(&cert_path, TEST_CERTIFICATE).unwrap();
std::fs::write(&key_path, TEST_PRIVATE_KEY).unwrap();
Mock::given(method("POST"))
.and(path("/v1/auth/cert/login"))
.and(body_json(json!({"name": "vault-role"})))
.respond_with(
ResponseTemplate::new(200).set_body_json(
json!({"auth": {"client_token": "cert-token", "lease_duration": 0}}),
),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/name"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.mount(&server)
.await;
let manager: HashicorpVault = HashicorpVault::new(
{
let environment_values: HashMap<String, String> = HashMap::from([
("HCP_VAULT_ADDR".to_owned(), server.uri()),
(
"HCP_VAULT_CLIENT_CERT".to_owned(),
cert_path.to_str().unwrap().to_owned(),
),
(
"HCP_VAULT_CLIENT_KEY".to_owned(),
key_path.to_str().unwrap().to_owned(),
),
("HCP_VAULT_CERT_ROLE".to_owned(), "vault-role".to_owned()),
]);
Arc::new(move |name: &str| environment_values.get(name).cloned())
},
true,
)
.unwrap();
assert!(manager.async_read_secret("name").await.unwrap().is_some());
assert_eq!(
manager.login_url().as_deref(),
Some(format!("{}/v1/auth/cert/login", server.uri()).as_str())
);
}
#[rstest::rstest]
#[case::missing(404, json!({}), 0)]
#[case::malformed(200, json!({}), 1)]
#[case::missing_key(200, json!({"data": {"data": {}}}), 0)]
#[case::non_string(200, json!({"data": {"data": {"key": 1}}}), 2)]
#[tokio::test]
async fn read_responses_distinguish_absence_and_malformed_payloads(
#[case] status: u16,
#[case] body: serde_json::Value,
#[case] expected: u8,
) {
let server: MockServer = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.expect(1)
.mount(&server)
.await;
let result: Result<Option<SecretValue>, Error> =
manager(&server, &[("HCP_VAULT_TOKEN", "token")])
.async_read_secret("name")
.await;
match expected {
0 => assert!(result.unwrap().is_none()),
1 => assert!(matches!(result, Err(Error::MalformedPayload))),
2 => assert!(matches!(result, Err(Error::NonStringValue))),
_ => unreachable!(),
}
}
#[tokio::test]
async fn write_and_delete_invalidate_the_read_cache() {
let server: MockServer = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/name"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})),
)
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/secret/data/name"))
.and(body_json(
json!({"data": {"key": "updated", "description": "description"}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": {"version": 2}})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/v1/secret/data/name"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let manager: HashicorpVault = manager(&server, &[("HCP_VAULT_TOKEN", "token")]);
assert!(manager.async_read_secret("name").await.unwrap().is_some());
assert!(
manager
.async_write_secret("name", SecretValue::new("updated"), Some("description"))
.await
.is_ok()
);
assert!(manager.async_read_secret("name").await.unwrap().is_some());
manager.async_delete_secret("name").await.unwrap();
}
#[tokio::test]
async fn no_auth_and_invalid_names_fail_without_requests() {
let server: MockServer = MockServer::start().await;
let manager: HashicorpVault = manager(&server, &[]);
assert!(matches!(
manager.async_read_secret("name").await,
Err(Error::NoAuthConfigured)
));
assert!(matches!(
manager.async_read_secret("../name").await,
Err(Error::InvalidSecretName(_))
));
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn debug_output_redacts_authentication_values() {
let server: MockServer = MockServer::start().await;
let manager: HashicorpVault = HashicorpVault::with_client(
reqwest::Client::new(),
config(&server, &[("HCP_VAULT_TOKEN", "token-value")]),
true,
)
.unwrap();
let debug: String = format!("{manager:?}");
assert!(!debug.contains("token-value"));
assert!(!debug.contains("secret-id"));
}
#[derive(Deserialize)]
struct ParityCase {
env: HashMap<String, String>,
expected_secret_url: String,
expected_login_url: Option<String>,
expected_login_namespace: Option<String>,
expected_secret_namespace: Option<String>,
secret_name: String,
}
#[test]
fn configuration_matches_python_parity_fixture() {
let cases: Vec<ParityCase> = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../tests/test_litellm/secret_managers/hashicorp_vault_parity.json"
)))
.unwrap();
for case in cases {
let values: HashMap<String, String> = case.env.clone();
let environment: Arc<dyn Lookup + Send + Sync> =
Arc::new(move |name: &str| values.get(name).cloned());
let config: HashicorpVaultConfig =
HashicorpVaultConfig::from_environment(environment.as_ref()).unwrap();
let manager: HashicorpVault =
HashicorpVault::with_client(reqwest::Client::new(), config, true).unwrap();
assert_eq!(
manager.secret_url(&case.secret_name).unwrap(),
case.expected_secret_url
);
assert_eq!(manager.login_url(), case.expected_login_url);
assert_eq!(
manager.config().login_namespace(),
case.expected_login_namespace.as_deref()
);
assert_eq!(
manager.config().secret_namespace(),
case.expected_secret_namespace.as_deref()
);
}
}
#[tokio::test]
#[ignore]
async fn live_vault_round_trip() {
let environment: Arc<dyn Lookup + Send + Sync> =
Arc::new(litellm_core_utils::settings::ProcessEnvironment);
let manager: HashicorpVault = HashicorpVault::new(environment, true).unwrap();
let name: String = std::env::var("LITELLM_VAULT_LIVE_SECRET_NAME").unwrap();
let value: SecretValue = SecretValue::new("native-live-value");
let url: String = manager.secret_url(&name).unwrap();
println!("native provenance: {} {}", module_path!(), url);
manager
.async_write_secret(&name, value.clone(), None)
.await
.unwrap();
assert_eq!(
manager.async_read_secret(&name).await.unwrap().unwrap(),
value
);
manager.async_delete_secret(&name).await.unwrap();
assert!(manager.async_read_secret(&name).await.unwrap().is_none());
}
const TEST_CERTIFICATE: &str = "-----BEGIN CERTIFICATE-----
MIIDDzCCAfegAwIBAgIUeMzLFLM/mRbPGbNAew5N2UTscocwDQYJKoZIhvcNAQEL
BQAwFzEVMBMGA1UEAwwMbGl0ZWxsbS10ZXN0MB4XDTI2MDkyMTIwMjA1OVoXDTI2
MDkyMjIwMjA1OVowFzEVMBMGA1UEAwwMbGl0ZWxsbS10ZXN0MIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAveYoSUJXybmkHmQsBfhBcv2Ob5Oy8ejZu+B3
vTnrPumW4ANi1XXKBSazRGB3fEtAgr+3KhKeHaSKEQeBwJkAEBfdmQv0tpXICwHs
1kFNtU0owy54HVW5/ia+LMszsFcPzVIoMnbUOuiKr9RaV7P+IEFzILPBVuV4DoYH
yocjD3+9QNqokWgNL8LK37JijmNEFVaKFz0X6SyL2VRDlfPWTEBK52Gp/pvDgA6G
eTSfyI+kCm9h5ECTYUAtmatk9WPVS8sWOqV1EXVanFyYBU+mDxoywAS1/6CHeIPh
bNmCOZjPoO9qWBJ7ZyGhOconBigXY8qnlXymev+44IPHrx4urwIDAQABo1MwUTAd
BgNVHQ4EFgQUvaZrZ6HKtbr3ekeZmgy4b5Pq95QwHwYDVR0jBBgwFoAUvaZrZ6HK
tbr3ekeZmgy4b5Pq95QwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AQEAEejrD8d1qDxW55XxQ4IC31rufoEvDV955jyvh2kALPaN/i5oWsBGI+UAQZna
aaoQXwzlmHrtDUBWl0LztVTUamIleUep2+PLLauqqt43vxppxMX8Jn2mnPO20YE/
hIzGx0jN/LBG8PDyLSvHdlgjP9ofA4Vg4rTQugdXRgOvlCE/epnH/MADcg9KYJtJ
C1RObCIkL3LcdUbjStJRCY/U/FeWcgyncEPz95OFDkbrlNDajb6o6CkYfouqvhTc
8XlgjjAVKIbAbRgbVu3elsquuFM97x2DzWDjkrMNmDt1FJ9ubK36gL6B3o0UMaoQ
00R7x/eqvH+EkWa/2ekW9lpleQ==
-----END CERTIFICATE-----
";
const TEST_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC95ihJQlfJuaQe
ZCwF+EFy/Y5vk7Lx6Nm74He9Oes+6ZbgA2LVdcoFJrNEYHd8S0CCv7cqEp4dpIoR
B4HAmQAQF92ZC/S2lcgLAezWQU21TSjDLngdVbn+Jr4syzOwVw/NUigydtQ66Iqv
1FpXs/4gQXMgs8FW5XgOhgfKhyMPf71A2qiRaA0vwsrfsmKOY0QVVooXPRfpLIvZ
VEOV89ZMQErnYan+m8OADoZ5NJ/Ij6QKb2HkQJNhQC2Zq2T1Y9VLyxY6pXURdVqc
XJgFT6YPGjLABLX/oId4g+Fs2YI5mM+g72pYEntnIaE5yicGKBdjyqeVfKZ6/7jg
g8evHi6vAgMBAAECggEAGdJjlP6b8Fa5bdaCM/ebcrbuuNZVJVbb0JPHxGfNSLs7
pE9hj5QaOdQW2Uviw3h6F61ZCzQH4xD+Iy2po5ZKb2XHYKnDB1bboj+LRGER337T
9aJqe9at2VTMVEv3Rdm40NsEk0QcPLxlK16NQFK90gYEUSSQPDAswJDSG2R/zHn+
vADI907mW/goEJHeLn8PWGlNlSiR6x+5JJtq+GXCzUzVvJYQSCLGxCSl2x2H+0g7
NhFI0zPpdzNmO/h+yhzaFb6Rp5U8+ZsnZ3qYjQ/03gw1myTDKJt1YaO9JvArnNYX
hcJQQ8Rt0bHhcrZA16bBOpqZlo5pKCicwI/netgN8QKBgQDcFz7AzdJ26sMSV32V
rwrMgIoggt8qDjO1ARwqW35A1TIge0FoW4M4KpsXQGGfT341uU1esXEcyZ/1L/5X
3ql2gX4DbOYLZLWYzZGR2hq33oi8HkhN98QrEwL9emSH8NqYX3Xxja3PrmCrSYJe
Zbnd9TIm2XkxyMoyXJu6M/QvnwKBgQDc4dzqTbxoGEGa5MuJoGmMwPnqgdG9UM5J
eExVnh7osxc2sOdsiPeRjjQTxs9v2kJwctC359OJoo9yGaaJeSghU4LEWJo1sqnA
fzSCLammYvtVAtniyNv5Mxk/6Uimi4NNDKaAKB+m4K2uSn3U9AmY7KPYMGaSbS9W
XSnobjxm8QKBgC8bPpAvvWs8ZhIn7bY659nLbUT2HeO3dHO6UBf0yzn/J6JyHxbB
93zvCZDZc8uQTRgcmCW7XtVlhjoJUqvl+Wlm39zF0xr/LCsPXKfWAb/2/lcdOCaP
8Emz4QD10EyUTYUtcWYJB/mafhBLRH8F0Nlj4J8WDu2L51MOJTqeYhZLAoGAWffN
icocAbJPlo22sdoa4+/+W5yBF8GAJMDRJtZ+9H1t6SLpQHYRkMIBSETkXUTjZvX9
Ocs9iIQkNW9pO/mTdO+VBfCo71JUfknR02xR+6m5gYjlws/ZeYlssXGN2/hbhNiw
QOcW7Vv6olFJK6Iy/oz0t6wPO3kpnN3Zogi0paECgYEAwo44M1DdYCtV0snhmYM9
5u0mPfYt5P2SVLXyUbr+vFTfrTL/WKnXIJgbsnj3Gvf+GIZv9tKcXhSNmEHQCYX4
X3w9iTPddCHuvZ1fpufi2TyArJh0OkoNtLXJHTKrHjf2N+61AQzFiv5WieJrdE+H
qr32PTUuVGPyO9LyTY4/RL0=
-----END PRIVATE KEY-----
";

View file

@ -9,11 +9,13 @@ repository.workspace = true
default = []
aws = ["dep:litellm-secrets-aws"]
google = ["dep:litellm-secrets-google"]
hashicorp = ["dep:litellm-secrets-hashicorp"]
[dependencies]
litellm-secrets-types.workspace = true
litellm-secrets-aws = { workspace = true, optional = true }
litellm-secrets-google = { workspace = true, optional = true }
litellm-secrets-hashicorp = { workspace = true, optional = true }
litellm-core-utils.workspace = true
base64.workspace = true
serde.workspace = true

View file

@ -9,3 +9,5 @@ Backend failures propagate by default. To allow fallback during a backend failur
`get_secret` preserves value types. `get_secret_str` accepts a string default and rejects boolean or JSON values with `Error::TypeMismatch`. `get_secret_bool` accepts a boolean default and converts strings containing `true` or `false`, ignoring surrounding whitespace and ASCII case. Other strings and JSON values produce `Error::TypeMismatch`. Conversion failures never activate fallback or replace a found value with the default
Provider payloads remain strings unless explicitly selecting a field from an AWS primary JSON secret. Google caches only successfully decoded string payloads, so reads have identical values and types before and after caching. Confirmed absence and failed reads are not cached. AWS resource-not-found responses and Google HTTP 404 responses indicate absence. Other provider errors remain errors, and successful responses without the required payload are malformed responses rather than missing secrets
The HashiCorp Vault backend is enabled with the `hashicorp` feature and reads KV v2 values from `HCP_VAULT_*` environment variables. It supports static tokens, AppRole authentication, and TLS certificate authentication

View file

@ -30,4 +30,7 @@ pub enum Error {
#[cfg(feature = "google")]
#[error(transparent)]
Google(#[from] litellm_secrets_google::Error),
#[cfg(feature = "hashicorp")]
#[error(transparent)]
Hashicorp(#[from] litellm_secrets_hashicorp::Error),
}

View file

@ -13,6 +13,8 @@ pub enum SecretManager {
GoogleKms(crate::google::GoogleKms),
#[cfg(feature = "google")]
GoogleSecretManager(crate::google::GoogleSecretManager),
#[cfg(feature = "hashicorp")]
HashicorpVault(crate::hashicorp::HashicorpVault),
}
impl SecretManager {
@ -27,6 +29,8 @@ impl SecretManager {
Self::GoogleKms(_) => KeyManagementSystem::GoogleKms,
#[cfg(feature = "google")]
Self::GoogleSecretManager(_) => KeyManagementSystem::GoogleSecretManager,
#[cfg(feature = "hashicorp")]
Self::HashicorpVault(_) => KeyManagementSystem::HashicorpVault,
}
}
}
@ -78,6 +82,12 @@ pub async fn get_secret_from_manager(
.get_secret_from_google_secret_manager(secret_name)
.await
.map_err(Error::from),
#[cfg(feature = "hashicorp")]
SecretManager::HashicorpVault(client) => client
.async_read_secret(secret_name)
.await
.map(|value| value.map(Secret::String))
.map_err(Error::from),
}
}

View file

@ -19,3 +19,5 @@ pub use state::{SecretManagerState, secret_manager_would_be_consulted};
pub use litellm_secrets_aws as aws;
#[cfg(feature = "google")]
pub use litellm_secrets_google as google;
#[cfg(feature = "hashicorp")]
pub use litellm_secrets_hashicorp as hashicorp;

View file

@ -105,3 +105,131 @@ async fn google_handler_requires_canonical_base64_and_preserves_plaintext_whites
Err(Error::MissingCiphertext)
));
}
#[cfg(feature = "hashicorp")]
#[tokio::test]
async fn hashicorp_handler_resolves_found_missing_and_failed_values() {
use std::sync::Arc;
use litellm_core_utils::settings::Lookup;
use litellm_secrets::{
Error, FailurePolicy, KeyManagementSettings, SecretManager, SecretManagerState,
SecretResolver, hashicorp::HashicorpVault, hashicorp::HashicorpVaultConfig,
};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let found_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/secret/data/KEY"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data": {"data": {"key": "remote"}}})),
)
.mount(&found_server)
.await;
let found_environment: Arc<dyn Lookup + Send + Sync> = Arc::new({
let address = found_server.uri();
move |name: &str| match name {
"HCP_VAULT_ADDR" => Some(address.clone()),
"HCP_VAULT_TOKEN" => Some("token".into()),
_ => None,
}
});
let found_config = HashicorpVaultConfig::from_environment(found_environment.as_ref()).unwrap();
let found_manager =
HashicorpVault::with_client(reqwest::Client::new(), found_config, true).unwrap();
let found_resolver = SecretResolver::new(
Arc::new(SecretManagerState::new(
SecretManager::HashicorpVault(found_manager),
KeyManagementSettings {
hosted_keys: Some(vec!["KEY".into()]),
..Default::default()
},
)),
Arc::new(|_: &str| None),
litellm_secrets::OidcResolver::default(),
);
assert_eq!(
found_resolver
.get_secret_str("KEY", None)
.await
.unwrap()
.unwrap()
.expose(),
"remote"
);
let missing_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&missing_server)
.await;
let missing_environment: Arc<dyn Lookup + Send + Sync> = Arc::new({
let address = missing_server.uri();
move |name: &str| match name {
"HCP_VAULT_ADDR" => Some(address.clone()),
"HCP_VAULT_TOKEN" => Some("token".into()),
_ => None,
}
});
let missing_config =
HashicorpVaultConfig::from_environment(missing_environment.as_ref()).unwrap();
let missing_manager =
HashicorpVault::with_client(reqwest::Client::new(), missing_config, true).unwrap();
let missing_state = SecretManagerState::new(
SecretManager::HashicorpVault(missing_manager),
KeyManagementSettings {
hosted_keys: Some(vec!["KEY".into()]),
..Default::default()
},
);
let missing = litellm_secrets::get_secret_from_manager(
missing_state.backend().unwrap(),
"KEY",
missing_state.settings().unwrap(),
&|_: &str| None,
)
.await
.unwrap();
assert!(missing.is_none());
let failed_server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&failed_server)
.await;
let failed_environment: Arc<dyn Lookup + Send + Sync> = Arc::new({
let address = failed_server.uri();
move |name: &str| match name {
"HCP_VAULT_ADDR" => Some(address.clone()),
"HCP_VAULT_TOKEN" => Some("token".into()),
_ => None,
}
});
let failed_config =
HashicorpVaultConfig::from_environment(failed_environment.as_ref()).unwrap();
let failed_manager =
HashicorpVault::with_client(reqwest::Client::new(), failed_config, true).unwrap();
let failed_state = SecretManagerState::new(
SecretManager::HashicorpVault(failed_manager),
KeyManagementSettings {
hosted_keys: Some(vec!["KEY".into()]),
..Default::default()
},
);
let failed_resolver = SecretResolver::new(
Arc::new(failed_state),
Arc::new(|_: &str| None),
litellm_secrets::OidcResolver::default(),
)
.with_failure_policy(FailurePolicy::Propagate);
assert!(matches!(
failed_resolver.get_secret_str("KEY", None).await,
Err(Error::Hashicorp(
litellm_secrets::hashicorp::Error::Status { status: 500 }
))
));
}

View file

@ -95,16 +95,16 @@ class HashicorpSecretManager(BaseSecretManager):
from litellm.proxy.proxy_server import CommonProxyErrors, premium_user
# Vault-specific config
self.vault_addr = os.getenv("HCP_VAULT_ADDR", "http://127.0.0.1:8200")
self.vault_addr = (os.getenv("HCP_VAULT_ADDR") or "http://127.0.0.1:8200").rstrip("/")
self.vault_token = os.getenv("HCP_VAULT_TOKEN", "")
self.vault_namespace = os.getenv("HCP_VAULT_NAMESPACE", None)
self.login_namespace_override = os.getenv("HCP_VAULT_LOGIN_NAMESPACE", None)
self.secret_namespace_override = os.getenv("HCP_VAULT_SECRET_NAMESPACE", None)
self.vault_namespace = self._sanitize_path_component(os.getenv("HCP_VAULT_NAMESPACE"))
self.login_namespace_override = self._sanitize_path_component(os.getenv("HCP_VAULT_LOGIN_NAMESPACE"))
self.secret_namespace_override = self._sanitize_path_component(os.getenv("HCP_VAULT_SECRET_NAMESPACE"))
# KV engine mount name (default: "secret")
# If your KV engine is mounted somewhere other than "secret", set HCP_VAULT_MOUNT_NAME
self.vault_mount_name = os.getenv("HCP_VAULT_MOUNT_NAME", "secret")
self.vault_mount_name = self._sanitize_path_component(os.getenv("HCP_VAULT_MOUNT_NAME")) or "secret"
# Optional path prefix for secrets (e.g., "myapp" -> secret/data/myapp/{secret_name})
self.vault_path_prefix = os.getenv("HCP_VAULT_PATH_PREFIX", None)
self.vault_path_prefix = self._sanitize_path_component(os.getenv("HCP_VAULT_PATH_PREFIX"))
# Optional config for TLS cert auth
self.tls_cert_path = os.getenv("HCP_VAULT_CLIENT_CERT", "")
@ -114,7 +114,9 @@ class HashicorpSecretManager(BaseSecretManager):
# Optional config for AppRole auth
self.approle_role_id = os.getenv("HCP_VAULT_APPROLE_ROLE_ID", "")
self.approle_secret_id = os.getenv("HCP_VAULT_APPROLE_SECRET_ID", "")
self.approle_mount_path = os.getenv("HCP_VAULT_APPROLE_MOUNT_PATH", "approle")
self.approle_mount_path = self._sanitize_path_component(
os.getenv("HCP_VAULT_APPROLE_MOUNT_PATH")
) or "approle"
self._verify_required_credentials_exist()

View file

@ -0,0 +1,97 @@
[
{
"name": "defaults",
"env": {
"HCP_VAULT_TOKEN": "token"
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://127.0.0.1:8200/v1/secret/data/OPENAI_API_KEY",
"expected_login_url": null,
"expected_login_namespace": null,
"expected_secret_namespace": null
},
{
"name": "global_namespace",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200",
"HCP_VAULT_TOKEN": "token",
"HCP_VAULT_NAMESPACE": " admin/ "
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://vault.test:8200/v1/admin/secret/data/OPENAI_API_KEY",
"expected_login_url": null,
"expected_login_namespace": "admin",
"expected_secret_namespace": "admin"
},
{
"name": "namespace_overrides",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200",
"HCP_VAULT_TOKEN": "token",
"HCP_VAULT_NAMESPACE": "admin",
"HCP_VAULT_LOGIN_NAMESPACE": " /root/ ",
"HCP_VAULT_SECRET_NAMESPACE": " /teams/team-a/ "
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://vault.test:8200/v1/teams/team-a/secret/data/OPENAI_API_KEY",
"expected_login_url": null,
"expected_login_namespace": "root",
"expected_secret_namespace": "teams/team-a"
},
{
"name": "custom_mount_and_prefix",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200",
"HCP_VAULT_TOKEN": "token",
"HCP_VAULT_MOUNT_NAME": " /kv-prod/ ",
"HCP_VAULT_PATH_PREFIX": " /virtual-keys/ "
},
"secret_name": "DB_PASSWORD",
"expected_secret_url": "http://vault.test:8200/v1/kv-prod/data/virtual-keys/DB_PASSWORD",
"expected_login_url": null,
"expected_login_namespace": null,
"expected_secret_namespace": null
},
{
"name": "approle_custom_mount",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200",
"HCP_VAULT_APPROLE_ROLE_ID": "role-id",
"HCP_VAULT_APPROLE_SECRET_ID": "secret-id",
"HCP_VAULT_APPROLE_MOUNT_PATH": " /custom-approle/ ",
"HCP_VAULT_NAMESPACE": "admin"
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://vault.test:8200/v1/admin/secret/data/OPENAI_API_KEY",
"expected_login_url": "http://vault.test:8200/v1/auth/custom-approle/login",
"expected_login_namespace": "admin",
"expected_secret_namespace": "admin"
},
{
"name": "tls_cert",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200",
"HCP_VAULT_CLIENT_CERT": "/tmp/client.crt",
"HCP_VAULT_CLIENT_KEY": "/tmp/client.key",
"HCP_VAULT_CERT_ROLE": "vault-role",
"HCP_VAULT_NAMESPACE": "admin"
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://vault.test:8200/v1/admin/secret/data/OPENAI_API_KEY",
"expected_login_url": "http://vault.test:8200/v1/auth/cert/login",
"expected_login_namespace": "admin",
"expected_secret_namespace": "admin"
},
{
"name": "trailing_address_slash",
"env": {
"HCP_VAULT_ADDR": "http://vault.test:8200///",
"HCP_VAULT_TOKEN": "token"
},
"secret_name": "OPENAI_API_KEY",
"expected_secret_url": "http://vault.test:8200/v1/secret/data/OPENAI_API_KEY",
"expected_login_url": null,
"expected_login_namespace": null,
"expected_secret_namespace": null
}
]

View file

@ -1,4 +1,5 @@
import datetime
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Final
@ -18,6 +19,23 @@ LOGIN_RESPONSE: Final = {"auth": {"client_token": "hvs.login-token", "lease_dura
SECRET_RESPONSE: Final = {"data": {"data": {"key": "sk-from-vault", "password": "pw-from-vault"}}}
NAMESPACE_ENV_VARS: Final = ("HCP_VAULT_NAMESPACE", "HCP_VAULT_LOGIN_NAMESPACE", "HCP_VAULT_SECRET_NAMESPACE")
PARITY_ENV_VARS: Final = (
"HCP_VAULT_ADDR",
"HCP_VAULT_TOKEN",
"HCP_VAULT_NAMESPACE",
"HCP_VAULT_LOGIN_NAMESPACE",
"HCP_VAULT_SECRET_NAMESPACE",
"HCP_VAULT_MOUNT_NAME",
"HCP_VAULT_PATH_PREFIX",
"HCP_VAULT_APPROLE_ROLE_ID",
"HCP_VAULT_APPROLE_SECRET_ID",
"HCP_VAULT_APPROLE_MOUNT_PATH",
"HCP_VAULT_CLIENT_CERT",
"HCP_VAULT_CLIENT_KEY",
"HCP_VAULT_CERT_ROLE",
"HCP_VAULT_REFRESH_INTERVAL",
"SECRET_MANAGER_REFRESH_INTERVAL",
)
def _build_manager(monkeypatch: pytest.MonkeyPatch, env: Mapping[str, str]) -> HashicorpSecretManager:
@ -236,3 +254,35 @@ def test_tls_login_uses_login_namespace(monkeypatch: pytest.MonkeyPatch, tmp_pat
assert manager._auth_via_tls_cert() == "hvs.login-token"
assert login_route.calls.last.request.headers["X-Vault-Namespace"] == "root"
with Path(__file__).with_name("hashicorp_vault_parity.json").open() as parity_file:
PARITY_CASES: Final = json.load(parity_file)
@pytest.mark.parametrize("case", PARITY_CASES, ids=lambda case: case["name"])
def test_configuration_matches_native_parity_fixture(
monkeypatch: pytest.MonkeyPatch, case: Mapping[str, object]
) -> None:
monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True)
for name in PARITY_ENV_VARS:
monkeypatch.delenv(name, raising=False)
for name, value in case["env"].items():
monkeypatch.setenv(name, value)
manager: Final = HashicorpSecretManager()
env: Final = case["env"]
expected_login_url: Final = case["expected_login_url"]
if env.get("HCP_VAULT_APPROLE_ROLE_ID") and env.get("HCP_VAULT_APPROLE_SECRET_ID"):
login_url: str | None = (
f"{manager.vault_addr}/v1/auth/{manager.approle_mount_path}/login"
)
elif env.get("HCP_VAULT_CLIENT_CERT") and env.get("HCP_VAULT_CLIENT_KEY"):
login_url = f"{manager.vault_addr}/v1/auth/cert/login"
else:
login_url = None
assert manager.get_url(case["secret_name"]) == case["expected_secret_url"]
assert manager.vault_login_namespace == case["expected_login_namespace"]
assert manager.vault_secret_namespace == case["expected_secret_namespace"]
assert login_url == expected_login_url