diff --git a/db_scripts/backfill_audit_log_aliases.sql b/db_scripts/backfill_audit_log_aliases.sql new file mode 100644 index 00000000000..03bff0c124a --- /dev/null +++ b/db_scripts/backfill_audit_log_aliases.sql @@ -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" <> ''; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120000_denormalize_audit_log_aliases/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120000_denormalize_audit_log_aliases/migration.sql index 0696abfd1a1..f4e9545a0ea 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120000_denormalize_audit_log_aliases/migration.sql +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120000_denormalize_audit_log_aliases/migration.sql @@ -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; diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120001_audit_log_object_lookup_index/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120001_audit_log_object_lookup_index/migration.sql new file mode 100644 index 00000000000..b2a54b6b080 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120001_audit_log_object_lookup_index/migration.sql @@ -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"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120002_audit_log_object_team_id_index/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120002_audit_log_object_team_id_index/migration.sql new file mode 100644 index 00000000000..7b56cc75d52 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120002_audit_log_object_team_id_index/migration.sql @@ -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"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120003_audit_log_updated_at_index/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120003_audit_log_updated_at_index/migration.sql new file mode 100644 index 00000000000..7c2d00b1d36 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120003_audit_log_updated_at_index/migration.sql @@ -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"); diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index 6231563450b..60c3f54ccfe 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -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, ) ) ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 3d7f0808fb9..3e86920307e 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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, diff --git a/litellm/proxy/management_helpers/audit_logs.py b/litellm/proxy/management_helpers/audit_logs.py index 60874a92753..2c51d989020 100644 --- a/litellm/proxy/management_helpers/audit_logs.py +++ b/litellm/proxy/management_helpers/audit_logs.py @@ -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, + ) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 272fbabf807..75669f1fe8e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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): diff --git a/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py b/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py index f9a097fb63c..155308cd26e 100644 --- a/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py +++ b/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py index 787e5776897..64aa9ad6292 100644 --- a/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py +++ b/tests/test_litellm/proxy/hooks/test_key_management_event_hooks.py @@ -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 diff --git a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py index 35a2ad90d73..89101fe34a8 100644 --- a/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py +++ b/tests/test_litellm/proxy/management_helpers/test_audit_log_callbacks.py @@ -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: