mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-25 01:02:15 +00:00
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:
parent
5216844c40
commit
3ba4a60d5e
18 changed files with 1314 additions and 8 deletions
2
.github/workflows/test-rust.yml
vendored
2
.github/workflows/test-rust.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
19
litellm-rust/Cargo.lock
generated
19
litellm-rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
23
litellm-rust/crates/secrets-hashicorp/Cargo.toml
Normal file
23
litellm-rust/crates/secrets-hashicorp/Cargo.toml
Normal 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"
|
||||
169
litellm-rust/crates/secrets-hashicorp/src/config.rs
Normal file
169
litellm-rust/crates/secrets-hashicorp/src/config.rs
Normal 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))
|
||||
}
|
||||
32
litellm-rust/crates/secrets-hashicorp/src/error.rs
Normal file
32
litellm-rust/crates/secrets-hashicorp/src/error.rs
Normal 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,
|
||||
}
|
||||
9
litellm-rust/crates/secrets-hashicorp/src/lib.rs
Normal file
9
litellm-rust/crates/secrets-hashicorp/src/lib.rs
Normal 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;
|
||||
333
litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs
Normal file
333
litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs
Normal 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)
|
||||
}
|
||||
424
litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs
Normal file
424
litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs
Normal 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-----
|
||||
";
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
))
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue