litellm/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py
ryan-crabbe-berri 9baa19c7d1 fix(proxy): stop hashing raw sk- values in list searches
The search= param on /key/list, /audit, and /spend/logs/ui, plus key_hash= on /key/list, now compare the pasted value verbatim. Only a copied key ID (the hash) matches, so a raw virtual key never needs to travel in a GET query string

Claude-Session: https://claude.ai/code/session_01Q5sbiogJzPcCRmYSbaHxZf
2026-09-03 15:54:42 -07:00

230 lines
7.7 KiB
Python

"""
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 typing import TYPE_CHECKING, Final
#### 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
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import AuditLogRepository
if TYPE_CHECKING:
from prisma import models as prisma_models
router = APIRouter()
def _build_json_field_or_condition(json_key: str, value: str) -> dict[str, object]:
"""
Build an OR condition that matches a value inside a JSON column at the
given key, checking both before_value and updated_values.
Uses Prisma's JSON path filtering (PostgreSQL only).
Example result (team_id="t1"):
{"OR": [
{"before_value": {"path": ["team_id"], "string_contains": "t1"}},
{"updated_values": {"path": ["team_id"], "string_contains": "t1"}},
]}
"""
return {
"OR": [
{"before_value": {"path": [json_key], "string_contains": value}},
{"updated_values": {"path": [json_key], "string_contains": value}},
]
}
def _build_search_condition(search: str) -> dict[str, object]:
"""Match a row whose id, changed_by, object_id, or changed_by_api_key equals the search value."""
return {
"OR": (
{"id": search},
{"changed_by": search},
{"object_id": search},
{"changed_by_api_key": search},
)
}
@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: str | None = Query(
None, description="Filter by user or system that performed the action"
),
changed_by_api_key: str | None = Query(
None, description="Filter by API key hash that performed the action"
),
action: str | None = Query(
None, description="Filter by action type (create, update, delete)"
),
table_name: str | None = Query(
None, description="Filter by table name that was modified"
),
object_id: str | None = Query(
None, description="Filter by ID of the object that was modified"
),
start_date: str | None = Query(None, description="Filter logs after this date"),
end_date: str | None = Query(None, description="Filter logs before this date"),
object_team_id: str | None = Query(
None,
description="Filter by team_id present in before_value or updated_values JSON (PostgreSQL only)",
),
object_key_hash: str | None = Query(
None,
description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)",
),
search: str | None = Query(
None,
description="Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value",
),
# Sorting parameters
sort_by: str | None = 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.
Note: object_team_id and object_key_hash use Prisma JSON path filtering,
which requires PostgreSQL.
"""
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},
)
date_filter: Final[dict[str, str]] = {
**({"gte": start_date} if start_date else {}),
**({"lte": end_date} if end_date else {}),
}
# JSON field filters (PostgreSQL only) — each filter is AND'd with the
# others, but checks both before_value and updated_values internally (OR).
json_field_conditions: Final[list[dict[str, object]]] = [
*([_build_json_field_or_condition("team_id", object_team_id)] if object_team_id else []),
*([_build_json_field_or_condition("token", object_key_hash)] if object_key_hash else []),
]
and_conditions: Final[tuple[dict[str, object], ...]] = (
*json_field_conditions,
*((_build_search_condition(search),) if search else ()),
)
# Build filter conditions
where_conditions: Final[dict[str, object]] = {
**({"changed_by": changed_by} if changed_by else {}),
**({"changed_by_api_key": changed_by_api_key} if changed_by_api_key else {}),
**({"action": action} if action else {}),
**({"table_name": table_name} if table_name else {}),
**({"object_id": object_id} if object_id else {}),
**({"updated_at": date_filter} if start_date or end_date else {}),
**({"AND": and_conditions} if and_conditions else {}),
}
order_by: Final[dict[str, str]] = (
{sort_by: sort_order} if sort_by and isinstance(sort_by, str) else {"updated_at": sort_order}
)
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
# Get paginated results
audit_logs: Final = await audit_log_table.find_many(
where=where_conditions,
order=order_by,
skip=(page - 1) * page_size,
take=page_size,
)
# Get total count for pagination
total_count: Final = await audit_log_table.count(where=where_conditions)
total_pages: Final = -(-total_count // page_size) # Ceiling division
# Return paginated response
return PaginatedAuditLogResponse(
audit_logs=[
AuditLogResponse.model_validate(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},
)
audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table
# Get the audit log by ID
audit_log: Final = await audit_log_table.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
return AuditLogResponse.model_validate(audit_log.model_dump())