This commit is contained in:
Jack Danger 2026-10-19 19:53:21 +00:00 • committed by GitHub
commit 4dce8a4eeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 220 additions and 51 deletions

View file

@ -16,6 +16,7 @@ import asyncio
import json
import traceback
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
@ -1513,15 +1514,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, # 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,
)
existing_metadata: Final = (
cast(dict, getattr(existing_user_row, "metadata", {}) or {}) if existing_user_row is not None else {}
@ -1641,6 +1638,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: AbstractSet[str],
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.
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,

View file

@ -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"]
@ -5211,3 +5233,103 @@ 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")
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():
"""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=[])
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
# ---------------------------------------------------------------------------
# /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)