mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(proxy): address audit denormalization review blockers
Migration is now a columns-only ALTER with each index shipped as its own single-statement CONCURRENTLY migration, and the row backfill moved to a documented manual script in db_scripts. Key delete and rotate hooks pass the in-memory key_alias so those rows keep an object_alias. Actor fields skip lookups when the caller supplied them and an email is only attributed when the credential's key row confirms the changed_by user, so header-spoofed ids are never decorated. Callback payloads gain the five alias keys, the dead org branches are gone, and a failed audit insert now logs loudly
This commit is contained in:
parent
eff4e5f826
commit
eebd1326d9
12 changed files with 481 additions and 151 deletions
92
db_scripts/backfill_audit_log_aliases.sql
Normal file
92
db_scripts/backfill_audit_log_aliases.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
-- One-shot backfill of the denormalized alias columns on LiteLLM_AuditLog
|
||||
-- (object_alias, object_team_id, object_team_alias, changed_by_user_email,
|
||||
-- changed_by_key_alias) for rows written before the columns existed.
|
||||
--
|
||||
-- This is an opt-in, manual operation. New deployments do not need it: the
|
||||
-- audit writer stamps the columns at write time from the moment the release
|
||||
-- is deployed. Run it only if you want /audit responses and the object_team
|
||||
-- filter to cover history from before the deploy.
|
||||
--
|
||||
-- Every statement only touches rows where the target column is NULL, so the
|
||||
-- script is idempotent and safe to re-run (including after a partial run).
|
||||
--
|
||||
-- Sources, matching what the writer produces:
|
||||
-- - object_alias comes from the before/updated JSON blobs captured at change
|
||||
-- time (updated_values wins over before_value; users prefer user_alias then
|
||||
-- user_email). Key rows are the exception: the writer masks key_alias inside
|
||||
-- the blobs, so their alias resolves via join on the current key table and
|
||||
-- rows for already-deleted keys stay NULL.
|
||||
-- - The actor columns resolve via joins on the current user and key tables,
|
||||
-- so deleted actors stay NULL.
|
||||
|
||||
UPDATE "LiteLLM_AuditLog"
|
||||
SET "object_alias" = CASE "table_name"
|
||||
WHEN 'LiteLLM_TeamTable' THEN COALESCE(
|
||||
NULLIF("updated_values"->>'team_alias', ''),
|
||||
NULLIF("before_value"->>'team_alias', '')
|
||||
)
|
||||
WHEN 'LiteLLM_UserTable' THEN COALESCE(
|
||||
NULLIF("updated_values"->>'user_alias', ''),
|
||||
NULLIF("updated_values"->>'user_email', ''),
|
||||
NULLIF("before_value"->>'user_alias', ''),
|
||||
NULLIF("before_value"->>'user_email', '')
|
||||
)
|
||||
WHEN 'LiteLLM_ProxyModelTable' THEN COALESCE(
|
||||
NULLIF("updated_values"->>'model_name', ''),
|
||||
NULLIF("before_value"->>'model_name', '')
|
||||
)
|
||||
END
|
||||
WHERE "object_alias" IS NULL
|
||||
AND "table_name" IN ('LiteLLM_TeamTable', 'LiteLLM_UserTable', 'LiteLLM_ProxyModelTable');
|
||||
|
||||
UPDATE "LiteLLM_AuditLog" a
|
||||
SET "object_alias" = v."key_alias"
|
||||
FROM "LiteLLM_VerificationToken" v
|
||||
WHERE a."object_alias" IS NULL
|
||||
AND a."table_name" = 'LiteLLM_VerificationToken'
|
||||
AND a."object_id" = v."token"
|
||||
AND v."key_alias" IS NOT NULL
|
||||
AND v."key_alias" <> '';
|
||||
|
||||
UPDATE "LiteLLM_AuditLog"
|
||||
SET "object_team_id" = COALESCE(
|
||||
NULLIF("updated_values"->>'team_id', ''),
|
||||
NULLIF("before_value"->>'team_id', '')
|
||||
)
|
||||
WHERE "object_team_id" IS NULL;
|
||||
|
||||
UPDATE "LiteLLM_AuditLog"
|
||||
SET "object_team_id" = "object_id"
|
||||
WHERE "object_team_id" IS NULL
|
||||
AND "table_name" = 'LiteLLM_TeamTable';
|
||||
|
||||
UPDATE "LiteLLM_AuditLog"
|
||||
SET "object_team_alias" = COALESCE(
|
||||
NULLIF("updated_values"->>'team_alias', ''),
|
||||
NULLIF("before_value"->>'team_alias', '')
|
||||
)
|
||||
WHERE "object_team_alias" IS NULL;
|
||||
|
||||
UPDATE "LiteLLM_AuditLog" a
|
||||
SET "object_team_alias" = t."team_alias"
|
||||
FROM "LiteLLM_TeamTable" t
|
||||
WHERE a."object_team_alias" IS NULL
|
||||
AND a."object_team_id" = t."team_id"
|
||||
AND t."team_alias" IS NOT NULL
|
||||
AND t."team_alias" <> '';
|
||||
|
||||
UPDATE "LiteLLM_AuditLog" a
|
||||
SET "changed_by_user_email" = u."user_email"
|
||||
FROM "LiteLLM_UserTable" u
|
||||
WHERE a."changed_by_user_email" IS NULL
|
||||
AND a."changed_by" = u."user_id"
|
||||
AND u."user_email" IS NOT NULL
|
||||
AND u."user_email" <> '';
|
||||
|
||||
UPDATE "LiteLLM_AuditLog" a
|
||||
SET "changed_by_key_alias" = v."key_alias"
|
||||
FROM "LiteLLM_VerificationToken" v
|
||||
WHERE a."changed_by_key_alias" IS NULL
|
||||
AND a."changed_by_api_key" = v."token"
|
||||
AND v."key_alias" IS NOT NULL
|
||||
AND v."key_alias" <> '';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue