fix(proxy): address audit denormalization review blockers

Migration is now a columns-only ALTER with each index shipped as its
own single-statement CONCURRENTLY migration, and the row backfill
moved to a documented manual script in db_scripts. Key delete and
rotate hooks pass the in-memory key_alias so those rows keep an
object_alias. Actor fields skip lookups when the caller supplied
them and an email is only attributed when the credential's key row
confirms the changed_by user, so header-spoofed ids are never
decorated. Callback payloads gain the five alias keys, the dead org
branches are gone, and a failed audit insert now logs loudly
This commit is contained in:
ryan-crabbe-berri 2026-08-17 09:38:41 -07:00
parent eff4e5f826
commit eebd1326d9
12 changed files with 481 additions and 151 deletions

View file

@ -0,0 +1,92 @@
-- One-shot backfill of the denormalized alias columns on LiteLLM_AuditLog
-- (object_alias, object_team_id, object_team_alias, changed_by_user_email,
-- changed_by_key_alias) for rows written before the columns existed.
--
-- This is an opt-in, manual operation. New deployments do not need it: the
-- audit writer stamps the columns at write time from the moment the release
-- is deployed. Run it only if you want /audit responses and the object_team
-- filter to cover history from before the deploy.
--
-- Every statement only touches rows where the target column is NULL, so the
-- script is idempotent and safe to re-run (including after a partial run).
--
-- Sources, matching what the writer produces:
-- - object_alias comes from the before/updated JSON blobs captured at change
-- time (updated_values wins over before_value; users prefer user_alias then
-- user_email). Key rows are the exception: the writer masks key_alias inside
-- the blobs, so their alias resolves via join on the current key table and
-- rows for already-deleted keys stay NULL.
-- - The actor columns resolve via joins on the current user and key tables,
-- so deleted actors stay NULL.
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_ProxyModelTable' THEN COALESCE(
NULLIF("updated_values"->>'model_name', ''),
NULLIF("before_value"->>'model_name', '')
)
END
WHERE "object_alias" IS NULL
AND "table_name" IN ('LiteLLM_TeamTable', 'LiteLLM_UserTable', 'LiteLLM_ProxyModelTable');
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
AND v."key_alias" <> '';
UPDATE "LiteLLM_AuditLog"
SET "object_team_id" = COALESCE(
NULLIF("updated_values"->>'team_id', ''),
NULLIF("before_value"->>'team_id', '')
)
WHERE "object_team_id" IS NULL;
UPDATE "LiteLLM_AuditLog"
SET "object_team_id" = "object_id"
WHERE "object_team_id" IS NULL
AND "table_name" = 'LiteLLM_TeamTable';
UPDATE "LiteLLM_AuditLog"
SET "object_team_alias" = COALESCE(
NULLIF("updated_values"->>'team_alias', ''),
NULLIF("before_value"->>'team_alias', '')
)
WHERE "object_team_alias" IS NULL;
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
AND t."team_alias" <> '';
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
AND u."user_email" <> '';
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
AND v."key_alias" <> '';

View file

@ -1,89 +1,10 @@
-- AlterTable
-- Nullable columns only: a metadata-only ALTER that stays fast regardless of table size.
-- Backfill of pre-existing rows is a manual, optional operation:
-- db_scripts/backfill_audit_log_aliases.sql. New rows are stamped at write time,
-- historical rows stay NULL until an operator runs the script.
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

@ -0,0 +1,10 @@
-- CreateIndex (CONCURRENTLY)
--
-- Disclaimer:
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
-- - Do not edit this file after it has been applied to any database: Prisma checksums
-- migrations; add a new migration instead.
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_AuditLog_table_name_object_id_idx" ON "LiteLLM_AuditLog"("table_name", "object_id");

View file

@ -0,0 +1,10 @@
-- CreateIndex (CONCURRENTLY)
--
-- Disclaimer:
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
-- - Do not edit this file after it has been applied to any database: Prisma checksums
-- migrations; add a new migration instead.
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_AuditLog_object_team_id_idx" ON "LiteLLM_AuditLog"("object_team_id");

View file

@ -0,0 +1,10 @@
-- CreateIndex (CONCURRENTLY)
--
-- Disclaimer:
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
-- - Do not edit this file after it has been applied to any database: Prisma checksums
-- migrations; add a new migration instead.
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_AuditLog_updated_at_idx" ON "LiteLLM_AuditLog"("updated_at");

