From 4e2d4b5ff9f163b6fb121487db1e2079e0fd8d44 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:23:54 +0000 Subject: [PATCH 1/3] feat(rust): add CyberArk Conjur secret manager backend Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-rust.yml | 2 +- litellm-rust/Cargo.lock | 21 + litellm-rust/Cargo.toml | 1 + .../crates/secrets-cyberark/Cargo.toml | 25 + .../crates/secrets-cyberark/src/error.rs | 27 + .../crates/secrets-cyberark/src/lib.rs | 7 + .../secrets-cyberark/src/secret_manager.rs | 315 +++++++++++ .../tests/fixtures/parity.json | 32 ++ .../secrets-cyberark/tests/secret_manager.rs | 488 ++++++++++++++++++ litellm-rust/crates/secrets/Cargo.toml | 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 | 53 ++ .../test_cyberark_secret_manager.py | 119 +++++ 15 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 litellm-rust/crates/secrets-cyberark/Cargo.toml create mode 100644 litellm-rust/crates/secrets-cyberark/src/error.rs create mode 100644 litellm-rust/crates/secrets-cyberark/src/lib.rs create mode 100644 litellm-rust/crates/secrets-cyberark/src/secret_manager.rs create mode 100644 litellm-rust/crates/secrets-cyberark/tests/fixtures/parity.json create mode 100644 litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs create mode 100644 tests/test_litellm/secret_managers/test_cyberark_secret_manager.py diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 278fa7c425f..161353708f0 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 cyberark aws,google aws,google,cyberark; 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..07c9eb8c437 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2697,6 +2697,7 @@ dependencies = [ "jsonwebtoken", "litellm-core-utils", "litellm-secrets-aws", + "litellm-secrets-cyberark", "litellm-secrets-google", "litellm-secrets-types", "moka", @@ -2731,6 +2732,26 @@ dependencies = [ "wiremock", ] +[[package]] +name = "litellm-secrets-cyberark" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "litellm-core-utils", + "litellm-secrets-types", + "moka", + "percent-encoding", + "reqwest 0.12.28", + "rstest", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "veil", + "wiremock", +] + [[package]] name = "litellm-secrets-google" version = "0.1.0" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 570d0dd3568..d35df1eafb8 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-cyberark = { path = "crates/secrets-cyberark" } litellm-http = { path = "crates/http" } litellm-llms = { path = "crates/llms" } litellm-types = { path = "crates/types" } diff --git a/litellm-rust/crates/secrets-cyberark/Cargo.toml b/litellm-rust/crates/secrets-cyberark/Cargo.toml new file mode 100644 index 00000000000..de8ae5b71dc --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "litellm-secrets-cyberark" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +litellm-secrets-types.workspace = true +litellm-core-utils.workspace = true +base64.workspace = true +moka.workspace = true +reqwest.workspace = true +serde_json.workspace = true +thiserror.workspace = true +veil.workspace = true +tracing = "0.1" +percent-encoding = "2.3" + +[dev-dependencies] +rstest.workspace = true +tokio.workspace = true +wiremock = "0.6.5" +serde.workspace = true +serde_json.workspace = true diff --git a/litellm-rust/crates/secrets-cyberark/src/error.rs b/litellm-rust/crates/secrets-cyberark/src/error.rs new file mode 100644 index 00000000000..5a14f4f3db8 --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/src/error.rs @@ -0,0 +1,27 @@ +#[derive(thiserror::Error, veil::Redact)] +pub enum Error { + #[error("CyberArk Conjur HTTP request failed")] + Http( + #[from] + #[redact] + reqwest::Error, + ), + #[error("CyberArk Conjur authentication returned HTTP {0}")] + AuthStatus(u16), + #[error("CyberArk Conjur returned HTTP {0}")] + Status(u16), + #[error( + "CyberArk credentials are missing: set CYBERARK_API_KEY or both CYBERARK_CLIENT_CERT and CYBERARK_CLIENT_KEY" + )] + MissingCredentials, + #[error("CyberArk client certificate could not be loaded")] + ClientCertificate, + #[error("invalid refresh interval")] + RefreshInterval, + #[error("invalid CyberArk Conjur endpoint")] + Endpoint, + #[error("CyberArk secret manager requires an enterprise license")] + EnterpriseRequired, + #[error(transparent)] + Operation(#[from] litellm_secrets_types::Error), +} diff --git a/litellm-rust/crates/secrets-cyberark/src/lib.rs b/litellm-rust/crates/secrets-cyberark/src/lib.rs new file mode 100644 index 00000000000..5288f8116b1 --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/src/lib.rs @@ -0,0 +1,7 @@ +#![forbid(unsafe_code)] + +mod error; +mod secret_manager; + +pub use error::Error; +pub use secret_manager::{CyberArkSecretManager, DeleteOutcome}; diff --git a/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs new file mode 100644 index 00000000000..ade257a5872 --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs @@ -0,0 +1,315 @@ +use std::{fs, sync::Arc, time::Duration}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use litellm_core_utils::settings::Lookup; +use litellm_secrets_types::{BaseSecretManager, SecretValue, validate_secret_name}; +use moka::future::Cache; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; + +use crate::Error; + +const CYBERARK_API_BASE: &str = "CYBERARK_API_BASE"; +const CYBERARK_ACCOUNT: &str = "CYBERARK_ACCOUNT"; +const CYBERARK_USERNAME: &str = "CYBERARK_USERNAME"; +const CYBERARK_API_KEY: &str = "CYBERARK_API_KEY"; +const CYBERARK_CLIENT_CERT: &str = "CYBERARK_CLIENT_CERT"; +const CYBERARK_CLIENT_KEY: &str = "CYBERARK_CLIENT_KEY"; +const CYBERARK_SSL_VERIFY: &str = "CYBERARK_SSL_VERIFY"; +const CYBERARK_REFRESH_INTERVAL: &str = "CYBERARK_REFRESH_INTERVAL"; +const DEFAULT_API_BASE: &str = "http://127.0.0.1:8080"; +const DEFAULT_ACCOUNT: &str = "default"; +const DEFAULT_USERNAME: &str = "admin"; +const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(300); +const SECRET_NAME_SAFE: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~'); + +#[derive(Clone)] +pub struct CyberArkSecretManager { + client: reqwest::Client, + endpoint: reqwest::Url, + account: String, + username: String, + api_key: SecretValue, + token: Cache<(), SecretValue>, + secrets: Cache, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeleteOutcome { + NotSupported, +} + +impl CyberArkSecretManager { + pub fn with_client( + client: reqwest::Client, + endpoint: reqwest::Url, + account: String, + username: String, + api_key: SecretValue, + refresh_interval: Option, + ) -> Self { + let endpoint = normalize_endpoint(endpoint); + let ttl = refresh_interval + .filter(|interval| !interval.is_zero()) + .unwrap_or(DEFAULT_REFRESH_INTERVAL); + let token = Cache::builder().time_to_live(ttl).build(); + let secrets = Cache::builder().time_to_live(ttl).build(); + Self { + client, + endpoint, + account, + username, + api_key, + token, + secrets, + } + } + + pub fn new( + environment: Arc, + enterprise_enabled: bool, + ) -> Result { + let api_key = environment.get(CYBERARK_API_KEY).unwrap_or_default(); + let cert = environment.get(CYBERARK_CLIENT_CERT).unwrap_or_default(); + let key = environment.get(CYBERARK_CLIENT_KEY).unwrap_or_default(); + if api_key.is_empty() && (cert.is_empty() || key.is_empty()) { + return Err(Error::MissingCredentials); + } + if !enterprise_enabled { + return Err(Error::EnterpriseRequired); + } + let verify = environment + .get(CYBERARK_SSL_VERIFY) + .map(|value| !value.trim().eq_ignore_ascii_case("false")) + .unwrap_or(true); + let mut builder = reqwest::Client::builder(); + if !verify { + tracing::warn!( + "CyberArk SSL verification is disabled. This is insecure and should only be used for testing with self-signed certificates." + ); + builder = builder.danger_accept_invalid_certs(true); + } + if !cert.is_empty() && !key.is_empty() { + let certificate = fs::read(cert).map_err(|_| Error::ClientCertificate)?; + let private_key = fs::read(key).map_err(|_| Error::ClientCertificate)?; + let identity = reqwest::Identity::from_pem(&[certificate, private_key].concat()) + .map_err(|_| Error::ClientCertificate)?; + builder = builder.identity(identity); + } + let client = builder.build()?; + let endpoint = reqwest::Url::parse( + &environment + .get(CYBERARK_API_BASE) + .unwrap_or_else(|| DEFAULT_API_BASE.to_owned()), + ) + .map_err(|_| Error::Endpoint)?; + let account = environment + .get(CYBERARK_ACCOUNT) + .unwrap_or_else(|| DEFAULT_ACCOUNT.to_owned()); + let username = environment + .get(CYBERARK_USERNAME) + .unwrap_or_else(|| DEFAULT_USERNAME.to_owned()); + let refresh_interval = environment + .get(CYBERARK_REFRESH_INTERVAL) + .map(|value| { + value + .parse::() + .map_err(|_| Error::RefreshInterval) + .map(|seconds| match seconds { + seconds if seconds < 0 => Duration::from_nanos(1), + 0 => DEFAULT_REFRESH_INTERVAL, + seconds => Duration::from_secs(seconds as u64), + }) + }) + .transpose()?; + Ok(Self::with_client( + client, + endpoint, + account, + username, + SecretValue::new(api_key), + refresh_interval, + )) + } + + fn secret_url(&self, name: &str) -> Result { + let encoded = utf8_percent_encode(name, SECRET_NAME_SAFE); + self.endpoint + .join(&format!("secrets/{}/variable/{}", self.account, encoded)) + .map_err(|_| Error::Endpoint) + } + + async fn authenticate(&self) -> Result { + if let Some(token) = self.token.get(&()).await { + return Ok(token); + } + let url = self + .endpoint + .join(&format!( + "authn/{}/{}/authenticate", + self.account, self.username + )) + .map_err(|_| Error::Endpoint)?; + let response = self + .client + .post(url) + .body(self.api_key.expose().to_owned()) + .send() + .await?; + if !response.status().is_success() { + return Err(Error::AuthStatus(response.status().as_u16())); + } + let token = SecretValue::new(STANDARD.encode(response.text().await?)); + self.token.insert((), token.clone()).await; + Ok(token) + } + + async fn authorization_header(&self) -> Result { + Ok(format!( + "Token token=\"{}\"", + self.authenticate().await?.expose() + )) + } + + pub async fn async_read_secret(&self, name: &str) -> Result, Error> { + if let Some(value) = self.secrets.get(name).await { + return Ok(Some(value)); + } + let response = self + .client + .get(self.secret_url(name)?) + .header("Authorization", self.authorization_header().await?) + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(Error::Status(response.status().as_u16())); + } + let value = SecretValue::new(response.text().await?); + self.secrets.insert(name.to_owned(), value.clone()).await; + Ok(Some(value)) + } + + pub async fn async_write_secret( + &self, + name: &str, + value: &SecretValue, + _description: Option<&str>, + ) -> Result<(), Error> { + validate_secret_name(name)?; + self.ensure_variable_exists(name).await; + let response = self + .client + .post(self.secret_url(name)?) + .header("Authorization", self.authorization_header().await?) + .body(value.expose().to_owned()) + .send() + .await?; + if !response.status().is_success() { + return Err(Error::Status(response.status().as_u16())); + } + self.secrets.insert(name.to_owned(), value.clone()).await; + Ok(()) + } + + async fn ensure_variable_exists(&self, name: &str) { + let policy_url = self + .endpoint + .join(&format!("policies/{}/policy/root", self.account)); + let Ok(policy_url) = policy_url else { + tracing::warn!("Could not build CyberArk policy endpoint"); + return; + }; + let Ok(authorization) = self.authorization_header().await else { + tracing::warn!("Could not authenticate while ensuring CyberArk variable exists"); + return; + }; + let body = format!( + "- !variable {}\n", + serde_json::to_string(name).expect("serializing a string cannot fail") + ); + let response = self + .client + .post(policy_url) + .header("Authorization", authorization) + .header("Content-Type", "application/x-yaml") + .body(body) + .send() + .await; + match response { + Ok(response) if response.status().is_success() => {} + Ok(response) + if matches!( + response.status(), + reqwest::StatusCode::CONFLICT | reqwest::StatusCode::UNPROCESSABLE_ENTITY + ) => + { + tracing::debug!( + "CyberArk variable policy already exists or conflicts: {}", + response.status() + ); + } + Ok(response) => { + tracing::warn!( + "Could not ensure CyberArk variable exists: {}", + response.status() + ); + } + Err(error) => { + tracing::warn!("Error ensuring CyberArk variable exists: {error}"); + } + } + } + + pub async fn async_delete_secret( + &self, + name: &str, + _recovery_window_in_days: i64, + ) -> Result { + tracing::warn!( + "CyberArk Conjur does not support direct secret deletion. Secrets must be removed through policy updates." + ); + self.secrets.invalidate(name).await; + Ok(DeleteOutcome::NotSupported) + } +} + +impl BaseSecretManager for CyberArkSecretManager { + type Error = Error; + type WriteResponse = (); + type DeleteResponse = DeleteOutcome; + + async fn async_read_secret(&self, name: &str) -> Result, Error> { + self.async_read_secret(name).await + } + + async fn async_write_secret( + &self, + name: &str, + value: &SecretValue, + description: Option<&str>, + ) -> Result<(), Error> { + self.async_write_secret(name, value, description).await + } + + async fn async_delete_secret( + &self, + name: &str, + recovery_window_in_days: i64, + ) -> Result { + self.async_delete_secret(name, recovery_window_in_days) + .await + } +} + +fn normalize_endpoint(mut endpoint: reqwest::Url) -> reqwest::Url { + if !endpoint.path().ends_with('/') { + endpoint.set_path(&format!("{}/", endpoint.path())); + } + endpoint +} diff --git a/litellm-rust/crates/secrets-cyberark/tests/fixtures/parity.json b/litellm-rust/crates/secrets-cyberark/tests/fixtures/parity.json new file mode 100644 index 00000000000..b7aab572985 --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/tests/fixtures/parity.json @@ -0,0 +1,32 @@ +{ + "endpoint": "http://conjur.test:8080", + "account": "acct", + "username": "admin", + "api_key": "k3y", + "authenticate_path": "/authn/acct/admin/authenticate", + "token_json": "{\"protected\":\"p\",\"payload\":\"q\",\"signature\":\"s\"}", + "authorization_header": "Token token=\"eyJwcm90ZWN0ZWQiOiJwIiwicGF5bG9hZCI6InEiLCJzaWduYXR1cmUiOiJzIn0=\"", + "policy_path": "/policies/acct/policy/root", + "secrets": [ + { + "name": "OPENAI_API_KEY", + "path": "/secrets/acct/variable/OPENAI_API_KEY", + "policy_body": "- !variable \"OPENAI_API_KEY\"\n" + }, + { + "name": "team/app/key", + "path": "/secrets/acct/variable/team%2Fapp%2Fkey", + "policy_body": "- !variable \"team/app/key\"\n" + }, + { + "name": "a b+c.d-e_f~g", + "path": "/secrets/acct/variable/a%20b%2Bc.d-e_f~g", + "policy_body": "- !variable \"a b+c.d-e_f~g\"\n" + }, + { + "name": "needs \"quote\"", + "path": "/secrets/acct/variable/needs%20%22quote%22", + "policy_body": "- !variable \"needs \\\"quote\\\"\"\n" + } + ] +} diff --git a/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs new file mode 100644 index 00000000000..e8158c46945 --- /dev/null +++ b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs @@ -0,0 +1,488 @@ +use std::{sync::Arc, time::Duration}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use litellm_secrets_cyberark::{CyberArkSecretManager, DeleteOutcome, Error}; +use litellm_secrets_types::{SecretValue, validate_secret_name}; +use serde::Deserialize; +use wiremock::{ + Match, Mock, MockServer, Request, ResponseTemplate, + matchers::{body_string, header, method, path}, +}; + +const TOKEN_JSON: &str = r#"{"protected":"p","payload":"q","signature":"s"}"#; + +#[derive(Deserialize)] +struct ParityFixture { + endpoint: String, + account: String, + username: String, + api_key: String, + authenticate_path: String, + token_json: String, + authorization_header: String, + policy_path: String, + secrets: Vec, +} + +#[derive(Deserialize)] +struct ParitySecret { + name: String, + path: String, + policy_body: String, +} + +#[derive(Debug)] +struct RawPath(String); + +impl Match for RawPath { + fn matches(&self, request: &Request) -> bool { + request.url.path() == self.0 + } +} + +fn fixture() -> ParityFixture { + serde_json::from_str(include_str!("fixtures/parity.json")).unwrap() +} + +fn manager(server: &MockServer, ttl: Duration) -> CyberArkSecretManager { + CyberArkSecretManager::with_client( + reqwest::Client::new(), + server.uri().parse().unwrap(), + "acct".into(), + "admin".into(), + SecretValue::new("k3y"), + Some(ttl), + ) +} + +async fn mount_auth(server: &MockServer, expected: u64) { + Mock::given(method("POST")) + .and(path("/authn/acct/admin/authenticate")) + .and(body_string("k3y")) + .respond_with(ResponseTemplate::new(200).set_body_string(TOKEN_JSON)) + .expect(expected) + .mount(server) + .await; +} + +#[tokio::test] +async fn successful_reads_cache_auth_secret_and_redact_values() { + let server = MockServer::start().await; + mount_auth(&server, 1).await; + let token = STANDARD.encode(TOKEN_JSON); + Mock::given(path("/secrets/acct/variable/OPENAI_API_KEY")) + .and(header("authorization", format!("Token token=\"{token}\""))) + .respond_with(ResponseTemplate::new(200).set_body_string("sk-live")) + .expect(1) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + + for _ in 0..2 { + let value = manager + .async_read_secret("OPENAI_API_KEY") + .await + .unwrap() + .unwrap(); + assert_eq!(value.expose(), "sk-live"); + assert!(!format!("{value:?}").contains("sk-live")); + } +} + +#[rstest::rstest] +#[case::not_found(404)] +#[case::unauthorized(401)] +#[case::forbidden(403)] +#[case::server_error(500)] +#[tokio::test] +async fn failed_reads_are_not_cached(#[case] status: u16) { + let server = MockServer::start().await; + mount_auth(&server, 1).await; + let failing = Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(status)) + .expect(1) + .mount_as_scoped(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + let result = manager.async_read_secret("key").await; + if status == 404 { + assert_eq!(result.unwrap(), None); + } else { + assert!(matches!(result, Err(Error::Status(actual)) if actual == status)); + } + drop(failing); + Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("recovered")) + .expect(1) + .mount(&server) + .await; + for _ in 0..2 { + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "recovered" + ); + } +} + +#[tokio::test] +async fn failed_authentication_is_not_cached_and_does_not_read_secret() { + let server = MockServer::start().await; + let failing = Mock::given(path("/authn/acct/admin/authenticate")) + .respond_with(ResponseTemplate::new(401)) + .expect(1) + .mount_as_scoped(&server) + .await; + let unused_secret = Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .expect(0) + .mount_as_scoped(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + assert!(matches!( + manager.async_read_secret("key").await, + Err(Error::AuthStatus(401)) + )); + drop(unused_secret); + drop(failing); + mount_auth(&server, 1).await; + Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .expect(1) + .mount(&server) + .await; + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "value" + ); +} + +#[tokio::test] +async fn expired_tokens_and_secrets_are_fetched_again() { + let server = MockServer::start().await; + mount_auth(&server, 2).await; + Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .expect(2) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_millis(1)); + for _ in 0..2 { + assert!(manager.async_read_secret("key").await.unwrap().is_some()); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +#[rstest::rstest] +#[tokio::test] +async fn secret_names_use_python_quote_encoding( + #[values("OPENAI_API_KEY", "team/app/key", "a b+c.d-e_f~g", "needs \"quote\"")] name: &str, +) { + let fixture = fixture(); + let secret = fixture + .secrets + .iter() + .find(|secret| secret.name == name) + .unwrap(); + let server = MockServer::start().await; + mount_auth(&server, 1).await; + Mock::given(RawPath(secret.path.clone())) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .expect(1) + .mount(&server) + .await; + assert_eq!( + manager(&server, Duration::from_secs(60)) + .async_read_secret(name) + .await + .unwrap() + .unwrap() + .expose(), + "value" + ); +} + +#[rstest::rstest] +#[case(201)] +#[case(409)] +#[case(422)] +#[case(500)] +#[tokio::test] +async fn writes_tolerate_policy_status_and_cache_value(#[case] policy_status: u16) { + let server = MockServer::start().await; + mount_auth(&server, 1).await; + Mock::given(path("/policies/acct/policy/root")) + .and(header("content-type", "application/x-yaml")) + .and(body_string("- !variable \"team/app\"\n")) + .respond_with(ResponseTemplate::new(policy_status)) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/secrets/acct/variable/team%2Fapp")) + .and(body_string("v")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + manager + .async_write_secret("team/app", &SecretValue::new("v"), None) + .await + .unwrap(); + assert_eq!( + manager + .async_read_secret("team/app") + .await + .unwrap() + .unwrap() + .expose(), + "v" + ); +} + +#[tokio::test] +async fn failed_value_write_is_not_cached() { + let server = MockServer::start().await; + mount_auth(&server, 1).await; + Mock::given(path("/policies/acct/policy/root")) + .respond_with(ResponseTemplate::new(409)) + .mount(&server) + .await; + Mock::given(path("/secrets/acct/variable/key")) + .and(body_string("v")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("recovered")) + .expect(1) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + assert!(matches!( + manager + .async_write_secret("key", &SecretValue::new("v"), None) + .await, + Err(Error::Status(403)) + )); + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "recovered" + ); +} + +#[tokio::test] +async fn unsafe_names_fail_before_http_calls() { + let server = MockServer::start().await; + let manager = manager(&server, Duration::from_secs(60)); + assert!(matches!( + manager + .async_write_secret("../etc", &SecretValue::new("v"), None) + .await, + Err(Error::Operation( + litellm_secrets_types::Error::UnsafeSecretName + )) + )); +} + +#[tokio::test] +async fn delete_invalidates_cache_and_reports_not_supported() { + let server = MockServer::start().await; + mount_auth(&server, 1).await; + Mock::given(path("/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("v")) + .expect(2) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "v" + ); + assert_eq!( + manager.async_delete_secret("key", 7).await.unwrap(), + DeleteOutcome::NotSupported + ); + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "v" + ); +} + +#[test] +fn new_validates_credentials_before_license_and_configuration() { + let empty: Arc = + Arc::new(|_: &str| None); + assert!(matches!( + CyberArkSecretManager::new(empty, true), + Err(Error::MissingCredentials) + )); + assert!(matches!( + CyberArkSecretManager::new( + Arc::new(|name: &str| (name == "CYBERARK_API_KEY").then(|| "k3y".into())), + false + ), + Err(Error::EnterpriseRequired) + )); + assert!(matches!( + CyberArkSecretManager::new( + Arc::new(|name: &str| (name == "CYBERARK_CLIENT_CERT").then(|| "cert".into())), + true + ), + Err(Error::MissingCredentials) + )); + assert!(matches!( + CyberArkSecretManager::new( + Arc::new(|name: &str| match name { + "CYBERARK_API_KEY" => Some("k3y".into()), + "CYBERARK_REFRESH_INTERVAL" => Some("abc".into()), + _ => None, + }), + true + ), + Err(Error::RefreshInterval) + )); + assert!(matches!( + CyberArkSecretManager::new( + Arc::new(|name: &str| match name { + "CYBERARK_API_KEY" => Some("k3y".into()), + "CYBERARK_API_BASE" => Some("not a url".into()), + _ => None, + }), + true + ), + Err(Error::Endpoint) + )); +} + +#[tokio::test] +async fn new_reads_environment_defaults_end_to_end() { + let server = MockServer::start().await; + Mock::given(path("/authn/default/admin/authenticate")) + .and(body_string("k3y")) + .respond_with(ResponseTemplate::new(200).set_body_string(TOKEN_JSON)) + .mount(&server) + .await; + Mock::given(path("/secrets/default/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .mount(&server) + .await; + let endpoint = server.uri(); + let manager = CyberArkSecretManager::new( + Arc::new(move |name: &str| match name { + "CYBERARK_API_BASE" => Some(endpoint.clone()), + "CYBERARK_API_KEY" => Some("k3y".into()), + _ => None, + }), + true, + ) + .unwrap(); + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "value" + ); +} + +#[test] +fn new_reports_missing_client_certificate_files() { + assert!(matches!( + CyberArkSecretManager::new( + Arc::new(|name: &str| match name { + "CYBERARK_CLIENT_CERT" => Some("/missing/cert".into()), + "CYBERARK_CLIENT_KEY" => Some("/missing/key".into()), + _ => None, + }), + true + ), + Err(Error::ClientCertificate) + )); +} + +#[test] +fn secret_name_validation_matches_write_guard() { + assert!(validate_secret_name("../etc").is_err()); +} + +#[tokio::test] +async fn trailing_slash_endpoint_preserves_base_path() { + let server = MockServer::start().await; + Mock::given(path("/prefix/authn/acct/admin/authenticate")) + .and(body_string("k3y")) + .respond_with(ResponseTemplate::new(200).set_body_string(TOKEN_JSON)) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/prefix/secrets/acct/variable/key")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .mount(&server) + .await; + let endpoint = format!("{}/prefix/", server.uri()).parse().unwrap(); + let manager = CyberArkSecretManager::with_client( + reqwest::Client::new(), + endpoint, + "acct".into(), + "admin".into(), + SecretValue::new("k3y"), + Some(Duration::from_secs(60)), + ); + assert_eq!( + manager + .async_read_secret("key") + .await + .unwrap() + .unwrap() + .expose(), + "value" + ); +} + +#[test] +fn parity_fixture_matches_authentication_contract() { + let fixture = fixture(); + assert_eq!(fixture.endpoint, "http://conjur.test:8080"); + assert_eq!(fixture.account, "acct"); + assert_eq!(fixture.username, "admin"); + assert_eq!(fixture.api_key, "k3y"); + assert_eq!(fixture.authenticate_path, "/authn/acct/admin/authenticate"); + assert_eq!(fixture.token_json, TOKEN_JSON); + assert_eq!( + fixture.authorization_header, + format!("Token token=\"{}\"", STANDARD.encode(TOKEN_JSON)) + ); + assert_eq!(fixture.policy_path, "/policies/acct/policy/root"); + assert_eq!(fixture.secrets.len(), 4); + assert_eq!( + fixture.secrets[1].policy_body, + "- !variable \"team/app/key\"\n" + ); +} diff --git a/litellm-rust/crates/secrets/Cargo.toml b/litellm-rust/crates/secrets/Cargo.toml index a7e7ec80636..1298cbd33fe 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"] +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-cyberark = { workspace = true, optional = true } litellm-core-utils.workspace = true base64.workspace = true serde.workspace = true diff --git a/litellm-rust/crates/secrets/src/error.rs b/litellm-rust/crates/secrets/src/error.rs index 0c6e681b8aa..cf08c7c2abb 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 = "cyberark")] + #[error(transparent)] + Cyberark(#[from] litellm_secrets_cyberark::Error), } diff --git a/litellm-rust/crates/secrets/src/handler.rs b/litellm-rust/crates/secrets/src/handler.rs index 943ffdf6158..190a67b7671 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 = "cyberark")] + Cyberark(crate::cyberark::CyberArkSecretManager), } impl SecretManager { @@ -27,6 +29,8 @@ impl SecretManager { Self::GoogleKms(_) => KeyManagementSystem::GoogleKms, #[cfg(feature = "google")] Self::GoogleSecretManager(_) => KeyManagementSystem::GoogleSecretManager, + #[cfg(feature = "cyberark")] + Self::Cyberark(_) => KeyManagementSystem::Cyberark, } } } @@ -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 = "cyberark")] + SecretManager::Cyberark(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..e132627345f 100644 --- a/litellm-rust/crates/secrets/src/lib.rs +++ b/litellm-rust/crates/secrets/src/lib.rs @@ -17,5 +17,7 @@ pub use state::{SecretManagerState, secret_manager_would_be_consulted}; #[cfg(feature = "aws")] pub use litellm_secrets_aws as aws; +#[cfg(feature = "cyberark")] +pub use litellm_secrets_cyberark as cyberark; #[cfg(feature = "google")] pub use litellm_secrets_google as google; diff --git a/litellm-rust/crates/secrets/tests/handler.rs b/litellm-rust/crates/secrets/tests/handler.rs index a2cbbd843e1..8f996cad63f 100644 --- a/litellm-rust/crates/secrets/tests/handler.rs +++ b/litellm-rust/crates/secrets/tests/handler.rs @@ -105,3 +105,56 @@ async fn google_handler_requires_canonical_base64_and_preserves_plaintext_whites Err(Error::MissingCiphertext) )); } + +#[cfg(feature = "cyberark")] +#[tokio::test] +async fn cyberark_handler_reads_values_and_surfaces_errors() { + use std::time::Duration; + + use litellm_secrets::{ + Error, KeyManagementSettings, SecretManager, SecretValue, cyberark::CyberArkSecretManager, + get_secret_from_manager, + }; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_string, path}, + }; + + let server = MockServer::start().await; + Mock::given(path("/authn/acct/admin/authenticate")) + .and(body_string("k3y")) + .respond_with(ResponseTemplate::new(200).set_body_string("token")) + .mount(&server) + .await; + Mock::given(path("/secrets/acct/variable/KEY")) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .mount(&server) + .await; + let manager = SecretManager::Cyberark(CyberArkSecretManager::with_client( + reqwest::Client::new(), + server.uri().parse().unwrap(), + "acct".into(), + "admin".into(), + SecretValue::new("k3y"), + Some(Duration::from_secs(60)), + )); + assert_eq!( + manager.system(), + litellm_secrets::KeyManagementSystem::Cyberark + ); + 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")); + + Mock::given(path("/secrets/acct/variable/ERROR")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + assert!(matches!( + get_secret_from_manager(&manager, "ERROR", &settings, &|_: &str| None).await, + Err(Error::Cyberark(_)) + )); +} diff --git a/tests/test_litellm/secret_managers/test_cyberark_secret_manager.py b/tests/test_litellm/secret_managers/test_cyberark_secret_manager.py new file mode 100644 index 00000000000..3f3669ab9ef --- /dev/null +++ b/tests/test_litellm/secret_managers/test_cyberark_secret_manager.py @@ -0,0 +1,119 @@ +import json +from pathlib import Path +from typing import Final, TypedDict, cast + +import pytest +import respx + +import litellm +import litellm.proxy.proxy_server +from litellm.secret_managers.cyberark_secret_manager import CyberArkSecretManager + +FIXTURE_PATH: Final = Path(__file__).resolve().parents[3] / "litellm-rust/crates/secrets-cyberark/tests/fixtures/parity.json" + + +class ParitySecret(TypedDict): + name: str + path: str + policy_body: str + + +class ParityFixture(TypedDict): + endpoint: str + account: str + username: str + api_key: str + authenticate_path: str + token_json: str + authorization_header: str + policy_path: str + secrets: list[ParitySecret] + + +def _fixture() -> ParityFixture: + return cast(ParityFixture, json.loads(FIXTURE_PATH.read_text())) + + +def _configure_manager(monkeypatch: pytest.MonkeyPatch, fixture: ParityFixture) -> CyberArkSecretManager: + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setenv("CYBERARK_API_BASE", fixture["endpoint"]) + monkeypatch.setenv("CYBERARK_ACCOUNT", fixture["account"]) + monkeypatch.setenv("CYBERARK_USERNAME", fixture["username"]) + monkeypatch.setenv("CYBERARK_API_KEY", fixture["api_key"]) + monkeypatch.setenv("CYBERARK_REFRESH_INTERVAL", "300") + monkeypatch.delenv("CYBERARK_CLIENT_CERT", raising=False) + monkeypatch.delenv("CYBERARK_CLIENT_KEY", raising=False) + return CyberArkSecretManager() + + +def _respond( + route: respx.Route, + *, + status_code: int = 200, + content: str | bytes | None = None, + text: str | None = None, +) -> respx.Route: + return route.respond( # pyright: ignore[reportUnknownMemberType] # respx route stubs leave response builder partially unknown + status_code=status_code, + content=content, + text=text, + ) + + +@respx.mock +def test_sync_read_matches_parity_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + fixture: Final = _fixture() + manager: Final = _configure_manager(monkeypatch, fixture) + endpoint: Final = fixture["endpoint"] + token_json: Final = fixture["token_json"] + auth_route: Final = _respond( + respx.post(endpoint + fixture["authenticate_path"]), + content=token_json.encode(), + ) + routes: Final = [ + _respond(respx.get(endpoint + secret["path"]), text="value") + for secret in fixture["secrets"] + ] + + for secret in fixture["secrets"]: + assert manager.sync_read_secret(secret["name"]) == "value" # pyright: ignore[reportUnknownMemberType] # legacy secret manager API is untyped + + expected_authorization: Final = fixture["authorization_header"] + assert auth_route.calls.last.request.content == fixture["api_key"].encode() + assert all(route.calls.last.request.headers["Authorization"] == expected_authorization for route in routes) + assert all( + route.calls.last.request.url.raw_path.decode() == secret["path"] + for route, secret in zip(routes, fixture["secrets"], strict=True) + ) + + +@pytest.mark.asyncio +@respx.mock +async def test_async_write_matches_parity_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + fixture: Final = _fixture() + manager: Final = _configure_manager(monkeypatch, fixture) + secret: Final = fixture["secrets"][0] + endpoint: Final = fixture["endpoint"] + token_json: Final = fixture["token_json"] + _respond(respx.post(endpoint + fixture["authenticate_path"]), content=token_json.encode()) + policy_route: Final = _respond(respx.post(endpoint + fixture["policy_path"]), status_code=201) + value_route: Final = _respond(respx.post(endpoint + secret["path"]), status_code=201) + + await manager.async_write_secret(secret["name"], "v") # pyright: ignore[reportUnknownMemberType] # legacy secret manager API is untyped + + assert policy_route.calls.last.request.content.decode() == secret["policy_body"] + assert policy_route.calls.last.request.headers["Content-Type"] == "application/x-yaml" + assert value_route.calls.last.request.content == b"v" + + +def test_missing_credentials_raise_value_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + for name in ( + "CYBERARK_API_KEY", + "CYBERARK_CLIENT_CERT", + "CYBERARK_CLIENT_KEY", + ): + monkeypatch.delenv(name, raising=False) + with pytest.raises(ValueError, match="Missing CyberArk credentials"): + CyberArkSecretManager() From fe6804ea74a7ec28bd3767b2c54647bee9d5fe5d Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:25:07 +0000 Subject: [PATCH 2/3] refactor(rust): reject negative CyberArk refresh intervals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/secrets-cyberark/src/secret_manager.rs | 8 ++------ .../crates/secrets-cyberark/tests/secret_manager.rs | 7 +------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs index ade257a5872..ee4bded4b29 100644 --- a/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs @@ -116,13 +116,9 @@ impl CyberArkSecretManager { .get(CYBERARK_REFRESH_INTERVAL) .map(|value| { value - .parse::() + .parse::() + .map(Duration::from_secs) .map_err(|_| Error::RefreshInterval) - .map(|seconds| match seconds { - seconds if seconds < 0 => Duration::from_nanos(1), - 0 => DEFAULT_REFRESH_INTERVAL, - seconds => Duration::from_secs(seconds as u64), - }) }) .transpose()?; Ok(Self::with_client( diff --git a/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs index e8158c46945..9ea8da63383 100644 --- a/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs +++ b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; use base64::{Engine, engine::general_purpose::STANDARD}; use litellm_secrets_cyberark::{CyberArkSecretManager, DeleteOutcome, Error}; -use litellm_secrets_types::{SecretValue, validate_secret_name}; +use litellm_secrets_types::SecretValue; use serde::Deserialize; use wiremock::{ Match, Mock, MockServer, Request, ResponseTemplate, @@ -428,11 +428,6 @@ fn new_reports_missing_client_certificate_files() { )); } -#[test] -fn secret_name_validation_matches_write_guard() { - assert!(validate_secret_name("../etc").is_err()); -} - #[tokio::test] async fn trailing_slash_endpoint_preserves_base_path() { let server = MockServer::start().await; From 7d597b2dd4232c9ad6ab59d7db760feb2be18410 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Mon, 21 Sep 2026 20:36:59 +0000 Subject: [PATCH 3/3] fix(rust): coalesce CyberArk authentication Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/secrets-cyberark/Cargo.toml | 1 + .../secrets-cyberark/src/secret_manager.rs | 6 ++++ .../secrets-cyberark/tests/secret_manager.rs | 33 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/litellm-rust/crates/secrets-cyberark/Cargo.toml b/litellm-rust/crates/secrets-cyberark/Cargo.toml index de8ae5b71dc..3c1159c40be 100644 --- a/litellm-rust/crates/secrets-cyberark/Cargo.toml +++ b/litellm-rust/crates/secrets-cyberark/Cargo.toml @@ -16,6 +16,7 @@ thiserror.workspace = true veil.workspace = true tracing = "0.1" percent-encoding = "2.3" +tokio = { workspace = true, features = ["sync"] } [dev-dependencies] rstest.workspace = true diff --git a/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs index ee4bded4b29..9d6eaaf1c4e 100644 --- a/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs +++ b/litellm-rust/crates/secrets-cyberark/src/secret_manager.rs @@ -35,6 +35,7 @@ pub struct CyberArkSecretManager { api_key: SecretValue, token: Cache<(), SecretValue>, secrets: Cache, + authentication_lock: Arc>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -65,6 +66,7 @@ impl CyberArkSecretManager { api_key, token, secrets, + authentication_lock: Arc::new(tokio::sync::Mutex::new(())), } } @@ -139,6 +141,10 @@ impl CyberArkSecretManager { } async fn authenticate(&self) -> Result { + if let Some(token) = self.token.get(&()).await { + return Ok(token); + } + let _guard = self.authentication_lock.lock().await; if let Some(token) = self.token.get(&()).await { return Ok(token); } diff --git a/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs index 9ea8da63383..fd7198b70fb 100644 --- a/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs +++ b/litellm-rust/crates/secrets-cyberark/tests/secret_manager.rs @@ -89,6 +89,39 @@ async fn successful_reads_cache_auth_secret_and_redact_values() { } } +#[tokio::test] +async fn concurrent_reads_share_authentication_request() { + let server = MockServer::start().await; + Mock::given(path("/authn/acct/admin/authenticate")) + .and(body_string("k3y")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(TOKEN_JSON) + .set_delay(Duration::from_millis(20)), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/secrets/acct/variable/key")) + .and(header( + "authorization", + format!("Token token=\"{}\"", STANDARD.encode(TOKEN_JSON)), + )) + .respond_with(ResponseTemplate::new(200).set_body_string("value")) + .expect(2) + .mount(&server) + .await; + let manager = manager(&server, Duration::from_secs(60)); + + let (first, second) = tokio::join!( + manager.async_read_secret("key"), + manager.async_read_secret("key") + ); + + assert_eq!(first.unwrap().unwrap().expose(), "value"); + assert_eq!(second.unwrap().unwrap().expose(), "value"); +} + #[rstest::rstest] #[case::not_found(404)] #[case::unauthorized(401)]