From bdbe265c7020a6533b8dd729708ff01725bac4ad Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:14:39 -0700 Subject: [PATCH 1/6] fix(proxy): /key/bulk_update writes only the fields each item carries A bulk item that carried only tags reached the DB with max_budget, team_id, and budget_id as explicit nulls, wiping the key's budget and detaching it from its team. The per-key update is now built from the fields the item actually set, so a field left out keeps its value and an explicit null still clears it, the same as /key/update. Items carrying a field the bulk path cannot apply (object_permission and the like) are rejected with 422 instead of being silently dropped. --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- .../key_management_endpoints.py | 14 ++-- .../key_management_endpoints.py | 4 +- .../test_key_management_endpoints.py | 83 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 +- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 73ea8cf1991..18849ef5b64 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19622,7 +19622,7 @@ } } }, - "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " + "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" }, "500": { "content": { diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 6c195d713c8..f28101f7072 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3517,7 +3517,10 @@ async def bulk_update_keys( - max_budget: Optional[float] - Max budget for key - team_id: Optional[str] - Team ID associated with key - tags: Optional[List[str]] - Tags for organizing keys - + + Only the fields an item carries are written: a field left out keeps its current value and an + explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422. + Returns: - total_requested: int - Total number of keys requested for update - successful_updates: List[SuccessfulKeyUpdate] - List of successfully updated keys with their updated info @@ -3586,15 +3589,8 @@ async def bulk_update_keys( for key_update_item in data.keys: try: - update_key_request = UpdateKeyRequest( - key=key_update_item.key, - budget_id=key_update_item.budget_id, - max_budget=key_update_item.max_budget, - team_id=key_update_item.team_id, - tags=key_update_item.tags, - ) updated_key_info = await _process_single_key_update( - update_key_request=update_key_request, + update_key_request=UpdateKeyRequest.model_validate(key_update_item.model_dump(exclude_unset=True)), user_api_key_dict=user_api_key_dict, litellm_changed_by=litellm_changed_by, prisma_client=prisma_client, diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 63bbaa5ba4e..3e193956d30 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -25,7 +25,9 @@ class KeySearchWhere(TypedDict): class BulkUpdateKeyRequestItem(BaseModel): - """Individual key update request item""" + """One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected.""" + + model_config = ConfigDict(extra="forbid") key: str # Key identifier (token) budget_id: str | None = None # Budget ID associated with the key diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index c852307b051..2f0f605b839 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -7097,6 +7097,89 @@ async def test_list_key_helper_applies_search_to_prisma_where(): assert _search_clause("key-id-123", "key-id-123") in where["AND"], f"search not in Prisma where: {where}" +_BULK_UPDATE_TOKEN: Final = "1f2e3d4c5b6a79880123456789abcdef0123456789abcdef0123456789abcdef" + + +async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> Mapping[str, object]: + """Runs /key/bulk_update with one item against a budgeted team key and returns the row written to the DB.""" + from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys + from litellm.types.proxy.management_endpoints.key_management_endpoints import BulkUpdateKeyRequest + + key_in_db = LiteLLM_VerificationToken( + token=_BULK_UPDATE_TOKEN, user_id="test-user", team_id="team-1", max_budget=100.0, budget_id="budget-1" + ) + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_in_db) + mock_prisma_client.update_data = AsyncMock(return_value={"data": {"token": _BULK_UPDATE_TOKEN}}) + _setup_update_key_mocks(monkeypatch, mock_prisma_client) + + with ( + patch( # test-quality-ok: the handler reads the cache and hook singletons from module globals, no injection seam + "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object", + new_callable=AsyncMock, + ), + patch( # test-quality-ok: the permission check is a classmethod the handler calls directly, no injection seam + "litellm.proxy.management_endpoints.key_management_endpoints.TeamMemberPermissionChecks.can_team_member_execute_key_management_endpoint", + new_callable=AsyncMock, + ), + patch( # test-quality-ok: the audit hook is a classmethod the handler calls directly, no injection seam + "litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_updated_hook", + new_callable=AsyncMock, + ), + ): + response = await bulk_update_keys( + data=BulkUpdateKeyRequest.model_validate({"keys": [{"key": _BULK_UPDATE_TOKEN, **item_payload}]}), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" + ), + litellm_changed_by=None, + ) + + assert response.failed_updates == [] + return mock_prisma_client.update_data.call_args.kwargs["data"] + + +@pytest.mark.asyncio +async def test_bulk_update_keys_item_without_a_field_leaves_that_column_alone(monkeypatch): + """A tags-only item used to reach the DB with max_budget, team_id, and budget_id as explicit + nulls, so tagging a key wiped its budget and detached it from its team.""" + written = await _bulk_update_one_key(monkeypatch, {"tags": ["team-a"]}) + + assert written["metadata"]["tags"] == ["team-a"] + assert not {"max_budget", "team_id", "budget_id"} & written.keys() + + +@pytest.mark.asyncio +async def test_bulk_update_keys_explicit_null_still_clears_the_field(monkeypatch): + """Sending `"max_budget": null` on an item is a request to remove the budget, as on /key/update.""" + written = await _bulk_update_one_key(monkeypatch, {"max_budget": None}) + + assert written["max_budget"] is None + assert not {"team_id", "budget_id"} & written.keys() + + +def test_bulk_update_keys_rejects_a_field_the_bulk_path_cannot_apply(): + """`object_permission` used to be accepted with 200 and dropped, leaving an item that carried + nothing but the key, so the call wiped the key's budget instead of granting the permission.""" + from fastapi import FastAPI + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.management_endpoints.key_management_endpoints import router + + test_app = FastAPI() + test_app.include_router(router) + test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" + ) + response = TestClient(test_app).post( + "/key/bulk_update", + json={"keys": [{"key": _BULK_UPDATE_TOKEN, "object_permission": {"vector_stores": ["vs-1"]}}]}, + ) + + assert response.status_code == 422, response.text + assert "object_permission" in response.text + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a5ede63bf7f..a9174603290 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7694,6 +7694,9 @@ export interface paths { * - team_id: Optional[str] - Team ID associated with key * - tags: Optional[List[str]] - Tags for organizing keys * + * Only the fields an item carries are written: a field left out keeps its current value and an + * explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422. + * * Returns: * - total_requested: int - Total number of keys requested for update * - successful_updates: List[SuccessfulKeyUpdate] - List of successfully updated keys with their updated info @@ -25237,7 +25240,7 @@ export interface components { }; /** * BulkUpdateKeyRequestItem - * @description Individual key update request item + * @description One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected. */ BulkUpdateKeyRequestItem: { /** Budget Id */ From ad4da0f8e6b1e22ec7ecd8fb0b0e3ad138b807c2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:28:42 -0700 Subject: [PATCH 2/6] chore(proxy): regenerate the lazy OpenAPI snapshot on Python 3.12 and drop a test helper docstring --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- .../proxy/management_endpoints/test_key_management_endpoints.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 18849ef5b64..73ea8cf1991 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19622,7 +19622,7 @@ } } }, - "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" + "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " }, "500": { "content": { diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 2f0f605b839..9d023e129aa 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -7101,7 +7101,6 @@ _BULK_UPDATE_TOKEN: Final = "1f2e3d4c5b6a79880123456789abcdef0123456789abcdef012 async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> Mapping[str, object]: - """Runs /key/bulk_update with one item against a budgeted team key and returns the row written to the DB.""" from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys from litellm.types.proxy.management_endpoints.key_management_endpoints import BulkUpdateKeyRequest From b746ac44563589a0e2b407b064470c2ee18b4b27 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:44:07 -0700 Subject: [PATCH 3/6] fix(proxy): accept object_permission on /key/bulk_update items instead of 422 --- .../key_management_endpoints.py | 3 +- .../key_management_endpoints.py | 12 ++++-- .../test_key_management_endpoints.py | 41 +++++++++---------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 ++- 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f28101f7072..959fee7b010 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3517,9 +3517,10 @@ async def bulk_update_keys( - max_budget: Optional[float] - Max budget for key - team_id: Optional[str] - Team ID associated with key - tags: Optional[List[str]] - Tags for organizing keys + - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission, as on /key/update Only the fields an item carries are written: a field left out keeps its current value and an - explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422. + explicit null clears it, the same as /key/update. Returns: - total_requested: int - Total number of keys requested for update diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 3e193956d30..001bc3c0d51 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -5,7 +5,12 @@ from pydantic import BaseModel, ConfigDict, model_validator from typing_extensions import ReadOnly, TypedDict from litellm.models.verification_token import LiteLLM_VerificationToken -from litellm.proxy._types import GenerateKeyRequest, RegenerateKeyRequest, UpdateKeyRequest +from litellm.proxy._types import ( + GenerateKeyRequest, + LiteLLM_ObjectPermissionBase, + RegenerateKeyRequest, + UpdateKeyRequest, +) from litellm.types.llms.base import LiteLLMPydanticObjectBase from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains @@ -25,15 +30,14 @@ class KeySearchWhere(TypedDict): class BulkUpdateKeyRequestItem(BaseModel): - """One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected.""" - - model_config = ConfigDict(extra="forbid") + """One /key/bulk_update item; only the fields it carries are written.""" key: str # Key identifier (token) budget_id: str | None = None # Budget ID associated with the key max_budget: float | None = None # Max budget for key team_id: str | None = None # Team ID associated with key tags: list[str] | None = None # Tags for organizing keys + object_permission: LiteLLM_ObjectPermissionBase | None = None class BulkUpdateKeyRequest(BaseModel): diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 9d023e129aa..1bf5018900a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -7100,7 +7100,7 @@ async def test_list_key_helper_applies_search_to_prisma_where(): _BULK_UPDATE_TOKEN: Final = "1f2e3d4c5b6a79880123456789abcdef0123456789abcdef0123456789abcdef" -async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> Mapping[str, object]: +async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> AsyncMock: from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys from litellm.types.proxy.management_endpoints.key_management_endpoints import BulkUpdateKeyRequest @@ -7109,6 +7109,10 @@ async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) ) mock_prisma_client = AsyncMock() mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_in_db) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock( + return_value=MagicMock(object_permission_id="objperm-bulk") + ) mock_prisma_client.update_data = AsyncMock(return_value={"data": {"token": _BULK_UPDATE_TOKEN}}) _setup_update_key_mocks(monkeypatch, mock_prisma_client) @@ -7135,14 +7139,18 @@ async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) ) assert response.failed_updates == [] - return mock_prisma_client.update_data.call_args.kwargs["data"] + return mock_prisma_client + + +def _written_key_row(prisma: AsyncMock) -> Mapping[str, object]: + return prisma.update_data.call_args.kwargs["data"] @pytest.mark.asyncio async def test_bulk_update_keys_item_without_a_field_leaves_that_column_alone(monkeypatch): """A tags-only item used to reach the DB with max_budget, team_id, and budget_id as explicit nulls, so tagging a key wiped its budget and detached it from its team.""" - written = await _bulk_update_one_key(monkeypatch, {"tags": ["team-a"]}) + written = _written_key_row(await _bulk_update_one_key(monkeypatch, {"tags": ["team-a"]})) assert written["metadata"]["tags"] == ["team-a"] assert not {"max_budget", "team_id", "budget_id"} & written.keys() @@ -7151,32 +7159,23 @@ async def test_bulk_update_keys_item_without_a_field_leaves_that_column_alone(mo @pytest.mark.asyncio async def test_bulk_update_keys_explicit_null_still_clears_the_field(monkeypatch): """Sending `"max_budget": null` on an item is a request to remove the budget, as on /key/update.""" - written = await _bulk_update_one_key(monkeypatch, {"max_budget": None}) + written = _written_key_row(await _bulk_update_one_key(monkeypatch, {"max_budget": None})) assert written["max_budget"] is None assert not {"team_id", "budget_id"} & written.keys() -def test_bulk_update_keys_rejects_a_field_the_bulk_path_cannot_apply(): +@pytest.mark.asyncio +async def test_bulk_update_keys_object_permission_is_granted_not_dropped(monkeypatch): """`object_permission` used to be accepted with 200 and dropped, leaving an item that carried nothing but the key, so the call wiped the key's budget instead of granting the permission.""" - from fastapi import FastAPI + prisma = await _bulk_update_one_key(monkeypatch, {"object_permission": {"vector_stores": ["vs-1"]}}) - from litellm.proxy.auth.user_api_key_auth import user_api_key_auth - from litellm.proxy.management_endpoints.key_management_endpoints import router - - test_app = FastAPI() - test_app.include_router(router) - test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" - ) - response = TestClient(test_app).post( - "/key/bulk_update", - json={"keys": [{"key": _BULK_UPDATE_TOKEN, "object_permission": {"vector_stores": ["vs-1"]}}]}, - ) - - assert response.status_code == 422, response.text - assert "object_permission" in response.text + upserted = prisma.db.litellm_objectpermissiontable.upsert.call_args.kwargs["data"]["create"] + assert upserted["vector_stores"] == ["vs-1"] + written = _written_key_row(prisma) + assert written["object_permission_id"] == "objperm-bulk" + assert not {"max_budget", "team_id", "budget_id"} & written.keys() @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a9174603290..4ada2c3372b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7693,9 +7693,10 @@ export interface paths { * - max_budget: Optional[float] - Max budget for key * - team_id: Optional[str] - Team ID associated with key * - tags: Optional[List[str]] - Tags for organizing keys + * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission, as on /key/update * * Only the fields an item carries are written: a field left out keeps its current value and an - * explicit null clears it, the same as /key/update. An item carrying any other field is rejected with 422. + * explicit null clears it, the same as /key/update. * * Returns: * - total_requested: int - Total number of keys requested for update @@ -25240,7 +25241,7 @@ export interface components { }; /** * BulkUpdateKeyRequestItem - * @description One /key/bulk_update item; only the fields it carries are written, and unknown fields are rejected. + * @description One /key/bulk_update item; only the fields it carries are written. */ BulkUpdateKeyRequestItem: { /** Budget Id */ @@ -25249,6 +25250,7 @@ export interface components { key: string; /** Max Budget */ max_budget?: number | null; + object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; /** Tags */ tags?: string[] | null; /** Team Id */ From f982d3e0469590fe8a1d05843fd6d9a04dfbc56a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:55:48 -0700 Subject: [PATCH 4/6] docs(proxy): state /key/bulk_update null handling as /key/update parity --- .../proxy/management_endpoints/key_management_endpoints.py | 4 ++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 959fee7b010..033ada2c50d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3519,8 +3519,8 @@ async def bulk_update_keys( - tags: Optional[List[str]] - Tags for organizing keys - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission, as on /key/update - Only the fields an item carries are written: a field left out keeps its current value and an - explicit null clears it, the same as /key/update. + Only the fields an item carries are written: a field left out keeps its current value, and a field + sent explicitly, null included, is applied exactly as /key/update applies it. Returns: - total_requested: int - Total number of keys requested for update diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4ada2c3372b..e1a4f4a743d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -7695,8 +7695,8 @@ export interface paths { * - tags: Optional[List[str]] - Tags for organizing keys * - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission, as on /key/update * - * Only the fields an item carries are written: a field left out keeps its current value and an - * explicit null clears it, the same as /key/update. + * Only the fields an item carries are written: a field left out keeps its current value, and a field + * sent explicitly, null included, is applied exactly as /key/update applies it. * * Returns: * - total_requested: int - Total number of keys requested for update From df6a222cb88cb9e44b1b8649d11c17466afc0d3c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:59:26 -0700 Subject: [PATCH 5/6] fix(proxy): validate bulk object_permission against the key's team as /key/update does --- .../key_management_endpoints.py | 52 +++++++++++++++---- .../test_key_management_endpoints.py | 40 ++++++++++++-- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 033ada2c50d..4554a85b225 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2798,9 +2798,18 @@ async def _process_single_key_update( llm_router=llm_router, ) + key_request: Final = await _with_validated_object_permission( + update_key_request=update_key_request, + team_obj=team_obj, + existing_key_row=existing_key_row, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_api_key_dict=user_api_key_dict, + ) + # Prepare update data non_default_values = await prepare_key_update_data( - data=update_key_request, existing_key_row=existing_key_row, prisma_client=prisma_client, llm_router=llm_router + data=key_request, existing_key_row=existing_key_row, prisma_client=prisma_client, llm_router=llm_router ) await _enforce_custom_key_policy( @@ -2809,7 +2818,7 @@ async def _process_single_key_update( operation="update", existing_key_row=existing_key_row, non_default_values=non_default_values, - request=update_key_request, + request=key_request, ), ) @@ -2825,15 +2834,15 @@ async def _process_single_key_update( existing_key_row=existing_key_row, prisma_client=prisma_client, ) - _data: Final = {**update_values, "token": update_key_request.key} + _data: Final = {**update_values, "token": key_request.key} response: Final[Mapping[str, object] | None] = cast( # cast-ok: every update_data branch returns a str-keyed dict "Mapping[str, object] | None", - await prisma_client.update_data(token=update_key_request.key, data=_data), + await prisma_client.update_data(token=key_request.key, data=_data), ) # Delete cache await _delete_cache_key_object( - hashed_token=_hash_token_if_needed(update_key_request.key), + hashed_token=_hash_token_if_needed(key_request.key), user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) @@ -2842,17 +2851,15 @@ async def _process_single_key_update( # authenticating against the access groups it just lost. await sync_key_update_access_group_membership( prisma_client=prisma_client, - key_token=_hash_token_if_needed( - _resolve_token_to_update(data=update_key_request, existing_key_row=existing_key_row) - ), - data=update_key_request, + key_token=_hash_token_if_needed(_resolve_token_to_update(data=key_request, existing_key_row=existing_key_row)), + data=key_request, existing_key_row=existing_key_row, ) # Trigger async hook asyncio.create_task( KeyManagementEventHooks.async_key_updated_hook( - data=update_key_request, + data=key_request, existing_key_row=existing_key_row, response=response, user_api_key_dict=user_api_key_dict, @@ -2875,6 +2882,31 @@ async def _process_single_key_update( return updated_key_info +async def _with_validated_object_permission( + update_key_request: UpdateKeyRequest, + team_obj: LiteLLM_TeamTableCachedObj | None, + existing_key_row: LiteLLM_VerificationToken, + prisma_client: PrismaClient | None, + user_api_key_cache: UserApiKeyCache, + user_api_key_dict: UserAPIKeyAuth, +) -> UpdateKeyRequest: + if update_key_request.object_permission is None: + return update_key_request + normalized_object_permission: Final = await _validate_mcp_servers_for_key_update( + data=update_key_request, + team_obj=team_obj, + existing_key_row=existing_key_row, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + is_proxy_admin=user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value, + ) + if normalized_object_permission is None: + return update_key_request + return update_key_request.model_copy( + update=MappingProxyType({"object_permission": LiteLLM_ObjectPermissionBase(**normalized_object_permission)}) + ) + + async def _validate_mcp_servers_for_key_update( data: "UpdateKeyRequest", team_obj: Optional["LiteLLM_TeamTableCachedObj"], diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 1bf5018900a..93c5a8c3ded 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -80,7 +80,11 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( validate_key_team_change, ) from litellm.proxy.proxy_server import app -from litellm.types.proxy.management_endpoints.key_management_endpoints import CustomKeyPolicyRequest +from litellm.types.proxy.management_endpoints.key_management_endpoints import ( + BulkUpdateKeyRequest, + BulkUpdateKeyResponse, + CustomKeyPolicyRequest, +) client = TestClient(app) @@ -7098,23 +7102,29 @@ async def test_list_key_helper_applies_search_to_prisma_where(): _BULK_UPDATE_TOKEN: Final = "1f2e3d4c5b6a79880123456789abcdef0123456789abcdef0123456789abcdef" +_BULK_UPDATE_TEAM: Final = LiteLLM_TeamTableCachedObj(team_id="team-1") -async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> AsyncMock: +async def _run_bulk_update_on_one_key( + monkeypatch, item_payload: Mapping[str, object], team: LiteLLM_TeamTableCachedObj = _BULK_UPDATE_TEAM +) -> tuple[BulkUpdateKeyResponse, AsyncMock]: from litellm.proxy.management_endpoints.key_management_endpoints import bulk_update_keys - from litellm.types.proxy.management_endpoints.key_management_endpoints import BulkUpdateKeyRequest key_in_db = LiteLLM_VerificationToken( token=_BULK_UPDATE_TOKEN, user_id="test-user", team_id="team-1", max_budget=100.0, budget_id="budget-1" ) mock_prisma_client = AsyncMock() mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=key_in_db) + mock_prisma_client.get_data = AsyncMock(return_value=key_in_db) mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None) mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock( return_value=MagicMock(object_permission_id="objperm-bulk") ) mock_prisma_client.update_data = AsyncMock(return_value={"data": {"token": _BULK_UPDATE_TOKEN}}) _setup_update_key_mocks(monkeypatch, mock_prisma_client) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", AsyncMock(return_value=team) + ) with ( patch( # test-quality-ok: the handler reads the cache and hook singletons from module globals, no injection seam @@ -7138,8 +7148,13 @@ async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) litellm_changed_by=None, ) + return response, mock_prisma_client + + +async def _bulk_update_one_key(monkeypatch, item_payload: Mapping[str, object]) -> AsyncMock: + response, prisma = await _run_bulk_update_on_one_key(monkeypatch, item_payload) assert response.failed_updates == [] - return mock_prisma_client + return prisma def _written_key_row(prisma: AsyncMock) -> Mapping[str, object]: @@ -7178,6 +7193,23 @@ 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_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 + checks it, so a team key cannot be granted a search tool its team does not allow.""" + team = LiteLLM_TeamTableCachedObj( + team_id="team-1", + object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-team-1", search_tools=["team-search"]), + ) + response, prisma = await _run_bulk_update_on_one_key( + monkeypatch, {"object_permission": {"search_tools": ["other-search"]}}, team=team + ) + + assert response.successful_updates == [] + assert "not allowed by team 'team-1'" in response.failed_updates[0].failed_reason + prisma.update_data.assert_not_called() + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ From dc02e5f5fb2b9f856a7fa80f33ef33588a6529a7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:12:12 -0700 Subject: [PATCH 6/6] test(proxy): stub the existing key's team in the bulk item policy tests --- .../management_endpoints/test_key_management_endpoints.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 93c5a8c3ded..e2a68988ee2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -13189,6 +13189,11 @@ async def _process_single_key_update_under_policy(prisma_client: AsyncMock, data "litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_updated_hook", new_callable=AsyncMock, ), + patch( # test-quality-ok: the existing key's team is outside the policy path, as in the /key/update tests + "litellm.proxy.management_endpoints.key_management_endpoints.get_team_object", + new_callable=AsyncMock, + return_value=None, + ), ): return await _process_single_key_update( update_key_request=data,