diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index 95bd2ccee68..c96ab570de4 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -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()) diff --git a/enterprise/litellm_enterprise/types/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/types/proxy/audit_logging_endpoints.py index 935f3b87f0c..5a73655fb8a 100644 --- a/enterprise/litellm_enterprise/types/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/types/proxy/audit_logging_endpoints.py @@ -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 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 new file mode 100644 index 00000000000..0696abfd1a1 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260805120000_denormalize_audit_log_aliases/migration.sql @@ -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; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 71345d2ccde..da44b6ea4c8 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a566d491597..b19cb1e876d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/management_helpers/audit_logs.py b/litellm/proxy/management_helpers/audit_logs.py index 2b714f06413..60874a92753 100644 --- a/litellm/proxy/management_helpers/audit_logs.py +++ b/litellm/proxy/management_helpers/audit_logs.py @@ -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( diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 71345d2ccde..da44b6ea4c8 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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 diff --git a/schema.prisma b/schema.prisma index 71345d2ccde..da44b6ea4c8 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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 diff --git a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py index 3dd78a72ac4..d6dd796fa30 100644 --- a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py +++ b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py @@ -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 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 0d137e552fb..f9a097fb63c 100644 --- a/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py +++ b/tests/test_litellm/enterprise/proxy/test_audit_logging_endpoints.py @@ -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 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 48e4353966b..35a2ad90d73 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 @@ -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 == []