mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #42309 from BerriAI/litellm_rust_secrets_azure_key_vault
feat(rust): add Azure Key Vault secret manager backend
This commit is contained in:
commit
41adbdaa05
17 changed files with 642 additions and 2 deletions
2
.github/workflows/test-rust.yml
vendored
2
.github/workflows/test-rust.yml
vendored
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
- name: Test secret manager feature combinations
|
||||
run: |
|
||||
cargo test -p litellm-auth-gcp --locked --no-default-features
|
||||
for features in '' aws google cyberark aws,google aws,google,cyberark; do
|
||||
for features in '' aws google azure cyberark aws,google aws,azure google,azure aws,google,azure aws,google,cyberark aws,google,azure,cyberark; do
|
||||
cargo test -p litellm-secrets --locked --no-default-features --features "$features"
|
||||
done
|
||||
|
||||
|
|
|
|||
21
litellm-rust/Cargo.lock
generated
21
litellm-rust/Cargo.lock
generated
|
|
@ -2704,6 +2704,7 @@ dependencies = [
|
|||
"jsonwebtoken",
|
||||
"litellm-core-utils",
|
||||
"litellm-secrets-aws",
|
||||
"litellm-secrets-azure",
|
||||
"litellm-secrets-cyberark",
|
||||
"litellm-secrets-google",
|
||||
"litellm-secrets-types",
|
||||
|
|
@ -2739,6 +2740,26 @@ dependencies = [
|
|||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-secrets-azure"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"litellm-auth-azure",
|
||||
"litellm-auth-types",
|
||||
"litellm-core-utils",
|
||||
"litellm-secrets-types",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"veil",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-secrets-cyberark"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ litellm-secrets = { path = "crates/secrets" }
|
|||
litellm-secrets-types = { path = "crates/secrets-types" }
|
||||
litellm-secrets-aws = { path = "crates/secrets-aws" }
|
||||
litellm-secrets-google = { path = "crates/secrets-google" }
|
||||
litellm-secrets-azure = { path = "crates/secrets-azure" }
|
||||
litellm-secrets-cyberark = { path = "crates/secrets-cyberark" }
|
||||
litellm-http = { path = "crates/http" }
|
||||
litellm-llms = { path = "crates/llms" }
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ mod resolve;
|
|||
mod types;
|
||||
|
||||
pub use resolve::AzureAuthService;
|
||||
pub use types::AzureAuthInputs;
|
||||
pub use types::{AzureAuthInputs, ConfigValue};
|
||||
|
|
|
|||
24
litellm-rust/crates/secrets-azure/Cargo.toml
Normal file
24
litellm-rust/crates/secrets-azure/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "litellm-secrets-azure"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
litellm-auth-azure.workspace = true
|
||||
litellm-auth-types.workspace = true
|
||||
litellm-secrets-types.workspace = true
|
||||
litellm-core-utils.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
veil.workspace = true
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
wiremock = "0.6.5"
|
||||
rstest.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
25
litellm-rust/crates/secrets-azure/src/error.rs
Normal file
25
litellm-rust/crates/secrets-azure/src/error.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#[derive(thiserror::Error, veil::Redact)]
|
||||
pub enum Error {
|
||||
#[error("{0} environment variable is missing")]
|
||||
MissingEnvironment(&'static str),
|
||||
#[error("AZURE_KEY_VAULT_URI is not a valid https vault URL")]
|
||||
VaultUri,
|
||||
#[error("Azure Key Vault credentials are not configured")]
|
||||
MissingCredentials,
|
||||
#[error(transparent)]
|
||||
Auth(
|
||||
#[from]
|
||||
#[redact]
|
||||
litellm_auth_types::Error,
|
||||
),
|
||||
#[error("Azure Key Vault request failed")]
|
||||
Http(
|
||||
#[source]
|
||||
#[redact]
|
||||
reqwest::Error,
|
||||
),
|
||||
#[error("Azure Key Vault returned HTTP {0}")]
|
||||
Status(u16),
|
||||
#[error("Azure Key Vault response is missing the secret value")]
|
||||
MissingValue,
|
||||
}
|
||||
118
litellm-rust/crates/secrets-azure/src/key_vault.rs
Normal file
118
litellm-rust/crates/secrets-azure/src/key_vault.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use litellm_auth_azure::{AzureAuthInputs, AzureAuthService, ConfigValue};
|
||||
use litellm_auth_types::{InputSource, Sourced};
|
||||
use litellm_core_utils::settings::Lookup;
|
||||
use litellm_secrets_types::{Secret, SecretValue};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::Error;
|
||||
|
||||
const AZURE_KEY_VAULT_URI: &str = "AZURE_KEY_VAULT_URI";
|
||||
const API_VERSION: &str = "7.4";
|
||||
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AzureKeyVault {
|
||||
client: reqwest::Client,
|
||||
vault: reqwest::Url,
|
||||
auth: Arc<AzureAuthService>,
|
||||
inputs: Arc<AzureAuthInputs>,
|
||||
environment: Arc<dyn Lookup + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SecretResponse {
|
||||
value: Option<String>,
|
||||
}
|
||||
|
||||
impl AzureKeyVault {
|
||||
pub fn with_client(
|
||||
client: reqwest::Client,
|
||||
vault: reqwest::Url,
|
||||
environment: Arc<dyn Lookup + Send + Sync>,
|
||||
) -> Result<Self, Error> {
|
||||
if vault.host_str().is_none() {
|
||||
return Err(Error::VaultUri);
|
||||
}
|
||||
let inputs = AzureAuthInputs {
|
||||
azure_scope: ConfigValue::Value(Sourced::new(
|
||||
scope_for(&vault),
|
||||
InputSource::Deployment,
|
||||
)),
|
||||
enable_azure_ad_token_refresh: Sourced::new(true, InputSource::Deployment),
|
||||
..AzureAuthInputs::default()
|
||||
};
|
||||
Ok(Self {
|
||||
client,
|
||||
vault,
|
||||
auth: Arc::new(AzureAuthService::default()),
|
||||
inputs: Arc::new(inputs),
|
||||
environment,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new(environment: Arc<dyn Lookup + Send + Sync>) -> Result<Self, Error> {
|
||||
let value = environment
|
||||
.get(AZURE_KEY_VAULT_URI)
|
||||
.ok_or(Error::MissingEnvironment(AZURE_KEY_VAULT_URI))?;
|
||||
let vault = reqwest::Url::parse(&value).map_err(|_| Error::VaultUri)?;
|
||||
if vault.scheme() != "https" || vault.host_str().is_none() {
|
||||
return Err(Error::VaultUri);
|
||||
}
|
||||
Self::with_client(reqwest::Client::new(), vault, environment)
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> &str {
|
||||
self.inputs
|
||||
.azure_scope
|
||||
.as_value()
|
||||
.map(|value| value.value().as_str())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn get_secret_from_azure_key_vault(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> Result<Option<Secret>, Error> {
|
||||
let token = self
|
||||
.auth
|
||||
.get_azure_ad_token(&self.inputs, &|key| self.environment.get(key))
|
||||
.await?
|
||||
.ok_or(Error::MissingCredentials)?;
|
||||
let encoded_name = percent_encoding::utf8_percent_encode(name, PATH_SEGMENT);
|
||||
let url = self
|
||||
.vault
|
||||
.join(&format!("secrets/{encoded_name}?api-version={API_VERSION}"))
|
||||
.map_err(|_| Error::VaultUri)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.bearer_auth(token.value().secret().expose())
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::Http)?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if response.status() != reqwest::StatusCode::OK {
|
||||
return Err(Error::Status(response.status().as_u16()));
|
||||
}
|
||||
let payload: SecretResponse = response.json().await.map_err(Error::Http)?;
|
||||
let value = payload.value.ok_or(Error::MissingValue)?;
|
||||
Ok(Some(Secret::String(SecretValue::new(value))))
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_for(vault: &reqwest::Url) -> String {
|
||||
let host = vault.host_str().unwrap_or_default();
|
||||
let resource = host
|
||||
.split_once('.')
|
||||
.map_or(host, |(_, remainder)| remainder);
|
||||
format!("https://{resource}/.default")
|
||||
}
|
||||
7
litellm-rust/crates/secrets-azure/src/lib.rs
Normal file
7
litellm-rust/crates/secrets-azure/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#![forbid(unsafe_code)]
|
||||
|
||||
mod error;
|
||||
mod key_vault;
|
||||
|
||||
pub use error::Error;
|
||||
pub use key_vault::AzureKeyVault;
|
||||
8
litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json
vendored
Normal file
8
litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"cases": [
|
||||
{"name": "plain_value", "secret_name": "OPENAI-API-KEY", "response": {"status": 200, "body": {"value": "sk-parity-1", "id": "https://example.vault.azure.net/secrets/OPENAI-API-KEY/abc"}}, "expected": {"value": "sk-parity-1"}},
|
||||
{"name": "json_value_is_kept_as_string", "secret_name": "JSON-SECRET", "response": {"status": 200, "body": {"value": "{\"api_key\": \"nested\"}", "id": "https://example.vault.azure.net/secrets/JSON-SECRET/abc"}}, "expected": {"value": "{\"api_key\": \"nested\"}"}},
|
||||
{"name": "missing_secret", "secret_name": "MISSING", "response": {"status": 404, "body": {"error": {"code": "SecretNotFound", "message": "not found"}}}, "expected": {"missing": true}},
|
||||
{"name": "forbidden", "secret_name": "FORBIDDEN", "response": {"status": 403, "body": {"error": {"code": "Forbidden", "message": "denied"}}}, "expected": {"error": true}}
|
||||
]
|
||||
}
|
||||
222
litellm-rust/crates/secrets-azure/tests/key_vault.rs
Normal file
222
litellm-rust/crates/secrets-azure/tests/key_vault.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use litellm_secrets_azure::{AzureKeyVault, Error};
|
||||
use litellm_secrets_types::{Secret, SecretValue};
|
||||
use serde::Deserialize;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{header, path, query_param},
|
||||
};
|
||||
|
||||
fn manager(server: &MockServer) -> AzureKeyVault {
|
||||
AzureKeyVault::with_client(
|
||||
reqwest::Client::new(),
|
||||
server.uri().parse().unwrap(),
|
||||
Arc::new(|name: &str| (name == "AZURE_AD_TOKEN").then(|| "fake".to_owned())),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_secret_with_bearer_token_and_api_version() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/OPENAI-API-KEY"))
|
||||
.and(query_param("api-version", "7.4"))
|
||||
.and(header("authorization", "Bearer fake"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({"value": "s3cret", "id": "secret-id"})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let secret = manager(&server)
|
||||
.get_secret_from_azure_key_vault("OPENAI-API-KEY")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secret, Secret::String(SecretValue::new("s3cret")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn percent_encodes_secret_name_path_segment() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/name%2Fwith%20spaces"))
|
||||
.and(query_param("api-version", "7.4"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "value"})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let secret = manager(&server)
|
||||
.get_secret_from_azure_key_vault("name/with spaces")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(secret.as_str(), Some("value"));
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case::not_found(404, None)]
|
||||
#[case::forbidden(403, Some(403))]
|
||||
#[tokio::test]
|
||||
async fn handles_statuses(#[case] status: u16, #[case] expected_status: Option<u16>) {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/NAME"))
|
||||
.respond_with(ResponseTemplate::new(status))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = manager(&server)
|
||||
.get_secret_from_azure_key_vault("NAME")
|
||||
.await;
|
||||
|
||||
match expected_status {
|
||||
None => assert_eq!(result.unwrap(), None),
|
||||
Some(status) => assert!(matches!(result, Err(Error::Status(actual)) if actual == status)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_value_is_an_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/NAME"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
manager(&server)
|
||||
.get_secret_from_azure_key_vault("NAME")
|
||||
.await,
|
||||
Err(Error::MissingValue)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_validates_vault_environment() {
|
||||
assert!(matches!(
|
||||
AzureKeyVault::new(Arc::new(|_: &str| None)),
|
||||
Err(Error::MissingEnvironment("AZURE_KEY_VAULT_URI"))
|
||||
));
|
||||
assert!(matches!(
|
||||
AzureKeyVault::new(Arc::new(|name: &str| {
|
||||
(name == "AZURE_KEY_VAULT_URI").then(|| "http://vault.example".to_owned())
|
||||
})),
|
||||
Err(Error::VaultUri)
|
||||
));
|
||||
assert!(matches!(
|
||||
AzureKeyVault::new(Arc::new(|name: &str| {
|
||||
(name == "AZURE_KEY_VAULT_URI").then(|| "vault.example".to_owned())
|
||||
})),
|
||||
Err(Error::VaultUri)
|
||||
));
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case("https://myvault.vault.azure.net", "https://vault.azure.net/.default")]
|
||||
#[case(
|
||||
"https://v.vault.usgovcloudapi.net/",
|
||||
"https://vault.usgovcloudapi.net/.default"
|
||||
)]
|
||||
#[case("http://localhost:8080", "https://localhost/.default")]
|
||||
#[test]
|
||||
fn derives_scope_from_vault_host(#[case] uri: &str, #[case] expected: &str) {
|
||||
let manager = AzureKeyVault::with_client(
|
||||
reqwest::Client::new(),
|
||||
uri.parse().unwrap(),
|
||||
Arc::new(|_: &str| None),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manager.scope(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_credentials_do_not_request_vault() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/NAME"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
manager_without_credentials(&server)
|
||||
.get_secret_from_azure_key_vault("NAME")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn manager_without_credentials(server: &MockServer) -> AzureKeyVault {
|
||||
AzureKeyVault::with_client(
|
||||
reqwest::Client::new(),
|
||||
server.uri().parse().unwrap(),
|
||||
Arc::new(|name: &str| {
|
||||
(name == "AZURE_CREDENTIAL").then(|| "ClientSecretCredential".to_owned())
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Fixture {
|
||||
cases: Vec<FixtureCase>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FixtureCase {
|
||||
secret_name: String,
|
||||
response: FixtureResponse,
|
||||
expected: FixtureExpected,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FixtureResponse {
|
||||
status: u16,
|
||||
body: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FixtureExpected {
|
||||
value: Option<String>,
|
||||
missing: Option<bool>,
|
||||
error: Option<bool>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parity_fixture_matches_python_backend_contract() {
|
||||
let fixture: Fixture =
|
||||
serde_json::from_str(include_str!("fixtures/key_vault_parity.json")).unwrap();
|
||||
for case in fixture.cases {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path(format!("/secrets/{}", case.secret_name)))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(case.response.status).set_body_json(case.response.body),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = manager(&server)
|
||||
.get_secret_from_azure_key_vault(&case.secret_name)
|
||||
.await;
|
||||
if case.expected.missing == Some(true) {
|
||||
assert_eq!(result.unwrap(), None);
|
||||
} else if case.expected.error == Some(true) {
|
||||
assert!(result.is_err());
|
||||
} else {
|
||||
assert_eq!(
|
||||
result.unwrap().unwrap().as_str(),
|
||||
case.expected.value.as_deref()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
litellm-rust/crates/secrets-azure/tests/live.rs
Normal file
30
litellm-rust/crates/secrets-azure/tests/live.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use litellm_core_utils::settings::ProcessEnvironment;
|
||||
use litellm_secrets_azure::AzureKeyVault;
|
||||
use litellm_secrets_types::Secret;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn reads_a_real_secret() {
|
||||
let environment = Arc::new(ProcessEnvironment);
|
||||
let manager = AzureKeyVault::new(environment).unwrap();
|
||||
let name = std::env::var("AZURE_KEY_VAULT_LIVE_SECRET_NAME").unwrap();
|
||||
let secret = manager
|
||||
.get_secret_from_azure_key_vault(&name)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(&secret, Secret::String(_)));
|
||||
let host = std::env::var("AZURE_KEY_VAULT_URI")
|
||||
.unwrap()
|
||||
.parse::<reqwest::Url>()
|
||||
.unwrap()
|
||||
.host_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let value_len = secret.as_str().unwrap().len();
|
||||
println!(
|
||||
"native provider=litellm-secrets-azure vault_host={host} secret={name} value_len={value_len}"
|
||||
);
|
||||
}
|
||||
|
|
@ -9,12 +9,14 @@ repository.workspace = true
|
|||
default = []
|
||||
aws = ["dep:litellm-secrets-aws"]
|
||||
google = ["dep:litellm-secrets-google"]
|
||||
azure = ["dep:litellm-secrets-azure"]
|
||||
cyberark = ["dep:litellm-secrets-cyberark"]
|
||||
|
||||
[dependencies]
|
||||
litellm-secrets-types.workspace = true
|
||||
litellm-secrets-aws = { workspace = true, optional = true }
|
||||
litellm-secrets-google = { workspace = true, optional = true }
|
||||
litellm-secrets-azure = { workspace = true, optional = true }
|
||||
litellm-secrets-cyberark = { workspace = true, optional = true }
|
||||
litellm-core-utils.workspace = true
|
||||
base64.workspace = true
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ pub enum Error {
|
|||
#[cfg(feature = "google")]
|
||||
#[error(transparent)]
|
||||
Google(#[from] litellm_secrets_google::Error),
|
||||
#[cfg(feature = "azure")]
|
||||
#[error(transparent)]
|
||||
Azure(#[from] litellm_secrets_azure::Error),
|
||||
#[cfg(feature = "cyberark")]
|
||||
#[error(transparent)]
|
||||
Cyberark(#[from] litellm_secrets_cyberark::Error),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub enum SecretManager {
|
|||
GoogleKms(crate::google::GoogleKms),
|
||||
#[cfg(feature = "google")]
|
||||
GoogleSecretManager(crate::google::GoogleSecretManager),
|
||||
#[cfg(feature = "azure")]
|
||||
AzureKeyVault(crate::azure::AzureKeyVault),
|
||||
#[cfg(feature = "cyberark")]
|
||||
Cyberark(crate::cyberark::CyberArkSecretManager),
|
||||
}
|
||||
|
|
@ -29,6 +31,8 @@ impl SecretManager {
|
|||
Self::GoogleKms(_) => KeyManagementSystem::GoogleKms,
|
||||
#[cfg(feature = "google")]
|
||||
Self::GoogleSecretManager(_) => KeyManagementSystem::GoogleSecretManager,
|
||||
#[cfg(feature = "azure")]
|
||||
Self::AzureKeyVault(_) => KeyManagementSystem::AzureKeyVault,
|
||||
#[cfg(feature = "cyberark")]
|
||||
Self::Cyberark(_) => KeyManagementSystem::Cyberark,
|
||||
}
|
||||
|
|
@ -82,6 +86,11 @@ pub async fn get_secret_from_manager(
|
|||
.get_secret_from_google_secret_manager(secret_name)
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
#[cfg(feature = "azure")]
|
||||
SecretManager::AzureKeyVault(client) => client
|
||||
.get_secret_from_azure_key_vault(secret_name)
|
||||
.await
|
||||
.map_err(Error::from),
|
||||
#[cfg(feature = "cyberark")]
|
||||
SecretManager::Cyberark(client) => client
|
||||
.async_read_secret(secret_name)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ pub use state::{SecretManagerState, secret_manager_would_be_consulted};
|
|||
|
||||
#[cfg(feature = "aws")]
|
||||
pub use litellm_secrets_aws as aws;
|
||||
#[cfg(feature = "azure")]
|
||||
pub use litellm_secrets_azure as azure;
|
||||
#[cfg(feature = "cyberark")]
|
||||
pub use litellm_secrets_cyberark as cyberark;
|
||||
#[cfg(feature = "google")]
|
||||
|
|
|
|||
|
|
@ -106,6 +106,67 @@ async fn google_handler_requires_canonical_base64_and_preserves_plaintext_whites
|
|||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "azure")]
|
||||
#[tokio::test]
|
||||
async fn azure_handler_reads_missing_and_failed_secrets() {
|
||||
use litellm_secrets::{
|
||||
Error, KeyManagementSettings, KeyManagementSystem, SecretManager, azure::AzureKeyVault,
|
||||
get_secret_from_manager,
|
||||
};
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{path, query_param},
|
||||
};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(path("/secrets/KEY"))
|
||||
.and(query_param("api-version", "7.4"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "value"})),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let manager = SecretManager::AzureKeyVault(
|
||||
AzureKeyVault::with_client(
|
||||
reqwest::Client::new(),
|
||||
server.uri().parse().unwrap(),
|
||||
std::sync::Arc::new(|name: &str| (name == "AZURE_AD_TOKEN").then(|| "fake".to_owned())),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(manager.system(), KeyManagementSystem::AzureKeyVault);
|
||||
let settings = KeyManagementSettings::default();
|
||||
let value = get_secret_from_manager(&manager, "KEY", &settings, &|_: &str| None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(value.as_str(), Some("value"));
|
||||
|
||||
let not_found = Mock::given(path("/secrets/MISSING"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.expect(1)
|
||||
.mount_as_scoped(&server)
|
||||
.await;
|
||||
assert_eq!(
|
||||
get_secret_from_manager(&manager, "MISSING", &settings, &|_: &str| None)
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
drop(not_found);
|
||||
|
||||
Mock::given(path("/secrets/FAILED"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
get_secret_from_manager(&manager, "FAILED", &settings, &|_: &str| None).await,
|
||||
Err(Error::Azure(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "cyberark")]
|
||||
#[tokio::test]
|
||||
async fn cyberark_handler_reads_values_and_surfaces_errors() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from litellm.secret_managers.secret_manager_handler import get_secret_from_manager
|
||||
from litellm.types.secret_managers.main import KeyManagementSystem
|
||||
|
||||
|
||||
def _azure_exception_types() -> tuple[type[Exception], type[Exception]]:
|
||||
try:
|
||||
from azure.core.exceptions import (
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
except ImportError:
|
||||
return Exception, Exception
|
||||
return HttpResponseError, ResourceNotFoundError
|
||||
|
||||
|
||||
_AZURE_EXCEPTION_TYPES: Final[tuple[type[Exception], type[Exception]]] = _azure_exception_types()
|
||||
AzureHttpResponseError: Final[type[Exception]] = _AZURE_EXCEPTION_TYPES[0]
|
||||
AzureResourceNotFoundError: Final[type[Exception]] = _AZURE_EXCEPTION_TYPES[1]
|
||||
|
||||
|
||||
class FixtureResponse(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
status: int
|
||||
body: dict[str, object]
|
||||
|
||||
|
||||
class FixtureExpected(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
value: str | None = None
|
||||
missing: bool = False
|
||||
error: bool = False
|
||||
|
||||
|
||||
class FixtureCase(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
secret_name: str
|
||||
response: FixtureResponse
|
||||
expected: FixtureExpected
|
||||
|
||||
|
||||
class Fixture(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
cases: tuple[FixtureCase, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FakeSecret:
|
||||
value: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FakeAzureKeyVaultClient:
|
||||
status: int
|
||||
value: str | None
|
||||
|
||||
def get_secret(self, name: str) -> FakeSecret:
|
||||
if self.status == 404:
|
||||
raise AzureResourceNotFoundError()
|
||||
if self.status != 200:
|
||||
raise AzureHttpResponseError()
|
||||
return FakeSecret(value=self.value)
|
||||
|
||||
|
||||
FIXTURE_PATH: Path = (
|
||||
Path(__file__).parents[3]
|
||||
/ "litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json"
|
||||
)
|
||||
|
||||
|
||||
def test_azure_key_vault_matches_rust_parity_fixture() -> None:
|
||||
fixture: Fixture = Fixture.model_validate_json(FIXTURE_PATH.read_text())
|
||||
for case in fixture.cases:
|
||||
value: object = case.response.body.get("value")
|
||||
secret: str | None = value if isinstance(value, str) else None
|
||||
client: FakeAzureKeyVaultClient = FakeAzureKeyVaultClient(
|
||||
status=case.response.status,
|
||||
value=secret,
|
||||
)
|
||||
if case.expected.missing or case.expected.error:
|
||||
with pytest.raises(
|
||||
AzureResourceNotFoundError if case.expected.missing else AzureHttpResponseError
|
||||
):
|
||||
get_secret_from_manager(
|
||||
secret_name=case.secret_name,
|
||||
key_manager=KeyManagementSystem.AZURE_KEY_VAULT.value,
|
||||
client=client,
|
||||
)
|
||||
continue
|
||||
|
||||
result: str | None = get_secret_from_manager(
|
||||
secret_name=case.secret_name,
|
||||
key_manager=KeyManagementSystem.AZURE_KEY_VAULT.value,
|
||||
client=client,
|
||||
)
|
||||
assert result == case.expected.value
|
||||
Loading…
Add table
Reference in a new issue