Merge pull request #22939 from BerriAI/litellm_hashicorp_vault_backend

feat: Hashicorp Vault config override backend endpoints
This commit is contained in:
yuneng-jiang 2026-03-06 17:59:46 -08:00 committed by GitHub
commit c2b03c15b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 822 additions and 14 deletions

Binary file not shown.

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT;

View File

@ -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==",

View File

@ -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,

View 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}",
}

View File

@ -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)

View File

@ -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())

View File

@ -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:

View 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"
)

View File

@ -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}

View File

@ -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

View File

@ -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()