feat(proxy): denormalize aliases into audit log rows

Adds object_alias, object_team_id, object_team_alias,
changed_by_user_email and changed_by_key_alias columns to
LiteLLM_AuditLog, stamped at write time from the audited object's
blob and the caller's auth context with single lookups as fallback.
The migration backfills existing rows idempotently and adds indexes
on (table_name, object_id), object_team_id and updated_at. The read
path returns the columns verbatim and object_team filters on them;
the previous read-time enrichment is removed. Key rows resolve
object_alias via the key table because blob key_alias is masked
This commit is contained in:
ryan-crabbe-berri 2026-08-05 18:24:06 -07:00
parent c2a03a137e
commit eff4e5f826
11 changed files with 583 additions and 442 deletions

View file

@ -7,7 +7,7 @@ GET - /audit/{id} - Get audit log by id
GET - /audit - Get all audit logs
"""
from typing import TYPE_CHECKING, Any, Dict, Final, List, NamedTuple, Optional, Sequence, Tuple
from typing import Any, Dict, Optional
#### AUDIT LOGGING ####
from fastapi import APIRouter, Depends, HTTPException, Query
@ -16,157 +16,11 @@ from litellm_enterprise.types.proxy.audit_logging_endpoints import (
PaginatedAuditLogResponse,
)
from litellm.proxy._types import CommonProxyErrors, LitellmTableNames, UserAPIKeyAuth
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
router = APIRouter()
_KEY_TABLE: Final[str] = LitellmTableNames.KEY_TABLE_NAME.value
_TEAM_TABLE: Final[str] = LitellmTableNames.TEAM_TABLE_NAME.value
_USER_TABLE: Final[str] = LitellmTableNames.USER_TABLE_NAME.value
_ORG_TABLE: Final[str] = "LiteLLM_OrganizationTable"
_MODEL_TABLE: Final[str] = LitellmTableNames.PROXY_MODEL_TABLE_NAME.value
_BLOB_ALIAS_KEYS: Final[Dict[str, Tuple[str, ...]]] = {
_KEY_TABLE: ("key_alias",),
_TEAM_TABLE: ("team_alias",),
_USER_TABLE: ("user_alias", "user_email"),
_ORG_TABLE: ("organization_alias",),
_MODEL_TABLE: ("model_name",),
}
class _AliasMaps(NamedTuple):
key_alias_by_token: Dict[str, str]
team_alias_by_id: Dict[str, str]
user_alias_by_id: Dict[str, str]
user_email_by_id: Dict[str, str]
org_alias_by_id: Dict[str, str]
model_name_by_id: Dict[str, str]
def _object_ids_for_table(audit_logs: Sequence[AuditLogResponse], table_name: str) -> frozenset:
return frozenset(log.object_id for log in audit_logs if log.table_name == table_name and log.object_id)
async def _fetch_alias_maps(prisma_client: "PrismaClient", audit_logs: Sequence[AuditLogResponse]) -> _AliasMaps:
tokens: Final = _object_ids_for_table(audit_logs, _KEY_TABLE) | frozenset(
log.changed_by_api_key for log in audit_logs if log.changed_by_api_key
)
user_ids: Final = _object_ids_for_table(audit_logs, _USER_TABLE) | frozenset(
log.changed_by for log in audit_logs if log.changed_by
)
team_ids: Final = _object_ids_for_table(audit_logs, _TEAM_TABLE)
org_ids: Final = _object_ids_for_table(audit_logs, _ORG_TABLE)
model_ids: Final = _object_ids_for_table(audit_logs, _MODEL_TABLE)
key_rows: Final = (
await prisma_client.db.litellm_verificationtoken.find_many(where={"token": {"in": list(tokens)}})
if tokens
else []
)
user_rows: Final = (
await prisma_client.db.litellm_usertable.find_many(where={"user_id": {"in": list(user_ids)}})
if user_ids
else []
)
team_rows: Final = (
await prisma_client.db.litellm_teamtable.find_many(where={"team_id": {"in": list(team_ids)}})
if team_ids
else []
)
org_rows: Final = (
await prisma_client.db.litellm_organizationtable.find_many(where={"organization_id": {"in": list(org_ids)}})
if org_ids
else []
)
model_rows: Final = (
await prisma_client.db.litellm_proxymodeltable.find_many(where={"model_id": {"in": list(model_ids)}})
if model_ids
else []
)
return _AliasMaps(
key_alias_by_token={row.token: row.key_alias for row in key_rows if row.key_alias},
team_alias_by_id={row.team_id: row.team_alias for row in team_rows if row.team_alias},
user_alias_by_id={row.user_id: row.user_alias for row in user_rows if row.user_alias},
user_email_by_id={row.user_id: row.user_email for row in user_rows if row.user_email},
org_alias_by_id={row.organization_id: row.organization_alias for row in org_rows if row.organization_alias},
model_name_by_id={row.model_id: row.model_name for row in model_rows if row.model_name},
)
def _db_object_alias(log: AuditLogResponse, aliases: _AliasMaps) -> str | None:
if log.table_name == _KEY_TABLE:
return aliases.key_alias_by_token.get(log.object_id)
if log.table_name == _TEAM_TABLE:
return aliases.team_alias_by_id.get(log.object_id)
if log.table_name == _USER_TABLE:
return aliases.user_alias_by_id.get(log.object_id) or aliases.user_email_by_id.get(log.object_id)
if log.table_name == _ORG_TABLE:
return aliases.org_alias_by_id.get(log.object_id)
if log.table_name == _MODEL_TABLE:
return aliases.model_name_by_id.get(log.object_id)
return None
def _alias_from_blobs(log: AuditLogResponse, blob_keys: Tuple[str, ...]) -> str | None:
for blob in (log.updated_values, log.before_value):
if not isinstance(blob, dict):
continue
for blob_key in blob_keys:
value = blob.get(blob_key)
if isinstance(value, str) and value:
return value
return None
def _enrich_audit_log(log: AuditLogResponse, aliases: _AliasMaps) -> AuditLogResponse:
object_alias: Final = _db_object_alias(log, aliases) or _alias_from_blobs(
log, _BLOB_ALIAS_KEYS.get(log.table_name, ())
)
return log.model_copy(
update={
"object_alias": object_alias,
"changed_by_user_email": aliases.user_email_by_id.get(log.changed_by),
"changed_by_key_alias": aliases.key_alias_by_token.get(log.changed_by_api_key),
}
)
async def _enrich_audit_logs(
prisma_client: "PrismaClient", audit_logs: Sequence[AuditLogResponse]
) -> List[AuditLogResponse]:
if not audit_logs:
return []
aliases: Final = await _fetch_alias_maps(prisma_client, audit_logs)
return [_enrich_audit_log(log, aliases) for log in audit_logs]
_TEAM_ALIAS_CANDIDATE_LIMIT: Final[int] = 100
_TEAM_ALIAS_CANDIDATE_SQL: Final[str] = (
f'SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_alias LIKE $1 LIMIT {_TEAM_ALIAS_CANDIDATE_LIMIT}'
)
def _contains_like_pattern(value: str) -> str:
escaped: Final = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
async def _build_object_team_condition(prisma_client: "PrismaClient", object_team: str) -> Dict[str, Any]:
team_rows: Final = await prisma_client.db.query_raw(_TEAM_ALIAS_CANDIDATE_SQL, _contains_like_pattern(object_team))
match_values: Final = dict.fromkeys([object_team, *(row["team_id"] for row in team_rows)])
return {
"OR": [
_build_json_field_or_condition("team_alias", object_team),
*(_build_json_field_or_condition("team_id", value) for value in match_values),
]
}
def _build_json_field_or_condition(json_key: str, value: str) -> Dict[str, Any]:
"""
@ -189,6 +43,15 @@ def _build_json_field_or_condition(json_key: str, value: str) -> Dict[str, Any]:
}
def _build_object_team_condition(object_team: str) -> Dict[str, Any]:
return {
"OR": [
{"object_team_id": object_team},
{"object_team_alias": {"contains": object_team}},
]
}
@router.get(
"/audit",
tags=["Audit Logging"],
@ -213,8 +76,8 @@ async def get_audit_logs(
object_team: str | None = Query(
None,
description=(
"Filter by team id or alias: matches team_id or team_alias present in before_value or "
"updated_values JSON, or teams whose team_alias contains this value (PostgreSQL only)"
"Filter by team: matches the row's object_team_id exactly "
"or rows whose object_team_alias contains this value"
),
),
object_key_hash: Optional[str] = Query(
@ -233,10 +96,9 @@ async def get_audit_logs(
Returns a paginated response of audit logs matching the specified filters.
Note: object_team_id, object_team and object_key_hash use Prisma JSON path
filtering, which requires PostgreSQL. object_team matches a team_id or
team_alias in the audit blobs, or any team whose team_alias contains the
value.
Note: object_team_id and object_key_hash use Prisma JSON path filtering,
which requires PostgreSQL. object_team filters on the denormalized
object_team_id and object_team_alias columns instead.
"""
from litellm.proxy.proxy_server import prisma_client
@ -277,9 +139,7 @@ async def get_audit_logs(
_build_json_field_or_condition("token", object_key_hash)
]
if object_team:
where_conditions["AND"] = where_conditions.get("AND", []) + [
await _build_object_team_condition(prisma_client, object_team)
]
where_conditions["AND"] = where_conditions.get("AND", []) + [_build_object_team_condition(object_team)]
# Build sort conditions
order_by: Dict[str, Any] = {}
@ -300,14 +160,9 @@ async def get_audit_logs(
total_count = await prisma_client.db.litellm_auditlog.count(where=where_conditions)
total_pages = -(-total_count // page_size) # Ceiling division
enriched_logs: Final = await _enrich_audit_logs(
prisma_client,
[AuditLogResponse(**audit_log.model_dump()) for audit_log in audit_logs] if audit_logs else [],
)
# Return paginated response
return PaginatedAuditLogResponse(
audit_logs=enriched_logs,
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,
@ -352,5 +207,5 @@ async def get_audit_log_by_id(id: str, user_api_key_dict: UserAPIKeyAuth = Depen
if audit_log is None:
raise HTTPException(status_code=404, detail={"message": f"Audit log with ID {id} not found"})
enriched_logs: Final = await _enrich_audit_logs(prisma_client, [AuditLogResponse(**audit_log.model_dump())])
return enriched_logs[0]
# Convert to response model
return AuditLogResponse(**audit_log.model_dump())

View file

@ -17,6 +17,8 @@ class AuditLogResponse(BaseModel):
before_value: Optional[Dict[str, Any]] = None
updated_values: Optional[Dict[str, Any]] = None
object_alias: str | None = None
object_team_id: str | None = None
object_team_alias: str | None = None
changed_by_user_email: str | None = None
changed_by_key_alias: str | None = None

View file

@ -0,0 +1,89 @@
-- AlterTable
ALTER TABLE "LiteLLM_AuditLog" ADD COLUMN IF NOT EXISTS "object_alias" TEXT,
ADD COLUMN IF NOT EXISTS "object_team_id" TEXT,
ADD COLUMN IF NOT EXISTS "object_team_alias" TEXT,
ADD COLUMN IF NOT EXISTS "changed_by_user_email" TEXT,
ADD COLUMN IF NOT EXISTS "changed_by_key_alias" TEXT;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AuditLog_table_name_object_id_idx" ON "LiteLLM_AuditLog"("table_name", "object_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AuditLog_object_team_id_idx" ON "LiteLLM_AuditLog"("object_team_id");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_AuditLog_updated_at_idx" ON "LiteLLM_AuditLog"("updated_at");
-- Backfill object_alias from the JSON blobs captured at change time (updated_values wins over before_value).
-- Key rows are excluded here: the audit writer masks key_alias inside the blobs, so they resolve via join below
UPDATE "LiteLLM_AuditLog"
SET "object_alias" = CASE "table_name"
WHEN 'LiteLLM_TeamTable' THEN COALESCE(
NULLIF("updated_values"->>'team_alias', ''),
NULLIF("before_value"->>'team_alias', '')
)
WHEN 'LiteLLM_UserTable' THEN COALESCE(
NULLIF("updated_values"->>'user_alias', ''),
NULLIF("updated_values"->>'user_email', ''),
NULLIF("before_value"->>'user_alias', ''),
NULLIF("before_value"->>'user_email', '')
)
WHEN 'LiteLLM_OrganizationTable' THEN COALESCE(
NULLIF("updated_values"->>'organization_alias', ''),
NULLIF("before_value"->>'organization_alias', '')
)
WHEN 'LiteLLM_ProxyModelTable' THEN COALESCE(
NULLIF("updated_values"->>'model_name', ''),
NULLIF("before_value"->>'model_name', '')
)
END
WHERE "object_alias" IS NULL;
-- Backfill object_alias for key rows via the current key table (blob key_alias is masked; deleted keys stay NULL)
UPDATE "LiteLLM_AuditLog" a
SET "object_alias" = v."key_alias"
FROM "LiteLLM_VerificationToken" v
WHERE a."object_alias" IS NULL
AND a."table_name" = 'LiteLLM_VerificationToken'
AND a."object_id" = v."token"
AND v."key_alias" IS NOT NULL;
-- Backfill object_team_id from the JSON blobs
UPDATE "LiteLLM_AuditLog"
SET "object_team_id" = COALESCE(
NULLIF("updated_values"->>'team_id', ''),
NULLIF("before_value"->>'team_id', '')
)
WHERE "object_team_id" IS NULL;
-- Backfill object_team_alias from the JSON blobs (team rows carry it even after the team is deleted)
UPDATE "LiteLLM_AuditLog"
SET "object_team_alias" = COALESCE(
NULLIF("updated_values"->>'team_alias', ''),
NULLIF("before_value"->>'team_alias', '')
)
WHERE "object_team_alias" IS NULL;
-- Backfill object_team_alias for remaining rows via the current team table
UPDATE "LiteLLM_AuditLog" a
SET "object_team_alias" = t."team_alias"
FROM "LiteLLM_TeamTable" t
WHERE a."object_team_alias" IS NULL
AND a."object_team_id" = t."team_id"
AND t."team_alias" IS NOT NULL;
-- Backfill changed_by_user_email via the current user table (deleted actors stay NULL)
UPDATE "LiteLLM_AuditLog" a
SET "changed_by_user_email" = u."user_email"
FROM "LiteLLM_UserTable" u
WHERE a."changed_by_user_email" IS NULL
AND a."changed_by" = u."user_id"
AND u."user_email" IS NOT NULL;
-- Backfill changed_by_key_alias via the current key table (deleted keys stay NULL)
UPDATE "LiteLLM_AuditLog" a
SET "changed_by_key_alias" = v."key_alias"
FROM "LiteLLM_VerificationToken" v
WHERE a."changed_by_key_alias" IS NULL
AND a."changed_by_api_key" = v."token"
AND v."key_alias" IS NOT NULL;

View file

@ -732,6 +732,15 @@ model LiteLLM_AuditLog {
object_id String // id of the object being audited. This can be the key id, team id, user id, model id
before_value Json? // value of the row
updated_values Json? // value of the row after change
object_alias String?
object_team_id String?
object_team_alias String?
changed_by_user_email String?
changed_by_key_alias String?
@@index([table_name, object_id])
@@index([object_team_id])
@@index([updated_at])
}
// Track daily user spend metrics per model and key

View file

@ -3148,6 +3148,11 @@ class LiteLLM_AuditLogs(LiteLLMPydanticObjectBase):
object_id: str
before_value: Json | None = None
updated_values: Json | None = None
object_alias: str | None = None
object_team_id: str | None = None
object_team_alias: str | None = None
changed_by_user_email: str | None = None
changed_by_key_alias: str | None = None
@model_validator(mode="before")
@classmethod

View file

@ -5,7 +5,9 @@ Functions to create audit logs for LiteLLM Proxy
import asyncio
import json
from datetime import datetime, timezone
from typing import Final
from typing import TYPE_CHECKING, Final
from pydantic import BaseModel, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
@ -20,6 +22,9 @@ from litellm.proxy._types import (
from litellm.repositories.table_repositories import AuditLogRepository
from litellm.types.utils import StandardAuditLogPayload
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
_audit_log_callback_cache: Final[dict[str, CustomLogger]] = {}
ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY: Final = "allow_litellm_changed_by_header"
@ -143,6 +148,128 @@ async def _dispatch_audit_log_to_callbacks(
verbose_proxy_logger.error("Failed dispatching audit log to callback: %s", e)
class _AuditBlobAliasFields(BaseModel):
team_alias: str | None = None
user_alias: str | None = None
user_email: str | None = None
organization_alias: str | None = None
model_name: str | None = None
team_id: str | None = None
def _blob_alias_fields(value: object) -> _AuditBlobAliasFields:
try:
if isinstance(value, dict):
return _AuditBlobAliasFields.model_validate(value)
if isinstance(value, str):
return _AuditBlobAliasFields.model_validate_json(value)
except ValidationError:
return _AuditBlobAliasFields()
return _AuditBlobAliasFields()
def _derive_object_alias(table_name: str, updated: _AuditBlobAliasFields, before: _AuditBlobAliasFields) -> str | None:
if table_name == LitellmTableNames.TEAM_TABLE_NAME.value:
return updated.team_alias or before.team_alias or None
if table_name == LitellmTableNames.USER_TABLE_NAME.value:
return updated.user_alias or updated.user_email or before.user_alias or before.user_email or None
if table_name == "LiteLLM_OrganizationTable":
return updated.organization_alias or before.organization_alias or None
if table_name == LitellmTableNames.PROXY_MODEL_TABLE_NAME.value:
return updated.model_name or before.model_name or None
return None
async def _lookup_team_alias(prisma_client: "PrismaClient", team_id: str) -> str | None:
try:
row: Final = await prisma_client.db.litellm_teamtable.find_unique(where={"team_id": team_id})
except Exception as e:
verbose_proxy_logger.debug("audit log team alias lookup failed: %s", e)
return None
alias: Final = None if row is None else row.team_alias
return alias if isinstance(alias, str) and alias else None
async def _lookup_user_email(prisma_client: "PrismaClient", user_id: str) -> str | None:
try:
row: Final = await prisma_client.db.litellm_usertable.find_unique(where={"user_id": user_id})
except Exception as e:
verbose_proxy_logger.debug("audit log user email lookup failed: %s", e)
return None
email: Final = None if row is None else row.user_email
return email if isinstance(email, str) and email else None
async def _lookup_key_alias(prisma_client: "PrismaClient", token: str) -> str | None:
try:
row: Final = await prisma_client.db.litellm_verificationtoken.find_unique(where={"token": token})
except Exception as e:
verbose_proxy_logger.debug("audit log key alias lookup failed: %s", e)
return None
alias: Final = None if row is None else row.key_alias
return alias if isinstance(alias, str) and alias else None
def _serialized_blob(value: object) -> object:
return json.dumps(value) if isinstance(value, dict) else value
async def _with_denormalized_aliases(
request_data: LiteLLM_AuditLogs, prisma_client: "PrismaClient | None"
) -> LiteLLM_AuditLogs:
updated: Final = _blob_alias_fields(request_data.updated_values)
before: Final = _blob_alias_fields(request_data.before_value)
table_name: Final = (
request_data.table_name.value
if isinstance(request_data.table_name, LitellmTableNames)
else str(request_data.table_name)
)
is_team_row: Final = table_name == LitellmTableNames.TEAM_TABLE_NAME.value
is_key_row: Final = table_name == LitellmTableNames.KEY_TABLE_NAME.value
changed_by: Final = request_data.changed_by if isinstance(request_data.changed_by, str) else None
object_alias: Final = request_data.object_alias or (
await _lookup_key_alias(prisma_client, request_data.object_id)
if is_key_row and prisma_client is not None
else _derive_object_alias(table_name, updated, before)
)
object_team_id: Final = (
request_data.object_team_id
or updated.team_id
or before.team_id
or (request_data.object_id if is_team_row else None)
or None
)
object_team_alias: Final = (
request_data.object_team_alias
or updated.team_alias
or before.team_alias
or (
await _lookup_team_alias(prisma_client, object_team_id)
if prisma_client is not None and object_team_id
else None
)
)
changed_by_user_email: Final = request_data.changed_by_user_email or (
await _lookup_user_email(prisma_client, changed_by) if prisma_client is not None and changed_by else None
)
changed_by_key_alias: Final = request_data.changed_by_key_alias or (
await _lookup_key_alias(prisma_client, request_data.changed_by_api_key)
if prisma_client is not None and request_data.changed_by_api_key
else None
)
return request_data.model_copy(
update={
"object_alias": object_alias,
"object_team_id": object_team_id,
"object_team_alias": object_team_alias,
"changed_by_user_email": changed_by_user_email,
"changed_by_key_alias": changed_by_key_alias,
"updated_values": _serialized_blob(request_data.updated_values),
"before_value": _serialized_blob(request_data.before_value),
}
)
async def create_object_audit_log(
object_id: str,
action: AUDIT_ACTIONS,
@ -188,6 +315,8 @@ async def create_object_audit_log(
action=action,
updated_values=after_value,
before_value=before_value,
changed_by_user_email=(user_api_key_dict.user_email if _changed_by == user_api_key_dict.user_id else None),
changed_by_key_alias=user_api_key_dict.key_alias,
)
)
@ -209,20 +338,16 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
verbose_proxy_logger.debug("creating audit log for %s", request_data)
if isinstance(request_data.updated_values, dict):
request_data.updated_values = json.dumps(request_data.updated_values)
if isinstance(request_data.before_value, dict):
request_data.before_value = json.dumps(request_data.before_value)
enriched: Final = await _with_denormalized_aliases(request_data, prisma_client)
# Dispatch to external audit log callbacks regardless of DB availability
await _dispatch_audit_log_to_callbacks(request_data)
await _dispatch_audit_log_to_callbacks(enriched)
if prisma_client is None:
verbose_proxy_logger.error("prisma_client is None, cannot write audit log to DB")
return
_request_data: Final = request_data.model_dump(exclude_none=True)
_request_data: Final = enriched.model_dump(exclude_none=True)
try:
await AuditLogRepository(prisma_client).table.create(

View file

@ -732,6 +732,15 @@ model LiteLLM_AuditLog {
object_id String // id of the object being audited. This can be the key id, team id, user id, model id
before_value Json? // value of the row
updated_values Json? // value of the row after change
object_alias String?
object_team_id String?
object_team_alias String?
changed_by_user_email String?
changed_by_key_alias String?
@@index([table_name, object_id])
@@index([object_team_id])
@@index([updated_at])
}
// Track daily user spend metrics per model and key

View file

@ -732,6 +732,15 @@ model LiteLLM_AuditLog {
object_id String // id of the object being audited. This can be the key id, team id, user id, model id
before_value Json? // value of the row
updated_values Json? // value of the row after change
object_alias String?
object_team_id String?
object_team_alias String?
changed_by_user_email String?
changed_by_key_alias String?
@@index([table_name, object_id])
@@index([object_team_id])
@@index([updated_at])
}
// Track daily user spend metrics per model and key

View file

@ -34,11 +34,6 @@ def mock_prisma_client():
mock.db.litellm_auditlog.find_many = AsyncMock()
mock.db.litellm_auditlog.find_unique = AsyncMock()
mock.db.litellm_auditlog.count = AsyncMock()
mock.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock.db.litellm_teamtable.find_many = AsyncMock(return_value=[])
mock.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
mock.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
yield mock

View file

@ -1,9 +1,8 @@
"""
Tests for audit log alias enrichment and the combined object_team filter (LIT-4997).
Tests for the denormalized audit log alias columns and the object_team filter (LIT-4997).
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
@ -11,7 +10,6 @@ from fastapi.testclient import TestClient
from litellm_enterprise.proxy.audit_logging_endpoints import (
_build_json_field_or_condition,
_build_object_team_condition,
_enrich_audit_logs,
)
from litellm_enterprise.proxy.audit_logging_endpoints import router as audit_router
from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse
@ -20,7 +18,7 @@ from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
class FakeTable:
class FakeAuditLogTable:
def __init__(self, rows=()):
self.rows = list(rows)
self.find_many_calls = []
@ -29,14 +27,7 @@ class FakeTable:
self.find_many_calls.append(where)
return self.rows
class FakeAuditLogTable(FakeTable):
def __init__(self, rows=()):
super().__init__(rows)
self.count_calls = []
async def count(self, where=None):
self.count_calls.append(where)
return len(self.rows)
async def find_unique(self, where):
@ -44,28 +35,8 @@ class FakeAuditLogTable(FakeTable):
class FakeDb:
def __init__(
self,
audit_logs=(),
keys=(),
users=(),
teams=(),
orgs=(),
models=(),
team_id_rows=(),
):
def __init__(self, audit_logs=()):
self.litellm_auditlog = FakeAuditLogTable(audit_logs)
self.litellm_verificationtoken = FakeTable(keys)
self.litellm_usertable = FakeTable(users)
self.litellm_teamtable = FakeTable(teams)
self.litellm_organizationtable = FakeTable(orgs)
self.litellm_proxymodeltable = FakeTable(models)
self.team_id_rows = list(team_id_rows)
self.query_raw_calls = []
async def query_raw(self, sql, *args):
self.query_raw_calls.append((sql, *args))
return self.team_id_rows
class FakePrismaClient:
@ -88,185 +59,6 @@ def make_log(**overrides) -> AuditLogResponse:
return AuditLogResponse(**{**defaults, **overrides})
async def test_enrichment_resolves_object_alias_per_table_name():
"""object_alias comes from the right table per table_name; changed_by fields resolve too."""
logs = [
make_log(id="l1", table_name="LiteLLM_VerificationToken", object_id="hash-1"),
make_log(id="l2", table_name="LiteLLM_TeamTable", object_id="team-1"),
make_log(id="l3", table_name="LiteLLM_UserTable", object_id="user-1"),
make_log(id="l4", table_name="LiteLLM_UserTable", object_id="user-2"),
make_log(id="l5", table_name="LiteLLM_OrganizationTable", object_id="org-1"),
make_log(id="l6", table_name="LiteLLM_ProxyModelTable", object_id="model-1"),
make_log(
id="l7",
table_name="LiteLLM_TeamTable",
object_id="team-1",
changed_by="admin-user",
changed_by_api_key="hash-admin",
),
]
db = FakeDb(
keys=[
SimpleNamespace(token="hash-1", key_alias="prod-key"),
SimpleNamespace(token="hash-admin", key_alias="admin-key"),
],
users=[
SimpleNamespace(user_id="user-1", user_alias="Alice", user_email="alice@example.com"),
SimpleNamespace(user_id="user-2", user_alias=None, user_email="bob@example.com"),
SimpleNamespace(user_id="admin-user", user_alias=None, user_email="admin@example.com"),
],
teams=[SimpleNamespace(team_id="team-1", team_alias="ml-team")],
orgs=[SimpleNamespace(organization_id="org-1", organization_alias="acme-org")],
models=[SimpleNamespace(model_id="model-1", model_name="gpt-5.2")],
)
enriched = await _enrich_audit_logs(FakePrismaClient(db), logs)
by_id = {log.id: log for log in enriched}
assert by_id["l1"].object_alias == "prod-key"
assert by_id["l2"].object_alias == "ml-team"
assert by_id["l3"].object_alias == "Alice"
assert by_id["l4"].object_alias == "bob@example.com"
assert by_id["l5"].object_alias == "acme-org"
assert by_id["l6"].object_alias == "gpt-5.2"
assert by_id["l7"].changed_by_user_email == "admin@example.com"
assert by_id["l7"].changed_by_key_alias == "admin-key"
assert by_id["l1"].changed_by_user_email is None
assert by_id["l1"].changed_by_key_alias is None
async def test_enrichment_runs_one_query_per_entity_type():
"""A page with many rows triggers at most one find_many per entity table, ids batched via `in`."""
logs = [
make_log(id="l1", table_name="LiteLLM_VerificationToken", object_id="hash-1", changed_by="u1"),
make_log(id="l2", table_name="LiteLLM_VerificationToken", object_id="hash-2", changed_by="u2"),
make_log(
id="l3",
table_name="LiteLLM_TeamTable",
object_id="team-1",
changed_by="u1",
changed_by_api_key="hash-caller",
),
]
db = FakeDb()
await _enrich_audit_logs(FakePrismaClient(db), logs)
assert len(db.litellm_verificationtoken.find_many_calls) == 1
assert len(db.litellm_usertable.find_many_calls) == 1
assert len(db.litellm_teamtable.find_many_calls) == 1
assert len(db.litellm_organizationtable.find_many_calls) == 0
assert len(db.litellm_proxymodeltable.find_many_calls) == 0
assert set(db.litellm_verificationtoken.find_many_calls[0]["token"]["in"]) == {
"hash-1",
"hash-2",
"hash-caller",
}
assert set(db.litellm_usertable.find_many_calls[0]["user_id"]["in"]) == {"u1", "u2"}
assert db.litellm_teamtable.find_many_calls[0] == {"team_id": {"in": ["team-1"]}}
async def test_enrichment_falls_back_to_blobs_for_deleted_objects():
"""When DB lookups miss (deleted objects), aliases come from updated_values then before_value."""
logs = [
make_log(
id="l1",
table_name="LiteLLM_TeamTable",
object_id="gone-team",
action="deleted",
before_value={"team_id": "gone-team", "team_alias": "old-team"},
),
make_log(
id="l2",
table_name="LiteLLM_VerificationToken",
object_id="gone-hash",
before_value={"key_alias": "old-alias"},
updated_values={"key_alias": "new-alias"},
),
make_log(
id="l3",
table_name="LiteLLM_UserTable",
object_id="gone-user",
updated_values={"user_email": "gone@example.com"},
),
make_log(
id="l4",
table_name="LiteLLM_OrganizationTable",
object_id="gone-org",
before_value={"organization_alias": "old-org"},
),
make_log(id="l5", table_name="SomeUnknownTable", object_id="x", updated_values={"team_alias": "nope"}),
]
enriched = await _enrich_audit_logs(FakePrismaClient(FakeDb()), logs)
by_id = {log.id: log for log in enriched}
assert by_id["l1"].object_alias == "old-team"
assert by_id["l2"].object_alias == "new-alias"
assert by_id["l3"].object_alias == "gone@example.com"
assert by_id["l4"].object_alias == "old-org"
assert by_id["l5"].object_alias is None
async def test_enrichment_db_lookup_wins_over_blob():
"""A live DB row beats a stale alias captured in the audit blobs."""
logs = [
make_log(
id="l1",
table_name="LiteLLM_TeamTable",
object_id="team-1",
before_value={"team_alias": "stale-alias"},
)
]
db = FakeDb(teams=[SimpleNamespace(team_id="team-1", team_alias="current-alias")])
enriched = await _enrich_audit_logs(FakePrismaClient(db), logs)
assert enriched[0].object_alias == "current-alias"
async def test_build_object_team_condition_matches_id_and_alias():
"""object_team ORs the raw value with every team_id whose team_alias contains it,
via a projected and capped query so one request cannot load the whole team table."""
db = FakeDb(team_id_rows=[{"team_id": "team-1"}, {"team_id": "team-2"}])
condition = await _build_object_team_condition(FakePrismaClient(db), "prod")
assert db.query_raw_calls == [
('SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_alias LIKE $1 LIMIT 100', "%prod%")
]
assert db.litellm_teamtable.find_many_calls == []
assert condition == {
"OR": [
_build_json_field_or_condition("team_alias", "prod"),
_build_json_field_or_condition("team_id", "prod"),
_build_json_field_or_condition("team_id", "team-1"),
_build_json_field_or_condition("team_id", "team-2"),
]
}
async def test_build_object_team_condition_escapes_like_wildcards():
"""LIKE wildcards in the user-supplied value are escaped, not treated as patterns."""
db = FakeDb()
await _build_object_team_condition(FakePrismaClient(db), "pr_od%te\\am")
assert db.query_raw_calls[0][1] == "%pr\\_od\\%te\\\\am%"
async def test_build_object_team_condition_deleted_team_matches_blob_alias():
"""With no live team rows the condition still matches blob team_alias and the raw value as team_id."""
condition = await _build_object_team_condition(FakePrismaClient(FakeDb()), "gone-team")
assert condition == {
"OR": [
_build_json_field_or_condition("team_alias", "gone-team"),
_build_json_field_or_condition("team_id", "gone-team"),
]
}
def _client_for(db: FakeDb) -> TestClient:
app = FastAPI()
app.include_router(audit_router)
@ -274,81 +66,116 @@ def _client_for(db: FakeDb) -> TestClient:
return TestClient(app)
def test_get_audit_logs_object_team_filter_and_enrichment():
"""GET /audit?object_team=... ANDs in the combined id/alias condition and returns enriched rows."""
def test_build_object_team_condition_matches_id_and_alias_columns():
"""object_team ORs an exact object_team_id match with an object_team_alias contains match."""
assert _build_object_team_condition("prod") == {
"OR": [
{"object_team_id": "prod"},
{"object_team_alias": {"contains": "prod"}},
]
}
def test_get_audit_logs_returns_denormalized_columns_verbatim():
"""GET /audit passes the alias columns straight through from the DB row."""
audit_row = make_log(
id="l1",
table_name="LiteLLM_TeamTable",
action="deleted",
object_id="team-1",
object_alias="ml-team",
object_team_id="team-1",
object_team_alias="ml-team",
changed_by="admin-user",
updated_values={"team_id": "team-1"},
changed_by_api_key="hash-admin",
changed_by_user_email="admin@example.com",
changed_by_key_alias="admin-key",
)
db = FakeDb(
audit_logs=[audit_row],
users=[SimpleNamespace(user_id="admin-user", user_alias=None, user_email="admin@example.com")],
teams=[SimpleNamespace(team_id="team-1", team_alias="prod-team")],
team_id_rows=[{"team_id": "team-1"}],
)
client = _client_for(db)
db = FakeDb(audit_logs=[audit_row])
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = client.get("/audit?object_team=prod")
response = _client_for(db).get("/audit")
assert response.status_code == 200
log = response.json()["audit_logs"][0]
assert log["object_alias"] == "ml-team"
assert log["object_team_id"] == "team-1"
assert log["object_team_alias"] == "ml-team"
assert log["changed_by_user_email"] == "admin@example.com"
assert log["changed_by_key_alias"] == "admin-key"
def test_get_audit_logs_object_team_filter_uses_columns():
"""GET /audit?object_team=... ANDs in the column-based id-or-alias condition."""
db = FakeDb()
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = _client_for(db).get("/audit?object_team=prod")
assert response.status_code == 200
where = db.litellm_auditlog.find_many_calls[0]
assert where["AND"] == [
{
"OR": [
_build_json_field_or_condition("team_alias", "prod"),
_build_json_field_or_condition("team_id", "prod"),
_build_json_field_or_condition("team_id", "team-1"),
{"object_team_id": "prod"},
{"object_team_alias": {"contains": "prod"}},
]
}
]
log = response.json()["audit_logs"][0]
assert log["object_alias"] == "prod-team"
assert log["changed_by_user_email"] == "admin@example.com"
assert log["changed_by_key_alias"] is None
def test_get_audit_logs_object_team_id_filter_unchanged():
"""The pre-existing object_team_id param still builds its exact condition, no alias lookup."""
"""The pre-existing object_team_id param still builds its exact JSON-blob condition."""
db = FakeDb()
client = _client_for(db)
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = client.get("/audit?object_team_id=team-1")
response = _client_for(db).get("/audit?object_team_id=team-1")
assert response.status_code == 200
where = db.litellm_auditlog.find_many_calls[0]
assert where["AND"] == [_build_json_field_or_condition("team_id", "team-1")]
assert db.litellm_teamtable.find_many_calls == []
assert db.query_raw_calls == []
assert where["AND"] == [
{
"OR": [
{"before_value": {"path": ["team_id"], "string_contains": "team-1"}},
{"updated_values": {"path": ["team_id"], "string_contains": "team-1"}},
]
}
]
def test_get_audit_log_by_id_is_enriched():
"""GET /audit/{id} carries the same alias enrichment as the list endpoint."""
def test_get_audit_log_by_id_returns_denormalized_columns():
"""GET /audit/{id} carries the same alias columns as the list endpoint."""
audit_row = make_log(
id="l1",
table_name="LiteLLM_VerificationToken",
object_id="gone-hash",
action="deleted",
before_value={"key_alias": "deleted-key"},
changed_by="admin-user",
changed_by_api_key="hash-admin",
object_alias="deleted-key",
changed_by_user_email="admin@example.com",
changed_by_key_alias="admin-key",
)
db = FakeDb(
audit_logs=[audit_row],
keys=[SimpleNamespace(token="hash-admin", key_alias="admin-key")],
users=[SimpleNamespace(user_id="admin-user", user_alias=None, user_email="admin@example.com")],
)
client = _client_for(db)
db = FakeDb(audit_logs=[audit_row])
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = client.get("/audit/l1")
response = _client_for(db).get("/audit/l1")
assert response.status_code == 200
body = response.json()
assert body["object_alias"] == "deleted-key"
assert body["changed_by_user_email"] == "admin@example.com"
assert body["changed_by_key_alias"] == "admin-key"
def test_alias_columns_default_to_none_for_legacy_rows():
"""Rows written before the migration serialize with null alias columns, not errors."""
db = FakeDb(audit_logs=[make_log(id="l1")])
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = _client_for(db).get("/audit")
log = response.json()["audit_logs"][0]
assert log["object_alias"] is None
assert log["object_team_id"] is None
assert log["object_team_alias"] is None
assert log["changed_by_user_email"] is None
assert log["changed_by_key_alias"] is None

View file

@ -7,18 +7,20 @@ Tests the flow: create_audit_log_for_update -> _dispatch_audit_log_to_callbacks
import asyncio
import json
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, UserAPIKeyAuth
from litellm.proxy.management_helpers.audit_logs import (
_audit_log_task_done_callback,
_build_audit_log_payload,
_dispatch_audit_log_to_callbacks,
create_audit_log_for_update,
create_object_audit_log,
)
from litellm.types.utils import StandardAuditLogPayload
@ -130,9 +132,7 @@ class TestDispatchAuditLogToCallbacks:
async def test_nonblocking_on_callback_failure(self):
"""Callback errors should not propagate."""
mock_logger = MagicMock(spec=CustomLogger)
mock_logger.async_log_audit_log_event = AsyncMock(
side_effect=RuntimeError("boom")
)
mock_logger.async_log_audit_log_event = AsyncMock(side_effect=RuntimeError("boom"))
litellm.audit_log_callbacks = [mock_logger]
audit_log = _make_audit_log()
@ -236,9 +236,7 @@ class TestCreateAuditLogForUpdateWithCallbacks:
patch("litellm.store_audit_logs", True),
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
):
mock_prisma.db.litellm_auditlog.create = AsyncMock(
side_effect=RuntimeError("DB connection lost")
)
mock_prisma.db.litellm_auditlog.create = AsyncMock(side_effect=RuntimeError("DB connection lost"))
audit_log = _make_audit_log()
await create_audit_log_for_update(audit_log)
@ -254,9 +252,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.return_value = RuntimeError("callback failed")
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_called_once()
assert "callback failed" in str(mock_logger.error.call_args)
@ -266,9 +262,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.return_value = None
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_not_called()
@ -277,9 +271,7 @@ class TestAuditLogTaskDoneCallback:
mock_task = MagicMock(spec=asyncio.Task)
mock_task.exception.side_effect = asyncio.CancelledError()
with patch(
"litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger"
) as mock_logger:
with patch("litellm.proxy.management_helpers.audit_logs.verbose_proxy_logger") as mock_logger:
_audit_log_task_done_callback(mock_task)
mock_logger.error.assert_not_called()
@ -424,7 +416,6 @@ class TestS3AuditCallbackParamsDecoupling:
def test_empty_dict_opts_in(self):
"""`s3_audit_callback_params = {}` is opt-in (truthy-by-presence) and
produces a separate instance with no bucket configured (env/IAM-only)."""
from litellm.integrations.s3_v2 import S3Logger
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
)
@ -469,3 +460,228 @@ class TestS3AuditCallbackParamsDecoupling:
assert second is not None
assert id(second) != id(first)
assert second.s3_bucket_name == "second"
class _FakeFindUniqueTable:
def __init__(self, row=None):
self.row = row
self.where_calls = []
async def find_unique(self, where):
self.where_calls.append(where)
return self.row
class _FakeAuditCreateTable:
def __init__(self):
self.created = []
async def create(self, data):
self.created.append(data)
class _FakeDb:
def __init__(self, team_row=None, user_row=None, key_row=None):
self.litellm_auditlog = _FakeAuditCreateTable()
self.litellm_teamtable = _FakeFindUniqueTable(team_row)
self.litellm_usertable = _FakeFindUniqueTable(user_row)
self.litellm_verificationtoken = _FakeFindUniqueTable(key_row)
class _FakePrismaClient:
def __init__(self, db):
self.db = db
def _gates(fake_prisma):
return (
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.store_audit_logs", True),
patch("litellm.proxy.proxy_server.prisma_client", fake_prisma),
)
class TestAuditLogAliasDenormalization:
@pytest.mark.asyncio
async def test_team_delete_denormalizes_from_blob_without_lookups(self):
"""A team delete row gets object_alias/object_team_id/object_team_alias straight from its blob."""
db = _FakeDb(
user_row=SimpleNamespace(user_email="admin@example.com"),
key_row=SimpleNamespace(key_alias="admin-key"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a1",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
changed_by="admin-user",
changed_by_api_key="hash-admin",
action="deleted",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id="team-1",
before_value=json.dumps({"team_id": "team-1", "team_alias": "ml-team"}),
)
)
data = db.litellm_auditlog.created[0]
assert data["object_alias"] == "ml-team"
assert data["object_team_id"] == "team-1"
assert data["object_team_alias"] == "ml-team"
assert data["changed_by_user_email"] == "admin@example.com"
assert data["changed_by_key_alias"] == "admin-key"
assert db.litellm_teamtable.where_calls == []
assert db.litellm_usertable.where_calls == [{"user_id": "admin-user"}]
assert db.litellm_verificationtoken.where_calls == [{"token": "hash-admin"}]
@pytest.mark.asyncio
async def test_key_create_resolves_aliases_via_lookup_not_masked_blob(self):
"""Key rows resolve object_alias through the key table because the blob's key_alias is masked,
and resolve object_team_alias through the team table from the blob's team_id."""
db = _FakeDb(
team_row=SimpleNamespace(team_alias="team-nine"),
key_row=SimpleNamespace(key_alias="prod-key"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a2",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
action="created",
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id="hash-1",
updated_values=json.dumps({"key_alias": "prod-key", "team_id": "team-9"}),
)
)
data = db.litellm_auditlog.created[0]
assert json.loads(data["updated_values"])["key_alias"] == "********"
assert data["object_alias"] == "prod-key"
assert data["object_team_id"] == "team-9"
assert data["object_team_alias"] == "team-nine"
assert db.litellm_verificationtoken.where_calls == [{"token": "hash-1"}]
assert db.litellm_teamtable.where_calls == [{"team_id": "team-9"}]
@pytest.mark.asyncio
async def test_deleted_key_object_alias_absent_never_masked_junk(self):
"""A key delete whose row is already gone leaves object_alias unset instead of writing the masked blob value."""
db = _FakeDb()
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a2b",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
action="deleted",
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id="gone-hash",
before_value=json.dumps({"key_alias": "prod-key"}),
)
)
data = db.litellm_auditlog.created[0]
assert "object_alias" not in data
@pytest.mark.asyncio
async def test_user_row_prefers_alias_then_email_updated_blob_first(self):
"""User rows derive object_alias as user_alias then user_email, updated_values before before_value."""
db = _FakeDb()
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a3",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
action="updated",
table_name=LitellmTableNames.USER_TABLE_NAME,
object_id="user-1",
before_value=json.dumps({"user_alias": "Old Alias", "user_email": "old@example.com"}),
updated_values=json.dumps({"user_email": "new@example.com"}),
)
)
assert db.litellm_auditlog.created[0]["object_alias"] == "new@example.com"
@pytest.mark.asyncio
async def test_caller_provided_actor_fields_skip_lookups(self):
"""Actor email and key alias passed by the caller are kept verbatim with no DB lookups."""
db = _FakeDb(
user_row=SimpleNamespace(user_email="wrong@example.com"),
key_row=SimpleNamespace(key_alias="wrong-key"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a4",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
changed_by="admin-user",
changed_by_api_key="hash-admin",
action="updated",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id="team-1",
updated_values=json.dumps({"team_alias": "ml-team"}),
changed_by_user_email="right@example.com",
changed_by_key_alias="right-key",
)
)
data = db.litellm_auditlog.created[0]
assert data["changed_by_user_email"] == "right@example.com"
assert data["changed_by_key_alias"] == "right-key"
assert db.litellm_usertable.where_calls == []
assert db.litellm_verificationtoken.where_calls == []
@pytest.mark.asyncio
async def test_deleted_actor_stays_absent(self):
"""When actor lookups miss, the actor columns are left unset rather than written as empty."""
db = _FakeDb()
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a5",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
changed_by="gone-user",
changed_by_api_key="gone-hash",
action="deleted",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id="team-1",
before_value=json.dumps({"team_id": "team-1", "team_alias": "ml-team"}),
)
)
data = db.litellm_auditlog.created[0]
assert "changed_by_user_email" not in data
assert "changed_by_key_alias" not in data
assert data["object_alias"] == "ml-team"
@pytest.mark.asyncio
async def test_create_object_audit_log_uses_auth_context(self):
"""create_object_audit_log stamps actor email and key alias from UserAPIKeyAuth without lookups."""
db = _FakeDb()
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_object_audit_log(
object_id="team-1",
action="updated",
litellm_changed_by=None,
user_api_key_dict=UserAPIKeyAuth(
api_key="hash-admin",
user_id="admin-user",
user_email="admin@example.com",
key_alias="admin-key",
),
litellm_proxy_admin_name="default_user_id",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
after_value=json.dumps({"team_id": "team-1", "team_alias": "ml-team"}),
)
data = db.litellm_auditlog.created[0]
assert data["changed_by"] == "admin-user"
assert data["changed_by_user_email"] == "admin@example.com"
assert data["changed_by_key_alias"] == "admin-key"
assert data["object_alias"] == "ml-team"
assert db.litellm_usertable.where_calls == []
assert db.litellm_verificationtoken.where_calls == []