fix(proxy): invalidate the written permission id on bulk update and regenerate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-10-03 04:43:05 +00:00
parent 1e9d10b531
commit 8d0f36bc59
2 changed files with 65 additions and 10 deletions

View file

@ -2982,7 +2982,7 @@ async def _process_single_key_update(
await invalidate_cached_object_permissions(
object_permission_ids=(
existing_key_row.object_permission_id,
non_default_values.get("object_permission_id"),
update_values.get("object_permission_id"),
),
user_api_key_cache=user_api_key_cache,
)
@ -5817,7 +5817,7 @@ async def _execute_virtual_key_regeneration(
await invalidate_cached_object_permissions(
object_permission_ids=(
key_in_db.object_permission_id,
non_default_values.get("object_permission_id"),
update_values.get("object_permission_id"),
),
user_api_key_cache=user_api_key_cache,
)

View file

@ -5,6 +5,7 @@ from typing import Final
from types import SimpleNamespace
import json
from datetime import datetime, timedelta, timezone
from uuid import UUID
import litellm
import pytest
@ -7146,7 +7147,10 @@ _BULK_UPDATE_TEAM: Final = LiteLLM_TeamTableCachedObj(team_id="team-1")
async def _run_bulk_update_on_one_key(
monkeypatch, item_payload: Mapping[str, object], team: LiteLLM_TeamTableCachedObj = _BULK_UPDATE_TEAM
monkeypatch: pytest.MonkeyPatch,
item_payload: Mapping[str, object],
team: LiteLLM_TeamTableCachedObj = _BULK_UPDATE_TEAM,
user_api_key_cache: UserApiKeyCache | None = None,
) -> tuple[BulkUpdateKeyResponse, AsyncMock]:
from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys
@ -7162,6 +7166,8 @@ async def _run_bulk_update_on_one_key(
)
mock_prisma_client.update_data = AsyncMock(return_value={"data": {"token": _BULK_UPDATE_TOKEN}})
_setup_update_key_mocks(monkeypatch, mock_prisma_client)
if user_api_key_cache is not None:
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", AsyncMock(return_value=team)
)
@ -7233,6 +7239,47 @@ async def test_bulk_update_keys_object_permission_is_granted_not_dropped(monkeyp
assert not {"max_budget", "team_id", "budget_id"} & written.keys()
@pytest.mark.asyncio
async def test_bulk_update_keys_invalidates_cache_for_new_object_permission(monkeypatch: pytest.MonkeyPatch):
from litellm.proxy.common_utils.user_api_key_cache import object_permission_cache_key
permission_id = str(UUID("00000000-0000-0000-0000-000000000001"))
permission_cache_key = object_permission_cache_key(permission_id)
user_api_key_cache = UserApiKeyCache()
user_api_key_cache.set_cache(
key=permission_cache_key,
value=LiteLLM_ObjectPermissionTable(object_permission_id=permission_id, vector_stores=["stale"]),
model_type=LiteLLM_ObjectPermissionTable,
)
assert (
user_api_key_cache.get_cache(
key=permission_cache_key,
model_type=LiteLLM_ObjectPermissionTable,
)
is not None
)
monkeypatch.setattr(
"litellm.proxy.management_helpers.object_permission_utils.uuid.uuid4",
lambda: UUID(permission_id),
)
response, prisma = await _run_bulk_update_on_one_key(
monkeypatch,
{"object_permission": {"vector_stores": ["vs-1"]}},
user_api_key_cache=user_api_key_cache,
)
assert response.failed_updates == []
assert _written_key_row(prisma)["object_permission_id"] == permission_id
assert (
user_api_key_cache.get_cache(
key=permission_cache_key,
model_type=LiteLLM_ObjectPermissionTable,
)
is None
)
@pytest.mark.asyncio
async def test_bulk_update_keys_object_permission_outside_the_team_allowlist_is_refused(monkeypatch):
"""A bulk item's object_permission is checked against the key's team exactly as /key/update
@ -21634,16 +21681,16 @@ async def test_key_update_invalidates_cached_object_permission(monkeypatch):
@pytest.mark.asyncio
async def test_key_regeneration_invalidates_cached_object_permission(monkeypatch):
async def test_key_regeneration_invalidates_cached_object_permission(monkeypatch: pytest.MonkeyPatch):
"""Regression: regenerating a key with new permissions must not keep serving the old grants."""
from litellm.proxy._types import LiteLLM_ObjectPermissionBase, RegenerateKeyRequest
from litellm.proxy.auth.auth_checks import get_object_permission
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, object_permission_cache_key
from litellm.proxy.management_endpoints.key_management_endpoints import (
_execute_virtual_key_regeneration,
)
permission_id = "objperm-regenerate"
permission_id = str(UUID("00000000-0000-0000-0000-000000000002"))
grants = {"served": ["tool_a"]}
def _row(**kwargs):
@ -21660,9 +21707,12 @@ async def test_key_regeneration_invalidates_cached_object_permission(monkeypatch
return_value=MagicMock(object_permission_id=permission_id)
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(
"litellm.proxy.management_helpers.object_permission_utils.uuid.uuid4",
lambda: UUID(permission_id),
)
existing_key = _make_regenerate_existing_key()
existing_key.object_permission_id = permission_id
user_api_key_cache = UserApiKeyCache()
assert (
await get_object_permission(
@ -21696,9 +21746,7 @@ async def test_key_regeneration_invalidates_cached_object_permission(monkeypatch
hashed_api_key="abc123",
key="abc123",
data=RegenerateKeyRequest(
object_permission=LiteLLM_ObjectPermissionBase(
mcp_tool_permissions={"server-1": ["tool_a", "tool_b"]}
)
object_permission=LiteLLM_ObjectPermissionBase(mcp_tool_permissions={"server-1": ["tool_a", "tool_b"]})
),
user_api_key_dict=_make_regenerate_user_api_key_dict(),
litellm_changed_by=None,
@ -21706,6 +21754,13 @@ async def test_key_regeneration_invalidates_cached_object_permission(monkeypatch
proxy_logging_obj=AsyncMock(),
)
assert (
user_api_key_cache.get_cache(
key=object_permission_cache_key(permission_id),
model_type=LiteLLM_ObjectPermissionTable,
)
is None
)
grants["served"] = ["tool_a", "tool_b"]
reread = await get_object_permission(
object_permission_id=permission_id,