mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
Merge 3f591eeeb2 into 2e427c6247
This commit is contained in:
commit
4dce8a4eeb
2 changed files with 220 additions and 51 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue