fix(proxy): add config_updated_at audit timestamp for virtual keys (#36488)

* fix(proxy): add config_updated_at audit timestamp for virtual keys

updated_at carries Prisma's @updatedAt, so every batched spend flush
rewrites it and it cannot distinguish config changes from usage. Add an
additive config_updated_at column stamped only by key management writes
(update, bulk update, regenerate, block, unblock) via a shared helper,
expose it on key responses, and switch the key page's Last Updated to it
with a created_at fallback.

* test(proxy): assert config_updated_at survives key archival

* refactor(proxy): rename config_updated_at to settings_updated_at
This commit is contained in:
ryan-crabbe-berri 2026-08-11 11:02:57 -07:00 committed by GitHub
parent 657c4c2b1e
commit b144b15d48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 269 additions and 14 deletions

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3);

View file

@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
settings_updated_at DateTime? @map("settings_updated_at")
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
settings_updated_at DateTime? // Last configuration change before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)

View file

@ -49,6 +49,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
created_by: str | None = None
updated_at: datetime | None = None
updated_by: str | None = None
settings_updated_at: datetime | None = None
last_active: datetime | None = None
object_permission_id: str | None = None
object_permission: LiteLLM_ObjectPermissionTable | None = None

View file

@ -88,6 +88,7 @@ from litellm.proxy.management_endpoints.common_utils import (
from litellm.proxy.management_endpoints.model_management_endpoints import (
_add_model_to_db,
)
from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at
from litellm.proxy.management_helpers.object_permission_utils import (
_set_object_permission,
attach_object_permission_to_dict,
@ -4693,7 +4694,7 @@ async def _execute_virtual_key_regeneration(
updated_token: Final = await VerificationTokenRepository(prisma_client).table.update(
where={"token": hashed_api_key},
data=jsonified_update_data,
data=with_settings_updated_at(jsonified_update_data),
)
updated_token_dict: Final[dict[str, object]] = dict(updated_token) if updated_token is not None else {}
updated_token_dict["key"] = new_token
@ -6203,7 +6204,7 @@ async def block_key(
record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update(
where={"token": hashed_token},
data={"blocked": True},
data=with_settings_updated_at({"blocked": True}),
)
## UPDATE KEY CACHE - invalidate so next read re-fetches from DB
@ -6316,7 +6317,7 @@ async def unblock_key(
record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update(
where={"token": hashed_token},
data={"blocked": False},
data=with_settings_updated_at({"blocked": False}),
)
## UPDATE KEY CACHE - invalidate so next read re-fetches from DB

View file

@ -0,0 +1,14 @@
"""Audit stamping for virtual key configuration changes."""
from collections.abc import Mapping
from datetime import datetime, timezone
def with_settings_updated_at(data: Mapping[str, object]) -> dict[str, object]:
"""Stamp a key update payload with the time its configuration changed.
``updated_at`` carries Prisma's ``@updatedAt`` and so is rewritten by every
spend flush, which makes it useless for auditing; ``settings_updated_at`` is
written only from key-management write paths.
"""
return {**data, "settings_updated_at": datetime.now(timezone.utc)}

View file

@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
settings_updated_at DateTime? @map("settings_updated_at")
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
settings_updated_at DateTime? // Last configuration change before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)

View file

@ -135,6 +135,7 @@ from litellm.proxy.hooks.sensitive_data_routing import (
_PROXY_SensitiveDataRoutingHandler,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at
from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.config_repository import ConfigRepository
@ -3996,7 +3997,7 @@ class PrismaClient:
db_data["token"] = token
response: Final = await VerificationTokenRepository(self).table.update(
where={"token": token},
data={**db_data},
data=with_settings_updated_at(db_data),
)
verbose_proxy_logger.debug("\033[91m" + f"DB Token Table update succeeded {response}" + "\033[0m")
_data: dict = {}

View file

@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
created_by String?
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
updated_by String?
settings_updated_at DateTime? @map("settings_updated_at")
last_active DateTime? // When this key was last used
rotation_count Int? @default(0) // Number of times key has been rotated
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
created_by String? // Original creator
updated_at DateTime? // Last update timestamp before deletion
updated_by String? // Last user who updated before deletion
settings_updated_at DateTime? // Last configuration change before deletion
last_active DateTime? // When this key was last used before deletion
rotation_count Int? @default(0)
auto_rotate Boolean? @default(false)

View file

@ -2157,3 +2157,67 @@ async def test_daily_transaction_compression_saved_tokens_zero_when_absent():
assert transaction["compression_saved_tokens"] == 0
assert transaction["compression_savings_spend"] == 0
assert transaction["prompt_caching_savings_spend"] == 0
@pytest.mark.asyncio
async def test_commit_spend_updates_to_db_does_not_stamp_key_settings_updated_at():
"""Spend flushes must leave settings_updated_at alone, or it decays into
another `updated_at` and stops being an audit signal."""
db_writer = DBSpendUpdateWriter()
mock_batcher = MagicMock()
mock_batcher.litellm_verificationtoken = MagicMock()
mock_batcher.litellm_verificationtoken.update_many = MagicMock()
mock_batcher.litellm_usertable = MagicMock()
mock_batcher.litellm_usertable.update_many = MagicMock()
mock_batcher.litellm_teamtable = MagicMock()
mock_batcher.litellm_teamtable.update_many = MagicMock()
mock_batcher.litellm_teammembership = MagicMock()
mock_batcher.litellm_teammembership.update_many = MagicMock()
mock_batcher.litellm_organizationtable = MagicMock()
mock_batcher.litellm_organizationtable.update_many = MagicMock()
mock_batcher.litellm_tagtable = MagicMock()
mock_batcher.litellm_tagtable.update_many = MagicMock()
mock_batcher.litellm_agentstable = MagicMock()
mock_batcher.litellm_agentstable.update_many = MagicMock()
mock_transaction = AsyncMock()
mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction)
mock_transaction.__aexit__ = AsyncMock(return_value=False)
mock_transaction.batch_ = MagicMock(
return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_batcher),
__aexit__=AsyncMock(return_value=False),
)
)
mock_prisma_client = MagicMock()
mock_prisma_client.db = MagicMock()
mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction)
token = "hashed-token-abc"
response_cost = 0.25
db_spend_update_transactions = {
"user_list_transactions": {},
"end_user_list_transactions": {},
"key_list_transactions": {token: response_cost},
"team_list_transactions": {},
"team_member_list_transactions": {},
"org_list_transactions": {},
"tag_list_transactions": {},
"agent_list_transactions": {},
}
with patch("litellm.proxy.utils._raise_failed_update_spend_exception"):
await db_writer._commit_spend_updates_to_db(
prisma_client=mock_prisma_client,
n_retry_times=0,
proxy_logging_obj=MagicMock(),
db_spend_update_transactions=db_spend_update_transactions,
)
mock_batcher.litellm_verificationtoken.update_many.assert_called_once()
call_kwargs = mock_batcher.litellm_verificationtoken.update_many.call_args[1]
assert call_kwargs["where"] == {"token": token}
assert set(call_kwargs["data"]) == {"spend", "last_active"}
assert call_kwargs["data"]["spend"] == {"increment": response_cost}

View file

@ -2293,9 +2293,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch):
)
# Verify that the database update was called with hashed token
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with(
where={"token": test_hashed_token}, data={"blocked": False}
)
sk_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
assert sk_token_call["where"] == {"token": test_hashed_token}
assert sk_token_call["data"]["blocked"] is False
assert result == mock_key_record
@ -2313,9 +2313,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch):
)
# Verify that the database update was called with the same hashed token
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with(
where={"token": test_hashed_token}, data={"blocked": False}
)
hashed_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
assert hashed_token_call["where"] == {"token": test_hashed_token}
assert hashed_token_call["data"]["blocked"] is False
assert result == mock_key_record
@ -2849,9 +2849,10 @@ async def test_block_key_existing_key_succeeds(monkeypatch):
mock_prisma_client.db.litellm_verificationtoken.find_unique.assert_called_once_with(
where={"token": test_hashed_token}
)
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once_with(
where={"token": test_hashed_token}, data={"blocked": True}
)
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once()
block_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
assert block_call["where"] == {"token": test_hashed_token}
assert block_call["data"]["blocked"] is True
assert result == mock_updated_record
@ -4717,6 +4718,7 @@ def test_transform_verification_tokens_to_deleted_records():
user_role=LitellmUserRoles.PROXY_ADMIN.value,
)
config_stamp = datetime(2026, 8, 10, 12, 30, 45, tzinfo=timezone.utc)
key1 = LiteLLM_VerificationToken(
token="hashed-token-1",
user_id="user-123",
@ -4733,6 +4735,7 @@ def test_transform_verification_tokens_to_deleted_records():
model_spend={},
soft_budget_cooldown=False,
allowed_routes=[],
settings_updated_at=config_stamp,
)
key2 = LiteLLM_VerificationToken(
@ -4775,6 +4778,7 @@ def test_transform_verification_tokens_to_deleted_records():
assert record1["token"] == "hashed-token-1"
assert record1["user_id"] == "user-123"
assert record1["team_id"] == "team-456"
assert record1["settings_updated_at"] == config_stamp
assert isinstance(record1["aliases"], str)
assert isinstance(record1["config"], str)
assert isinstance(record1["permissions"], str)
@ -15817,3 +15821,91 @@ async def test_regenerate_key_output_token_estimate_lowered_rejected_for_non_adm
assert exc.value.status_code == 403
assert "Only proxy admins can set" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_execute_virtual_key_regeneration_stamps_settings_updated_at():
"""Regenerate rewrites the key's config, so it must move settings_updated_at."""
from datetime import datetime, timezone
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
_execute_virtual_key_regeneration,
)
mock_prisma_client = _make_regenerate_mock_prisma()
with _patch_regenerate_side_effects():
before = datetime.now(timezone.utc)
await _execute_virtual_key_regeneration(
prisma_client=mock_prisma_client,
key_in_db=_make_regenerate_existing_key(),
hashed_api_key="abc123",
key="abc123",
data=RegenerateKeyRequest(max_budget=42.0),
user_api_key_dict=_make_regenerate_user_api_key_dict(),
litellm_changed_by=None,
user_api_key_cache=MagicMock(),
proxy_logging_obj=MagicMock(),
)
after = datetime.now(timezone.utc)
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
assert sent["max_budget"] == 42.0
assert before <= sent["settings_updated_at"] <= after
@pytest.mark.asyncio
async def test_block_key_stamps_settings_updated_at(monkeypatch):
"""Blocking a key is a config change, not spend activity."""
from datetime import datetime, timezone
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import block_key
mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch)
before = datetime.now(timezone.utc)
await block_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin",
user_id="admin_user",
),
litellm_changed_by=None,
)
after = datetime.now(timezone.utc)
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
assert sent["blocked"] is True
assert before <= sent["settings_updated_at"] <= after
@pytest.mark.asyncio
async def test_unblock_key_stamps_settings_updated_at(monkeypatch):
"""Unblocking a key is a config change, not spend activity."""
from datetime import datetime, timezone
from litellm.proxy._types import BlockKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import unblock_key
mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch)
before = datetime.now(timezone.utc)
await unblock_key(
data=BlockKeyRequest(key="sk-test123456789"),
http_request=MagicMock(),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key="sk-admin",
user_id="admin_user",
),
litellm_changed_by=None,
)
after = datetime.now(timezone.utc)
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
assert sent["blocked"] is False
assert before <= sent["settings_updated_at"] <= after

View file

@ -1169,3 +1169,25 @@ async def test_prisma_health_check_failure_redacts_database_credentials(caplog):
assert emitted
assert all("hunter2" not in message for message in emitted)
assert any("postgresql://REDACTED@db.internal" in message for message in emitted)
@pytest.mark.asyncio
async def test_update_data_key_branch_stamps_settings_updated_at():
"""`updated_at` carries Prisma's @updatedAt and is rewritten by every spend
flush, so key config edits need their own audit column."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from litellm.proxy.utils import PrismaClient
client = MagicMock()
client.jsonify_object = MagicMock(side_effect=lambda data: dict(data))
client.db.litellm_verificationtoken.update = AsyncMock(return_value=None)
before = datetime.now(timezone.utc)
await PrismaClient.update_data(client, token="sk-test-key", data={"models": ["gpt-4"]})
after = datetime.now(timezone.utc)
sent = client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
assert sent["models"] == ["gpt-4"]
assert before <= sent["settings_updated_at"] <= after

View file

@ -60,6 +60,7 @@ export interface KeyResponse {
created_at: string;
created_by?: string;
updated_at: string;
settings_updated_at?: string | null;
last_active: string | null;
team_spend: number;
team_alias: string;

View file

@ -149,6 +149,46 @@ describe("KeyInfoView", () => {
await userEvent.click(await screen.findByRole("button", { name: /more key actions/i }));
};
describe("last updated", () => {
const renderWithTimestamps = (overrides: Partial<KeyResponse>) => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
return renderWithProviders(
<KeyInfoView
keyData={{
...MOCK_KEY_DATA,
created_at: "2021-06-15T12:00:00Z",
updated_at: "2023-06-15T12:00:00Z",
...overrides,
}}
onClose={() => {}}
keyId={"test-key-id"}
onKeyDataUpdate={() => {}}
teams={[]}
/>,
);
};
const findLastUpdatedText = async () => {
const label = await screen.findByText("Last Updated");
return label.closest("div")?.parentElement?.parentElement?.textContent ?? "";
};
it("should show when the key was last configured, not when it last recorded spend", async () => {
renderWithTimestamps({ settings_updated_at: "2022-06-15T12:00:00Z" });
expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2022/);
expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument();
});
it("should fall back to creation time for a key that was never reconfigured", async () => {
renderWithTimestamps({ settings_updated_at: null });
expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2021/);
expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument();
});
});
it("should render tags", async () => {
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);

View file

@ -444,6 +444,8 @@ export default function KeyInfoView({
);
};
const lastConfiguredAt = currentKeyData.settings_updated_at || currentKeyData.created_at;
const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null;
const budgetDisplay =
@ -468,7 +470,7 @@ export default function KeyInfoView({
currentKeyData.created_by ||
"",
createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "",
lastUpdated: currentKeyData.updated_at ? formatTimestamp(currentKeyData.updated_at) : "",
lastUpdated: lastConfiguredAt ? formatTimestamp(lastConfiguredAt) : "",
lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never",
expires: currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never",
}}

View file

@ -26280,6 +26280,8 @@ export interface components {
} | null;
/** Rpm Limit */
rpm_limit?: number | null;
/** Settings Updated At */
settings_updated_at?: string | null;
/**
* Soft Budget Cooldown
* @default false
@ -27719,6 +27721,8 @@ export interface components {
} | null;
/** Rpm Limit */
rpm_limit?: number | null;
/** Settings Updated At */
settings_updated_at?: string | null;
/**
* Soft Budget Cooldown
* @default false
@ -34887,6 +34891,8 @@ export interface components {
rpm_limit_per_model?: {
[key: string]: number;
} | null;
/** Settings Updated At */
settings_updated_at?: string | null;
/** Soft Budget */
soft_budget?: number | null;
/**