mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(proxy)!: default audit logs on for enterprise licenses (#37518)
* feat(proxy): enable audit logs by premium license Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): support premium audit logging mocks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): disable audit logging for key rotation mocks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
0edd245545
commit
3a04860122
18 changed files with 250 additions and 82 deletions
|
|
@ -221,7 +221,7 @@ overwrite_user_with_key_hash: bool = (
|
|||
bedrock_request_metadata_fields: Optional[Sequence[str]] = (
|
||||
None # allow-list of `user_api_key_*` fields (+ `spend_logs_metadata`) sent as Bedrock `requestMetadata`
|
||||
)
|
||||
store_audit_logs = False # Enterprise feature, allow users to see audit logs
|
||||
store_audit_logs: bool | None = None
|
||||
skip_system_message_in_guardrail: bool = False
|
||||
skip_tool_message_in_guardrail: bool = False
|
||||
### end of callbacks #############
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class KeyManagementEventHooks:
|
|||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
|
|
@ -53,8 +54,7 @@ class KeyManagementEventHooks:
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.warning("Failed to send key created email: %s", e)
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
_updated_values: Final = response.model_dump_json(exclude_none=True)
|
||||
asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
|
|
@ -103,11 +103,11 @@ class KeyManagementEventHooks:
|
|||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
_updated_values: Final = json.dumps(data.json(exclude_none=True), default=str)
|
||||
|
||||
_before_value = existing_key_row.json(exclude_none=True)
|
||||
|
|
@ -144,6 +144,7 @@ class KeyManagementEventHooks:
|
|||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
|
|
@ -180,7 +181,7 @@ class KeyManagementEventHooks:
|
|||
verbose_proxy_logger.warning("Failed to send key rotated email: %s", e)
|
||||
|
||||
# store the audit log
|
||||
if litellm.store_audit_logs is True and existing_key_row.token is not None:
|
||||
if is_audit_logging_enabled() and existing_key_row.token is not None:
|
||||
asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
@ -218,12 +219,12 @@ class KeyManagementEventHooks:
|
|||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
|
||||
if litellm.store_audit_logs is True and data.keys is not None:
|
||||
if is_audit_logging_enabled() and data.keys is not None:
|
||||
# make an audit log for each key deleted
|
||||
for key in keys_being_deleted:
|
||||
if key.token is None:
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
WebhookEvent,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
|
||||
|
||||
|
|
@ -203,7 +206,7 @@ class UserManagementEventHooks:
|
|||
- user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
|
||||
- litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
|
||||
"""
|
||||
if not litellm.store_audit_logs:
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from typing import TYPE_CHECKING, Any, Final, Protocol
|
|||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._redis import _redis_kwargs_from_environment
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -299,14 +298,15 @@ async def _emit_cache_settings_audit_log(
|
|||
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,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
task: Final = asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
|
|||
|
|
@ -100,16 +100,15 @@ async def _emit_hashicorp_vault_audit_log(
|
|||
``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,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
task: Final = asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
|
|||
|
|
@ -243,12 +243,15 @@ async def _emit_coordination_redis_audit_log(
|
|||
litellm_changed_by: str | None,
|
||||
) -> None:
|
||||
"""Emit an audit-log row for a /coordination_redis/settings mutation."""
|
||||
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.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
task: Final = asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
|
|||
|
|
@ -2220,6 +2220,7 @@ async def delete_user(
|
|||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
create_audit_log_for_update,
|
||||
|
|
@ -2298,9 +2299,8 @@ async def delete_user(
|
|||
},
|
||||
)
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
# make an audit log for each team deleted
|
||||
_user_row = user_row.json(exclude_none=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -6245,6 +6245,7 @@ async def block_key(
|
|||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
create_audit_log_for_update,
|
||||
|
|
@ -6291,7 +6292,7 @@ async def block_key(
|
|||
code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
@ -6358,6 +6359,7 @@ async def unblock_key(
|
|||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
create_audit_log_for_update,
|
||||
|
|
@ -6404,7 +6406,7 @@ async def unblock_key(
|
|||
code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
asyncio.create_task(
|
||||
create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
decrypt_value_helper,
|
||||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import get_audit_log_changed_by
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.repositories.table_repositories import (
|
||||
MCPServerRepository,
|
||||
MCPUserCredentialsRepository,
|
||||
|
|
@ -2018,7 +2021,7 @@ if MCP_AVAILABLE:
|
|||
await global_mcp_server_manager.reload_servers_from_database()
|
||||
|
||||
# TODO: Enterprise: Finish audit log trail
|
||||
if litellm.store_audit_logs:
|
||||
if is_audit_logging_enabled():
|
||||
pass
|
||||
|
||||
# TODO: Delete from virtual keys
|
||||
|
|
@ -2613,7 +2616,7 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
# TODO: Enterprise: Finish audit log trail
|
||||
if litellm.store_audit_logs:
|
||||
if is_audit_logging_enabled():
|
||||
pass
|
||||
|
||||
return _redact_mcp_credentials(mcp_server_record_updated)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ from typing import Annotated, Any, Final
|
|||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -182,14 +181,15 @@ async def _emit_team_callback_audit_log(
|
|||
Callback secrets are redacted before serialization so the audit table
|
||||
cannot itself become a credential-harvest sink.
|
||||
"""
|
||||
if litellm.store_audit_logs is not True:
|
||||
return
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
redacted_before: Final = _redact_callback_secrets(before_metadata)
|
||||
redacted_after: Final = _redact_callback_secrets(after_metadata)
|
||||
|
||||
|
|
|
|||
|
|
@ -1249,6 +1249,7 @@ async def new_team(
|
|||
try:
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
_license_check,
|
||||
|
|
@ -1551,8 +1552,7 @@ async def new_team(
|
|||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
_updated_values = complete_team_data.json(exclude_none=True)
|
||||
|
||||
_updated_values = json.dumps(_updated_values, default=str)
|
||||
|
|
@ -1944,6 +1944,7 @@ async def update_team(
|
|||
```
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
|
||||
from litellm.proxy.proxy_server import (
|
||||
litellm_proxy_admin_name,
|
||||
llm_router,
|
||||
|
|
@ -2246,8 +2247,7 @@ async def update_team(
|
|||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
await _create_team_update_audit_log(
|
||||
existing_team_row=existing_team_row,
|
||||
updated_kv=updated_kv,
|
||||
|
|
@ -3712,6 +3712,7 @@ async def delete_team(
|
|||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
create_audit_log_for_update,
|
||||
|
|
@ -3756,9 +3757,8 @@ async def delete_team(
|
|||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
|
||||
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
|
||||
if litellm.store_audit_logs is True:
|
||||
if is_audit_logging_enabled():
|
||||
# make an audit log for each team deleted
|
||||
for team_id in data.team_ids:
|
||||
team_row: LiteLLM_TeamTable | None = await prisma_client.get_data(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ _audit_log_callback_cache: Final[dict[str, CustomLogger]] = {}
|
|||
ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY: Final = "allow_litellm_changed_by_header"
|
||||
|
||||
|
||||
def is_audit_logging_enabled(store_audit_logs: bool | None = None) -> bool:
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
configured_value: Final[bool | None] = litellm.store_audit_logs if store_audit_logs is None else store_audit_logs
|
||||
if configured_value is not None:
|
||||
return configured_value
|
||||
|
||||
environment_value: Final[bool | None] = get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
|
||||
if environment_value is not None:
|
||||
return environment_value
|
||||
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
return premium_user is True
|
||||
|
||||
|
||||
def _allows_litellm_changed_by_header(user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
|
||||
if (
|
||||
|
|
@ -164,11 +180,7 @@ async def create_object_audit_log(
|
|||
- user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
|
||||
- litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
|
||||
"""
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
_store_audit_logs: Final[bool | None] = litellm.store_audit_logs or get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
|
||||
|
||||
if _store_audit_logs is not True:
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
_changed_by: Final = get_audit_log_changed_by(
|
||||
|
|
@ -196,10 +208,7 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
|
|||
"""
|
||||
Create an audit log for an object.
|
||||
"""
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
_store_audit_logs: Final[bool | None] = litellm.store_audit_logs or get_secret_bool("LITELLM_STORE_AUDIT_LOGS")
|
||||
if _store_audit_logs is not True:
|
||||
if not is_audit_logging_enabled():
|
||||
return
|
||||
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
|
|
|||
|
|
@ -5035,6 +5035,7 @@ class ProxyConfig:
|
|||
)
|
||||
elif key == "audit_log_callbacks":
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
is_audit_logging_enabled,
|
||||
reset_audit_log_callback_cache,
|
||||
)
|
||||
|
||||
|
|
@ -5053,14 +5054,14 @@ class ProxyConfig:
|
|||
litellm.audit_log_callbacks.append(callback)
|
||||
|
||||
_store_audit_logs = litellm_settings.get("store_audit_logs", litellm.store_audit_logs)
|
||||
if _store_audit_logs:
|
||||
if is_audit_logging_enabled(store_audit_logs=_store_audit_logs):
|
||||
print( # noqa: T201
|
||||
f"{blue_color_code} Initialized Audit Log Callbacks - {litellm.audit_log_callbacks} {reset_color_code}"
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
"'audit_log_callbacks' is configured but 'store_audit_logs' is not enabled. "
|
||||
"Audit log callbacks will not fire until 'store_audit_logs: true' is added to litellm_settings."
|
||||
"'audit_log_callbacks' is configured but audit logging is not enabled. "
|
||||
"Audit log callbacks will not fire."
|
||||
)
|
||||
elif key == "cache_params":
|
||||
# this is set in the cache branch
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.common_utils.key_rotation_manager import KeyRotationManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_audit_logging_for_mocked_key(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("litellm.store_audit_logs", False)
|
||||
|
||||
|
||||
class TestKeyRotationManagerPassesKeyAlias:
|
||||
"""
|
||||
Regression tests to ensure KeyRotationManager passes key_alias
|
||||
|
|
@ -155,7 +160,10 @@ class TestKeyRotationSecretNamingStability:
|
|||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotation_hook_uses_initial_secret_name_fallback(self):
|
||||
async def test_rotation_hook_uses_initial_secret_name_fallback(
|
||||
self,
|
||||
disable_audit_logging_for_mocked_key,
|
||||
):
|
||||
"""
|
||||
GIVEN: A key WITHOUT an alias (has an initial_secret_name based on token ID)
|
||||
WHEN: The key is rotated
|
||||
|
|
@ -206,7 +214,10 @@ class TestKeyRotationSecretNamingStability:
|
|||
), f"Secret name drift! Expected {initial_secret_name}, got {call_kwargs['new_secret_name']}. This causes secret sprawl."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotation_hook_pre_rotation_alias_consistency(self):
|
||||
async def test_rotation_hook_pre_rotation_alias_consistency(
|
||||
self,
|
||||
disable_audit_logging_for_mocked_key,
|
||||
):
|
||||
"""
|
||||
GIVEN: A key WITH an alias
|
||||
WHEN: The key is rotated
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Tests for KeyManagementEventHooks.
|
|||
Validates that email and secret manager operations are independent and non-blocking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -155,6 +156,44 @@ class TestKeyManagementEventHooksIndependentOperations:
|
|||
assert email_called["called"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("premium_user", "expected_audit_log_calls"),
|
||||
((True, 1), (False, 0)),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_generated_audit_log_uses_license_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
premium_user: bool,
|
||||
expected_audit_log_calls: int,
|
||||
):
|
||||
from litellm.proxy._types import GenerateKeyRequest, GenerateKeyResponse, UserAPIKeyAuth
|
||||
|
||||
monkeypatch.setattr("litellm.store_audit_logs", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
|
||||
monkeypatch.delenv("LITELLM_STORE_AUDIT_LOGS", raising=False)
|
||||
|
||||
response = GenerateKeyResponse(key="sk-test-key", token_id="token-123")
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_audit_log,
|
||||
patch.object(
|
||||
KeyManagementEventHooks,
|
||||
"_store_virtual_key_in_secret_manager",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await KeyManagementEventHooks.async_key_generated_hook(
|
||||
data=GenerateKeyRequest(),
|
||||
response=response,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-admin-key", user_id="admin"),
|
||||
)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert mock_create_audit_log.await_count == expected_audit_log_calls
|
||||
|
||||
|
||||
class TestRotateVirtualKeyInSecretManager:
|
||||
"""Tests for _rotate_virtual_key_in_secret_manager with team_id support."""
|
||||
|
||||
|
|
|
|||
|
|
@ -212,8 +212,9 @@ async def test_v1_key_generation_sends_email_when_send_invite_email_true():
|
|||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
|
||||
|
||||
with patch.object(
|
||||
KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email
|
||||
with (
|
||||
patch("litellm.store_audit_logs", False),
|
||||
patch.object(KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email),
|
||||
):
|
||||
with patch(
|
||||
"litellm.logging_callback_manager.get_custom_loggers_for_type",
|
||||
|
|
@ -257,8 +258,9 @@ async def test_v1_key_generation_no_email_when_send_invite_email_false():
|
|||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.slack_alerting_instance = mock_slack_alerting
|
||||
|
||||
with patch.object(
|
||||
KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email
|
||||
with (
|
||||
patch("litellm.store_audit_logs", False),
|
||||
patch.object(KeyManagementEventHooks, "_send_key_created_email", mock_send_key_created_email),
|
||||
):
|
||||
with patch(
|
||||
"litellm.logging_callback_manager.get_custom_loggers_for_type",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ mock_prisma_client = MagicMock()
|
|||
mock_prisma_client.db = MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable = MagicMock()
|
||||
mock_prisma_client.db.litellm_teamtable.update = AsyncMock()
|
||||
mock_prisma_client.db.litellm_auditlog = MagicMock()
|
||||
mock_prisma_client.db.litellm_auditlog.create = AsyncMock()
|
||||
|
||||
|
||||
# Fixture to provide the mock prisma client
|
||||
|
|
@ -103,6 +105,11 @@ def mock_db_client():
|
|||
mock_prisma_client.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_audit_logging_for_mocked_team(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("litellm.store_audit_logs", False)
|
||||
|
||||
|
||||
# Fixture to provide a mock admin user auth object
|
||||
@pytest.fixture
|
||||
def mock_admin_auth():
|
||||
|
|
@ -2060,7 +2067,9 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_team_member_budget_not_passed_to_db():
|
||||
async def test_update_team_team_member_budget_not_passed_to_db(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that 'team_member_budget' is never passed to prisma_client.db.litellm_teamtable.update
|
||||
regardless of whether the value is set or None.
|
||||
|
|
@ -2498,7 +2507,9 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_with_team_member_budget_duration():
|
||||
async def test_update_team_with_team_member_budget_duration(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that team/update endpoint properly handles team_member_budget_duration.
|
||||
"""
|
||||
|
|
@ -5171,7 +5182,9 @@ async def test_update_team_standalone_budget_raise_blocked_for_team_admin():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin():
|
||||
async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that a proxy admin CAN raise a standalone team's budget on /team/update.
|
||||
|
||||
|
|
@ -5325,7 +5338,9 @@ async def test_update_team_standalone_budget_removal_blocked_for_team_admin():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed():
|
||||
async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
When a team currently has NO cap (max_budget=None / unlimited), a team admin
|
||||
setting a finite max_budget is a RESTRICTION, not a raise, and is
|
||||
|
|
@ -5407,7 +5422,9 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_standalone_unchanged_budget_allowed():
|
||||
async def test_update_team_standalone_unchanged_budget_allowed(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for a standalone team does NOT compare against the
|
||||
caller's personal max_budget when the budget is unchanged.
|
||||
|
|
@ -5508,7 +5525,9 @@ async def test_update_team_standalone_unchanged_budget_allowed():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_standalone_lower_budget_allowed():
|
||||
async def test_update_team_standalone_lower_budget_allowed(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for a standalone team allows lowering the budget
|
||||
below the team's current value even when the new value still exceeds the
|
||||
|
|
@ -5691,7 +5710,9 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_standalone_models_not_gated_by_user_limit():
|
||||
async def test_update_team_standalone_models_not_gated_by_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for a standalone team does NOT gate the team's models
|
||||
by the caller's personal allowed models.
|
||||
|
|
@ -5775,7 +5796,9 @@ async def test_update_team_standalone_models_not_gated_by_user_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_org_scoped_budget_bypasses_user_limit():
|
||||
async def test_update_team_org_scoped_budget_bypasses_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for an org-scoped team does NOT validate budget against user's personal max_budget.
|
||||
|
||||
|
|
@ -5890,7 +5913,9 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_org_scoped_models_bypasses_user_limit():
|
||||
async def test_update_team_org_scoped_models_bypasses_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for an org-scoped team does NOT validate models against user's personal models.
|
||||
|
||||
|
|
@ -6080,7 +6105,9 @@ async def test_update_team_org_scoped_models_not_in_org_models():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_org_scoped_models_with_all_proxy_models():
|
||||
async def test_update_team_org_scoped_models_with_all_proxy_models(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for an org-scoped team succeeds when organization has 'all-proxy-models'.
|
||||
|
||||
|
|
@ -6196,7 +6223,9 @@ async def test_update_team_org_scoped_models_with_all_proxy_models():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_tpm_limit_not_gated_by_user_limit():
|
||||
async def test_update_team_tpm_limit_not_gated_by_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update does NOT gate the team's tpm_limit by the caller's
|
||||
personal tpm_limit.
|
||||
|
|
@ -6279,7 +6308,9 @@ async def test_update_team_tpm_limit_not_gated_by_user_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_rpm_limit_not_gated_by_user_limit():
|
||||
async def test_update_team_rpm_limit_not_gated_by_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update does NOT gate the team's rpm_limit by the caller's
|
||||
personal rpm_limit.
|
||||
|
|
@ -6795,7 +6826,9 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit():
|
||||
async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update for an org-scoped team bypasses user's TPM/RPM limits.
|
||||
|
||||
|
|
@ -6905,7 +6938,9 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_guardrails_with_org_id():
|
||||
async def test_update_team_guardrails_with_org_id(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that updating team guardrails works when team has an organization_id.
|
||||
The fix ensures 'teams' field is included when fetching organization data.
|
||||
|
|
@ -7242,7 +7277,10 @@ async def test_persist_deleted_team_records():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_persists_deleted_teams(monkeypatch):
|
||||
async def test_delete_team_persists_deleted_teams(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
from litellm.proxy._types import DeleteTeamRequest
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
|
|
@ -7325,7 +7363,10 @@ async def test_delete_team_persists_deleted_teams(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_sweeps_references_outside_members_with_roles(monkeypatch):
|
||||
async def test_delete_team_sweeps_references_outside_members_with_roles(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Regression pin for LIT-5511: a deleted team stayed visible on user records.
|
||||
|
||||
|
|
@ -7431,7 +7472,10 @@ async def test_delete_team_sweeps_references_outside_members_with_roles(monkeypa
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(monkeypatch):
|
||||
async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
A virtual key scoped to the team is deleted from the db with the team, but auth resolves a
|
||||
cached key object without re-reading the team, so leaving the cache entry behind lets that key
|
||||
|
|
@ -7493,7 +7537,10 @@ async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(monkeypa
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cache(monkeypatch):
|
||||
async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cache(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
The reconcile sweep runs after the team row is committed deleted. If it ran before cache
|
||||
eviction, a sweep failure would return an error with the team gone from the db but still
|
||||
|
|
@ -7555,7 +7602,10 @@ async def test_delete_team_failing_reconcile_sweep_cannot_strand_the_team_in_cac
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(monkeypatch):
|
||||
async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Evicting locally only reaches the worker that handled the delete. Without the broadcast, every
|
||||
other worker keeps serving the deleted team, and the deleted team's keys, out of its own
|
||||
|
|
@ -7619,7 +7669,10 @@ async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(monkey
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_survives_a_failing_cache_backend(monkeypatch):
|
||||
async def test_delete_team_survives_a_failing_cache_backend(
|
||||
monkeypatch,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Cache eviction runs after the reference sweep has already committed, so a cache backend that
|
||||
is unreachable must not abort the delete. If it did, `/team/delete` would fail with the team
|
||||
|
|
@ -8088,6 +8141,7 @@ async def test_update_team_soft_budget_validation(
|
|||
expected_soft_budget,
|
||||
expected_max_budget,
|
||||
error_message,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test soft_budget validation in /team/update endpoint.
|
||||
|
|
@ -8498,7 +8552,11 @@ async def test_get_team_daily_activity_member_without_permission_filters_by_keys
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth):
|
||||
async def test_update_team_with_router_settings(
|
||||
mock_db_client,
|
||||
mock_admin_auth,
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
Test that /team/update correctly handles router_settings by:
|
||||
1. Accepting router_settings as a dict parameter
|
||||
|
|
@ -11594,7 +11652,9 @@ async def test_update_team_output_token_estimate_lowered_rejected_for_team_admin
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edit():
|
||||
async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edit(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""The team settings form resends every field it renders, so gating on
|
||||
presence would break a team admin editing an unrelated setting."""
|
||||
import contextlib
|
||||
|
|
@ -11957,7 +12017,9 @@ class _FakeMirrorDb:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_team_syncs_access_group_assigned_team_ids_in_both_directions():
|
||||
async def test_update_team_syncs_access_group_assigned_team_ids_in_both_directions(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""
|
||||
A team-side edit of `access_group_ids` must be mirrored onto every affected access
|
||||
group's `assigned_team_ids`, in one transaction, in both directions.
|
||||
|
|
@ -12113,7 +12175,9 @@ async def test_sync_reads_the_committed_team_row_rather_than_the_callers_snapsho
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_team_and_delete_team_both_drive_the_mirror():
|
||||
async def test_new_team_and_delete_team_both_drive_the_mirror(
|
||||
disable_audit_logging_for_mocked_team,
|
||||
):
|
||||
"""Every writer of `team.access_group_ids` has to reach the mirror, not just update.
|
||||
These pin the wiring on the other two paths; the mirror's own behavior is covered above.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.proxy.management_helpers.audit_logs import (
|
|||
_build_audit_log_payload,
|
||||
_dispatch_audit_log_to_callbacks,
|
||||
create_audit_log_for_update,
|
||||
is_audit_logging_enabled,
|
||||
)
|
||||
from litellm.types.utils import StandardAuditLogPayload
|
||||
|
||||
|
|
@ -49,6 +50,34 @@ def _make_audit_log(
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("premium_user", "configured_value", "environment_value", "expected"),
|
||||
(
|
||||
(True, None, None, True),
|
||||
(True, False, None, False),
|
||||
(True, None, "false", False),
|
||||
(False, None, None, False),
|
||||
(False, True, None, True),
|
||||
(True, True, "false", True),
|
||||
),
|
||||
)
|
||||
def test_is_audit_logging_enabled_precedence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
premium_user: bool,
|
||||
configured_value: bool | None,
|
||||
environment_value: str | None,
|
||||
expected: bool,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "store_audit_logs", configured_value)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
|
||||
if environment_value is None:
|
||||
monkeypatch.delenv("LITELLM_STORE_AUDIT_LOGS", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("LITELLM_STORE_AUDIT_LOGS", environment_value)
|
||||
|
||||
assert is_audit_logging_enabled() is expected
|
||||
|
||||
|
||||
class TestBuildAuditLogPayload:
|
||||
def test_builds_correct_payload(self):
|
||||
audit_log = _make_audit_log()
|
||||
|
|
@ -185,12 +214,14 @@ class TestCreateAuditLogForUpdateWithCallbacks:
|
|||
with (
|
||||
patch("litellm.proxy.proxy_server.premium_user", False),
|
||||
patch("litellm.store_audit_logs", True),
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
):
|
||||
audit_log = _make_audit_log()
|
||||
await create_audit_log_for_update(audit_log)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
mock_logger.async_log_audit_log_event.assert_not_called()
|
||||
mock_prisma.db.litellm_auditlog.create.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_dispatch_when_store_audit_logs_false(self):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue