From 606ead50bb38c9bc0cabfa3c607ab939c4b98909 Mon Sep 17 00:00:00 2001 From: JackDanger Date: Sun, 18 Oct 2026 05:53:26 +0000 Subject: [PATCH 1/7] fix(proxy): self-updates cannot lift user-level ceilings or reassign membership Generated-by: termag --- .../internal_user_endpoints.py | 64 +++++++++-- .../test_internal_user_endpoints.py | 103 ++++++++++++++++++ 2 files changed, 158 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index b563dbc5c4c..e2dfa5f840a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1513,15 +1513,11 @@ async def _update_single_user_helper( # because `_update_internal_user_params` drops empty values, and `object_permission: {}` is # precisely the clear-my-own-ceiling case this must refuse. _sent_fields: Final = user_request.fields_set() if hasattr(user_request, "fields_set") else set() - _protected_fields: Final = ("max_budget", "model_max_budget", "soft_budget", "spend", "object_permission") - for _field in _protected_fields: - if _field in non_default_values or _field in _sent_fields: - raise HTTPException( - status_code=403, - detail={ - "error": f"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin." - }, - ) + _reject_ceiling_self_update( + sent_fields=_sent_fields, + non_default_values=non_default_values, + user_api_key_dict=user_api_key_dict, + ) existing_metadata: Final = ( cast(dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None else {} @@ -1641,6 +1637,56 @@ async def _update_single_user_helper( return response +_SELF_UPDATE_PROTECTED_FIELDS: Final[tuple[str, ...]] = ( + "max_budget", + "model_max_budget", + "soft_budget", + "spend", + "object_permission", + # User-level ceilings sit above per-key self-service sizing, so a + # self-write that lifts them is an escalation path. + "budget_duration", + "rpm_limit", + "tpm_limit", + "max_parallel_requests", + "models", + # Membership authority: user_object.teams / organizations feed + # GrantResolver.resolve and scope /key/list, team-model visibility and + # BYOK team scoping, so a self-write would grant real team access while + # the real gate (/team/member_add) stays admin-only (R1-M3). + "teams", + "organizations", +) + + +def _reject_ceiling_self_update( + *, + sent_fields: set[str], + non_default_values: dict, + user_api_key_dict: UserAPIKeyAuth, +) -> None: + """Raise 403 when a non-admin self-update touches a user-level ceiling. + + object_permission is a ceiling on what this human may reach, so a + self-write is an escalation path: an empty grant list means "no + restriction" and would lift a restriction an admin placed on them. + Checked against the fields the caller actually SENT, because + `_update_internal_user_params` drops empty values, and + `object_permission: {}` is precisely the clear-my-own-ceiling case + this must refuse. + """ + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: + return + for _field in _SELF_UPDATE_PROTECTED_FIELDS: + if _field in non_default_values or _field in sent_fields: + raise HTTPException( + status_code=403, + detail={ + "error": f"Non-admin users cannot modify '{_field}' on their own record. Contact your proxy admin." + }, + ) + + def can_user_call_user_update( user_api_key_dict: UserAPIKeyAuth, user_info: LiteLLM_UserTable, diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index 037ccd26236..a70b5d1f3f4 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -5211,3 +5211,106 @@ async def test_user_update_without_password_revokes_nothing(_admin_prisma, mocke await _update_single_user_helper(user_request=user_request, user_api_key_dict=admin_caller) revoke_mock.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# /user/update self-update ceiling fields (fork hardening R2-17) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "field,value", + [ + ("rpm_limit", 1000000), + ("tpm_limit", 1000000), + ("max_parallel_requests", 1000), + ("budget_duration", "1s"), + ("models", []), + ], +) +def test_user_update_self_write_of_ceiling_fields_rejected(field, value): + """A non-admin self-update must not lift user-wide ceilings; those sit + above per-key settings and were admin-imposed.""" + from fastapi import HTTPException + from litellm.proxy._types import LitellmUserRoles, UpdateUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _reject_ceiling_self_update, + ) + + data = UpdateUserRequest(user_id="self", **{field: value}) + with pytest.raises(HTTPException) as exc: + _reject_ceiling_self_update( + sent_fields=set(data.fields_set()), + non_default_values={field: value}, + user_api_key_dict=UserAPIKeyAuth( + user_id="self", user_role=LitellmUserRoles.INTERNAL_USER + ), + ) + assert int(getattr(exc.value, "status_code", 0)) == 403 + assert f"'{field}'" in str(exc.value.detail) + + +def test_user_update_self_write_of_innocuous_field_allowed(): + """Guardrail on the tightening: user_alias self-edits keep flowing.""" + from litellm.proxy._types import LitellmUserRoles, UpdateUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _reject_ceiling_self_update, + ) + + data = UpdateUserRequest(user_id="self", user_alias="me") + _reject_ceiling_self_update( + sent_fields=set(data.fields_set()), + non_default_values={"user_alias": "me"}, + user_api_key_dict=UserAPIKeyAuth( + user_id="self", user_role=LitellmUserRoles.INTERNAL_USER + ), + ) + + +def test_user_update_admin_self_write_unrestricted(): + """Proxy admins still edit every field on their own record.""" + from litellm.proxy._types import LitellmUserRoles, UpdateUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _reject_ceiling_self_update, + ) + + data = UpdateUserRequest(user_id="admin", rpm_limit=1000000, models=[]) + _reject_ceiling_self_update( + sent_fields=set(data.fields_set()), + non_default_values={"rpm_limit": 1000000, "models": []}, + user_api_key_dict=UserAPIKeyAuth( + user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN + ), + ) + + +# --------------------------------------------------------------------------- +# /user/update membership self-grant (fork hardening R1-M3) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_user_update_self_write_of_membership_rejected(): + """A non-admin self-write of `teams` / `organizations` must be rejected: + user_object.teams is GrantResolver's membership authority and scopes + /key/list, team-model visibility and BYOK scoping.""" + from fastapi import HTTPException + from litellm.proxy._types import LitellmUserRoles, UpdateUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _reject_ceiling_self_update, + ) + for field, value in ( + ("teams", ["victim-team"]), + ("organizations", ["victim-org"]), + ): + data = UpdateUserRequest(user_id="self", **{field: value}) + with pytest.raises(HTTPException) as exc: + _reject_ceiling_self_update( + sent_fields=set(data.fields_set()), + non_default_values={field: value}, + user_api_key_dict=UserAPIKeyAuth( + user_id="self", user_role=LitellmUserRoles.INTERNAL_USER + ), + ) + assert int(getattr(exc.value, "status_code", 0)) == 403 + assert f"'{field}'" in str(exc.value.detail) From 6d9d6d9eb5d44cdd45f105fbf6e5d331f7d9e296 Mon Sep 17 00:00:00 2001 From: JackDanger Date: Sun, 18 Oct 2026 08:39:24 +0000 Subject: [PATCH 2/7] style: ruff-format the internal-user tests Generated-by: termag --- .../test_internal_user_endpoints.py | 123 ++++++++++-------- 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index a70b5d1f3f4..5e9be008892 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1672,9 +1672,7 @@ async def test_new_user_clears_recent_missing_user_lookup(mocker: MockerFixture) ) mocker.patch( "litellm.proxy.management_endpoints.internal_user_endpoints.generate_key_helper_fn", - new=mocker.AsyncMock( - return_value={"user_id": user_id, "token": "sk-sso-created-user", "expires": None} - ), + new=mocker.AsyncMock(return_value={"user_id": user_id, "token": "sk-sso-created-user", "expires": None}), ) try: @@ -2301,7 +2299,11 @@ def test_update_internal_user_params_preserves_model_budget_presence_and_neighbo "user_alias": "Spruce", } - replacement: Final = {"model-spruce": {"budget_limit": 0, "time_period": "1d"}, "model-birch": 5.0, "model-cedar": 0} + replacement: Final = { + "model-spruce": {"budget_limit": 0, "time_period": "1d"}, + "model-birch": 5.0, + "model-cedar": 0, + } request: Final = UpdateUserRequest( user_id="user-spruce", model_max_budget=replacement, @@ -2343,10 +2345,14 @@ async def test_user_model_budget_update_by_email_refreshes_cached_user(mocker: M prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=saved_user) prisma_client.get_data = mocker.AsyncMock(return_value=[saved_user]) prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": saved_user.user_id, "data": saved_user}) - mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ) # test-quality-ok: substitute the database dependency cache: Final = UserApiKeyCache() await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable) - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -2382,10 +2388,14 @@ async def test_user_status_update_refreshes_cached_user( prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock(return_value=saved_user) prisma_client.get_data = mocker.AsyncMock(return_value=[saved_user]) prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": saved_user.user_id, "data": saved_user}) - mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ) # test-quality-ok: substitute the database dependency cache: Final = UserApiKeyCache() await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable) - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -2419,10 +2429,14 @@ async def test_bulk_user_model_budget_clear_serializes_and_refreshes_cache(mocke prisma_client: Final = mocker.MagicMock() prisma_client.db.litellm_usertable.find_many = mocker.AsyncMock(return_value=[saved_user]) prisma_client.db.litellm_usertable.update_many = mocker.AsyncMock(return_value=1) - mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ) # test-quality-ok: substitute the database dependency cache: Final = UserApiKeyCache() await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable) - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -2454,7 +2468,9 @@ async def test_bulk_user_model_budget_clear_serializes_and_refreshes_cache(mocke @pytest.mark.asyncio @pytest.mark.parametrize("all_users", [False, True], ids=["single-user", "bulk-all-users"]) -async def test_user_max_budget_update_evicts_cached_user_on_every_worker(mocker: MockerFixture, all_users: bool) -> None: +async def test_user_max_budget_update_evicts_cached_user_on_every_worker( + mocker: MockerFixture, all_users: bool +) -> None: from litellm.proxy._types import LiteLLM_UserTable from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.internal_user_endpoints import _update_single_user_helper, bulk_user_update @@ -2467,10 +2483,14 @@ async def test_user_max_budget_update_evicts_cached_user_on_every_worker(mocker: prisma_client.db.litellm_usertable.update_many = mocker.AsyncMock(return_value=1) prisma_client.get_data = mocker.AsyncMock(return_value=[saved_user]) prisma_client.update_data = mocker.AsyncMock(return_value={"user_id": saved_user.user_id, "data": saved_user}) - mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ) # test-quality-ok: substitute the database dependency cache: Final = UserApiKeyCache() await cache.async_set_cache(key=saved_user.user_id, value=saved_user, model_type=LiteLLM_UserTable) - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -2875,24 +2895,12 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker): mock_prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(return_value=[]) # Mock all delete_many calls - mock_prisma_client.db.litellm_verificationtoken.find_many = mocker.AsyncMock( - return_value=[] - ) - mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock( - return_value=0 - ) - mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock( - return_value=1 - ) - mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock( - return_value=0 - ) - mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock( - return_value=0 - ) - mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock( - return_value=1 - ) + mock_prisma_client.db.litellm_verificationtoken.find_many = mocker.AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(return_value=1) + mock_prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0) + mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1) _serve_locked_user_delete(mock_prisma_client, mock_user_row) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -2985,9 +2993,15 @@ async def test_delete_user_evicts_jwt_key_mapping_cache_of_its_keys(mocker): mock_prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1) _serve_locked_user_delete(mock_prisma_client, user_row) - mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) # test-quality-ok: substitute the database dependency - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache - mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", None) # test-quality-ok: delete_user reads it off proxy_server at call time + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.proxy_logging_obj", None + ) # test-quality-ok: delete_user reads it off proxy_server at call time await delete_user( data=DeleteUserRequest(user_ids=["jwt-user"]), @@ -3160,9 +3174,7 @@ async def test_user_info_v2_proxy_admin_can_query_any_user(mocker: MockerFixture return None mock_prisma_client: Final = _PrismaClient( - db=_Database( - litellm_usertable=_UserTable(find_unique=mocker.AsyncMock(side_effect=mock_find_unique)) - ) + db=_Database(litellm_usertable=_UserTable(find_unique=mocker.AsyncMock(side_effect=mock_find_unique))) ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -4398,7 +4410,9 @@ async def test_user_update_broadcasts_the_entitlement_invalidation_to_other_work _object_permission_mocks(mocker) cache: Final = mocker.MagicMock() cache.async_delete_cache = mocker.AsyncMock() - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: substitute the cache dependency + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: substitute the cache dependency broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -4945,12 +4959,18 @@ async def test_delete_user_evicts_cached_user_rows(mocker: MockerFixture) -> Non prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0) prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1) _serve_locked_user_delete(prisma_client, deleted) - mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency + mocker.patch( + "litellm.proxy.proxy_server.prisma_client", prisma_client + ) # test-quality-ok: substitute the database dependency cache: Final = UserApiKeyCache() for row in (deleted, survivor): await cache.async_set_cache(key=row.user_id, value=row, model_type=LiteLLM_UserTable) - mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache - mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", None) # test-quality-ok: delete_user reads it off proxy_server at call time + mocker.patch( + "litellm.proxy.proxy_server.user_api_key_cache", cache + ) # test-quality-ok: exercise a real isolated cache + mocker.patch( + "litellm.proxy.proxy_server.proxy_logging_obj", None + ) # test-quality-ok: delete_user reads it off proxy_server at call time broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary "litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation", new_callable=mocker.AsyncMock, @@ -5056,7 +5076,9 @@ async def test_ui_view_users_answers_a_db_outage_as_503_no_db_connection_not_as_ assert raised.value.code == "503" assert raised.value.type == ProxyErrorTypes.no_db_connection assert isinstance(raised.value.__cause__, httpx.ConnectError) - outage_logs: Final = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING and "ConnectError" in r.getMessage()] + outage_logs: Final = [ + r.getMessage() for r in caplog.records if r.levelno == logging.WARNING and "ConnectError" in r.getMessage() + ] assert outage_logs == ["Database unavailable during user search: ConnectError"] @@ -5242,9 +5264,7 @@ def test_user_update_self_write_of_ceiling_fields_rejected(field, value): _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={field: value}, - user_api_key_dict=UserAPIKeyAuth( - user_id="self", user_role=LitellmUserRoles.INTERNAL_USER - ), + user_api_key_dict=UserAPIKeyAuth(user_id="self", user_role=LitellmUserRoles.INTERNAL_USER), ) assert int(getattr(exc.value, "status_code", 0)) == 403 assert f"'{field}'" in str(exc.value.detail) @@ -5261,9 +5281,7 @@ def test_user_update_self_write_of_innocuous_field_allowed(): _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={"user_alias": "me"}, - user_api_key_dict=UserAPIKeyAuth( - user_id="self", user_role=LitellmUserRoles.INTERNAL_USER - ), + user_api_key_dict=UserAPIKeyAuth(user_id="self", user_role=LitellmUserRoles.INTERNAL_USER), ) @@ -5278,9 +5296,7 @@ def test_user_update_admin_self_write_unrestricted(): _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={"rpm_limit": 1000000, "models": []}, - user_api_key_dict=UserAPIKeyAuth( - user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN - ), + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), ) @@ -5299,6 +5315,7 @@ async def test_user_update_self_write_of_membership_rejected(): from litellm.proxy.management_endpoints.internal_user_endpoints import ( _reject_ceiling_self_update, ) + for field, value in ( ("teams", ["victim-team"]), ("organizations", ["victim-org"]), @@ -5308,9 +5325,7 @@ async def test_user_update_self_write_of_membership_rejected(): _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={field: value}, - user_api_key_dict=UserAPIKeyAuth( - user_id="self", user_role=LitellmUserRoles.INTERNAL_USER - ), + user_api_key_dict=UserAPIKeyAuth(user_id="self", user_role=LitellmUserRoles.INTERNAL_USER), ) assert int(getattr(exc.value, "status_code", 0)) == 403 assert f"'{field}'" in str(exc.value.detail) From 90094d53c77f930cd522a8e456f69ab74e43e324 Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Sun, 18 Oct 2026 09:20:41 +0000 Subject: [PATCH 3/7] style: annotate self-update gate params with read-only views (LIT001) Generated-by: termag --- .../proxy/management_endpoints/internal_user_endpoints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e2dfa5f840a..62c3b67cc7b 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -15,7 +15,7 @@ These are members of a Team on LiteLLM import asyncio import json import traceback -from collections.abc import Awaitable, Mapping, Sequence +from collections.abc import Awaitable, Mapping, Sequence, Set as AbstractSet from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final, Literal, Protocol, cast, overload @@ -1661,8 +1661,8 @@ _SELF_UPDATE_PROTECTED_FIELDS: Final[tuple[str, ...]] = ( def _reject_ceiling_self_update( *, - sent_fields: set[str], - non_default_values: dict, + sent_fields: AbstractSet[str], + non_default_values: Mapping[str, Any], user_api_key_dict: UserAPIKeyAuth, ) -> None: """Raise 403 when a non-admin self-update touches a user-level ceiling. From 582f337aad37dc2519968cfc70050e31c595697c Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Sun, 18 Oct 2026 09:33:37 +0000 Subject: [PATCH 4/7] style: split the aliased AbstractSet import (ruff I001) Generated-by: termag --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 62c3b67cc7b..1b919c8e424 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -15,7 +15,8 @@ These are members of a Team on LiteLLM import asyncio import json import traceback -from collections.abc import Awaitable, Mapping, Sequence, Set as AbstractSet +from collections.abc import Awaitable, Mapping, Sequence +from collections.abc import Set as AbstractSet from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final, Literal, Protocol, cast, overload From bc14992545c6107906981e2ef5eedb77c7cdf1b3 Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Sun, 18 Oct 2026 09:46:22 +0000 Subject: [PATCH 5/7] test: assert the allowed self-edit path returns clean (test-quality TQ001) Generated-by: termag --- .../management_endpoints/test_internal_user_endpoints.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index 5e9be008892..b2956d3ed42 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -5278,11 +5278,13 @@ def test_user_update_self_write_of_innocuous_field_allowed(): ) data = UpdateUserRequest(user_id="self", user_alias="me") - _reject_ceiling_self_update( + no_raise = _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={"user_alias": "me"}, user_api_key_dict=UserAPIKeyAuth(user_id="self", user_role=LitellmUserRoles.INTERNAL_USER), ) + # The observable on an allowed self-edit is that the guard raised nothing. + assert no_raise is None def test_user_update_admin_self_write_unrestricted(): @@ -5293,11 +5295,13 @@ def test_user_update_admin_self_write_unrestricted(): ) data = UpdateUserRequest(user_id="admin", rpm_limit=1000000, models=[]) - _reject_ceiling_self_update( + no_raise = _reject_ceiling_self_update( sent_fields=set(data.fields_set()), non_default_values={"rpm_limit": 1000000, "models": []}, user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), ) + # The observable on an admin self-edit is that the guard raised nothing. + assert no_raise is None # --------------------------------------------------------------------------- From 61c200ac1e0d2d3a74d569f8ca417d6bc1cdfb8d Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Sun, 18 Oct 2026 10:03:11 +0000 Subject: [PATCH 6/7] style: annotate the self-update payload as a read-only object mapping (reportExplicitAny) Generated-by: termag --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1b919c8e424..b83177ac3d4 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1663,7 +1663,7 @@ _SELF_UPDATE_PROTECTED_FIELDS: Final[tuple[str, ...]] = ( def _reject_ceiling_self_update( *, sent_fields: AbstractSet[str], - non_default_values: Mapping[str, Any], + non_default_values: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth, ) -> None: """Raise 403 when a non-admin self-update touches a user-level ceiling. From 3f591eeeb20caad1c4639bfe8ae67d759adc5e09 Mon Sep 17 00:00:00 2001 From: Jack Danger Date: Sun, 18 Oct 2026 10:16:14 +0000 Subject: [PATCH 7/7] style: scope pyright disables to the untyped fields-set call site (basedpyright ceiling) Generated-by: termag --- litellm/proxy/management_endpoints/internal_user_endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index b83177ac3d4..d86c6b93289 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1515,8 +1515,8 @@ async def _update_single_user_helper( # precisely the clear-my-own-ceiling case this must refuse. _sent_fields: Final = user_request.fields_set() if hasattr(user_request, "fields_set") else set() _reject_ceiling_self_update( - sent_fields=_sent_fields, - non_default_values=non_default_values, + sent_fields=_sent_fields, # pyright: ignore[reportUnknownArgumentType] # pydantic's fields_set() is untyped on this request model + non_default_values=non_default_values, # pyright: ignore[reportUnknownArgumentType] # payload is the raw sent-fields mapping, shape-checked inside the guard user_api_key_dict=user_api_key_dict, )