From 3ba4a60d5ed5eeeb217b63ac7898746927f0db12 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:26:28 +0000 Subject: [PATCH 1/6] feat(rust): add HashiCorp Vault secret manager crate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-rust.yml | 2 +- litellm-rust/Cargo.lock | 19 + litellm-rust/Cargo.toml | 1 + .../crates/secrets-hashicorp/Cargo.toml | 23 + .../crates/secrets-hashicorp/src/config.rs | 169 +++++++ .../crates/secrets-hashicorp/src/error.rs | 32 ++ .../crates/secrets-hashicorp/src/lib.rs | 9 + .../secrets-hashicorp/src/secret_manager.rs | 333 ++++++++++++++ .../secrets-hashicorp/tests/secret_manager.rs | 424 ++++++++++++++++++ litellm-rust/crates/secrets/Cargo.toml | 2 + litellm-rust/crates/secrets/README.md | 2 + litellm-rust/crates/secrets/src/error.rs | 3 + litellm-rust/crates/secrets/src/handler.rs | 10 + litellm-rust/crates/secrets/src/lib.rs | 2 + litellm-rust/crates/secrets/tests/handler.rs | 128 ++++++ .../hashicorp_secret_manager.py | 16 +- .../hashicorp_vault_parity.json | 97 ++++ .../test_hashicorp_secret_manager.py | 50 +++ 18 files changed, 1314 insertions(+), 8 deletions(-) create mode 100644 litellm-rust/crates/secrets-hashicorp/Cargo.toml create mode 100644 litellm-rust/crates/secrets-hashicorp/src/config.rs create mode 100644 litellm-rust/crates/secrets-hashicorp/src/error.rs create mode 100644 litellm-rust/crates/secrets-hashicorp/src/lib.rs create mode 100644 litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs create mode 100644 litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs create mode 100644 tests/test_litellm/secret_managers/hashicorp_vault_parity.json diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 278fa7c425f..56bb9a568fc 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -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 diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index ed4ae4e3353..6f05bba9417 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -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" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 570d0dd3568..5030a94b140 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -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" } diff --git a/litellm-rust/crates/secrets-hashicorp/Cargo.toml b/litellm-rust/crates/secrets-hashicorp/Cargo.toml new file mode 100644 index 00000000000..0656980a033 --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/Cargo.toml @@ -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" diff --git a/litellm-rust/crates/secrets-hashicorp/src/config.rs b/litellm-rust/crates/secrets-hashicorp/src/config.rs new file mode 100644 index 00000000000..f9491f71afb --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/src/config.rs @@ -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, +} + +#[derive(Clone, Debug)] +pub struct HashicorpVaultConfig { + pub address: String, + pub token: Option, + pub namespace: Option, + pub login_namespace: Option, + pub secret_namespace: Option, + pub mount: String, + pub path_prefix: Option, + pub approle: Option, + pub tls_cert: Option, + pub refresh_interval: Duration, +} + +impl HashicorpVaultConfig { + pub fn from_environment(environment: &dyn Lookup) -> Result { + 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 = environment + .get(HCP_VAULT_TOKEN) + .and_then(nonempty) + .map(SecretValue::new); + let namespace: Option = path_component(environment.get(HCP_VAULT_NAMESPACE)); + let login_namespace: Option = + path_component(environment.get(HCP_VAULT_LOGIN_NAMESPACE)); + let secret_namespace: Option = + 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 = path_component(environment.get(HCP_VAULT_PATH_PREFIX)); + let approle: Option = 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 = 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::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) -> Option { + let value: &str = value.as_ref(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn path_component(value: Option) -> Option { + 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 { + let value: Option = 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)) +} diff --git a/litellm-rust/crates/secrets-hashicorp/src/error.rs b/litellm-rust/crates/secrets-hashicorp/src/error.rs new file mode 100644 index 00000000000..3f6a5b66475 --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/src/error.rs @@ -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, +} diff --git a/litellm-rust/crates/secrets-hashicorp/src/lib.rs b/litellm-rust/crates/secrets-hashicorp/src/lib.rs new file mode 100644 index 00000000000..a561a58b59e --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/src/lib.rs @@ -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; diff --git a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs new file mode 100644 index 00000000000..6b887289443 --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs @@ -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, +} + +#[derive(Clone)] +pub struct HashicorpVault { + client: Arc, + config: HashicorpVaultConfig, + cache: Cache, + auth_token: Arc>>, +} + +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, + enterprise_enabled: bool, + ) -> Result { + 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 { + if !enterprise_enabled { + return Err(Error::EnterpriseRequired); + } + let cache: Cache = 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 { + 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 { + 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, 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 = + 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 { + 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 { + async_rotate_secret(self, current_name, new_name, value).await + } + + async fn vault_token(&self) -> Result { + let mut cached: tokio::sync::MutexGuard<'_, Option> = + 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 = 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 = + (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, Error> { + HashicorpVault::async_read_secret(self, name).await + } + + async fn async_write_secret( + &self, + name: &str, + value: &SecretValue, + description: Option<&str>, + ) -> Result { + 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 { + let mut builder: reqwest::ClientBuilder = Client::builder(); + if let Some(tls) = config.tls_cert.as_ref() { + let cert: Vec = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity { + path: tls.cert_path.clone(), + message: source.to_string(), + })?; + let key: Vec = 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) +} diff --git a/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs new file mode 100644 index 00000000000..1cf13a79c77 --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs @@ -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 = 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 = + 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 = 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, 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, + expected_secret_url: String, + expected_login_url: Option, + expected_login_namespace: Option, + expected_secret_namespace: Option, + secret_name: String, +} + +#[test] +fn configuration_matches_python_parity_fixture() { + let cases: Vec = 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 = case.env.clone(); + let environment: Arc = + 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 = + 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----- +"; diff --git a/litellm-rust/crates/secrets/Cargo.toml b/litellm-rust/crates/secrets/Cargo.toml index a7e7ec80636..7369f9e1db6 100644 --- a/litellm-rust/crates/secrets/Cargo.toml +++ b/litellm-rust/crates/secrets/Cargo.toml @@ -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 diff --git a/litellm-rust/crates/secrets/README.md b/litellm-rust/crates/secrets/README.md index 183a39e15bb..0d333c7d116 100644 --- a/litellm-rust/crates/secrets/README.md +++ b/litellm-rust/crates/secrets/README.md @@ -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 diff --git a/litellm-rust/crates/secrets/src/error.rs b/litellm-rust/crates/secrets/src/error.rs index 0c6e681b8aa..e5b82580cfe 100644 --- a/litellm-rust/crates/secrets/src/error.rs +++ b/litellm-rust/crates/secrets/src/error.rs @@ -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), } diff --git a/litellm-rust/crates/secrets/src/handler.rs b/litellm-rust/crates/secrets/src/handler.rs index 943ffdf6158..c035d4cb586 100644 --- a/litellm-rust/crates/secrets/src/handler.rs +++ b/litellm-rust/crates/secrets/src/handler.rs @@ -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), } } diff --git a/litellm-rust/crates/secrets/src/lib.rs b/litellm-rust/crates/secrets/src/lib.rs index ff2e95f7b2f..6cb391ff844 100644 --- a/litellm-rust/crates/secrets/src/lib.rs +++ b/litellm-rust/crates/secrets/src/lib.rs @@ -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; diff --git a/litellm-rust/crates/secrets/tests/handler.rs b/litellm-rust/crates/secrets/tests/handler.rs index a2cbbd843e1..58941171fd9 100644 --- a/litellm-rust/crates/secrets/tests/handler.rs +++ b/litellm-rust/crates/secrets/tests/handler.rs @@ -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 = 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 = 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 = 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 } + )) + )); +} diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index e37a912c7e1..259b2416d7f 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -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() diff --git a/tests/test_litellm/secret_managers/hashicorp_vault_parity.json b/tests/test_litellm/secret_managers/hashicorp_vault_parity.json new file mode 100644 index 00000000000..5f28d9602a0 --- /dev/null +++ b/tests/test_litellm/secret_managers/hashicorp_vault_parity.json @@ -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 + } +] diff --git a/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py b/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py index 1676540e4ec..fc18cb8b8f7 100644 --- a/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py +++ b/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py @@ -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 From beba2576be4b015574dccb3f916af5dfaf5d189f Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:29:06 +0000 Subject: [PATCH 2/6] fix(rust): align vault namespace handling with python and drop unused settings hook Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/secrets-hashicorp/src/config.rs | 10 +----- .../secrets-hashicorp/src/secret_manager.rs | 31 ++++++++--------- .../secrets-hashicorp/tests/secret_manager.rs | 33 +++++++++++++++++++ .../hashicorp_secret_manager.py | 16 ++++----- .../hashicorp_vault_parity.json | 20 +++-------- 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/litellm-rust/crates/secrets-hashicorp/src/config.rs b/litellm-rust/crates/secrets-hashicorp/src/config.rs index f9491f71afb..d32491199c4 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/config.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/config.rs @@ -1,7 +1,6 @@ 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; @@ -117,13 +116,6 @@ impl HashicorpVaultConfig { }) } - pub fn from_settings( - _settings: &KeyManagementSettings, - environment: &dyn Lookup, - ) -> Result { - Self::from_environment(environment) - } - pub fn login_namespace(&self) -> Option<&str> { self.login_namespace .as_deref() @@ -163,7 +155,7 @@ fn refresh_interval(environment: &dyn Lookup) -> Result { }; let seconds: i64 = value.parse().map_err(|_| Error::RefreshInterval)?; if seconds < 0 { - return Ok(Duration::from_nanos(1)); + return Err(Error::RefreshInterval); } Ok(Duration::from_secs(seconds as u64)) } diff --git a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs index 6b887289443..e4ca8c387c3 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs @@ -120,14 +120,12 @@ impl HashicorpVault { return Ok(Some(value)); } let token: SecretValue = self.vault_token().await?; - let mut request = self + let response: reqwest::Response = 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?; + .header("X-Vault-Token", token.expose()) + .send() + .await?; if response.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); } @@ -166,14 +164,13 @@ impl HashicorpVault { None => json!({"key": value.expose()}), }; let token: SecretValue = self.vault_token().await?; - let mut request = self + let response: reqwest::Response = 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?; + .header("X-Vault-Token", token.expose()) + .json(&json!({"data": data})) + .send() + .await?; if !response.status().is_success() { return Err(Error::Status { status: response.status().as_u16(), @@ -190,14 +187,12 @@ impl HashicorpVault { 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 + let response: reqwest::Response = 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?; + .header("X-Vault-Token", token.expose()) + .send() + .await?; if !response.status().is_success() { return Err(Error::Status { status: response.status().as_u16(), diff --git a/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs index 1cf13a79c77..b7a81f49ad4 100644 --- a/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs @@ -87,6 +87,39 @@ async fn namespace_mount_and_prefix_are_sanitized_in_the_url() { assert!(manager.async_read_secret("name").await.unwrap().is_some()); } +#[test] +fn trailing_address_slashes_are_removed() { + let environment: Arc = Arc::new(|name: &str| match name { + "HCP_VAULT_ADDR" => Some("http://vault.test:8200///".to_owned()), + "HCP_VAULT_TOKEN" => Some("token".to_owned()), + _ => None, + }); + 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("name").unwrap(), + "http://vault.test:8200/v1/secret/data/name" + ); +} + +#[rstest::rstest] +#[case("-1")] +#[case("not-a-number")] +fn invalid_refresh_intervals_are_rejected(#[case] value: &str) { + let environment: Arc = Arc::new(move |name: &str| match name { + "HCP_VAULT_REFRESH_INTERVAL" => Some(value.to_owned()), + _ => None, + }); + + assert!(matches!( + HashicorpVaultConfig::from_environment(environment.as_ref()), + Err(Error::RefreshInterval) + )); +} + #[tokio::test] async fn approle_login_uses_namespace_and_reuses_the_token() { let server: MockServer = MockServer::start().await; diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index 259b2416d7f..e37a912c7e1 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -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") or "http://127.0.0.1:8200").rstrip("/") + self.vault_addr = os.getenv("HCP_VAULT_ADDR", "http://127.0.0.1:8200") self.vault_token = os.getenv("HCP_VAULT_TOKEN", "") - 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")) + 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) # 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 = self._sanitize_path_component(os.getenv("HCP_VAULT_MOUNT_NAME")) or "secret" + self.vault_mount_name = os.getenv("HCP_VAULT_MOUNT_NAME", "secret") # Optional path prefix for secrets (e.g., "myapp" -> secret/data/myapp/{secret_name}) - self.vault_path_prefix = self._sanitize_path_component(os.getenv("HCP_VAULT_PATH_PREFIX")) + self.vault_path_prefix = os.getenv("HCP_VAULT_PATH_PREFIX", None) # Optional config for TLS cert auth self.tls_cert_path = os.getenv("HCP_VAULT_CLIENT_CERT", "") @@ -114,9 +114,7 @@ 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 = self._sanitize_path_component( - os.getenv("HCP_VAULT_APPROLE_MOUNT_PATH") - ) or "approle" + self.approle_mount_path = os.getenv("HCP_VAULT_APPROLE_MOUNT_PATH", "approle") self._verify_required_credentials_exist() diff --git a/tests/test_litellm/secret_managers/hashicorp_vault_parity.json b/tests/test_litellm/secret_managers/hashicorp_vault_parity.json index 5f28d9602a0..f1faefd48e8 100644 --- a/tests/test_litellm/secret_managers/hashicorp_vault_parity.json +++ b/tests/test_litellm/secret_managers/hashicorp_vault_parity.json @@ -15,7 +15,7 @@ "env": { "HCP_VAULT_ADDR": "http://vault.test:8200", "HCP_VAULT_TOKEN": "token", - "HCP_VAULT_NAMESPACE": " admin/ " + "HCP_VAULT_NAMESPACE": "admin" }, "secret_name": "OPENAI_API_KEY", "expected_secret_url": "http://vault.test:8200/v1/admin/secret/data/OPENAI_API_KEY", @@ -29,8 +29,8 @@ "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/ " + "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", @@ -58,7 +58,7 @@ "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_APPROLE_MOUNT_PATH": "custom-approle", "HCP_VAULT_NAMESPACE": "admin" }, "secret_name": "OPENAI_API_KEY", @@ -81,17 +81,5 @@ "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 } ] From c7458bf5fe20bcbb7902fd259fd3b113b2b9e168 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:42:49 +0000 Subject: [PATCH 3/6] refactor(rust): build vault requests without local reassignment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../secrets-hashicorp/src/secret_manager.rs | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs index e4ca8c387c3..52cc143c1e3 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs @@ -9,11 +9,11 @@ use litellm_secrets_types::{ BaseSecretManager, SecretValue, async_rotate_secret, validate_secret_name, }; use moka::future::Cache; -use reqwest::Client; +use reqwest::{Client, Identity, RequestBuilder}; use serde_json::{Value, json}; use tokio::sync::Mutex; -use crate::{Error, HashicorpVaultConfig}; +use crate::{Error, HashicorpVaultConfig, TlsCertAuth}; const CACHE_CAPACITY: u64 = 200; @@ -242,10 +242,10 @@ impl HashicorpVault { 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 request: RequestBuilder = with_namespace( + self.client.post(login_url).json(&body), + self.config.login_namespace(), + ); let response: reqwest::Response = request.send().await?; if !response.status().is_success() { return Err(Error::LoginStatus { @@ -305,24 +305,33 @@ impl BaseSecretManager for HashicorpVault { } fn client_for_config(config: &HashicorpVaultConfig) -> Result { - let mut builder: reqwest::ClientBuilder = Client::builder(); - if let Some(tls) = config.tls_cert.as_ref() { - let cert: Vec = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity { - path: tls.cert_path.clone(), - message: source.to_string(), - })?; - let key: Vec = 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); - } + let builder: reqwest::ClientBuilder = match config.tls_cert.as_ref() { + Some(tls) => Client::builder().identity(identity_for(tls)?), + None => Client::builder(), + }; builder.build().map_err(Error::Request) } + +fn with_namespace(request: RequestBuilder, namespace: Option<&str>) -> RequestBuilder { + match namespace { + Some(namespace) => request.header("X-Vault-Namespace", namespace), + None => request, + } +} + +fn identity_for(tls: &TlsCertAuth) -> Result { + let cert: Vec = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity { + path: tls.cert_path.clone(), + message: source.to_string(), + })?; + let key: Vec = std::fs::read(&tls.key_path).map_err(|source| Error::TlsIdentity { + path: tls.key_path.clone(), + message: source.to_string(), + })?; + Identity::from_pem(&[cert.as_slice(), key.as_slice()].concat()).map_err(|source| { + Error::TlsIdentity { + path: tls.cert_path.clone(), + message: source.to_string(), + } + }) +} From ef2ab74c7aecab4f970827ec21d740ed3aaa430b Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 21:33:38 +0000 Subject: [PATCH 4/6] refactor(rust): back the vault secret manager with vaultrs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/Cargo.lock | 91 +++- litellm-rust/Cargo.toml | 3 + .../crates/secrets-hashicorp/Cargo.toml | 4 +- .../secrets-hashicorp/src/cert_login.rs | 25 ++ .../crates/secrets-hashicorp/src/error.rs | 8 +- .../crates/secrets-hashicorp/src/lib.rs | 3 +- .../secrets-hashicorp/src/secret_manager.rs | 406 +++++++++--------- .../secrets-hashicorp/tests/secret_manager.rs | 293 +++++++++---- litellm-rust/crates/secrets/tests/handler.rs | 39 +- 9 files changed, 586 insertions(+), 286 deletions(-) create mode 100644 litellm-rust/crates/secrets-hashicorp/src/cert_login.rs diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 6f05bba9417..7b59533ad06 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2763,13 +2763,15 @@ dependencies = [ "litellm-core-utils", "litellm-secrets-types", "moka", - "reqwest 0.12.28", "rstest", + "rustify", + "rustify_derive", "serde", "serde_json", "tempfile", "thiserror 2.0.19", "tokio", + "vaultrs", "veil", "wiremock", ] @@ -3975,6 +3977,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4800ce4c1cc2fec12c559dae2ddbf0e17fcee7569b796e6d75898efef443368b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http 1.4.2", + "reqwest 0.13.5", + "rustify_derive", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 1.0.69", + "tracing", + "url", +] + +[[package]] +name = "rustify_derive" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ea7fda74240f7410d0198b603a8a2f662acc7d76b6667a49f9b162cd8d9b4f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "serde_urlencoded", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "rustix" version = "1.1.5" @@ -4502,6 +4538,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4533,6 +4580,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -4947,6 +5006,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5130,6 +5190,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -5189,6 +5255,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vaultrs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ffcc0e81025065dda612ec1e26a3d81bb16ef3062354873d17a35965d68522" +dependencies = [ + "async-trait", + "derive_builder", + "http 1.4.2", + "reqwest 0.13.5", + "rustify", + "rustify_derive", + "serde", + "serde_json", + "thiserror 2.0.19", + "tracing", + "url", +] + [[package]] name = "veil" version = "0.3.0" @@ -5632,7 +5717,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -5673,7 +5758,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 5030a94b140..edc9f5a91f6 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -51,6 +51,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul rstest = "0.26.1" rstest_reuse = "0.7.0" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustify = "=0.7.0" +rustify_derive = "=0.5.5" +vaultrs = { version = "=0.8.0", default-features = false, features = ["rustls"] } rustls-native-certs = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["float_roundtrip"] } diff --git a/litellm-rust/crates/secrets-hashicorp/Cargo.toml b/litellm-rust/crates/secrets-hashicorp/Cargo.toml index 0656980a033..c646e02ef09 100644 --- a/litellm-rust/crates/secrets-hashicorp/Cargo.toml +++ b/litellm-rust/crates/secrets-hashicorp/Cargo.toml @@ -9,11 +9,13 @@ repository.workspace = true litellm-core-utils.workspace = true litellm-secrets-types.workspace = true moka.workspace = true -reqwest.workspace = true +rustify.workspace = true +rustify_derive.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true +vaultrs.workspace = true veil.workspace = true [dev-dependencies] diff --git a/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs b/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs new file mode 100644 index 00000000000..1eb09a89715 --- /dev/null +++ b/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs @@ -0,0 +1,25 @@ +#[derive(Debug, rustify_derive::Endpoint)] +#[endpoint(path = "/auth/{self.mount}/login", method = "POST")] +pub struct CertLoginRequest { + #[endpoint(skip)] + pub mount: String, + #[endpoint(skip)] + #[allow(dead_code)] + pub name: Option, + #[endpoint(raw)] + body: Vec, +} + +impl CertLoginRequest { + pub fn new(name: Option) -> Self { + let body: Vec = match name.as_deref() { + Some(name) => serde_json::to_vec(&serde_json::json!({ "name": name })).unwrap(), + None => b"{}".to_vec(), + }; + Self { + mount: "cert".to_owned(), + name, + body, + } + } +} diff --git a/litellm-rust/crates/secrets-hashicorp/src/error.rs b/litellm-rust/crates/secrets-hashicorp/src/error.rs index 3f6a5b66475..e26033af085 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/error.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/error.rs @@ -4,12 +4,14 @@ pub enum Error { EnterpriseRequired, #[error("invalid secret name")] InvalidSecretName(#[from] litellm_secrets_types::Error), - #[error("HashiCorp Vault request failed")] - Request( + #[error("HashiCorp Vault client failed")] + Client( #[from] #[redact] - reqwest::Error, + vaultrs::error::ClientError, ), + #[error("HashiCorp Vault client settings are invalid: {message}")] + ClientSettings { message: String }, #[error("HashiCorp Vault TLS identity could not be configured for {path}: {message}")] TlsIdentity { path: std::path::PathBuf, diff --git a/litellm-rust/crates/secrets-hashicorp/src/lib.rs b/litellm-rust/crates/secrets-hashicorp/src/lib.rs index a561a58b59e..0c2b05647f8 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/lib.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/lib.rs @@ -1,9 +1,10 @@ #![forbid(unsafe_code)] +mod cert_login; mod config; mod error; pub mod secret_manager; pub use config::{AppRoleAuth, HashicorpVaultConfig, TlsCertAuth}; pub use error::Error; -pub use secret_manager::HashicorpVault; +pub use secret_manager::{HashicorpVault, SecretLocation}; diff --git a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs index 52cc143c1e3..7937fa93f8f 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fmt, sync::Arc, time::{Duration, Instant}, @@ -9,26 +10,39 @@ use litellm_secrets_types::{ BaseSecretManager, SecretValue, async_rotate_secret, validate_secret_name, }; use moka::future::Cache; -use reqwest::{Client, Identity, RequestBuilder}; -use serde_json::{Value, json}; +use rustify::errors::ClientError as RustifyClientError; +use serde_json::Value; use tokio::sync::Mutex; +use vaultrs::{ + api, + auth::approle, + client::{Identity, VaultClient, VaultClientSettingsBuilder}, + error::ClientError, + kv2, +}; -use crate::{Error, HashicorpVaultConfig, TlsCertAuth}; +use crate::{Error, HashicorpVaultConfig, TlsCertAuth, cert_login::CertLoginRequest}; const CACHE_CAPACITY: u64 = 200; #[derive(Clone)] -struct CachedToken { - token: SecretValue, +struct CachedClient { + client: Arc, expires_at: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SecretLocation { + pub namespace: Option, + pub mount: String, + pub path: String, +} + #[derive(Clone)] pub struct HashicorpVault { - client: Arc, config: HashicorpVaultConfig, cache: Cache, - auth_token: Arc>>, + auth_client: Arc>>, } impl fmt::Debug for HashicorpVault { @@ -45,17 +59,12 @@ impl HashicorpVault { environment: Arc, enterprise_enabled: bool, ) -> Result { - 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) + Self::from_config(config, enterprise_enabled) } - pub fn with_client( - client: Client, + pub fn from_config( config: HashicorpVaultConfig, enterprise_enabled: bool, ) -> Result { @@ -67,47 +76,27 @@ impl HashicorpVault { .time_to_live(config.refresh_interval) .build(); Ok(Self { - client: Arc::new(client), config, cache, - auth_token: Arc::new(Mutex::new(None)), + auth_client: Arc::new(Mutex::new(None)), }) } - pub fn secret_url(&self, secret_name: &str) -> Result { + pub fn secret_location(&self, secret_name: &str) -> Result { 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 { - 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 - )) - }, - ) + let path: String = [ + self.config.path_prefix.clone(), + Some(secret_name.to_owned()), + ] + .into_iter() + .flatten() + .collect::>() + .join("/"); + Ok(SecretLocation { + namespace: self.config.secret_namespace().map(str::to_owned), + mount: self.config.mount.clone(), + path, + }) } pub fn config(&self) -> &HashicorpVaultConfig { @@ -115,40 +104,24 @@ impl HashicorpVault { } pub async fn async_read_secret(&self, secret_name: &str) -> Result, Error> { - let url: String = self.secret_url(secret_name)?; - if let Some(value) = self.cache.get(&url).await { + let location: SecretLocation = self.secret_location(secret_name)?; + let cache_key: String = cache_key(&location); + if let Some(value) = self.cache.get(&cache_key).await { return Ok(Some(value)); } - let token: SecretValue = self.vault_token().await?; - let response: reqwest::Response = self - .client - .get(&url) - .header("X-Vault-Token", token.expose()) - .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 = - data.as_object().ok_or(Error::MalformedPayload)?; + let client: Arc = self.vault_client().await?; + let data: HashMap = + match kv2::read(client.as_ref(), &location.mount, &location.path).await { + Ok(data) => data, + Err(error) if api_status(&error) == Some(404) => return Ok(None), + Err(error) => return Err(map_api_error(error, ErrorContext::Read)), + }; 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; + self.cache.insert(cache_key, value.clone()).await; Ok(Some(value)) } @@ -158,47 +131,39 @@ impl HashicorpVault { value: SecretValue, description: Option<&str>, ) -> Result { - 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 location: SecretLocation = self.secret_location(secret_name)?; + let cache_key: String = cache_key(&location); + let data: HashMap = match description { + Some(description) => [ + ("key".to_owned(), Value::String(value.expose().to_owned())), + ( + "description".to_owned(), + Value::String(description.to_owned()), + ), + ] + .into_iter() + .collect(), + None => [("key".to_owned(), Value::String(value.expose().to_owned()))] + .into_iter() + .collect(), }; - let token: SecretValue = self.vault_token().await?; - let response: reqwest::Response = self - .client - .post(&url) - .header("X-Vault-Token", token.expose()) - .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) + let client: Arc = self.vault_client().await?; + let metadata = kv2::set(client.as_ref(), &location.mount, &location.path, &data) + .await + .map_err(|error| map_api_error(error, ErrorContext::Secret))?; + self.cache.invalidate(&cache_key).await; + serde_json::to_value(metadata) + .map_err(|source| Error::Client(ClientError::JsonParseError { source })) } 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 response: reqwest::Response = self - .client - .delete(&url) - .header("X-Vault-Token", token.expose()) - .send() - .await?; - if !response.status().is_success() { - return Err(Error::Status { - status: response.status().as_u16(), - }); - } - self.cache.invalidate(&url).await; + let location: SecretLocation = self.secret_location(secret_name)?; + let cache_key: String = cache_key(&location); + let client: Arc = self.vault_client().await?; + kv2::delete_latest(client.as_ref(), &location.mount, &location.path) + .await + .map_err(|error| map_api_error(error, ErrorContext::Secret))?; + self.cache.invalidate(&cache_key).await; Ok(()) } @@ -211,69 +176,76 @@ impl HashicorpVault { async_rotate_secret(self, current_name, new_name, value).await } - async fn vault_token(&self) -> Result { - let mut cached: tokio::sync::MutexGuard<'_, Option> = - self.auth_token.lock().await; + async fn vault_client(&self) -> Result, Error> { + let mut cached = self.auth_client.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()); + return Ok(entry.client.clone()); } - let Some(login_url) = self.login_url() else { - let Some(token) = self.config.token.clone() else { - return Err(Error::NoAuthConfigured); + + let (client, expires_at): (VaultClient, Option) = + match (self.config.approle.as_ref(), self.config.tls_cert.as_ref()) { + (Some(approle), _) => { + let login_client: VaultClient = + self.build_client(self.config.login_namespace(), "")?; + let auth = approle::login( + &login_client, + &approle.mount_path, + &approle.role_id, + approle.secret_id.expose(), + ) + .await + .map_err(|error| map_api_error(error, ErrorContext::Login))?; + ( + self.build_client(self.config.secret_namespace(), &auth.client_token)?, + token_expiry(auth.lease_duration), + ) + } + (None, Some(tls)) => { + let login_client: VaultClient = + self.build_client(self.config.login_namespace(), "")?; + let endpoint: CertLoginRequest = CertLoginRequest::new(tls.role.clone()); + let auth = api::auth(&login_client, endpoint) + .await + .map_err(|error| map_api_error(error, ErrorContext::Login))?; + ( + self.build_client(self.config.secret_namespace(), &auth.client_token)?, + token_expiry(auth.lease_duration), + ) + } + (None, None) => { + let token: SecretValue = + self.config.token.clone().ok_or(Error::NoAuthConfigured)?; + ( + self.build_client(self.config.secret_namespace(), token.expose())?, + None, + ) + } }; - 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 request: RequestBuilder = with_namespace( - self.client.post(login_url).json(&body), - self.config.login_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 = 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 = - (lease_duration > 0).then(|| Instant::now() + Duration::from_secs(lease_duration)); - *cached = Some(CachedToken { - token: token.clone(), + let client: Arc = Arc::new(client); + *cached = Some(CachedClient { + client: client.clone(), expires_at, }); - Ok(token) + Ok(client) + } + + fn build_client(&self, namespace: Option<&str>, token: &str) -> Result { + let settings = VaultClientSettingsBuilder::default() + .address(&self.config.address) + .token(token.to_owned()) + .namespace(namespace.map(str::to_owned)) + .identity(identity_for(self.config.tls_cert.as_ref())?) + .ca_certs(Vec::new()) + .verify(true) + .build() + .map_err(|message| Error::ClientSettings { + message: message.to_string(), + })?; + VaultClient::new(settings).map_err(Error::Client) } } @@ -304,34 +276,84 @@ impl BaseSecretManager for HashicorpVault { } } -fn client_for_config(config: &HashicorpVaultConfig) -> Result { - let builder: reqwest::ClientBuilder = match config.tls_cert.as_ref() { - Some(tls) => Client::builder().identity(identity_for(tls)?), - None => Client::builder(), - }; - builder.build().map_err(Error::Request) +#[derive(Clone, Copy)] +enum ErrorContext { + Login, + Read, + Secret, } -fn with_namespace(request: RequestBuilder, namespace: Option<&str>) -> RequestBuilder { - match namespace { - Some(namespace) => request.header("X-Vault-Namespace", namespace), - None => request, +fn cache_key(location: &SecretLocation) -> String { + format!( + "{:?}/{}/{}", + location.namespace, location.mount, location.path + ) +} + +fn identity_for(tls: Option<&TlsCertAuth>) -> Result, Error> { + tls.map(|tls| { + let cert: Vec = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity { + path: tls.cert_path.clone(), + message: source.to_string(), + })?; + let key: Vec = std::fs::read(&tls.key_path).map_err(|source| Error::TlsIdentity { + path: tls.key_path.clone(), + message: source.to_string(), + })?; + Identity::from_pem(&[cert.as_slice(), key.as_slice()].concat()).map_err(|source| { + Error::TlsIdentity { + path: tls.cert_path.clone(), + message: source.to_string(), + } + }) + }) + .transpose() +} + +fn map_api_error(error: ClientError, context: ErrorContext) -> Error { + match error { + ClientError::APIError { code, .. } => match context { + ErrorContext::Login => Error::LoginStatus { status: code }, + ErrorContext::Read | ErrorContext::Secret => Error::Status { status: code }, + }, + ClientError::JsonParseError { source } => match context { + ErrorContext::Login => Error::MalformedLogin, + ErrorContext::Read => Error::MalformedPayload, + ErrorContext::Secret => Error::Client(ClientError::JsonParseError { source }), + }, + ClientError::ResponseEmptyError | ClientError::ResponseDataEmptyError => { + malformed_response(context) + } + ClientError::RestClientError { source } => match source { + RustifyClientError::ServerResponseError { code, .. } => match context { + ErrorContext::Login => Error::LoginStatus { status: code }, + ErrorContext::Read | ErrorContext::Secret => Error::Status { status: code }, + }, + RustifyClientError::ResponseParseError { .. } => malformed_response(context), + source => Error::Client(ClientError::RestClientError { source }), + }, + error => Error::Client(error), } } -fn identity_for(tls: &TlsCertAuth) -> Result { - let cert: Vec = std::fs::read(&tls.cert_path).map_err(|source| Error::TlsIdentity { - path: tls.cert_path.clone(), - message: source.to_string(), - })?; - let key: Vec = std::fs::read(&tls.key_path).map_err(|source| Error::TlsIdentity { - path: tls.key_path.clone(), - message: source.to_string(), - })?; - Identity::from_pem(&[cert.as_slice(), key.as_slice()].concat()).map_err(|source| { - Error::TlsIdentity { - path: tls.cert_path.clone(), - message: source.to_string(), - } - }) +fn api_status(error: &ClientError) -> Option { + match error { + ClientError::APIError { code, .. } => Some(*code), + ClientError::RestClientError { + source: RustifyClientError::ServerResponseError { code, .. }, + } => Some(*code), + _ => None, + } +} + +fn malformed_response(context: ErrorContext) -> Error { + match context { + ErrorContext::Login => Error::MalformedLogin, + ErrorContext::Read => Error::MalformedPayload, + ErrorContext::Secret => Error::Client(ClientError::ResponseDataEmptyError), + } +} + +fn token_expiry(lease_duration: u64) -> Option { + (lease_duration > 0).then(|| Instant::now() + Duration::from_secs(lease_duration)) } diff --git a/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs index b7a81f49ad4..c52db46e41e 100644 --- a/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/tests/secret_manager.rs @@ -22,7 +22,51 @@ fn config(server: &MockServer, values: &[(&str, &str)]) -> HashicorpVaultConfig } fn manager(server: &MockServer, values: &[(&str, &str)]) -> HashicorpVault { - HashicorpVault::with_client(reqwest::Client::new(), config(server, values), true).unwrap() + HashicorpVault::from_config(config(server, values), true).unwrap() +} + +fn auth_response(token: &str, lease_duration: u64) -> serde_json::Value { + json!({ + "auth": { + "client_token": token, + "accessor": "", + "policies": [], + "token_policies": [], + "metadata": null, + "lease_duration": lease_duration, + "renewable": false, + "entity_id": "", + "token_type": "service", + "orphan": false + }, + "lease_id": "", + "lease_duration": lease_duration, + "renewable": false, + "request_id": "", + "warnings": null, + "wrap_info": null + }) +} + +fn read_response(data: serde_json::Value) -> serde_json::Value { + json!({ + "data": { + "data": data, + "metadata": { + "created_time": "", + "deletion_time": "", + "custom_metadata": null, + "destroyed": false, + "version": 1 + } + }, + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "request_id": "", + "warnings": null, + "wrap_info": null + }) } #[tokio::test] @@ -32,7 +76,7 @@ async fn token_reads_use_vault_headers_and_cache_values() { .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"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) .expect(1) .mount(&server) @@ -48,6 +92,12 @@ async fn token_reads_use_vault_headers_and_cache_values() { .expose(), "value" ); + let requests = server.received_requests().await.unwrap(); + assert!( + requests + .iter() + .all(|request| !request.headers.contains_key("X-Vault-Namespace")) + ); assert_eq!( manager .async_read_secret("name") @@ -63,9 +113,10 @@ async fn token_reads_use_vault_headers_and_cache_values() { 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")) + .and(path("/v1/kv-prod/data/virtual-keys/name")) + .and(header("X-Vault-Namespace", "team-a")) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) .expect(1) .mount(&server) @@ -80,10 +131,10 @@ async fn namespace_mount_and_prefix_are_sanitized_in_the_url() { ], ); - assert_eq!( - manager.secret_url("name").unwrap(), - format!("{}/v1/team-a/kv-prod/data/virtual-keys/name", server.uri()) - ); + let location = manager.secret_location("name").unwrap(); + assert_eq!(location.namespace.as_deref(), Some("team-a")); + assert_eq!(location.mount, "kv-prod"); + assert_eq!(location.path, "virtual-keys/name"); assert!(manager.async_read_secret("name").await.unwrap().is_some()); } @@ -96,12 +147,15 @@ fn trailing_address_slashes_are_removed() { }); let config: HashicorpVaultConfig = HashicorpVaultConfig::from_environment(environment.as_ref()).unwrap(); - let manager: HashicorpVault = - HashicorpVault::with_client(reqwest::Client::new(), config, true).unwrap(); + let manager: HashicorpVault = HashicorpVault::from_config(config, true).unwrap(); assert_eq!( - manager.secret_url("name").unwrap(), - "http://vault.test:8200/v1/secret/data/name" + manager.secret_location("name").unwrap(), + litellm_secrets_hashicorp::SecretLocation { + namespace: None, + mount: "secret".to_owned(), + path: "name".to_owned(), + } ); } @@ -127,21 +181,26 @@ async fn approle_login_uses_namespace_and_reuses_the_token() { .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}}), - )) + .respond_with(ResponseTemplate::new(200).set_body_json(auth_response("login-token", 3600))) .expect(1) .mount(&server) .await; Mock::given(method("GET")) - .and(path("/v1/secret-root/secret/data/name")) + .and(path("/v1/secret/data/name")) .and(header("X-Vault-Token", "login-token")) + .and(header("X-Vault-Namespace", "secret-root")) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) .expect(1) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/v1/secret/data/name-2")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({"errors": ["missing"]}))) + .expect(1) + .mount(&server) + .await; let manager: HashicorpVault = manager( &server, &[ @@ -162,17 +221,13 @@ 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}}), - ), - ) + .respond_with(ResponseTemplate::new(200).set_body_json(auth_response("login-token", 1))) .expect(2) .mount(&server) .await; Mock::given(method("GET")) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) .expect(2) .mount(&server) @@ -201,54 +256,98 @@ async fn tls_login_posts_the_role_and_uses_the_client_identity() { 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) + .and(header("X-Vault-Namespace", "login-ns")) + .respond_with(ResponseTemplate::new(200).set_body_json(auth_response("cert-token", 0))) + .expect(2) .mount(&server) .await; Mock::given(method("GET")) .and(path("/v1/secret/data/name")) + .and(header("X-Vault-Token", "cert-token")) + .and(header("X-Vault-Namespace", "secret-ns")) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) + .expect(2) .mount(&server) .await; - let manager: HashicorpVault = HashicorpVault::new( - { - let environment_values: HashMap = 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()) + let role_values: HashMap = 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()), + ( + "HCP_VAULT_LOGIN_NAMESPACE".to_owned(), + "login-ns".to_owned(), + ), + ( + "HCP_VAULT_SECRET_NAMESPACE".to_owned(), + "secret-ns".to_owned(), + ), + ]); + let role_environment: Arc = + Arc::new(move |name: &str| role_values.get(name).cloned()); + let role_manager: HashicorpVault = HashicorpVault::new(role_environment, true).unwrap(); + assert!( + role_manager + .async_read_secret("name") + .await + .unwrap() + .is_some() ); + + let no_role_values: HashMap = 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_LOGIN_NAMESPACE".to_owned(), + "login-ns".to_owned(), + ), + ( + "HCP_VAULT_SECRET_NAMESPACE".to_owned(), + "secret-ns".to_owned(), + ), + ]); + let no_role_environment: Arc = + Arc::new(move |name: &str| no_role_values.get(name).cloned()); + let no_role_manager: HashicorpVault = HashicorpVault::new(no_role_environment, true).unwrap(); + assert!( + no_role_manager + .async_read_secret("name") + .await + .unwrap() + .is_some() + ); + let login_bodies: Vec = server + .received_requests() + .await + .unwrap() + .iter() + .filter(|request| request.method.as_str() == "POST") + .map(|request| serde_json::from_slice(&request.body).unwrap()) + .collect(); + assert!(login_bodies.contains(&json!({"name": "vault-role"}))); + assert!(login_bodies.contains(&json!({}))); } #[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)] +#[case::missing(404, json!({"errors": ["missing"]}), 0)] +#[case::malformed(200, json!({"data": "invalid"}), 1)] +#[case::missing_key(200, json!({}), 0)] +#[case::non_string(200, json!({"key": 1}), 2)] #[tokio::test] async fn read_responses_distinguish_absence_and_malformed_payloads( #[case] status: u16, @@ -257,7 +356,13 @@ async fn read_responses_distinguish_absence_and_malformed_payloads( ) { let server: MockServer = MockServer::start().await; Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .respond_with(ResponseTemplate::new(status).set_body_json( + if status == 200 && expected != 1 { + read_response(body) + } else { + body + }, + )) .expect(1) .mount(&server) .await; @@ -279,7 +384,7 @@ async fn write_and_delete_invalidate_the_read_cache() { Mock::given(method("GET")) .and(path("/v1/secret/data/name")) .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"data": {"data": {"key": "value"}}})), + ResponseTemplate::new(200).set_body_json(read_response(json!({"key": "value"}))), ) .expect(2) .mount(&server) @@ -289,7 +394,21 @@ async fn write_and_delete_invalidate_the_read_cache() { .and(body_json( json!({"data": {"key": "updated", "description": "description"}}), )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"data": {"version": 2}}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "created_time": "", + "deletion_time": "", + "custom_metadata": null, + "destroyed": false, + "version": 2 + }, + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "request_id": "", + "warnings": null, + "wrap_info": null + }))) .expect(1) .mount(&server) .await; @@ -331,12 +450,9 @@ async fn no_auth_and_invalid_names_fail_without_requests() { #[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 manager: HashicorpVault = + HashicorpVault::from_config(config(&server, &[("HCP_VAULT_TOKEN", "token-value")]), true) + .unwrap(); let debug: String = format!("{manager:?}"); assert!(!debug.contains("token-value")); assert!(!debug.contains("secret-id")); @@ -365,13 +481,35 @@ fn configuration_matches_python_parity_fixture() { 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(); + let manager: HashicorpVault = HashicorpVault::from_config(config.clone(), true).unwrap(); + let location = manager.secret_location(&case.secret_name).unwrap(); + let namespace = location + .namespace + .as_deref() + .map(|namespace| format!("{namespace}/")) + .unwrap_or_default(); assert_eq!( - manager.secret_url(&case.secret_name).unwrap(), + format!( + "{}/v1/{}{}/data/{}", + config.address, namespace, location.mount, location.path + ), case.expected_secret_url ); - assert_eq!(manager.login_url(), case.expected_login_url); + let login_url = config.approle.as_ref().map_or_else( + || { + config + .tls_cert + .as_ref() + .map(|_| format!("{}/v1/auth/cert/login", config.address)) + }, + |approle| { + Some(format!( + "{}/v1/auth/{}/login", + config.address, approle.mount_path + )) + }, + ); + assert_eq!(login_url, case.expected_login_url); assert_eq!( manager.config().login_namespace(), case.expected_login_namespace.as_deref() @@ -391,8 +529,15 @@ async fn live_vault_round_trip() { 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); + let location = manager.secret_location(&name).unwrap(); + println!( + "native provenance: {} vaultrs {} {:?} {} {}", + module_path!(), + manager.config().address, + location.namespace, + location.mount, + location.path + ); manager .async_write_secret(&name, value.clone(), None) .await diff --git a/litellm-rust/crates/secrets/tests/handler.rs b/litellm-rust/crates/secrets/tests/handler.rs index 58941171fd9..c169d411f8f 100644 --- a/litellm-rust/crates/secrets/tests/handler.rs +++ b/litellm-rust/crates/secrets/tests/handler.rs @@ -124,10 +124,24 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { 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"}}})), - ) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "data": {"key": "remote"}, + "metadata": { + "created_time": "", + "deletion_time": "", + "custom_metadata": null, + "destroyed": false, + "version": 1 + } + }, + "lease_id": "", + "lease_duration": 0, + "renewable": false, + "request_id": "", + "warnings": null, + "wrap_info": null + }))) .mount(&found_server) .await; let found_environment: Arc = Arc::new({ @@ -139,8 +153,7 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { } }); 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_manager = HashicorpVault::from_config(found_config, true).unwrap(); let found_resolver = SecretResolver::new( Arc::new(SecretManagerState::new( SecretManager::HashicorpVault(found_manager), @@ -164,7 +177,9 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { let missing_server = MockServer::start().await; Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(404)) + .respond_with( + ResponseTemplate::new(404).set_body_json(serde_json::json!({"errors": ["missing"]})), + ) .mount(&missing_server) .await; let missing_environment: Arc = Arc::new({ @@ -177,8 +192,7 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { }); 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_manager = HashicorpVault::from_config(missing_config, true).unwrap(); let missing_state = SecretManagerState::new( SecretManager::HashicorpVault(missing_manager), KeyManagementSettings { @@ -198,7 +212,9 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { let failed_server = MockServer::start().await; Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(500)) + .respond_with( + ResponseTemplate::new(500).set_body_json(serde_json::json!({"errors": ["failed"]})), + ) .mount(&failed_server) .await; let failed_environment: Arc = Arc::new({ @@ -211,8 +227,7 @@ async fn hashicorp_handler_resolves_found_missing_and_failed_values() { }); 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_manager = HashicorpVault::from_config(failed_config, true).unwrap(); let failed_state = SecretManagerState::new( SecretManager::HashicorpVault(failed_manager), KeyManagementSettings { From d90ff4a30b67b41199e171ee997d93cdea4b3a57 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 21:35:39 +0000 Subject: [PATCH 5/6] refactor(rust): trim vault cert login endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/secrets-hashicorp/src/cert_login.rs | 11 ++++------- .../crates/secrets-hashicorp/src/secret_manager.rs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs b/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs index 1eb09a89715..f99df62db12 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/cert_login.rs @@ -3,22 +3,19 @@ pub struct CertLoginRequest { #[endpoint(skip)] pub mount: String, - #[endpoint(skip)] - #[allow(dead_code)] - pub name: Option, #[endpoint(raw)] body: Vec, } impl CertLoginRequest { - pub fn new(name: Option) -> Self { - let body: Vec = match name.as_deref() { - Some(name) => serde_json::to_vec(&serde_json::json!({ "name": name })).unwrap(), + pub fn new(name: Option<&str>) -> Self { + let body: Vec = match name { + Some(name) => serde_json::to_vec(&serde_json::json!({ "name": name })) + .expect("json object serialization is infallible"), None => b"{}".to_vec(), }; Self { mount: "cert".to_owned(), - name, body, } } diff --git a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs index 7937fa93f8f..3ad9a549438 100644 --- a/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-hashicorp/src/secret_manager.rs @@ -207,7 +207,7 @@ impl HashicorpVault { (None, Some(tls)) => { let login_client: VaultClient = self.build_client(self.config.login_namespace(), "")?; - let endpoint: CertLoginRequest = CertLoginRequest::new(tls.role.clone()); + let endpoint: CertLoginRequest = CertLoginRequest::new(tls.role.as_deref()); let auth = api::auth(&login_client, endpoint) .await .map_err(|error| map_api_error(error, ErrorContext::Login))?; From 636a60d3bbc9fbfc59fa594b128520eb3e43f433 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 22:51:14 +0000 Subject: [PATCH 6/6] chore(rust): update merged workspace lockfile Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/Cargo.lock | 108 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 1a4eb51af08..5acfa22e824 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2850,6 +2850,7 @@ dependencies = [ "litellm-secrets-azure", "litellm-secrets-cyberark", "litellm-secrets-google", + "litellm-secrets-hashicorp", "litellm-secrets-types", "moka", "reqwest 0.12.28", @@ -2947,6 +2948,26 @@ dependencies = [ "wiremock", ] +[[package]] +name = "litellm-secrets-hashicorp" +version = "0.1.0" +dependencies = [ + "litellm-core-utils", + "litellm-secrets-types", + "moka", + "rstest", + "rustify", + "rustify_derive", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "vaultrs", + "veil", + "wiremock", +] + [[package]] name = "litellm-secrets-types" version = "0.1.0" @@ -4185,6 +4206,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4800ce4c1cc2fec12c559dae2ddbf0e17fcee7569b796e6d75898efef443368b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http 1.4.2", + "reqwest 0.13.5", + "rustify_derive", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 1.0.69", + "tracing", + "url", +] + +[[package]] +name = "rustify_derive" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ea7fda74240f7410d0198b603a8a2f662acc7d76b6667a49f9b162cd8d9b4f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "serde_urlencoded", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "rustix" version = "1.1.5" @@ -4737,6 +4792,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4768,6 +4834,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5182,6 +5260,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5366,6 +5445,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -5425,6 +5510,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vaultrs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ffcc0e81025065dda612ec1e26a3d81bb16ef3062354873d17a35965d68522" +dependencies = [ + "async-trait", + "derive_builder", + "http 1.4.2", + "reqwest 0.13.5", + "rustify", + "rustify_derive", + "serde", + "serde_json", + "thiserror 2.0.19", + "tracing", + "url", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -5874,7 +5978,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -5915,7 +6019,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]]