mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Mask sensitive fields in GET response, restore env vars on failure, add change detection
- GET endpoint now masks sensitive fields (vault_token, approle credentials, client_key) using SensitiveDataMasker instead of returning plaintext - POST merges missing sensitive fields from existing DB record so users can update non-secret fields without re-entering credentials - POST updates _last_hashicorp_vault_config to prevent redundant re-init on next poll - Env var rollback restores previous values on init failure instead of wiping all - Background reload skips reinit when DB config unchanged (change detection cache) - UI strips masked values from form inputs, shows them as placeholder hints instead - Form remounts on data change to prevent stale values after save - Add test for init failure env var rollback
This commit is contained in:
parent
6dcf22601b
commit
6358d0ed25
4 changed files with 157 additions and 18 deletions
|
|
@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
|
|
@ -41,6 +42,8 @@ HASHICORP_SENSITIVE_FIELDS: Set[str] = {
|
|||
"client_key",
|
||||
}
|
||||
|
||||
_sensitive_masker = SensitiveDataMasker()
|
||||
|
||||
|
||||
# --- Shared helpers (reusable by future config types) ---
|
||||
|
||||
|
|
@ -77,6 +80,19 @@ def _decrypt_sensitive_fields(
|
|||
return decrypted
|
||||
|
||||
|
||||
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 = {}
|
||||
|
|
@ -161,6 +177,19 @@ async def update_hashicorp_vault_config(
|
|||
|
||||
config_data = config.model_dump(exclude_none=True)
|
||||
|
||||
# Merge with existing DB record: preserve sensitive fields the user didn't re-enter
|
||||
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 = _decrypt_sensitive_fields(
|
||||
existing_data, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
for field in HASHICORP_SENSITIVE_FIELDS:
|
||||
if field not in config_data and existing_decrypted.get(field):
|
||||
config_data[field] = existing_decrypted[field]
|
||||
|
||||
# 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"))
|
||||
|
|
@ -183,6 +212,9 @@ async def update_hashicorp_vault_config(
|
|||
},
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
@ -191,7 +223,7 @@ async def update_hashicorp_vault_config(
|
|||
key_management_system="hashicorp_vault"
|
||||
)
|
||||
except Exception as e:
|
||||
_set_env_vars({})
|
||||
_set_env_vars(previous_env)
|
||||
verbose_proxy_logger.exception(
|
||||
"Error reinitializing Hashicorp Vault secret manager: %s", str(e)
|
||||
)
|
||||
|
|
@ -204,19 +236,23 @@ async def update_hashicorp_vault_config(
|
|||
|
||||
# Only persist to DB after successful init
|
||||
encrypted_data = _encrypt_sensitive_fields(config_data, HASHICORP_SENSITIVE_FIELDS)
|
||||
config_value = json.dumps(encrypted_data)
|
||||
await prisma_client.db.litellm_configoverrides.upsert(
|
||||
where={"config_type": "hashicorp_vault"},
|
||||
data={
|
||||
"create": {
|
||||
"config_type": "hashicorp_vault",
|
||||
"config_value": json.dumps(encrypted_data),
|
||||
"config_value": config_value,
|
||||
},
|
||||
"update": {
|
||||
"config_value": json.dumps(encrypted_data),
|
||||
"config_value": config_value,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# Update change-detection cache so the background reload doesn't redundantly re-init
|
||||
proxy_config._last_hashicorp_vault_config = json.loads(config_value)
|
||||
|
||||
return {
|
||||
"message": "Hashicorp Vault configuration updated successfully",
|
||||
"status": "success",
|
||||
|
|
@ -260,22 +296,28 @@ async def get_hashicorp_vault_config(
|
|||
if db_record is not None and db_record.config_value is not None:
|
||||
config_data = _parse_config_value(db_record.config_value)
|
||||
|
||||
# Decrypt sensitive fields
|
||||
# Decrypt then mask sensitive fields so plaintext secrets are never sent to the UI
|
||||
decrypted_data = _decrypt_sensitive_fields(
|
||||
config_data, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
masked_data = _mask_sensitive_fields(
|
||||
decrypted_data, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
|
||||
return ConfigOverrideSettingsResponse(
|
||||
config_type="hashicorp_vault",
|
||||
values=decrypted_data,
|
||||
values=masked_data,
|
||||
field_schema=field_schema,
|
||||
)
|
||||
|
||||
# Fallback to env vars
|
||||
# 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=env_values,
|
||||
values=masked_env_values,
|
||||
field_schema=field_schema,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2238,6 +2238,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):
|
||||
|
|
@ -4558,8 +4559,10 @@ class ProxyConfig:
|
|||
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,
|
||||
HASHICORP_SENSITIVE_FIELDS,
|
||||
_decrypt_sensitive_fields,
|
||||
_get_current_env_values,
|
||||
_parse_config_value,
|
||||
_set_env_vars,
|
||||
)
|
||||
|
|
@ -4574,10 +4577,17 @@ class ProxyConfig:
|
|||
|
||||
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 sensitive fields and set env vars
|
||||
decrypted_data = _decrypt_sensitive_fields(
|
||||
config_data, HASHICORP_SENSITIVE_FIELDS
|
||||
)
|
||||
|
||||
# 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
|
||||
|
|
@ -4586,10 +4596,11 @@ class ProxyConfig:
|
|||
key_management_system="hashicorp_vault"
|
||||
)
|
||||
except Exception:
|
||||
# Roll back env vars so the broken config doesn't affect secret lookups
|
||||
_set_env_vars({})
|
||||
# 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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ async def test_get_hashicorp_config_fallback_to_env(client, monkeypatch):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hashicorp_config_from_db(client, monkeypatch):
|
||||
"""When a DB record exists, GET should return decrypted values."""
|
||||
"""When a DB record exists, GET should return masked sensitive values."""
|
||||
mock_record = MagicMock()
|
||||
mock_record.config_value = {
|
||||
"vault_addr": "https://vault.db.com",
|
||||
|
|
@ -139,7 +139,7 @@ async def test_get_hashicorp_config_from_db(client, monkeypatch):
|
|||
with patch(
|
||||
"litellm.proxy.management_endpoints.config_override_endpoints.decrypt_value_helper"
|
||||
) as mock_decrypt:
|
||||
mock_decrypt.return_value = "decrypted_token"
|
||||
mock_decrypt.return_value = "decrypted_token_value"
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
|
|
@ -150,9 +150,13 @@ async def test_get_hashicorp_config_from_db(client, monkeypatch):
|
|||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["config_type"] == "hashicorp_vault"
|
||||
# Non-sensitive fields returned as-is
|
||||
assert data["values"]["vault_addr"] == "https://vault.db.com"
|
||||
assert data["values"]["vault_token"] == "decrypted_token"
|
||||
assert data["values"]["vault_namespace"] == "db-ns"
|
||||
# Sensitive fields should be masked, not plaintext
|
||||
vault_token_value = data["values"]["vault_token"]
|
||||
assert "*" in vault_token_value
|
||||
assert vault_token_value != "decrypted_token_value"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
|
@ -161,6 +165,7 @@ async def test_get_hashicorp_config_from_db(client, monkeypatch):
|
|||
async def test_update_hashicorp_config_success(client, monkeypatch):
|
||||
"""POST should set env vars, encrypt sensitive fields, upsert DB, and reinit secret manager."""
|
||||
mock_configoverrides = MagicMock()
|
||||
mock_configoverrides.find_unique = AsyncMock(return_value=None)
|
||||
mock_configoverrides.upsert = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
|
|
@ -169,6 +174,7 @@ async def test_update_hashicorp_config_success(client, monkeypatch):
|
|||
|
||||
mock_proxy_config = MagicMock()
|
||||
mock_proxy_config.initialize_secret_manager = MagicMock()
|
||||
mock_proxy_config._last_hashicorp_vault_config = None
|
||||
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "proxy_config", mock_proxy_config)
|
||||
|
|
@ -215,6 +221,9 @@ async def test_update_hashicorp_config_success(client, monkeypatch):
|
|||
mock_proxy_config.initialize_secret_manager.assert_called_once_with(
|
||||
key_management_system="hashicorp_vault"
|
||||
)
|
||||
|
||||
# Verify change-detection cache was updated
|
||||
assert mock_proxy_config._last_hashicorp_vault_config is not None
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
# Clean up env vars
|
||||
|
|
@ -227,6 +236,7 @@ async def test_update_hashicorp_config_success(client, monkeypatch):
|
|||
async def test_update_hashicorp_config_excludes_none_fields(client, monkeypatch):
|
||||
"""POST with partial fields should only store provided fields (None fields excluded)."""
|
||||
mock_configoverrides = MagicMock()
|
||||
mock_configoverrides.find_unique = AsyncMock(return_value=None)
|
||||
mock_configoverrides.upsert = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
|
|
@ -235,6 +245,7 @@ async def test_update_hashicorp_config_excludes_none_fields(client, monkeypatch)
|
|||
|
||||
mock_proxy_config = MagicMock()
|
||||
mock_proxy_config.initialize_secret_manager = MagicMock()
|
||||
mock_proxy_config._last_hashicorp_vault_config = None
|
||||
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "proxy_config", mock_proxy_config)
|
||||
|
|
@ -274,10 +285,64 @@ async def test_update_hashicorp_config_excludes_none_fields(client, monkeypatch)
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_hashicorp_config_missing_vault_addr(client, monkeypatch):
|
||||
"""POST without vault_addr should return 400."""
|
||||
async def test_update_hashicorp_config_init_failure_restores_env_vars(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""When initialize_secret_manager fails, env vars should be restored to previous values and DB should not be updated."""
|
||||
mock_configoverrides = MagicMock()
|
||||
mock_configoverrides.find_unique = AsyncMock(return_value=None)
|
||||
mock_configoverrides.upsert = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_configoverrides = mock_configoverrides
|
||||
|
||||
mock_proxy_config = MagicMock()
|
||||
mock_proxy_config.initialize_secret_manager = MagicMock(
|
||||
side_effect=Exception("Vault connection refused")
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
monkeypatch.setattr(ps, "proxy_config", mock_proxy_config)
|
||||
|
||||
# Set pre-existing env vars that should be restored on failure
|
||||
monkeypatch.setenv("HCP_VAULT_ADDR", "https://vault.old.com")
|
||||
monkeypatch.setenv("HCP_VAULT_TOKEN", "old-token")
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.post(
|
||||
"/config_overrides/hashicorp_vault",
|
||||
json={
|
||||
"vault_addr": "https://vault.bad.com",
|
||||
"vault_token": "bad-token",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 500
|
||||
assert "Vault connection refused" in response.json()["detail"]["error"]
|
||||
|
||||
# Env vars should be restored to previous values, not wiped
|
||||
assert os.environ.get("HCP_VAULT_ADDR") == "https://vault.old.com"
|
||||
assert os.environ.get("HCP_VAULT_TOKEN") == "old-token"
|
||||
|
||||
# DB should NOT have been updated
|
||||
mock_configoverrides.upsert.assert_not_awaited()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_hashicorp_config_missing_vault_addr(client, monkeypatch):
|
||||
"""POST without vault_addr should return 400."""
|
||||
mock_configoverrides = MagicMock()
|
||||
mock_configoverrides.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_configoverrides = mock_configoverrides
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
|
|
@ -298,8 +363,12 @@ async def test_update_hashicorp_config_missing_vault_addr(client, monkeypatch):
|
|||
@pytest.mark.asyncio
|
||||
async def test_update_hashicorp_config_missing_auth(client, monkeypatch):
|
||||
"""POST with vault_addr but no auth method should return 400."""
|
||||
mock_configoverrides = MagicMock()
|
||||
mock_configoverrides.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_configoverrides = mock_configoverrides
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,17 @@ export default function HashicorpVault() {
|
|||
|
||||
const schema = data?.field_schema;
|
||||
const properties = schema?.properties ?? {};
|
||||
const values = data?.values ?? {};
|
||||
const rawValues = data?.values ?? {};
|
||||
|
||||
// Strip masked sensitive values so form inputs start empty — users enter new values or leave blank to keep existing
|
||||
const values: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(rawValues)) {
|
||||
if (SENSITIVE_FIELDS.has(key) && typeof value === "string" && value.includes("*")) {
|
||||
// Don't populate form with masked value — it would be saved literally
|
||||
continue;
|
||||
}
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
const handleSave = (formValues: Record<string, any>) => {
|
||||
// Only send fields that have a value
|
||||
|
|
@ -94,6 +104,13 @@ export default function HashicorpVault() {
|
|||
? [{ type: "url" as const, message: "Please enter a valid URL" }]
|
||||
: undefined;
|
||||
|
||||
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
|
||||
const maskedValue = rawValues[fieldName];
|
||||
const hasExistingValue = isSensitive && typeof maskedValue === "string" && maskedValue.includes("*");
|
||||
const placeholder = hasExistingValue
|
||||
? `Current: ${maskedValue}`
|
||||
: fieldSchema?.description;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={fieldName}
|
||||
|
|
@ -101,8 +118,8 @@ export default function HashicorpVault() {
|
|||
label={FIELD_LABELS[fieldName] ?? fieldName}
|
||||
rules={rules}
|
||||
>
|
||||
{SENSITIVE_FIELDS.has(fieldName) ? (
|
||||
<Input.Password placeholder={fieldSchema?.description} />
|
||||
{isSensitive ? (
|
||||
<Input.Password placeholder={placeholder} />
|
||||
) : (
|
||||
<Input placeholder={fieldSchema?.description} />
|
||||
)}
|
||||
|
|
@ -128,7 +145,7 @@ export default function HashicorpVault() {
|
|||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
<Form layout="vertical" initialValues={values} onFinish={handleSave}>
|
||||
<Form key={JSON.stringify(values)} layout="vertical" initialValues={values} onFinish={handleSave}>
|
||||
{FIELD_GROUPS.map((group, index) => (
|
||||
<div key={group.title}>
|
||||
{index > 0 && <Divider />}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue