Expose /list and /info endpoints for Audit Log events (#11102)

* feat(audit_logging_endpoints.py): expose list endpoint to show all audit logs

make it easier for user to retrieve individual endpoints

* feat(enterprise/): add audit logging endpoint

* feat(audit_logging_endpoints.py): expose new GET `/audit/{id}` endpoint

make it easier to retrieve view individual audit logs

* feat(key_management_event_hooks.py): correctly show the key of the user who initiated the change

* fix(key_management_event_hooks.py): add key rotations as an audit log event

'

* test(test_audit_logging_endpoints.py): add simple unit testing for audit log endpoint

* fix: testing fixes

* fix: fix ruff check
This commit is contained in:
Krish Dholakia
2025-05-23 22:54:59 -07:00
committed by GitHub
parent 8d7e234efd
commit 2efaa3cf36
10 changed files with 378 additions and 25 deletions
@@ -0,0 +1,169 @@
"""
AUDIT LOGGING
All /audit logging endpoints. Attempting to write these as CRUD endpoints.
GET - /audit/{id} - Get audit log by id
GET - /audit - Get all audit logs
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
#### AUDIT LOGGING ####
from fastapi import APIRouter, Depends, HTTPException, Query
from litellm_enterprise.types.proxy.audit_logging_endpoints import (
AuditLogResponse,
PaginatedAuditLogResponse,
)
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
router = APIRouter()
@router.get(
"/audit",
tags=["Audit Logging"],
dependencies=[Depends(user_api_key_auth)],
response_model=PaginatedAuditLogResponse,
)
async def get_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
# Filter parameters
changed_by: Optional[str] = Query(
None, description="Filter by user or system that performed the action"
),
changed_by_api_key: Optional[str] = Query(
None, description="Filter by API key hash that performed the action"
),
action: Optional[str] = Query(
None, description="Filter by action type (create, update, delete)"
),
table_name: Optional[str] = Query(
None, description="Filter by table name that was modified"
),
object_id: Optional[str] = Query(
None, description="Filter by ID of the object that was modified"
),
start_date: Optional[str] = Query(None, description="Filter logs after this date"),
end_date: Optional[str] = Query(None, description="Filter logs before this date"),
# Sorting parameters
sort_by: Optional[str] = Query(
None,
description="Column to sort by (e.g. 'updated_at', 'action', 'table_name')",
),
sort_order: str = Query("desc", description="Sort order ('asc' or 'desc')"),
):
"""
Get all audit logs with filtering and pagination.
Returns a paginated response of audit logs matching the specified filters.
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"message": CommonProxyErrors.db_not_connected_error.value},
)
# Build filter conditions
where_conditions: Dict[str, Any] = {}
if changed_by:
where_conditions["changed_by"] = changed_by
if changed_by_api_key:
where_conditions["changed_by_api_key"] = changed_by_api_key
if action:
where_conditions["action"] = action
if table_name:
where_conditions["table_name"] = table_name
if object_id:
where_conditions["object_id"] = object_id
if start_date or end_date:
date_filter = {}
if start_date:
date_filter["gte"] = start_date
if end_date:
date_filter["lte"] = end_date
where_conditions["updated_at"] = date_filter
# Build sort conditions
order_by = {}
if sort_by and isinstance(sort_by, str):
order_by[sort_by] = sort_order
elif sort_order and isinstance(sort_order, str):
order_by["updated_at"] = sort_order # Default sort by updated_at
# Get paginated results
audit_logs = await prisma_client.db.litellm_auditlog.find_many(
where=where_conditions,
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
# Get total count for pagination
total_count = await prisma_client.db.litellm_auditlog.count(where=where_conditions)
total_pages = -(-total_count // page_size) # Ceiling division
# Return paginated response
return PaginatedAuditLogResponse(
audit_logs=[
AuditLogResponse(**audit_log.model_dump()) for audit_log in audit_logs
]
if audit_logs
else [],
total=total_count,
page=page,
page_size=page_size,
total_pages=total_pages,
)
@router.get(
"/audit/{id}",
tags=["Audit Logging"],
dependencies=[Depends(user_api_key_auth)],
response_model=AuditLogResponse,
responses={
404: {"description": "Audit log not found"},
500: {"description": "Database connection error"},
},
)
async def get_audit_log_by_id(
id: str, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth)
):
"""
Get detailed information about a specific audit log entry by its ID.
Args:
id (str): The unique identifier of the audit log entry
Returns:
AuditLogResponse: Detailed information about the audit log entry
Raises:
HTTPException: If the audit log is not found or if there's a database connection error
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"message": CommonProxyErrors.db_not_connected_error.value},
)
# Get the audit log by ID
audit_log = await prisma_client.db.litellm_auditlog.find_unique(where={"id": id})
if audit_log is None:
raise HTTPException(
status_code=404, detail={"message": f"Audit log with ID {id} not found"}
)
# Convert to response model
print(audit_log)
return AuditLogResponse(**audit_log.model_dump())
@@ -4,6 +4,7 @@ from litellm_enterprise.enterprise_callbacks.send_emails.endpoints import (
router as email_events_router,
)
from .audit_logging_endpoints import router as audit_logging_router
from .guardrails.endpoints import router as guardrails_router
from .utils import _should_block_robots
from .vector_stores.endpoints import router as vector_stores_router
@@ -12,6 +13,7 @@ router = APIRouter()
router.include_router(vector_stores_router)
router.include_router(guardrails_router)
router.include_router(email_events_router)
router.include_router(audit_logging_router)
@router.get("/robots.txt")
@@ -4,3 +4,8 @@
This directory contains enterprise features used on the LiteLLM proxy.
## Format
Create a file for every group of endpoints (e.g. `key_management_endpoints.py`, `user_management_endpoints.py`, etc.)
If there is a broader semantic group of endpoints, create a folder for that group (e.g. `management_endpoints`, `auth_endpoints`, etc.)
@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class AuditLogResponse(BaseModel):
"""Response model for a single audit log entry"""
id: str
updated_at: datetime
changed_by: str
changed_by_api_key: str
action: str
table_name: str
object_id: str
before_value: Optional[Dict[str, Any]] = None
updated_values: Optional[Dict[str, Any]] = None
class PaginatedAuditLogResponse(BaseModel):
"""Response model for paginated audit logs"""
audit_logs: List[AuditLogResponse]
total: int = Field(
..., description="Total number of audit logs matching the filters"
)
page: int = Field(..., description="Current page number")
page_size: int = Field(..., description="Number of items per page")
total_pages: int = Field(..., description="Total number of pages")
@@ -0,0 +1,3 @@
from .deepeval import DeepEvalLogger
__all__ = ["DeepEvalLogger"]
+3
View File
@@ -78,6 +78,9 @@ model_list:
prompt_label: "latest"
api_key: os.environ/OPENAI_API_KEY
litellm_settings:
store_audit_logs: true
general_settings:
store_model_in_db: true
store_prompts_in_spend_logs: true
+1 -1
View File
@@ -1790,7 +1790,7 @@ class LiteLLM_ErrorLogs(LiteLLMPydanticObjectBase):
endTime: Union[str, datetime, None]
AUDIT_ACTIONS = Literal["created", "updated", "deleted", "blocked"]
AUDIT_ACTIONS = Literal["created", "updated", "deleted", "blocked", "rotated"]
class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
@@ -4,8 +4,6 @@ import uuid
from datetime import datetime, timezone
from typing import Any, List, Optional
from fastapi import status
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import (
@@ -16,8 +14,6 @@ from litellm.proxy._types import (
Litellm_EntityType,
LiteLLM_VerificationToken,
LitellmTableNames,
ProxyErrorTypes,
ProxyException,
RegenerateKeyRequest,
UpdateKeyRequest,
UserAPIKeyAuth,
@@ -126,11 +122,16 @@ class KeyManagementEventHooks:
@staticmethod
async def async_key_rotated_hook(
data: Optional[RegenerateKeyRequest],
existing_key_row: Any,
existing_key_row: LiteLLM_VerificationToken,
response: GenerateKeyResponse,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: Optional[str] = None,
):
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
# store the generated key in the secret manager
if data is not None and response.token_id is not None:
initial_secret_name = (
@@ -142,6 +143,28 @@ class KeyManagementEventHooks:
new_secret_value=response.key,
)
# store the audit log
if litellm.store_audit_logs is True and existing_key_row.token is not None:
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.token,
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=existing_key_row.token,
action="rotated",
updated_values=response.model_dump_json(exclude_none=True),
before_value=existing_key_row.model_dump_json(
exclude_none=True
),
)
)
)
@staticmethod
async def async_key_deleted_hook(
data: KeyRequest,
@@ -159,27 +182,16 @@ class KeyManagementEventHooks:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
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:
# make an audit log for each team deleted
for key in data.keys:
key_row = await prisma_client.get_data( # type: ignore
token=key, table_name="key", query_type="find_unique"
)
if key_row is None:
raise ProxyException(
message=f"Key {key} not found",
type=ProxyErrorTypes.bad_request_error,
param="key",
code=status.HTTP_404_NOT_FOUND,
)
key_row = key_row.json(exclude_none=True)
_key_row = json.dumps(key_row, default=str)
# make an audit log for each key deleted
for key in keys_being_deleted:
if key.token is None:
continue
_key_row = key.model_dump_json(exclude_none=True)
asyncio.create_task(
create_audit_log_for_update(
@@ -189,9 +201,9 @@ class KeyManagementEventHooks:
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,
changed_by_api_key=user_api_key_dict.token,
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=key,
object_id=key.token,
action="deleted",
updated_values="{}",
before_value=_key_row,
@@ -0,0 +1,128 @@
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.proxy_server import app
client = TestClient(app)
# Mock data for testing
MOCK_AUDIT_LOG = {
"id": "test-audit-log-1",
"updated_at": datetime.utcnow(),
"changed_by": "test-user",
"changed_by_api_key": "test-api-key-hash",
"action": "create",
"table_name": "test_table",
"object_id": "test-object-1",
"before_value": None,
"updated_values": {"name": "test", "value": 123},
}
@pytest.fixture
def mock_prisma_client():
with patch("litellm.proxy.proxy_server.prisma_client") as mock:
mock.db.litellm_auditlog.find_many = AsyncMock()
mock.db.litellm_auditlog.find_unique = AsyncMock()
mock.db.litellm_auditlog.count = AsyncMock()
yield mock
@pytest.mark.asyncio
async def test_get_audit_logs(mock_prisma_client):
"""Test successful retrieval of audit logs with pagination"""
# Mock the database responses
mock_prisma_client.db.litellm_auditlog.find_many.return_value = [
AuditLogResponse(**MOCK_AUDIT_LOG)
]
mock_prisma_client.db.litellm_auditlog.count.return_value = 1
# Mock the auth dependency
with patch("litellm.proxy.auth.user_api_key_auth.user_api_key_auth") as mock_auth:
mock_auth.return_value = UserAPIKeyAuth(
api_key="test-key",
user_id="test-user",
team_id=None,
organization_id=None,
user_role="proxy_admin",
)
# Make the request
response = client.get("/audit?page=1&page_size=10")
# Assert response
assert response.status_code == 200
data = response.json()
assert "audit_logs" in data
assert len(data["audit_logs"]) == 1
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 10
assert data["total_pages"] == 1
# Verify the audit log data
audit_log = data["audit_logs"][0]
assert audit_log["id"] == MOCK_AUDIT_LOG["id"]
assert audit_log["action"] == MOCK_AUDIT_LOG["action"]
assert audit_log["table_name"] == MOCK_AUDIT_LOG["table_name"]
@pytest.mark.asyncio
async def test_get_audit_log_by_id(mock_prisma_client):
"""Test successful retrieval of a specific audit log by ID"""
# Mock the database response
mock_prisma_client.db.litellm_auditlog.find_unique.return_value = AuditLogResponse(
**MOCK_AUDIT_LOG
)
# Mock the auth dependency
with patch("litellm.proxy.auth.user_api_key_auth.user_api_key_auth") as mock_auth:
mock_auth.return_value = UserAPIKeyAuth(
api_key="test-key",
user_id="test-user",
team_id=None,
organization_id=None,
user_role="proxy_admin",
)
# Make the request
response = client.get(f"/audit/{MOCK_AUDIT_LOG['id']}")
# Assert response
assert response.status_code == 200
data = response.json()
assert data["id"] == MOCK_AUDIT_LOG["id"]
assert data["action"] == MOCK_AUDIT_LOG["action"]
assert data["table_name"] == MOCK_AUDIT_LOG["table_name"]
assert data["object_id"] == MOCK_AUDIT_LOG["object_id"]
@pytest.mark.asyncio
async def test_get_audit_log_by_id_not_found(mock_prisma_client):
"""Test error handling when audit log is not found"""
# Mock the database response to return None
mock_prisma_client.db.litellm_auditlog.find_unique.return_value = None
# Mock the auth dependency
with patch("litellm.proxy.auth.user_api_key_auth.user_api_key_auth") as mock_auth:
mock_auth.return_value = UserAPIKeyAuth(
api_key="test-key",
user_id="test-user",
team_id=None,
organization_id=None,
user_role="proxy_admin",
)
# Make the request
response = client.get("/audit/non-existent-id")
# Assert response
assert response.status_code == 404
data = response.json()
assert "message" in data["detail"]
assert "not found" in data["detail"]["message"].lower()
@@ -18,6 +18,7 @@ from litellm._logging import verbose_logger
from prometheus_client import REGISTRY, CollectorRegistry
from litellm.integrations.lago import LagoLogger
from litellm.integrations.deepeval import DeepEvalLogger
from litellm.integrations.openmeter import OpenMeterLogger
from litellm.integrations.braintrust_logging import BraintrustLogger
from litellm.integrations.galileo import GalileoObserve