mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge pull request #26953 from stuxf/chore/audit-log-cache-vault-settings
chore(audit): audit-log /cache/settings + /config_overrides/hashicorp_vault mutations
This commit is contained in:
commit
e995156462
5 changed files with 592 additions and 8 deletions
|
|
@ -190,6 +190,8 @@ class LitellmTableNames(str, enum.Enum):
|
|||
PROXY_MODEL_TABLE_NAME = "LiteLLM_ProxyModelTable"
|
||||
MANAGED_FILE_TABLE_NAME = "LiteLLM_ManagedFileTable"
|
||||
TOOL_TABLE_NAME = "LiteLLM_ToolTable"
|
||||
CACHE_CONFIG_TABLE_NAME = "LiteLLM_CacheConfig"
|
||||
CONFIG_OVERRIDES_TABLE_NAME = "LiteLLM_ConfigOverrides"
|
||||
|
||||
|
||||
class Litellm_EntityType(enum.Enum):
|
||||
|
|
|
|||
|
|
@ -8,14 +8,23 @@ POST /cache/settings/test - Test cache connection with provided credentials
|
|||
POST /cache/settings - Save cache settings to database
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import (
|
||||
AUDIT_ACTIONS,
|
||||
LiteLLM_AuditLogs,
|
||||
LitellmTableNames,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.types.management_endpoints import (
|
||||
CACHE_SETTINGS_FIELDS,
|
||||
|
|
@ -26,6 +35,85 @@ from litellm.types.management_endpoints import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
_REDACTED_VALUE = "***REDACTED***"
|
||||
|
||||
|
||||
def _redact_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
"""Replace every value in a settings map with a fixed marker.
|
||||
|
||||
Cache config carries Redis credentials (passwords, connection strings).
|
||||
The audit-log row preserves the field names so a reader can see *which*
|
||||
fields changed, but values are stripped so the audit table can't itself
|
||||
become a credential-harvest sink.
|
||||
"""
|
||||
if not settings:
|
||||
return {}
|
||||
return {k: _REDACTED_VALUE for k in settings.keys()}
|
||||
|
||||
|
||||
def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
|
||||
"""Surface a fire-and-forget audit-log task failure as a warning.
|
||||
|
||||
``asyncio.create_task`` swallows exceptions silently — if the audit
|
||||
write fails we'd otherwise lose the row without any signal.
|
||||
"""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to write cache-settings audit log: %s", exc
|
||||
)
|
||||
|
||||
|
||||
async def _emit_cache_settings_audit_log(
|
||||
*,
|
||||
action: AUDIT_ACTIONS,
|
||||
before_settings: Optional[Mapping[str, Any]],
|
||||
after_settings: Optional[Mapping[str, Any]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: Optional[str],
|
||||
) -> None:
|
||||
"""Emit an audit-log row for a /cache/settings mutation.
|
||||
|
||||
Mirrors the ``store_audit_logs``-gated pattern used in
|
||||
``team_callback_endpoints.py``: fire-and-forget, no-op when audit
|
||||
logging is disabled, with a done-callback that surfaces any task
|
||||
exception. Captured under ``LiteLLM_CacheConfig`` so the row
|
||||
co-locates with the table it mutates.
|
||||
"""
|
||||
if litellm.store_audit_logs is not True:
|
||||
return
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
task = asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.CACHE_CONFIG_TABLE_NAME,
|
||||
object_id="cache_config",
|
||||
action=action,
|
||||
updated_values=json.dumps(
|
||||
{"settings": _redact_settings(after_settings)}, default=str
|
||||
),
|
||||
before_value=json.dumps(
|
||||
{"settings": _redact_settings(before_settings)}, default=str
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
task.add_done_callback(_log_audit_task_exception)
|
||||
|
||||
|
||||
class CacheSettingsManager:
|
||||
"""
|
||||
Manages cache settings initialization and updates.
|
||||
|
|
@ -282,6 +370,10 @@ async def test_cache_connection(
|
|||
async def update_cache_settings(
|
||||
request: CacheSettingsUpdateRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
litellm_changed_by: Optional[str] = Header(
|
||||
None,
|
||||
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Save cache settings to database and initialize cache.
|
||||
|
|
@ -314,6 +406,19 @@ async def update_cache_settings(
|
|||
try:
|
||||
cache_settings = request.cache_settings.copy()
|
||||
|
||||
# Snapshot the prior settings (key set only — values get redacted in
|
||||
# the audit row) so the audit-log entry shows which fields changed.
|
||||
existing_row = await prisma_client.db.litellm_cacheconfig.find_unique(
|
||||
where={"id": "cache_config"}
|
||||
)
|
||||
before_settings: Optional[Dict[str, Any]] = None
|
||||
if existing_row is not None and existing_row.cache_settings:
|
||||
try:
|
||||
before_settings = json.loads(existing_row.cache_settings)
|
||||
except (TypeError, ValueError):
|
||||
before_settings = None
|
||||
action: AUDIT_ACTIONS = "updated" if existing_row is not None else "created"
|
||||
|
||||
# Encrypt sensitive fields (keep redis_type for storage)
|
||||
encrypted_settings = proxy_config._encrypt_env_variables(
|
||||
environment_variables=cache_settings
|
||||
|
|
@ -353,6 +458,18 @@ async def update_cache_settings(
|
|||
# Switch on LLM response caching
|
||||
proxy_config.switch_on_llm_response_caching()
|
||||
|
||||
# Cache settings carry Redis credentials and connection strings that
|
||||
# control where LLM responses are cached. An admin (or compromised
|
||||
# admin) flipping the cache backend silently is a data-routing
|
||||
# pivot; emit an audit-log row so the action is traceable.
|
||||
await _emit_cache_settings_audit_log(
|
||||
action=action,
|
||||
before_settings=before_settings,
|
||||
after_settings=cache_settings,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Cache settings updated successfully",
|
||||
"status": "success",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Set
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Mapping, Optional, Set
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
|
||||
|
|
@ -18,8 +21,11 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy._types import (
|
||||
AUDIT_ACTIONS,
|
||||
CommonProxyErrors,
|
||||
KeyManagementSystem,
|
||||
LiteLLM_AuditLogs,
|
||||
LitellmTableNames,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
|
|
@ -32,6 +38,82 @@ from litellm.types.proxy.management_endpoints.config_overrides import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
_AUDIT_REDACTED = "***REDACTED***"
|
||||
|
||||
|
||||
def _redact_config(config: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
"""Strip values from a config snapshot before audit-log emission.
|
||||
|
||||
Hashicorp Vault config carries ``vault_token``, ``approle_secret_id``,
|
||||
``client_key`` etc. Persisting them verbatim into ``LiteLLM_AuditLogs``
|
||||
would let anyone with read access to the audit table harvest the
|
||||
proxy's KMS credentials. Keep keys, redact values.
|
||||
"""
|
||||
if not config:
|
||||
return {}
|
||||
return {k: _AUDIT_REDACTED for k in config.keys()}
|
||||
|
||||
|
||||
def _log_audit_task_exception(task: "asyncio.Task[None]") -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to write hashicorp-vault config audit log: %s", exc
|
||||
)
|
||||
|
||||
|
||||
async def _emit_hashicorp_vault_audit_log(
|
||||
*,
|
||||
action: AUDIT_ACTIONS,
|
||||
before_config: Optional[Mapping[str, Any]],
|
||||
after_config: Optional[Mapping[str, Any]],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_changed_by: Optional[str],
|
||||
) -> None:
|
||||
"""Emit an audit-log row for a /config_overrides/hashicorp_vault mutation.
|
||||
|
||||
Mirrors the ``store_audit_logs``-gated pattern from
|
||||
``team_callback_endpoints.py``. Captured under
|
||||
``LiteLLM_ConfigOverrides`` so the row co-locates with the table it
|
||||
mutates.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
if litellm.store_audit_logs is not True:
|
||||
return
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
task = asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.CONFIG_OVERRIDES_TABLE_NAME,
|
||||
object_id="hashicorp_vault",
|
||||
action=action,
|
||||
updated_values=json.dumps(
|
||||
{"config": _redact_config(after_config)}, default=str
|
||||
),
|
||||
before_value=json.dumps(
|
||||
{"config": _redact_config(before_config)}, default=str
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
task.add_done_callback(_log_audit_task_exception)
|
||||
|
||||
|
||||
# --- Hashicorp Vault constants ---
|
||||
|
||||
HASHICORP_ENV_VAR_MAPPING: Dict[str, str] = {
|
||||
|
|
@ -144,6 +226,10 @@ def _clear_hashicorp_vault_state(proxy_config: Any) -> None:
|
|||
async def update_hashicorp_vault_config(
|
||||
config: HashicorpVaultConfig,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
litellm_changed_by: Optional[str] = Header(
|
||||
None,
|
||||
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Update Hashicorp Vault secret manager configuration.
|
||||
|
|
@ -171,6 +257,8 @@ async def update_hashicorp_vault_config(
|
|||
existing_record = await prisma_client.db.litellm_configoverrides.find_unique(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
existing_decrypted: Optional[Dict[str, Any]] = None
|
||||
env_values: Dict[str, Any] = {}
|
||||
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)
|
||||
|
|
@ -178,7 +266,8 @@ async def update_hashicorp_vault_config(
|
|||
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
|
||||
# No DB record (or DB record with null config_value) — merge from
|
||||
# current env vars instead.
|
||||
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):
|
||||
|
|
@ -248,6 +337,22 @@ async def update_hashicorp_vault_config(
|
|||
# Update change-detection cache so the background reload doesn't redundantly re-init
|
||||
proxy_config._last_hashicorp_vault_config = safe_json_loads(config_value)
|
||||
|
||||
# Mutating the proxy's KMS config affects every secret retrieval going
|
||||
# forward — emit an audit-log row so the action is traceable even
|
||||
# though the secret_manager_client itself was just swapped under us.
|
||||
# Action keys off row existence (a row with NULL ``config_value`` is
|
||||
# still an update). ``before_config`` falls back to env vars when the
|
||||
# row was absent or its ``config_value`` was NULL.
|
||||
before_config = existing_decrypted if existing_decrypted is not None else env_values
|
||||
action: AUDIT_ACTIONS = "updated" if existing_record is not None else "created"
|
||||
await _emit_hashicorp_vault_audit_log(
|
||||
action=action,
|
||||
before_config=before_config,
|
||||
after_config=config_data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Hashicorp Vault configuration updated successfully",
|
||||
"status": "success",
|
||||
|
|
@ -321,6 +426,10 @@ async def get_hashicorp_vault_config(
|
|||
)
|
||||
async def delete_hashicorp_vault_config(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
litellm_changed_by: Optional[str] = Header(
|
||||
None,
|
||||
description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability",
|
||||
),
|
||||
):
|
||||
"""Delete Hashicorp Vault configuration. Idempotent."""
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
|
|
@ -337,11 +446,27 @@ async def delete_hashicorp_vault_config(
|
|||
detail=CommonProxyErrors.db_not_connected_error.value,
|
||||
)
|
||||
|
||||
# Capture the prior config before delete so the audit-log row can
|
||||
# show *what* was removed (keys only — values get redacted).
|
||||
existing_record = await prisma_client.db.litellm_configoverrides.find_unique(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
before_config: Optional[Dict[str, Any]] = None
|
||||
if existing_record is not None and existing_record.config_value is not None:
|
||||
try:
|
||||
before_config = proxy_config._decrypt_db_variables(
|
||||
_parse_config_value(existing_record.config_value)
|
||||
)
|
||||
except Exception:
|
||||
before_config = None
|
||||
|
||||
# Delete DB record if it exists — ignore if not found
|
||||
deleted = False
|
||||
try:
|
||||
await prisma_client.db.litellm_configoverrides.delete(
|
||||
where={"config_type": "hashicorp_vault"}
|
||||
)
|
||||
deleted = True
|
||||
except RecordNotFoundError:
|
||||
verbose_proxy_logger.debug(
|
||||
"No existing Hashicorp Vault config record to delete"
|
||||
|
|
@ -349,6 +474,17 @@ async def delete_hashicorp_vault_config(
|
|||
|
||||
_clear_hashicorp_vault_state(proxy_config)
|
||||
|
||||
# Only emit audit log if a row was actually removed; an idempotent
|
||||
# delete on a non-existent row produces no security-relevant change.
|
||||
if deleted:
|
||||
await _emit_hashicorp_vault_audit_log(
|
||||
action="deleted",
|
||||
before_config=before_config,
|
||||
after_config=None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Hashicorp Vault configuration deleted successfully",
|
||||
"status": "success",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
Unit tests for cache settings management endpoints
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -12,12 +14,15 @@ sys.path.insert(
|
|||
0, os.path.abspath("../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
import litellm
|
||||
from litellm.proxy._types import LitellmTableNames, LitellmUserRoles
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.cache_settings_endpoints import (
|
||||
CacheSettingsManager,
|
||||
CacheSettingsUpdateRequest,
|
||||
CacheTestRequest,
|
||||
test_cache_connection,
|
||||
update_cache_settings,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -253,3 +258,140 @@ class TestCacheSettingsManager:
|
|||
# Verify cache was NOT initialized (params unchanged)
|
||||
mock_proxy_config._init_cache.assert_not_called()
|
||||
mock_proxy_config.switch_on_llm_response_caching.assert_not_called()
|
||||
|
||||
|
||||
# ── Audit-log emission for /cache/settings ────────────────────────────────────
|
||||
|
||||
|
||||
def _admin_auth() -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(
|
||||
api_key="hashed",
|
||||
user_id="admin-user",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_settings_emits_audit_log_when_enabled(monkeypatch):
|
||||
"""Cache config carries Redis credentials; mutation must emit an
|
||||
audit-log row when ``store_audit_logs`` is on, with values redacted."""
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", True)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma.db.litellm_cacheconfig.upsert = AsyncMock()
|
||||
|
||||
proxy_config = MagicMock()
|
||||
proxy_config._encrypt_env_variables = MagicMock(
|
||||
side_effect=lambda environment_variables: dict(environment_variables)
|
||||
)
|
||||
proxy_config._decrypt_db_variables = MagicMock(
|
||||
side_effect=lambda variables_dict: dict(variables_dict)
|
||||
)
|
||||
proxy_config._init_cache = MagicMock()
|
||||
proxy_config.switch_on_llm_response_caching = MagicMock()
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
mock_prisma,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.proxy_config",
|
||||
proxy_config,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.store_model_in_db",
|
||||
True,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"),
|
||||
patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
),
|
||||
):
|
||||
await update_cache_settings(
|
||||
request=CacheSettingsUpdateRequest(
|
||||
cache_settings={
|
||||
"type": "redis",
|
||||
"host": "redis.example.com",
|
||||
"password": "super-secret-redis-pw",
|
||||
}
|
||||
),
|
||||
user_api_key_dict=_admin_auth(),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
# asyncio.create_task fires the coroutine eagerly; await one tick to let
|
||||
# the audit-log emit run before the test exits.
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(audit_calls) == 1
|
||||
log = audit_calls[0]
|
||||
assert log.table_name == LitellmTableNames.CACHE_CONFIG_TABLE_NAME
|
||||
assert log.object_id == "cache_config"
|
||||
assert log.action == "created" # no existing row → create
|
||||
|
||||
after = json.loads(log.updated_values)
|
||||
# Field names are preserved so an auditor can see what changed.
|
||||
assert set(after["settings"].keys()) == {"type", "host", "password"}
|
||||
# Plaintext values must NOT appear in the serialized row.
|
||||
assert "super-secret-redis-pw" not in log.updated_values
|
||||
assert "redis.example.com" not in log.updated_values
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cache_settings_no_audit_when_disabled(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", False)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_cacheconfig.find_unique = AsyncMock(return_value=None)
|
||||
mock_prisma.db.litellm_cacheconfig.upsert = AsyncMock()
|
||||
|
||||
proxy_config = MagicMock()
|
||||
proxy_config._encrypt_env_variables = MagicMock(
|
||||
side_effect=lambda environment_variables: dict(environment_variables)
|
||||
)
|
||||
proxy_config._decrypt_db_variables = MagicMock(
|
||||
side_effect=lambda variables_dict: dict(variables_dict)
|
||||
)
|
||||
proxy_config._init_cache = MagicMock()
|
||||
proxy_config.switch_on_llm_response_caching = MagicMock()
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
mock_prisma,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.proxy_config",
|
||||
proxy_config,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.store_model_in_db",
|
||||
True,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
),
|
||||
):
|
||||
await update_cache_settings(
|
||||
request=CacheSettingsUpdateRequest(
|
||||
cache_settings={"type": "redis", "host": "redis.example.com"}
|
||||
),
|
||||
user_api_key_dict=_admin_auth(),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
|
||||
assert audit_calls == []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -272,3 +273,189 @@ async def test_hashicorp_vault_validation_errors_and_access_control(
|
|||
litellm.secret_manager_client = old_client
|
||||
litellm._key_management_system = old_kms
|
||||
_cleanup()
|
||||
|
||||
|
||||
# ── Audit-log emission for /config_overrides/hashicorp_vault ─────────────────
|
||||
|
||||
|
||||
class TestHashicorpVaultAuditLog:
|
||||
"""The KMS config endpoint controls every secret retrieval on the proxy.
|
||||
A mutation (create/update/delete) must emit an audit-log row when
|
||||
``litellm.store_audit_logs`` is True, with credential values redacted
|
||||
so the audit table can't itself be a credential-harvest sink."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_emits_audit_log_with_redacted_values(self, client, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", True)
|
||||
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)
|
||||
_set_admin()
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
):
|
||||
r = client.post(
|
||||
VAULT_URL,
|
||||
json={
|
||||
"vault_addr": "https://vault.example.com",
|
||||
"vault_token": "my-very-secret-token",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(audit_calls) == 1
|
||||
log = audit_calls[0]
|
||||
assert log.action == "created"
|
||||
assert log.object_id == "hashicorp_vault"
|
||||
# Plaintext credentials must NOT appear anywhere in the row.
|
||||
assert "my-very-secret-token" not in log.updated_values
|
||||
assert "vault.example.com" not in log.updated_values
|
||||
# Field names are kept so the auditor can see what changed.
|
||||
after = json.loads(log.updated_values)
|
||||
assert "vault_token" in after["config"]
|
||||
assert "vault_addr" in after["config"]
|
||||
finally:
|
||||
_cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_action_is_updated_when_row_exists_with_null_config_value(
|
||||
self, client, monkeypatch
|
||||
):
|
||||
"""A row can exist in ``litellm_configoverrides`` with a NULL
|
||||
``config_value`` (e.g. an earlier failed write left a stub).
|
||||
Re-POSTing must label the audit row as ``updated`` — the row
|
||||
already exists — not ``created``."""
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", True)
|
||||
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)
|
||||
_set_admin()
|
||||
|
||||
# Row exists but ``config_value`` is NULL.
|
||||
null_record = MagicMock()
|
||||
null_record.config_value = None
|
||||
mock_db.find_unique = AsyncMock(return_value=null_record)
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
):
|
||||
r = client.post(
|
||||
VAULT_URL,
|
||||
json={
|
||||
"vault_addr": "https://vault.example.com",
|
||||
"vault_token": "tok",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(audit_calls) == 1
|
||||
assert audit_calls[0].action == "updated"
|
||||
finally:
|
||||
_cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_emits_audit_log_only_when_row_existed(
|
||||
self, client, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", True)
|
||||
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)
|
||||
_set_admin()
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
):
|
||||
# Idempotent delete on an empty table → no row, no audit log.
|
||||
mock_db.find_unique = AsyncMock(return_value=None)
|
||||
mock_db.delete = AsyncMock(side_effect=RecordNotFoundError(MagicMock()))
|
||||
r = client.delete(VAULT_URL)
|
||||
assert r.status_code == 200
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
assert audit_calls == []
|
||||
|
||||
# Delete a real row → audit log fires with action="deleted".
|
||||
mock_db.find_unique = AsyncMock(
|
||||
return_value=_db_record(
|
||||
{
|
||||
"vault_addr": "enc_https://v.example.com",
|
||||
"vault_token": "enc_t",
|
||||
}
|
||||
)
|
||||
)
|
||||
mock_db.delete = AsyncMock(return_value=None)
|
||||
r = client.delete(VAULT_URL)
|
||||
assert r.status_code == 200
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(audit_calls) == 1
|
||||
log = audit_calls[0]
|
||||
assert log.action == "deleted"
|
||||
# The before-snapshot must redact the token before logging.
|
||||
assert "enc_t" not in log.before_value
|
||||
assert "v.example.com" not in log.before_value
|
||||
finally:
|
||||
_cleanup()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_audit_when_store_audit_logs_is_off(self, client, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", False)
|
||||
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)
|
||||
_set_admin()
|
||||
|
||||
audit_calls = []
|
||||
|
||||
async def capture(request_data):
|
||||
audit_calls.append(request_data)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new=capture,
|
||||
):
|
||||
r = client.post(
|
||||
VAULT_URL,
|
||||
json={
|
||||
"vault_addr": "https://vault.example.com",
|
||||
"vault_token": "tok",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
assert audit_calls == []
|
||||
finally:
|
||||
_cleanup()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue