litellm/litellm-rust/crates/python-bridge/src/secrets/operations.rs
devin-ai-integration[bot] 19556952d9
feat(secrets): route secret resolution through native Rust backends (#42619)
* fix(secrets): verify provider API request and payload contracts

* wip

* fix(secrets): unify backend reads and route secret resolution

* feat(secrets): bind built-in managers to retained Rust backends

* refactor(secrets): centralize catalog dispatch and native binding

* test(secrets): split provider integration tests

* refactor(secrets): enforce cache and rotation contracts

* test(secrets): stub parent packages in failing resolver fixture

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(secrets): pass manager settings through the interop boundary

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): align cloud KMS auth and harden provider reads

* ci(rust): raise native wheel size gate to 40 MB for secrets backends

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): treat unset google kms flag as disabled like the old loader

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): preserve certificate credentials and disabled KMS flags

* test(secrets): cover certificate validation and bounded auth retries

* test(secrets): cover Python dispatch without the native extension

* test(proxy): skip legacy secret manager cases when the optional SDK is missing

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): port Python parity tests and preserve provider behavior

* fix(secrets): store the captured native config without setattr to satisfy the strict lint budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): preserve missing Azure manager values

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(secrets): pin typed values and recovery failure precedence

* refactor(secrets): organize provider internals and behavioral test suites

* refactor(secrets): simplify recovery and isolate Python compatibility

* fix(secrets): distinguish Azure callback absence from HTTP not found

* fix(secrets): preserve Python AWS read results at the bridge

* fix(secrets): route public reads through the native catalog bridge

* fix(secrets): keep JSON selection outside the bridge

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): preserve provider JSON reads at the bridge

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): preserve Python primary JSON semantics

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(secrets): preserve CyberArk mutation behavior through the native bridge

* docs(secrets): record public API replacement gaps

* refactor(secrets): share Vault write payload preparation

* feat(secrets): route Vault mutations through the native bridge

* fix(secrets): preserve typed Vault rotation failures

* refactor(secrets): move Python dispatch into bridge

* refactor(secrets): move CyberArk Python policy into bridge

* refactor(secrets): move Vault Python policy into bridge

* test(secrets): assert Vault rotation request paths

* fix(secrets): keep bridge JSON interop centralized

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 08:24:57 -07:00

247 lines
8.6 KiB
Rust

use litellm_core_utils::settings::Lookup;
use litellm_secrets::Secret;
use litellm_secrets::cyberark::AuthenticationRetry;
use litellm_secrets::{Error, SecretManager};
use litellm_secrets_types::{PythonSecretRead, SecretOperationContext};
pub(super) struct PythonReadRequest {
pub secret_name: String,
pub primary_secret_name: Option<String>,
pub context: SecretOperationContext,
pub synchronous: bool,
}
pub(super) async fn read_python_provider(
manager: &SecretManager,
request: &PythonReadRequest,
_environment: &(dyn Lookup + Send + Sync),
) -> Result<PythonSecretRead, Error> {
match (manager, &request.context) {
(SecretManager::AwsSecretsManagerV2(client), SecretOperationContext::Aws(context)) => {
client
.read_provider_payload_for_python(
&request.secret_name,
request.primary_secret_name.as_deref(),
context,
request.synchronous,
_environment,
)
.await
.map_err(Error::from)
}
(SecretManager::HashicorpVault(client), SecretOperationContext::Hashicorp(context)) => {
Ok(PythonSecretRead::Value(
client
.async_read_secret_with_context(&request.secret_name, context)
.await
.unwrap_or(None)
.map(Secret::String),
))
}
(SecretManager::Cyberark(client), SecretOperationContext::Cyberark(_)) => {
Ok(PythonSecretRead::Value(
client
.read_with_retry(
&request.secret_name,
&Default::default(),
AuthenticationRetry::Never,
)
.await
.unwrap_or(None)
.map(Secret::String),
))
}
(SecretManager::GoogleSecretManager(client), SecretOperationContext::Google(_)) => client
.get_secret_for_python(&request.secret_name)
.await
.map(PythonSecretRead::Value)
.map_err(Error::from),
_ => Err(Error::NativeBackendUnavailable),
}
}
#[derive(Debug)]
pub(super) enum PythonMutationError {
Unsupported,
Vault(Box<super::vault::Failure>),
CyberarkWrite {
name: String,
failure: Box<litellm_secrets::cyberark::WriteFailure>,
},
CurrentMissing(String),
ReplacementMissing(String),
ReplacementMismatch,
}
pub(super) async fn write_python_provider(
manager: &SecretManager,
name: &str,
value: &litellm_secrets::SecretValue,
) -> Result<serde_json::Value, PythonMutationError> {
match manager {
SecretManager::Cyberark(client) => {
client
.write_with_retry(name, value, &Default::default(), AuthenticationRetry::Never)
.await
.map_err(|failure| PythonMutationError::CyberarkWrite {
name: name.to_owned(),
failure: Box::new(failure),
})?;
Ok(write_success(name))
}
_ => Err(PythonMutationError::Unsupported),
}
}
pub(super) async fn delete_python_provider(
manager: &SecretManager,
name: &str,
) -> Result<serde_json::Value, PythonMutationError> {
match manager {
SecretManager::Cyberark(client) => {
client
.async_delete_secret(name, None)
.await
.map_err(|failure| PythonMutationError::CyberarkWrite {
name: name.to_owned(),
failure: Box::new(litellm_secrets::cyberark::WriteFailure {
source: failure,
request_url: None,
authentication: false,
}),
})?;
Ok(serde_json::json!({
"status": "not_supported",
"message": "CyberArk Conjur does not support direct secret deletion. Use policy updates to remove variables.",
}))
}
_ => Err(PythonMutationError::Unsupported),
}
}
pub(super) async fn rotate_python_provider(
manager: &SecretManager,
current_name: &str,
new_name: &str,
value: &litellm_secrets::SecretValue,
) -> Result<serde_json::Value, PythonMutationError> {
match manager {
SecretManager::Cyberark(client) => {
if client
.read_fresh_with_retry(
current_name,
&Default::default(),
AuthenticationRetry::Never,
)
.await
.ok()
.flatten()
.is_none()
{
return Err(PythonMutationError::CurrentMissing(current_name.to_owned()));
}
client
.write_with_retry(
new_name,
value,
&Default::default(),
AuthenticationRetry::Never,
)
.await
.map_err(|failure| PythonMutationError::CyberarkWrite {
name: new_name.to_owned(),
failure: Box::new(failure),
})?;
let actual = client
.read_fresh_with_retry(new_name, &Default::default(), AuthenticationRetry::Never)
.await
.ok()
.flatten()
.ok_or_else(|| PythonMutationError::ReplacementMissing(new_name.to_owned()))?;
if actual != *value {
return Err(PythonMutationError::ReplacementMismatch);
}
if current_name != new_name {
client.invalidate_cached_secret(current_name).await;
}
Ok(write_success(new_name))
}
_ => Err(PythonMutationError::Unsupported),
}
}
fn write_success(name: &str) -> serde_json::Value {
serde_json::json!({"status": "success", "message": format!("Secret {name} written successfully")})
}
pub(super) enum PythonMutationResponse {
Value(serde_json::Value),
Json(Vec<u8>),
}
pub(super) async fn write_python_provider_with_context(
manager: &SecretManager,
name: &str,
value: &litellm_secrets::SecretValue,
context: &litellm_secrets_types::SecretWriteContext,
) -> Result<PythonMutationResponse, PythonMutationError> {
if let (SecretManager::HashicorpVault(client), SecretOperationContext::Hashicorp(operation)) =
(manager, &context.operation)
{
return super::vault::write(
client,
name,
value,
&litellm_secrets_types::SecretWriteContext {
description: context.description.clone(),
tags: context.tags.clone(),
operation: operation.clone(),
},
)
.await
.map(PythonMutationResponse::Json)
.map_err(|failure| PythonMutationError::Vault(Box::new(failure)));
}
write_python_provider(manager, name, value)
.await
.map(PythonMutationResponse::Value)
}
pub(super) async fn delete_python_provider_with_context(
manager: &SecretManager,
name: &str,
context: &SecretOperationContext,
) -> Result<PythonMutationResponse, PythonMutationError> {
if let (SecretManager::HashicorpVault(client), SecretOperationContext::Hashicorp(context)) =
(manager, context)
{
super::vault::delete(client, name, context)
.await
.map_err(|failure| PythonMutationError::Vault(Box::new(failure)))?;
return Ok(PythonMutationResponse::Value(serde_json::json!({
"status": "success", "message": format!("Secret {name} deleted successfully"),
})));
}
delete_python_provider(manager, name)
.await
.map(PythonMutationResponse::Value)
}
pub(super) async fn rotate_python_provider_with_context(
manager: &SecretManager,
current_name: &str,
new_name: &str,
value: &litellm_secrets::SecretValue,
context: &SecretOperationContext,
) -> Result<PythonMutationResponse, PythonMutationError> {
if let (SecretManager::HashicorpVault(client), SecretOperationContext::Hashicorp(context)) =
(manager, context)
{
return super::vault::rotate(client, current_name, new_name, value, context)
.await
.map(PythonMutationResponse::Json)
.map_err(|failure| PythonMutationError::Vault(Box::new(failure)));
}
rotate_python_provider(manager, current_name, new_name, value)
.await
.map(PythonMutationResponse::Value)
}