Merge pull request #22939 from BerriAI/litellm_hashicorp_vault_backend
feat: Hashicorp Vault config override backend endpoints
This commit is contained in:
commit
c2b03c15b9
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51-py3-none-any.whl
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51.tar.gz
vendored
Normal file
BIN
litellm-proxy-extras/dist/litellm_proxy_extras-0.4.51.tar.gz
vendored
Normal file
Binary file not shown.
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.50"
|
||||
version = "0.4.51"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
authors = ["BerriAI"]
|
||||
readme = "README.md"
|
||||
@ -22,7 +22,7 @@ requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.50"
|
||||
version = "0.4.51"
|
||||
version_files = [
|
||||
"pyproject.toml:version",
|
||||
"../requirements.txt:litellm-proxy-extras==",
|
||||
|
||||
@ -78,6 +78,7 @@ class SupportedDBObjectType(str, enum.Enum):
|
||||
PROMPTS = "prompts"
|
||||
MODEL_COST_MAP = "model_cost_map"
|
||||
TOOLS = "tools"
|
||||
CONFIG_OVERRIDES = "config_overrides"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
@ -2167,7 +2168,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
||||
user_header_mappings: Optional[List[UserHeaderMapping]] = None
|
||||
supported_db_objects: Optional[List[SupportedDBObjectType]] = Field(
|
||||
None,
|
||||
description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools'. If not set, all objects are loaded (default behavior).",
|
||||
description="Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).",
|
||||
)
|
||||
user_mcp_management_mode: Optional[UserMCPManagementMode] = Field(
|
||||
None,
|
||||
|
||||
409
litellm/proxy/management_endpoints/config_override_endpoints.py
Normal file
409
litellm/proxy/management_endpoints/config_override_endpoints.py
Normal file
@ -0,0 +1,409 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from prisma.errors import RecordNotFoundError
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
from litellm.proxy._types import CommonProxyErrors, KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.types.proxy.management_endpoints.config_overrides import (
|
||||
ConfigOverrideSettingsResponse,
|
||||
HashicorpVaultConfig,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Hashicorp Vault constants ---
|
||||
|
||||
HASHICORP_ENV_VAR_MAPPING: Dict[str, str] = {
|
||||
"vault_addr": "HCP_VAULT_ADDR",
|
||||
"vault_token": "HCP_VAULT_TOKEN",
|
||||
"approle_role_id": "HCP_VAULT_APPROLE_ROLE_ID",
|
||||
"approle_secret_id": "HCP_VAULT_APPROLE_SECRET_ID",
|
||||
"approle_mount_path": "HCP_VAULT_APPROLE_MOUNT_PATH",
|
||||
"client_cert": "HCP_VAULT_CLIENT_CERT",
|
||||
"client_key": "HCP_VAULT_CLIENT_KEY",
|
||||
"vault_cert_role": "HCP_VAULT_CERT_ROLE",
|
||||
"vault_namespace": "HCP_VAULT_NAMESPACE",
|
||||
"vault_mount_name": "HCP_VAULT_MOUNT_NAME",
|
||||
"vault_path_prefix": "HCP_VAULT_PATH_PREFIX",
|
||||
}
|
||||
|
||||
HASHICORP_SENSITIVE_FIELDS: Set[str] = {
|
||||
"vault_token",
|
||||
"approle_secret_id",
|
||||
"client_key",
|
||||
}
|
||||
|
||||
_sensitive_masker = SensitiveDataMasker()
|
||||
|
||||
|
||||
# --- Shared helpers ---
|
||||
|
||||
|
||||
def _mask_sensitive_fields(
|
||||
data: Dict[str, Any], sensitive_fields: Set[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Mask sensitive fields for API responses. Non-sensitive fields are left as-is."""
|
||||
masked = {}
|
||||
for key, value in data.items():
|
||||
if value is not None and key in sensitive_fields and isinstance(value, str):
|
||||
masked[key] = _sensitive_masker._mask_value(value)
|
||||
else:
|
||||
masked[key] = value
|
||||
return masked
|
||||
|
||||
|
||||
def _get_current_env_values(env_var_mapping: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Read current env var values as fallback when no DB record exists."""
|
||||
values = {}
|
||||
for field_name, env_var_name in env_var_mapping.items():
|
||||
env_value = os.environ.get(env_var_name)
|
||||
values[field_name] = env_value
|
||||
return values
|
||||
|
||||
|
||||
def _extract_field_type(field_info: Dict[str, Any]) -> str:
|
||||
"""Extract the non-null type from a Pydantic v2 JSON schema field."""
|
||||
if "type" in field_info:
|
||||
return field_info["type"]
|
||||
for option in field_info.get("anyOf", []):
|
||||
if option.get("type") != "null":
|
||||
return option.get("type", "string")
|
||||
return "string"
|
||||
|
||||
|
||||
def _build_field_schema(model_class: type) -> Dict[str, Any]:
|
||||
"""Build field_schema dict from a Pydantic model for UI rendering."""
|
||||
schema = TypeAdapter(model_class).json_schema(by_alias=True)
|
||||
properties = {}
|
||||
for field_name, field_info in schema.get("properties", {}).items():
|
||||
properties[field_name] = {
|
||||
"description": field_info.get("description", ""),
|
||||
"type": _extract_field_type(field_info),
|
||||
}
|
||||
return {
|
||||
"description": schema.get("description", ""),
|
||||
"properties": properties,
|
||||
}
|
||||
|
||||
|
||||
def _parse_config_value(raw: Any) -> Dict[str, Any]:
|
||||
"""Parse a config_value from DB (may be JSON string or dict)."""
|
||||
if isinstance(raw, str):
|
||||
return safe_json_loads(raw, default={})
|
||||
return dict(raw)
|
||||
|
||||
|
||||
def _set_env_vars(config_data: Dict[str, Any]) -> None:
|
||||
"""Set HCP_VAULT_* env vars from config data. Unsets vars for missing/None/empty fields."""
|
||||
for field_name, env_var_name in HASHICORP_ENV_VAR_MAPPING.items():
|
||||
value = config_data.get(field_name)
|
||||
if value is not None and value != "":
|
||||
os.environ[env_var_name] = str(value)
|
||||
else:
|
||||
os.environ.pop(env_var_name, None)
|
||||
|
||||
|
||||
def _clear_hashicorp_vault_state(proxy_config: Any) -> None:
|
||||
"""Clear all Hashicorp Vault state: env vars, secret manager, and change-detection cache."""
|
||||
_set_env_vars({})
|
||||
if litellm._key_management_system == KeyManagementSystem.HASHICORP_VAULT:
|
||||
litellm.secret_manager_client = None
|
||||
litellm._key_management_system = None
|
||||
proxy_config._last_hashicorp_vault_config = None
|
||||
|
||||
|
||||
# --- Hashicorp Vault endpoints ---
|
||||
|
||||
|
||||
@router.post(
|
||||
"/config_overrides/hashicorp_vault",
|
||||
tags=["Config Overrides"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def update_hashicorp_vault_config(
|
||||
config: HashicorpVaultConfig,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Update Hashicorp Vault secret manager configuration.
|
||||
Sets environment variables, encrypts sensitive fields, and stores in DB.
|
||||
Reinitializes the secret manager on this pod.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admin users can update config overrides",
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
|
||||
config_data = config.model_dump(exclude_none=True)
|
||||
|
||||
# Merge ALL fields the user didn't send: try DB first, fall back to env vars.
|
||||
# Omitted field = keep existing; empty string = clear/remove the field.
|
||||
existing_record = await prisma_client.db.litellm_configoverrides.find_unique(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
if existing_record is not None and existing_record.config_value is not None:
|
||||
existing_data = _parse_config_value(existing_record.config_value)
|
||||
existing_decrypted = proxy_config._decrypt_db_variables(existing_data)
|
||||
for field in HASHICORP_ENV_VAR_MAPPING:
|
||||
if field not in config_data and existing_decrypted.get(field):
|
||||
config_data[field] = existing_decrypted[field]
|
||||
else:
|
||||
# No DB record yet — merge from current env vars
|
||||
env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING)
|
||||
for field in HASHICORP_ENV_VAR_MAPPING:
|
||||
if field not in config_data and env_values.get(field):
|
||||
config_data[field] = env_values[field]
|
||||
|
||||
# Strip empty strings — they signal "clear this field"
|
||||
config_data = {k: v for k, v in config_data.items() if v != ""}
|
||||
|
||||
# Validate that the config has enough fields to initialize
|
||||
has_vault_addr = bool(config_data.get("vault_addr"))
|
||||
has_token_auth = bool(config_data.get("vault_token"))
|
||||
has_approle_auth = bool(
|
||||
config_data.get("approle_role_id") and config_data.get("approle_secret_id")
|
||||
)
|
||||
has_tls_cert_auth = bool(
|
||||
config_data.get("client_cert") and config_data.get("client_key")
|
||||
)
|
||||
|
||||
if not has_vault_addr:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Vault Address is required",
|
||||
)
|
||||
|
||||
if not has_token_auth and not has_approle_auth and not has_tls_cert_auth:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least one authentication method is required: "
|
||||
"provide a Token, both AppRole Role ID and Secret ID, "
|
||||
"or both Client Certificate and Client Key",
|
||||
)
|
||||
|
||||
# Snapshot current env vars so we can restore on failure
|
||||
previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING)
|
||||
|
||||
# Set env vars and verify the secret manager can initialize before persisting
|
||||
_set_env_vars(config_data)
|
||||
|
||||
try:
|
||||
proxy_config.initialize_secret_manager(
|
||||
key_management_system="hashicorp_vault"
|
||||
)
|
||||
except Exception as e:
|
||||
_set_env_vars(previous_env)
|
||||
verbose_proxy_logger.exception(
|
||||
"Error reinitializing Hashicorp Vault secret manager: %s", str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to initialize secret manager: {e}",
|
||||
)
|
||||
|
||||
# Only persist to DB after successful init
|
||||
encrypted_data = proxy_config._encrypt_env_variables(config_data)
|
||||
config_value = safe_dumps(encrypted_data)
|
||||
await prisma_client.db.litellm_configoverrides.upsert(
|
||||
where={"config_type": "hashicorp_vault"},
|
||||
data={
|
||||
"create": {
|
||||
"config_type": "hashicorp_vault",
|
||||
"config_value": config_value,
|
||||
},
|
||||
"update": {
|
||||
"config_value": config_value,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Update change-detection cache so the background reload doesn't redundantly re-init
|
||||
proxy_config._last_hashicorp_vault_config = safe_json_loads(config_value)
|
||||
|
||||
return {
|
||||
"message": "Hashicorp Vault configuration updated successfully",
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/config_overrides/hashicorp_vault",
|
||||
tags=["Config Overrides"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
response_model=ConfigOverrideSettingsResponse,
|
||||
)
|
||||
async def get_hashicorp_vault_config(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Get current Hashicorp Vault configuration.
|
||||
Returns decrypted values from DB, or falls back to current env vars.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admin users can view config overrides",
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
|
||||
field_schema = _build_field_schema(HashicorpVaultConfig)
|
||||
|
||||
# Try to load from DB
|
||||
db_record = await prisma_client.db.litellm_configoverrides.find_unique(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
|
||||
if db_record is not None and db_record.config_value is not None:
|
||||
config_data = _parse_config_value(db_record.config_value)
|
||||
|
||||
# Decrypt then mask sensitive fields so plaintext secrets are never sent to the UI
|
||||
decrypted_data = proxy_config._decrypt_db_variables(config_data)
|
||||
masked_data = _mask_sensitive_fields(
|
||||
decrypted_data, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
|
||||
return ConfigOverrideSettingsResponse(
|
||||
config_type="hashicorp_vault",
|
||||
values=masked_data,
|
||||
field_schema=field_schema,
|
||||
)
|
||||
|
||||
# Fallback to env vars — also mask sensitive values
|
||||
env_values = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING)
|
||||
masked_env_values = _mask_sensitive_fields(
|
||||
env_values, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
|
||||
return ConfigOverrideSettingsResponse(
|
||||
config_type="hashicorp_vault",
|
||||
values=masked_env_values,
|
||||
field_schema=field_schema,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/config_overrides/hashicorp_vault",
|
||||
tags=["Config Overrides"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def delete_hashicorp_vault_config(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""Delete Hashicorp Vault configuration. Idempotent."""
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admin users can delete config overrides",
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
|
||||
# Delete DB record if it exists — ignore if not found
|
||||
try:
|
||||
await prisma_client.db.litellm_configoverrides.delete(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
except RecordNotFoundError:
|
||||
verbose_proxy_logger.debug(
|
||||
"No existing Hashicorp Vault config record to delete"
|
||||
)
|
||||
|
||||
_clear_hashicorp_vault_state(proxy_config)
|
||||
|
||||
return {
|
||||
"message": "Hashicorp Vault configuration deleted successfully",
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/config_overrides/hashicorp_vault/test_connection",
|
||||
tags=["Config Overrides"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def test_hashicorp_vault_connection(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Test the connection to the currently configured Hashicorp Vault.
|
||||
Uses the already-initialized secret manager client. Does not modify any state.
|
||||
"""
|
||||
from litellm.secret_managers.hashicorp_secret_manager import (
|
||||
HashicorpSecretManager,
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admin users can test Vault connection",
|
||||
)
|
||||
|
||||
client = litellm.secret_manager_client
|
||||
if not isinstance(client, HashicorpSecretManager):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Hashicorp Vault is not configured. Save a configuration first.",
|
||||
)
|
||||
|
||||
# Step 1: Authenticate (exercises AppRole login, TLS cert login, or direct token)
|
||||
try:
|
||||
headers = await asyncio.to_thread(client._get_request_headers)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Vault authentication failed: {e}",
|
||||
)
|
||||
|
||||
# Step 2: Verify the token is valid via token/lookup-self
|
||||
try:
|
||||
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.SecretManager)
|
||||
lookup_url = f"{client.vault_addr}/v1/auth/token/lookup-self"
|
||||
if client.vault_namespace:
|
||||
headers["X-Vault-Namespace"] = client.vault_namespace
|
||||
response = await async_client.get(lookup_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Vault token validation failed: {e}",
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Successfully connected to Vault at {client.vault_addr}",
|
||||
}
|
||||
@ -350,6 +350,9 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import (
|
||||
from litellm.proxy.management_endpoints.callback_management_endpoints import (
|
||||
router as callback_management_endpoints_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.config_override_endpoints import (
|
||||
router as config_override_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_privileges,
|
||||
admin_can_invite_user,
|
||||
@ -2248,6 +2251,7 @@ class ProxyConfig:
|
||||
def __init__(self) -> None:
|
||||
self.config: Dict[str, Any] = {}
|
||||
self._last_semantic_filter_config: Optional[Dict[str, Any]] = None
|
||||
self._last_hashicorp_vault_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
def is_yaml(self, config_file_path: str) -> bool:
|
||||
if not os.path.isfile(config_file_path):
|
||||
@ -4448,6 +4452,11 @@ class ProxyConfig:
|
||||
if self._should_load_db_object(object_type="semantic_filter_settings"):
|
||||
await self._init_semantic_filter_settings_in_db(prisma_client=prisma_client)
|
||||
|
||||
if self._should_load_db_object(object_type="config_overrides"):
|
||||
await self._init_hashicorp_vault_config_override(
|
||||
prisma_client=prisma_client
|
||||
)
|
||||
|
||||
async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient):
|
||||
"""
|
||||
Initialize MCP semantic filter settings from database.
|
||||
@ -4557,6 +4566,65 @@ class ProxyConfig:
|
||||
)
|
||||
)
|
||||
|
||||
async def _init_hashicorp_vault_config_override(
|
||||
self, prisma_client: PrismaClient
|
||||
):
|
||||
"""
|
||||
Load Hashicorp Vault config override from DB.
|
||||
Decrypts sensitive fields, sets HCP_VAULT_* env vars, and reinitializes the secret manager.
|
||||
Called periodically via _init_non_llm_objects_in_db to sync config across pods.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.config_override_endpoints import (
|
||||
HASHICORP_ENV_VAR_MAPPING,
|
||||
_clear_hashicorp_vault_state,
|
||||
_get_current_env_values,
|
||||
_parse_config_value,
|
||||
_set_env_vars,
|
||||
)
|
||||
|
||||
try:
|
||||
db_record = await prisma_client.db.litellm_configoverrides.find_unique(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
|
||||
if db_record is None or db_record.config_value is None:
|
||||
if self._last_hashicorp_vault_config is not None:
|
||||
_clear_hashicorp_vault_state(self)
|
||||
return
|
||||
|
||||
config_data = _parse_config_value(db_record.config_value)
|
||||
|
||||
# Skip reinit if config hasn't changed since last poll
|
||||
if self._last_hashicorp_vault_config == config_data:
|
||||
return
|
||||
|
||||
# Decrypt all fields and set env vars
|
||||
decrypted_data = self._decrypt_db_variables(config_data)
|
||||
|
||||
# Snapshot current env vars so we can restore on failure
|
||||
previous_env = _get_current_env_values(HASHICORP_ENV_VAR_MAPPING)
|
||||
_set_env_vars(decrypted_data)
|
||||
|
||||
# Reinitialize the secret manager
|
||||
try:
|
||||
self.initialize_secret_manager(
|
||||
key_management_system="hashicorp_vault"
|
||||
)
|
||||
except Exception:
|
||||
# Restore previous working env vars instead of wiping all
|
||||
_set_env_vars(previous_env)
|
||||
raise
|
||||
|
||||
self._last_hashicorp_vault_config = config_data.copy()
|
||||
verbose_proxy_logger.debug(
|
||||
"Hashicorp Vault config override loaded from DB"
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Error loading Hashicorp Vault config override from DB: %s",
|
||||
str(e),
|
||||
)
|
||||
|
||||
async def _check_and_reload_model_cost_map(self, prisma_client: PrismaClient):
|
||||
"""
|
||||
Check if model cost map needs to be reloaded based on database configuration.
|
||||
@ -13062,6 +13130,7 @@ app.include_router(cost_tracking_settings_router)
|
||||
app.include_router(router_settings_router)
|
||||
app.include_router(fallback_management_router)
|
||||
app.include_router(cache_settings_router)
|
||||
app.include_router(config_override_router)
|
||||
app.include_router(user_agent_analytics_router)
|
||||
app.include_router(enterprise_router)
|
||||
app.include_router(ui_discovery_endpoints_router)
|
||||
|
||||
@ -1057,6 +1057,14 @@ model LiteLLM_UISettings {
|
||||
updated_at DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Generic config overrides table - one row per config_type
|
||||
model LiteLLM_ConfigOverrides {
|
||||
config_type String @id
|
||||
config_value Json
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Skills table for storing LiteLLM-managed skills
|
||||
model LiteLLM_SkillsTable {
|
||||
skill_id String @id @default(uuid())
|
||||
|
||||
@ -44,6 +44,11 @@ class HashicorpSecretManager(BaseSecretManager):
|
||||
|
||||
self._verify_required_credentials_exist()
|
||||
|
||||
if premium_user is not True:
|
||||
raise ValueError(
|
||||
f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}"
|
||||
)
|
||||
|
||||
litellm.secret_manager_client = self
|
||||
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT
|
||||
_refresh_interval = os.environ.get(
|
||||
@ -58,11 +63,6 @@ class HashicorpSecretManager(BaseSecretManager):
|
||||
default_ttl=_refresh_interval
|
||||
) # store in memory for 1 day
|
||||
|
||||
if premium_user is not True:
|
||||
raise ValueError(
|
||||
f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}"
|
||||
)
|
||||
|
||||
def _verify_required_credentials_exist(self) -> None:
|
||||
"""
|
||||
Validate that at least one authentication method is configured.
|
||||
@ -70,13 +70,16 @@ class HashicorpSecretManager(BaseSecretManager):
|
||||
Raises:
|
||||
ValueError: If no valid authentication credentials are provided
|
||||
"""
|
||||
if not self.vault_token and not (
|
||||
self.approle_role_id and self.approle_secret_id
|
||||
):
|
||||
has_token = bool(self.vault_token)
|
||||
has_approle = bool(self.approle_role_id and self.approle_secret_id)
|
||||
has_tls_cert = bool(self.tls_cert_path and self.tls_key_path)
|
||||
|
||||
if not has_token and not has_approle and not has_tls_cert:
|
||||
raise ValueError(
|
||||
"Missing Vault authentication credentials. Please set either:\n"
|
||||
" - HCP_VAULT_TOKEN for token-based auth, or\n"
|
||||
" - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth"
|
||||
" - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth, or\n"
|
||||
" - HCP_VAULT_CLIENT_CERT and HCP_VAULT_CLIENT_KEY for TLS certificate auth"
|
||||
)
|
||||
|
||||
def _auth_via_approle(self) -> str:
|
||||
|
||||
64
litellm/types/proxy/management_endpoints/config_overrides.py
Normal file
64
litellm/types/proxy/management_endpoints/config_overrides.py
Normal file
@ -0,0 +1,64 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HashicorpVaultConfig(BaseModel):
|
||||
"""Configuration for Hashicorp Vault secret manager integration."""
|
||||
|
||||
vault_addr: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The address of the Vault server (e.g., https://vault.example.com:8200)",
|
||||
)
|
||||
vault_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Token for Vault token-based authentication",
|
||||
)
|
||||
approle_role_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Role ID for Vault AppRole authentication",
|
||||
)
|
||||
approle_secret_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Secret ID for Vault AppRole authentication",
|
||||
)
|
||||
approle_mount_path: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Mount path for the AppRole auth method (default: approle)",
|
||||
)
|
||||
client_cert: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Path to the client TLS certificate for Vault",
|
||||
)
|
||||
client_key: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Path to the client TLS private key for Vault",
|
||||
)
|
||||
vault_cert_role: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Certificate role name for TLS cert authentication",
|
||||
)
|
||||
vault_namespace: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Vault namespace (for multi-tenant Vault, sent as X-Vault-Namespace header)",
|
||||
)
|
||||
vault_mount_name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="KV engine mount name (default: secret)",
|
||||
)
|
||||
vault_path_prefix: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional path prefix for secrets (e.g., myapp -> secret/data/myapp/{secret_name})",
|
||||
)
|
||||
|
||||
|
||||
class ConfigOverrideSettingsResponse(BaseModel):
|
||||
"""Response model for config override settings GET endpoints."""
|
||||
|
||||
config_type: str = Field(description="The type of config override")
|
||||
values: Dict[str, Any] = Field(
|
||||
description="Current configuration values (sensitive fields decrypted)"
|
||||
)
|
||||
field_schema: Dict[str, Any] = Field(
|
||||
description="Schema information for UI rendering"
|
||||
)
|
||||
@ -61,7 +61,7 @@ boto3 = { version = "1.40.76", optional = true }
|
||||
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
|
||||
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
|
||||
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
|
||||
litellm-proxy-extras = {version = "0.4.50", optional = true}
|
||||
litellm-proxy-extras = {version = "0.4.51", optional = true}
|
||||
rich = {version = "13.7.1", optional = true}
|
||||
litellm-enterprise = {version = "0.1.33", optional = true}
|
||||
diskcache = {version = "^5.6.1", optional = true}
|
||||
|
||||
@ -57,7 +57,7 @@ grpcio>=1.75.0; python_version >= "3.14"
|
||||
sentry_sdk==2.21.0 # for sentry error handling
|
||||
detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests
|
||||
tzdata==2025.1 # IANA time zone database
|
||||
litellm-proxy-extras==0.4.50 # for proxy extras - e.g. prisma migrations
|
||||
litellm-proxy-extras==0.4.51 # for proxy extras - e.g. prisma migrations
|
||||
llm-sandbox==0.3.31 # for skill execution in sandbox
|
||||
### LITELLM PACKAGE DEPENDENCIES
|
||||
python-dotenv==1.0.1 # for env
|
||||
|
||||
@ -0,0 +1,251 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from prisma.errors import RecordNotFoundError
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import KeyManagementSystem, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.config_override_endpoints import (
|
||||
HASHICORP_ENV_VAR_MAPPING,
|
||||
_build_field_schema,
|
||||
_set_env_vars,
|
||||
)
|
||||
from litellm.proxy.proxy_server import app
|
||||
from litellm.types.proxy.management_endpoints.config_overrides import (
|
||||
HashicorpVaultConfig,
|
||||
)
|
||||
|
||||
VAULT_URL = "/config_overrides/hashicorp_vault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_mock_db():
|
||||
mock = MagicMock()
|
||||
mock.find_unique = AsyncMock(return_value=None)
|
||||
mock.upsert = AsyncMock(return_value=None)
|
||||
mock.delete = AsyncMock(return_value=None)
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_configoverrides = mock
|
||||
return prisma, mock
|
||||
|
||||
|
||||
def _make_mock_proxy_config():
|
||||
cfg = MagicMock()
|
||||
cfg.initialize_secret_manager = MagicMock()
|
||||
cfg._last_hashicorp_vault_config = None
|
||||
cfg._encrypt_env_variables = MagicMock(
|
||||
side_effect=lambda d: {k: f"enc_{v}" for k, v in d.items()}
|
||||
)
|
||||
cfg._decrypt_db_variables = MagicMock(
|
||||
side_effect=lambda d: {
|
||||
k: v.replace("enc_", "") if isinstance(v, str) else v
|
||||
for k, v in d.items()
|
||||
}
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _upserted_data(mock_db):
|
||||
return json.loads(mock_db.upsert.call_args.kwargs["data"]["create"]["config_value"])
|
||||
|
||||
|
||||
def _db_record(data):
|
||||
rec = MagicMock()
|
||||
rec.config_value = json.dumps(data)
|
||||
return rec
|
||||
|
||||
|
||||
def _cleanup():
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
for env_var in HASHICORP_ENV_VAR_MAPPING.values():
|
||||
os.environ.pop(env_var, None)
|
||||
|
||||
|
||||
def _set_admin():
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hashicorp_vault_crud_lifecycle(client, monkeypatch):
|
||||
"""Create → read (masked) → partial update (merge from DB) → clear field →
|
||||
only-provided fields → delete → idempotent delete → env fallback →
|
||||
merge from env → helpers → encrypt/decrypt roundtrip."""
|
||||
mock_prisma, mock_db = _make_mock_db()
|
||||
mock_cfg = _make_mock_proxy_config()
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
|
||||
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
|
||||
_set_admin()
|
||||
|
||||
try:
|
||||
# 1. POST: create
|
||||
r = client.post(VAULT_URL, json={
|
||||
"vault_addr": "https://vault.example.com",
|
||||
"vault_token": "my-secret-vault-token",
|
||||
"vault_namespace": "admin",
|
||||
"vault_mount_name": "secret",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert os.environ["HCP_VAULT_ADDR"] == "https://vault.example.com"
|
||||
data = _upserted_data(mock_db)
|
||||
assert data["vault_token"] == "enc_my-secret-vault-token"
|
||||
mock_cfg.initialize_secret_manager.assert_called_with(key_management_system="hashicorp_vault")
|
||||
assert mock_cfg._last_hashicorp_vault_config is not None
|
||||
|
||||
# 2. GET: sensitive fields masked
|
||||
mock_db.find_unique = AsyncMock(return_value=_db_record(data))
|
||||
r = client.get(VAULT_URL)
|
||||
assert r.status_code == 200
|
||||
vals = r.json()["values"]
|
||||
assert vals["vault_addr"] == "https://vault.example.com"
|
||||
assert "*" in vals["vault_token"]
|
||||
assert "properties" in r.json()["field_schema"]
|
||||
|
||||
# 3. POST partial: omitted fields merge from DB
|
||||
r = client.post(VAULT_URL, json={"vault_addr": "https://vault.new.com"})
|
||||
assert r.status_code == 200
|
||||
data = _upserted_data(mock_db)
|
||||
assert data["vault_addr"] == "enc_https://vault.new.com"
|
||||
assert data["vault_token"] == "enc_my-secret-vault-token"
|
||||
assert data["vault_namespace"] == "enc_admin"
|
||||
|
||||
# 4. POST empty string: clears field, preserves others
|
||||
step3 = {**data, "approle_role_id": "enc_role", "approle_secret_id": "enc_secret"}
|
||||
mock_db.find_unique = AsyncMock(return_value=_db_record(step3))
|
||||
mock_db.upsert = AsyncMock(return_value=None)
|
||||
r = client.post(VAULT_URL, json={"vault_token": ""})
|
||||
assert r.status_code == 200
|
||||
data = _upserted_data(mock_db)
|
||||
assert "vault_token" not in data
|
||||
assert data["approle_role_id"] == "enc_role"
|
||||
|
||||
# 5. POST only provided fields (clean slate)
|
||||
for v in HASHICORP_ENV_VAR_MAPPING.values():
|
||||
os.environ.pop(v, None)
|
||||
mock_db.find_unique = AsyncMock(return_value=None)
|
||||
mock_db.upsert = AsyncMock(return_value=None)
|
||||
r = client.post(VAULT_URL, json={"vault_addr": "https://v.com", "vault_token": "tok"})
|
||||
assert r.status_code == 200
|
||||
assert _upserted_data(mock_db) == {"vault_addr": "enc_https://v.com", "vault_token": "enc_tok"}
|
||||
|
||||
# 6. DELETE: clears everything
|
||||
litellm.secret_manager_client = MagicMock()
|
||||
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT
|
||||
r = client.delete(VAULT_URL)
|
||||
assert r.status_code == 200
|
||||
assert os.environ.get("HCP_VAULT_ADDR") is None
|
||||
assert litellm.secret_manager_client is None
|
||||
|
||||
# 7. DELETE idempotent
|
||||
mock_db.delete = AsyncMock(
|
||||
side_effect=RecordNotFoundError(data={"clientVersion": "0.0.0"}, message="Not found")
|
||||
)
|
||||
assert client.delete(VAULT_URL).status_code == 200
|
||||
|
||||
# 8. GET: env var fallback
|
||||
mock_db.find_unique = AsyncMock(return_value=None)
|
||||
monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.env.com")
|
||||
monkeypatch.setenv("HCP_VAULT_NAMESPACE", "env-ns")
|
||||
r = client.get(VAULT_URL)
|
||||
assert r.json()["values"]["vault_addr"] == "https://vault.env.com"
|
||||
|
||||
# 9. POST: merge from env vars
|
||||
monkeypatch.setenv("HCP_VAULT_TOKEN", "env-token")
|
||||
monkeypatch.setenv("HCP_VAULT_MOUNT_NAME", "env-mount")
|
||||
mock_cfg.initialize_secret_manager = MagicMock()
|
||||
mock_db.upsert = AsyncMock(return_value=None)
|
||||
r = client.post(VAULT_URL, json={"vault_addr": "https://vault.merged.com"})
|
||||
assert r.status_code == 200
|
||||
data = _upserted_data(mock_db)
|
||||
assert data["vault_token"] == "enc_env-token"
|
||||
assert data["vault_mount_name"] == "enc_env-mount"
|
||||
|
||||
# 10. _set_env_vars: empty string unsets
|
||||
monkeypatch.setenv("HCP_VAULT_TOKEN", "existing")
|
||||
_set_env_vars({"vault_token": "", "vault_addr": "https://v.com"})
|
||||
assert os.environ.get("HCP_VAULT_TOKEN") is None
|
||||
assert os.environ["HCP_VAULT_ADDR"] == "https://v.com"
|
||||
|
||||
# 11. _build_field_schema
|
||||
schema = _build_field_schema(HashicorpVaultConfig)
|
||||
assert "vault_addr" in schema["properties"]
|
||||
assert len(schema["properties"]["vault_addr"]["description"]) > 0
|
||||
|
||||
# 12. encrypt/decrypt roundtrip
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key")
|
||||
pc = ProxyConfig()
|
||||
orig = {"vault_addr": "https://v.com", "vault_token": "secret"}
|
||||
encrypted = pc._encrypt_env_variables(orig)
|
||||
assert all(encrypted[k] != orig[k] for k in orig)
|
||||
decrypted = pc._decrypt_db_variables(encrypted)
|
||||
assert all(decrypted[k] == orig[k] for k in orig)
|
||||
|
||||
finally:
|
||||
litellm.secret_manager_client = old_client
|
||||
litellm._key_management_system = old_kms
|
||||
_cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hashicorp_vault_validation_errors_and_access_control(client, monkeypatch):
|
||||
"""Validation (missing fields, init failure rollback), DELETE preserves
|
||||
non-Vault secret managers, non-admin 403 on all endpoints."""
|
||||
mock_prisma, mock_db = _make_mock_db()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg._last_hashicorp_vault_config = {"vault_addr": "old"}
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "proxy_config", mock_cfg)
|
||||
old_client, old_kms = litellm.secret_manager_client, litellm._key_management_system
|
||||
_set_admin()
|
||||
|
||||
try:
|
||||
# 1. Missing vault_addr → 400
|
||||
r = client.post(VAULT_URL, json={"vault_token": "tok"})
|
||||
assert r.status_code == 400
|
||||
assert "Vault Address" in r.json()["detail"]
|
||||
|
||||
# 2. Missing auth → 400
|
||||
r = client.post(VAULT_URL, json={"vault_addr": "https://v.com"})
|
||||
assert r.status_code == 400
|
||||
assert "authentication" in r.json()["detail"].lower()
|
||||
|
||||
# 3. Init failure → 500, env vars restored
|
||||
mock_cfg.initialize_secret_manager = MagicMock(side_effect=Exception("fail"))
|
||||
monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.old.com")
|
||||
monkeypatch.setenv("HCP_VAULT_TOKEN", "old-token")
|
||||
r = client.post(VAULT_URL, json={"vault_addr": "https://bad.com", "vault_token": "bad"})
|
||||
assert r.status_code == 500
|
||||
assert os.environ["HCP_VAULT_ADDR"] == "https://vault.old.com"
|
||||
mock_db.upsert.assert_not_awaited()
|
||||
|
||||
# 4. DELETE preserves non-Vault secret manager
|
||||
aws = MagicMock()
|
||||
litellm.secret_manager_client = aws
|
||||
litellm._key_management_system = KeyManagementSystem.AWS_SECRET_MANAGER
|
||||
assert client.delete(VAULT_URL).status_code == 200
|
||||
assert litellm.secret_manager_client is aws
|
||||
assert litellm._key_management_system == KeyManagementSystem.AWS_SECRET_MANAGER
|
||||
|
||||
# 5. Non-admin → 403
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user"
|
||||
)
|
||||
assert client.get(VAULT_URL).status_code == 403
|
||||
assert client.post(VAULT_URL, json={"vault_addr": "https://v.com"}).status_code == 403
|
||||
assert client.delete(VAULT_URL).status_code == 403
|
||||
|
||||
finally:
|
||||
litellm.secret_manager_client = old_client
|
||||
litellm._key_management_system = old_kms
|
||||
_cleanup()
|
||||
Loading…
Reference in New Issue
Block a user