View file

@ -42,6 +42,7 @@ class KeyManagementEventHooks:
"""
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_actor_fields,
get_audit_log_changed_by,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -56,6 +57,11 @@ class KeyManagementEventHooks:
# Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True
if litellm.store_audit_logs is True:
_updated_values: Final = response.model_dump_json(exclude_none=True)
_actor: Final = get_audit_log_actor_fields(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
@ -72,6 +78,9 @@ class KeyManagementEventHooks:
action="created",
updated_values=_updated_values,
before_value=None,
object_alias=response.key_alias,
changed_by_user_email=_actor.changed_by_user_email,
changed_by_key_alias=_actor.changed_by_key_alias,
)
)
)
@ -102,6 +111,7 @@ class KeyManagementEventHooks:
"""
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_actor_fields,
get_audit_log_changed_by,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -113,6 +123,11 @@ class KeyManagementEventHooks:
_before_value = existing_key_row.json(exclude_none=True)
_before_value = json.dumps(_before_value, default=str)
_actor: Final = get_audit_log_actor_fields(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
@ -129,6 +144,9 @@ class KeyManagementEventHooks:
action="updated",
updated_values=_updated_values,
before_value=_before_value,
object_alias=data.key_alias or existing_key_row.key_alias,
changed_by_user_email=_actor.changed_by_user_email,
changed_by_key_alias=_actor.changed_by_key_alias,
)
)
)
@ -143,6 +161,7 @@ class KeyManagementEventHooks:
):
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_actor_fields,
get_audit_log_changed_by,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -181,6 +200,11 @@ class KeyManagementEventHooks:
# store the audit log
if litellm.store_audit_logs is True and existing_key_row.token is not None:
_actor: Final = get_audit_log_actor_fields(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
@ -197,6 +221,9 @@ class KeyManagementEventHooks:
action="rotated",
updated_values=response.model_dump_json(exclude_none=True),
before_value=existing_key_row.model_dump_json(exclude_none=True),
object_alias=response.key_alias or existing_key_row.key_alias,
changed_by_user_email=_actor.changed_by_user_email,
changed_by_key_alias=_actor.changed_by_key_alias,
)
)
)
@ -217,6 +244,7 @@ class KeyManagementEventHooks:
"""
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
get_audit_log_actor_fields,
get_audit_log_changed_by,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
@ -224,6 +252,11 @@ class KeyManagementEventHooks:
# 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:
_actor: Final = get_audit_log_actor_fields(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
# make an audit log for each key deleted
for key in keys_being_deleted:
if key.token is None:
@ -246,6 +279,9 @@ class KeyManagementEventHooks:
action="deleted",
updated_values="{}",
before_value=_key_row,
object_alias=key.key_alias,
changed_by_user_email=_actor.changed_by_user_email,
changed_by_key_alias=_actor.changed_by_key_alias,
)
)
)

View file

@ -2860,6 +2860,7 @@ def _members_audit_value(members: Sequence[Member]) -> str:
async def _create_team_member_add_audit_logs(
team_id: str,
team_alias: str | None,
updated_users: Sequence[LiteLLM_UserTable],
existing_user_ids: frozenset[str],
before_members: Sequence[Member],
@ -2898,6 +2899,7 @@ async def _create_team_member_add_audit_logs(
table_name=LitellmTableNames.TEAM_TABLE_NAME,
before_value=_members_audit_value(before_members),
after_value=_members_audit_value(after_members),
object_alias=team_alias,
)
await asyncio.gather(*created_user_entries, membership_entry)
@ -3135,6 +3137,7 @@ async def team_member_add(
await _create_team_member_add_audit_logs(
team_id=data.team_id,
team_alias=complete_team_data.team_alias,
updated_users=updated_users,
existing_user_ids=pre_existing_user_ids,
before_members=members_before_add,

View file

@ -5,7 +5,7 @@ Functions to create audit logs for LiteLLM Proxy
import asyncio
import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, NamedTuple
from pydantic import BaseModel, ValidationError
@ -50,6 +50,32 @@ def get_audit_log_changed_by(
return user_api_key_dict.user_id or litellm_proxy_admin_name
class AuditLogActorFields(NamedTuple):
changed_by_user_email: str | None
changed_by_key_alias: str | None
def get_audit_log_actor_fields(
*,
litellm_changed_by: str | None,
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str | None,
) -> AuditLogActorFields:
"""Actor fields from the authenticated credential. The email is only attributed when the
resolved changed_by IS the credential's user, so a litellm-changed-by header value is
never decorated with a real user's email."""
changed_by: Final = get_audit_log_changed_by(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
is_credential_user: Final = user_api_key_dict.user_id is not None and changed_by == user_api_key_dict.user_id
return AuditLogActorFields(
changed_by_user_email=(user_api_key_dict.user_email or None) if is_credential_user else None,
changed_by_key_alias=user_api_key_dict.key_alias or None,
)
def _resolve_audit_log_callback(name: str) -> CustomLogger | None:
"""Resolve a string callback name to a CustomLogger instance, with caching.
@ -110,6 +136,11 @@ def _build_audit_log_payload(
object_id=request_data.object_id,
before_value=request_data.before_value,
updated_values=request_data.updated_values,
object_alias=request_data.object_alias,
object_team_id=request_data.object_team_id,
object_team_alias=request_data.object_team_alias,
changed_by_user_email=request_data.changed_by_user_email,
changed_by_key_alias=request_data.changed_by_key_alias,
)
@ -152,7 +183,6 @@ 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
@ -173,8 +203,6 @@ def _derive_object_alias(table_name: str, updated: _AuditBlobAliasFields, before
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
@ -200,14 +228,28 @@ async def _lookup_user_email(prisma_client: "PrismaClient", user_id: str) -> str
return email if isinstance(email, str) and email else None
async def _lookup_key_alias(prisma_client: "PrismaClient", token: str) -> str | None:
class _ActorKey(NamedTuple):
key_alias: str | None
user_id: str | None
async def _lookup_actor_key(prisma_client: "PrismaClient", token: str) -> _ActorKey:
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
verbose_proxy_logger.debug("audit log key lookup failed: %s", e)
return _ActorKey(key_alias=None, user_id=None)
alias: Final = None if row is None else row.key_alias
return alias if isinstance(alias, str) and alias else None
user_id: Final = None if row is None else row.user_id
return _ActorKey(
key_alias=alias if isinstance(alias, str) and alias else None,
user_id=user_id if isinstance(user_id, str) and user_id else None,
)
async def _lookup_key_alias(prisma_client: "PrismaClient", token: str) -> str | None:
actor_key: Final = await _lookup_actor_key(prisma_client, token)
return actor_key.key_alias
def _serialized_blob(value: object) -> object:
@ -226,11 +268,16 @@ async def _with_denormalized_aliases(
)
is_team_row: Final = table_name == LitellmTableNames.TEAM_TABLE_NAME.value
is_key_row: Final = table_name == LitellmTableNames.KEY_TABLE_NAME.value
fields_set: Final = request_data.model_fields_set
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_alias: Final = (
request_data.object_alias
if "object_alias" in fields_set
else (
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
@ -243,19 +290,34 @@ async def _with_denormalized_aliases(
request_data.object_team_alias
or updated.team_alias
or before.team_alias
or (object_alias if is_team_row else None)
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
need_actor_key: Final = (
prisma_client is not None
and bool(request_data.changed_by_api_key)
and ("changed_by_key_alias" not in fields_set or "changed_by_user_email" not in fields_set)
)
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
actor_key: Final = (
await _lookup_actor_key(prisma_client, request_data.changed_by_api_key)
if need_actor_key and prisma_client is not None and request_data.changed_by_api_key
else _ActorKey(key_alias=None, user_id=None)
)
changed_by_key_alias: Final = (
request_data.changed_by_key_alias if "changed_by_key_alias" in fields_set else actor_key.key_alias
)
changed_by_user_email: Final = (
request_data.changed_by_user_email
if "changed_by_user_email" in fields_set
else (
await _lookup_user_email(prisma_client, changed_by)
if prisma_client is not None and changed_by is not None and actor_key.user_id == changed_by
else None
)
)
return request_data.model_copy(
update={
@ -279,6 +341,7 @@ async def create_object_audit_log(
table_name: LitellmTableNames,
before_value: str | None = None,
after_value: str | None = None,
object_alias: str | None = None,
):
"""
Create an audit log for an internal user.
@ -290,6 +353,7 @@ async def create_object_audit_log(
- litellm_changed_by: Optional[str] - The user id of the user who is changing the user.
- user_api_key_dict: UserAPIKeyAuth - The user api key dictionary.
- litellm_proxy_admin_name: Optional[str] - The name of the proxy admin.
- object_alias: Optional[str] - Alias of the audited object when the caller already holds it.
"""
from litellm.secret_managers.main import get_secret_bool
@ -303,23 +367,31 @@ async def create_object_audit_log(
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
await create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=_changed_by,
changed_by_api_key=user_api_key_dict.api_key,
table_name=table_name,
object_id=object_id,
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,
)
_actor: Final = get_audit_log_actor_fields(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
base_request: Final = LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=_changed_by,
changed_by_api_key=user_api_key_dict.api_key,
table_name=table_name,
object_id=object_id,
action=action,
updated_values=after_value,
before_value=before_value,
changed_by_user_email=_actor.changed_by_user_email,
changed_by_key_alias=_actor.changed_by_key_alias,
)
request: Final = (
base_request.model_copy(update={"object_alias": object_alias}) if object_alias is not None else base_request
)
await create_audit_log_for_update(request_data=request)
async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
"""
@ -357,4 +429,10 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
)
except Exception as e:
# [Non-Blocking Exception. Do not allow blocking LLM API call]
verbose_proxy_logger.error("Failed Creating audit log %s", e)
verbose_proxy_logger.error(
"Failed creating audit log row %s (audit logs are NOT being stored; "
"an unmigrated DB missing the alias columns causes this): %s",
enriched.id,
e,
exc_info=e,
)

View file

@ -3127,6 +3127,11 @@ class StandardAuditLogPayload(TypedDict):
object_id: str
before_value: str | None
updated_values: str | None
object_alias: str | None
object_team_id: str | None
object_team_alias: str | None
changed_by_user_email: str | None
changed_by_key_alias: str | None
class StandardLoggingPayload(TypedDict):

View file

@ -12,12 +12,22 @@ from litellm_enterprise.proxy.audit_logging_endpoints import (
_build_object_team_condition,
)
from litellm_enterprise.proxy.audit_logging_endpoints import router as audit_router
from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
class FakeDbRow:
"""Mimics a prisma model instance: attribute-free, only model_dump() like the endpoint uses."""
def __init__(self, values):
self._values = dict(values)
self.id = self._values["id"]
def model_dump(self):
return dict(self._values)
class FakeAuditLogTable:
def __init__(self, rows=()):
self.rows = list(rows)
@ -44,7 +54,7 @@ class FakePrismaClient:
self.db = db
def make_log(**overrides) -> AuditLogResponse:
def make_row(**overrides) -> FakeDbRow:
defaults = {
"id": "log-1",
"updated_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
@ -55,8 +65,13 @@ def make_log(**overrides) -> AuditLogResponse:
"object_id": "obj-1",
"before_value": None,
"updated_values": None,
"object_alias": None,
"object_team_id": None,
"object_team_alias": None,
"changed_by_user_email": None,
"changed_by_key_alias": None,
}
return AuditLogResponse(**{**defaults, **overrides})
return FakeDbRow({**defaults, **overrides})
def _client_for(db: FakeDb) -> TestClient:
@ -78,7 +93,7 @@ def test_build_object_team_condition_matches_id_and_alias_columns():
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(
audit_row = make_row(
id="l1",
action="deleted",
object_id="team-1",
@ -145,7 +160,7 @@ def test_get_audit_logs_object_team_id_filter_unchanged():
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(
audit_row = make_row(
id="l1",
table_name="LiteLLM_VerificationToken",
object_id="gone-hash",
@ -166,13 +181,25 @@ def test_get_audit_log_by_id_returns_denormalized_columns():
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")])
def test_rows_without_alias_columns_serialize_as_null():
"""A row dict lacking the five columns entirely (pre-migration DB) serializes as nulls, not errors."""
legacy_values = {
"id": "l1",
"updated_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
"changed_by": "",
"changed_by_api_key": "",
"action": "updated",
"table_name": "LiteLLM_TeamTable",
"object_id": "obj-1",
"before_value": None,
"updated_values": None,
}
db = FakeDb(audit_logs=[FakeDbRow(legacy_values)])
with patch("litellm.proxy.proxy_server.prisma_client", FakePrismaClient(db)):
response = _client_for(db).get("/audit")
assert response.status_code == 200
log = response.json()["audit_logs"][0]
assert log["object_alias"] is None
assert log["object_team_id"] is None

View file

@ -72,9 +72,7 @@ class TestKeyManagementEventHooksIndependentOperations:
return_value=True,
),
patch("litellm.store_audit_logs", False),
patch(
"litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"
),
patch("litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"),
):
# Should not raise even though email fails
await KeyManagementEventHooks.async_key_generated_hook(
@ -140,9 +138,7 @@ class TestKeyManagementEventHooksIndependentOperations:
return_value=True,
),
patch("litellm.store_audit_logs", False),
patch(
"litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"
),
patch("litellm.proxy.hooks.key_management_event_hooks.verbose_proxy_logger"),
):
# Should not raise even though secret manager fails
await KeyManagementEventHooks.async_key_generated_hook(
@ -170,9 +166,7 @@ class TestRotateVirtualKeyInSecretManager:
# Setup - Create a mock that inherits from BaseSecretManager
mock_secret_manager = MagicMock(spec=BaseSecretManager)
mock_secret_manager.async_rotate_secret = AsyncMock(
return_value={"status": "success"}
)
mock_secret_manager.async_rotate_secret = AsyncMock(return_value={"status": "success"})
litellm.secret_manager_client = mock_secret_manager
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT
@ -246,9 +240,7 @@ class TestRotateVirtualKeyInSecretManager:
# Setup - Create a mock that inherits from BaseSecretManager
mock_secret_manager = MagicMock(spec=BaseSecretManager)
mock_secret_manager.async_rotate_secret = AsyncMock(
return_value={"status": "success"}
)
mock_secret_manager.async_rotate_secret = AsyncMock(return_value={"status": "success"})
litellm.secret_manager_client = mock_secret_manager
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT
@ -314,9 +306,7 @@ class TestRotateVirtualKeyInSecretManager:
# Setup
mock_secret_manager = MagicMock()
mock_secret_manager.async_rotate_secret = AsyncMock(
return_value={"status": "success"}
)
mock_secret_manager.async_rotate_secret = AsyncMock(return_value={"status": "success"})
litellm.secret_manager_client = mock_secret_manager
litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT
@ -504,3 +494,92 @@ class TestKeyUpdatedAuditLogObjectId:
audit_row = await self._run_updated_hook_and_capture_audit_log(request_key=hashed_key)
assert audit_row.object_id == hashed_key
class TestKeyLifecycleAuditAliases:
"""Delete and rotate rows carry object_alias from the in-memory row: the token row is gone or
rewritten before the writer's lookup could run, and blob key_alias is masked (LIT-4997)."""
@pytest.mark.asyncio
async def test_key_delete_audit_log_carries_object_alias(self):
import asyncio
from litellm.proxy._types import KeyRequest, LiteLLM_VerificationToken, UserAPIKeyAuth
captured = []
async def capture_audit_log(request_data):
captured.append(request_data)
doomed_key = LiteLLM_VerificationToken(token="hash-doomed", key_alias="doomed-key")
with (
patch("litellm.store_audit_logs", True),
patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
new=capture_audit_log,
),
patch.object(
KeyManagementEventHooks,
"_delete_virtual_keys_from_secret_manager",
new=AsyncMock(),
),
):
await KeyManagementEventHooks.async_key_deleted_hook(
data=KeyRequest(keys=["hash-doomed"]),
keys_being_deleted=[doomed_key],
response={},
user_api_key_dict=UserAPIKeyAuth(api_key="hash-admin", user_id="admin-user", key_alias="admin-key"),
)
for _ in range(100):
if captured:
break
await asyncio.sleep(0.01)
assert len(captured) == 1
audit_row = captured[0]
assert audit_row.action == "deleted"
assert audit_row.object_alias == "doomed-key"
assert "object_alias" in audit_row.model_fields_set
assert audit_row.changed_by_key_alias == "admin-key"
@pytest.mark.asyncio
async def test_key_rotate_audit_log_carries_object_alias(self):
import asyncio
from litellm.proxy._types import (
GenerateKeyResponse,
LiteLLM_VerificationToken,
UserAPIKeyAuth,
)
captured = []
async def capture_audit_log(request_data):
captured.append(request_data)
rotated_key = LiteLLM_VerificationToken(token="hash-old", key_alias="rotated-key")
with (
patch("litellm.store_audit_logs", True),
patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
new=capture_audit_log,
),
):
await KeyManagementEventHooks.async_key_rotated_hook(
data=None,
existing_key_row=rotated_key,
response=GenerateKeyResponse(key="sk-new-secret"),
user_api_key_dict=UserAPIKeyAuth(api_key="hash-admin", user_id="admin-user", key_alias="admin-key"),
)
for _ in range(100):
if captured:
break
await asyncio.sleep(0.01)
assert len(captured) == 1
audit_row = captured[0]
assert audit_row.action == "rotated"
assert audit_row.object_alias == "rotated-key"
assert "object_alias" in audit_row.model_fields_set

View file

@ -66,6 +66,26 @@ class TestBuildAuditLogPayload:
assert payload["updated_values"] == json.dumps({"name": "new-team"})
assert payload["before_value"] == json.dumps({"name": "old-team"})
def test_includes_denormalized_alias_keys(self):
"""Callback payloads carry the five alias keys so S3/Datadog/custom sinks receive them."""
audit_log = _make_audit_log().model_copy(
update={
"object_alias": "ml-team",
"object_team_id": "team-1",
"object_team_alias": "ml-team",
"changed_by_user_email": "admin@example.com",
"changed_by_key_alias": "admin-key",
}
)
payload = _build_audit_log_payload(audit_log)
assert payload["object_alias"] == "ml-team"
assert payload["object_team_id"] == "team-1"
assert payload["object_team_alias"] == "ml-team"
assert payload["changed_by_user_email"] == "admin@example.com"
assert payload["changed_by_key_alias"] == "admin-key"
def test_handles_none_values(self):
audit_log = LiteLLM_AuditLogs(
id="test-id",
@ -507,7 +527,7 @@ class TestAuditLogAliasDenormalization:
"""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"),
key_row=SimpleNamespace(key_alias="admin-key", user_id="admin-user"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
@ -540,7 +560,7 @@ class TestAuditLogAliasDenormalization:
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"),
key_row=SimpleNamespace(key_alias="prod-key", user_id=None),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
@ -564,24 +584,63 @@ class TestAuditLogAliasDenormalization:
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()
async def test_service_account_without_aliases_triggers_no_lookups(self):
"""Explicitly supplied None actor fields (service keys with no alias or email) skip the
guaranteed-miss lookups instead of querying on every audit row."""
db = _FakeDb(
user_row=SimpleNamespace(user_email="someone@example.com"),
key_row=SimpleNamespace(key_alias="some-key", user_id="someone"),
)
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"}),
changed_by="service-account",
changed_by_api_key="hash-service",
action="updated",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id="team-1",
updated_values=json.dumps({"team_alias": "ml-team"}),
changed_by_user_email=None,
changed_by_key_alias=None,
)
)
data = db.litellm_auditlog.created[0]
assert "object_alias" not in data
assert "changed_by_user_email" not in data
assert "changed_by_key_alias" not in data
assert db.litellm_usertable.where_calls == []
assert db.litellm_verificationtoken.where_calls == []
@pytest.mark.asyncio
async def test_spoofed_changed_by_is_not_resolved_to_an_email(self):
"""A changed_by that is not the credential's own user (litellm-changed-by header) is never
decorated with a real user's email."""
db = _FakeDb(
user_row=SimpleNamespace(user_email="victim@example.com"),
key_row=SimpleNamespace(key_alias="attacker-key", user_id="attacker-user"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3:
await create_audit_log_for_update(
LiteLLM_AuditLogs(
id="a2c",
updated_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
changed_by="victim-user",
changed_by_api_key="hash-attacker",
action="updated",
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id="team-1",
updated_values=json.dumps({"team_alias": "ml-team"}),
)
)
data = db.litellm_auditlog.created[0]
assert "changed_by_user_email" not in data
assert data["changed_by_key_alias"] == "attacker-key"
assert db.litellm_usertable.where_calls == []
@pytest.mark.asyncio
async def test_user_row_prefers_alias_then_email_updated_blob_first(self):
@ -608,7 +667,7 @@ class TestAuditLogAliasDenormalization:
"""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"),
key_row=SimpleNamespace(key_alias="wrong-key", user_id="admin-user"),
)
p1, p2, p3 = _gates(_FakePrismaClient(db))
with p1, p2, p3: