From 3a048601220606f3b0c123c155684bbdb5dd2cf4 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:49:21 -0700 Subject: [PATCH] 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 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 2 +- .../proxy/hooks/key_management_event_hooks.py | 15 +-- .../hooks/user_management_event_hooks.py | 7 +- .../cache_settings_endpoints.py | 8 +- .../config_override_endpoints.py | 9 +- .../coordination_redis_endpoints.py | 11 +- .../internal_user_endpoints.py | 4 +- .../key_management_endpoints.py | 6 +- .../mcp_management_endpoints.py | 9 +- .../team_callback_endpoints.py | 8 +- .../management_endpoints/team_endpoints.py | 12 +- .../proxy/management_helpers/audit_logs.py | 27 +++-- litellm/proxy/proxy_server.py | 7 +- .../test_key_rotation_integration.py | 15 ++- .../hooks/test_key_management_event_hooks.py | 39 ++++++ .../proxy/hooks/test_send_invite_email.py | 10 +- .../test_team_endpoints.py | 112 ++++++++++++++---- .../test_audit_log_callbacks.py | 31 +++++ 18 files changed, 250 insertions(+), 82 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 1ecb04b6e54..00f67ea0ff5 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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 ############# diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index 6231563450b..88803d6442d 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -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: diff --git a/litellm/proxy/hooks/user_management_event_hooks.py b/litellm/proxy/hooks/user_management_event_hooks.py index 929df2a778c..6d978929c05 100644 --- a/litellm/proxy/hooks/user_management_event_hooks.py +++ b/litellm/proxy/hooks/user_management_event_hooks.py @@ -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 ( diff --git a/litellm/proxy/management_endpoints/cache_settings_endpoints.py b/litellm/proxy/management_endpoints/cache_settings_endpoints.py index 53d03bc7ba6..385073edc90 100644 --- a/litellm/proxy/management_endpoints/cache_settings_endpoints.py +++ b/litellm/proxy/management_endpoints/cache_settings_endpoints.py @@ -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( diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py index 12c99477d3c..dde0751d98d 100644 --- a/litellm/proxy/management_endpoints/config_override_endpoints.py +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -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( diff --git a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py index fe9a613656d..86ce336c7a3 100644 --- a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py +++ b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py @@ -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( diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 2b88658e1b4..99a85e02b52 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index ab343cbde2d..5f823c5b20d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 06c32af2dc2..54a591a5e1a 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 472e25bbc28..0e8c4e1825d 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -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) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 95632d7cb35..0211d86b83d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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( diff --git a/litellm/proxy/management_helpers/audit_logs.py b/litellm/proxy/management_helpers/audit_logs.py index 2b714f06413..ecd6abea3c3 100644 --- a/litellm/proxy/management_helpers/audit_logs.py +++ b/litellm/proxy/management_helpers/audit_logs.py @@ -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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0f39ef2e18e..af082f04706 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 diff --git a/tests/test_litellm/proxy/common_utils/test_key_rotation_integration.py b/tests/test_litellm/proxy/common_utils/test_key_rotation_integration.py index bdea37f1358..3bc62d549b0 100644 --- a/tests/test_litellm/proxy/common_utils/test_key_rotation_integration.py +++ b/tests/test_litellm/proxy/common_utils/test_key_rotation_integration.py @@ -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 diff --git a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py index 787e5776897..fa7320b2bc6 100644 --- a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py +++ b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py @@ -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.""" diff --git a/tests/test_litellm/proxy/hooks/test_send_invite_email.py b/tests/test_litellm/proxy/hooks/test_send_invite_email.py index c916af5c128..83fb1f5faba 100644 --- a/tests/test_litellm/proxy/hooks/test_send_invite_email.py +++ b/tests/test_litellm/proxy/hooks/test_send_invite_email.py @@ -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", diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index db39fcd3799..c20b6062e7a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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. diff --git a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py index 48e4353966b..2d54d249713 100644 --- a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py +++ b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py @@ -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):