From d84499e0f2fa37f7781147b62bc86f4e4cd961b6 Mon Sep 17 00:00:00 2001 From: milan-berri Date: Tue, 9 Jun 2026 19:19:15 +0300 Subject: [PATCH] fix(team): reserve team budget raises for proxy admins on /team/update (#30030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller's PERSONAL max_budget was the wrong yardstick for /team/update: a team's spend ceiling has nothing to do with the admin's own key budget. That comparison was an unintended side effect of reusing _check_user_team_limits() (which exists for the /team/new path) and broke the UI, which re-sends the unchanged budget on every save. New behavior on /team/update for standalone teams: - A team admin (already authorized via _verify_team_access) may freely KEEP or LOWER the team budget, and change models/tpm/rpm, without being gated by their personal limits. - GROWING a team's spend ceiling is a budget-authority action reserved for proxy admins -> 403 for team admins. "Growing" covers both raising max_budget above the team's current finite value and removing the cap entirely (max_budget=null, detected via model_fields_set so an explicit null is distinguished from an omitted field). For a team that currently has no cap, setting a finite value is a restriction and is allowed. - Org-scoped teams remain governed by _check_org_team_limits() (capped by the org budget). Also reverts the #29525 existing_team_max_budget workaround in _check_user_team_limits() back to the create-only form; /team/new still enforces the creator's personal caps. docs(access_control): resolve the contradiction in the team-admin section — team admins can keep/lower the budget and manage rate limits/models, but cannot raise the team budget (proxy-admin only). tests: unit + behavior coverage for raise-blocked, cap-removal-blocked (team admin), raise/removal allowed (proxy admin), uncapped-team restriction allowed, keep/lower/resend allowed, and unchanged create-path guards. Co-authored-by: Cursor --- .../management_endpoints/team_endpoints.py | 124 ++--- .../management/test_team_budget_limits.py | 116 ++++- .../test_team_endpoints.py | 455 ++++++++++++++---- 3 files changed, 543 insertions(+), 152 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0e69de87ce..435a8cae37 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -779,55 +779,40 @@ async def _check_user_team_limits( user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient, user_api_key_cache: Any, - existing_team_max_budget: Optional[float] = None, ) -> None: """ - Check user team limits for standalone teams (not org-scoped). + Enforce the caller's personal limits when CREATING a standalone team. - This validates: - - Team budget vs user's max_budget - - Team models vs user's allowed models + This validates the requested team budget / models / tpm / rpm against the + caller's own limits, so a non-admin user cannot mint a brand-new team that + is richer than themselves. - Should only be called for standalone teams (when organization_id is None). - For org-scoped teams, use _check_org_team_limits() instead. - - `existing_team_max_budget` is the team's current `max_budget` on the - /team/update path. When the incoming `max_budget` is unchanged or lower - than the team's current budget, the personal-budget comparison is skipped - so a team admin can edit other fields (e.g. tpm_limit, team name) without - being blocked by a budget the team already has. The UI sends the full team - object on every update, so the unchanged `max_budget` would otherwise fail. + Only used by /team/new for standalone teams (organization_id is None). + /team/update does NOT call this — an existing team's admin is already + authorized via _verify_team_access() and is not gated by their personal + wallet. Org-scoped teams use _check_org_team_limits() instead. """ # Validate team budget against user's max_budget if data.max_budget is not None and user_api_key_dict.user_id is not None: - # On /team/update, allow unchanged or lower budgets without checking - # the caller's personal max_budget. Only increases above the team's - # current budget are validated against the user's personal limit. - budget_unchanged_or_lower = ( - existing_team_max_budget is not None - and data.max_budget <= existing_team_max_budget + user_obj = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, ) - if not budget_unchanged_or_lower: - user_obj = await get_user_object( - user_id=user_api_key_dict.user_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - user_id_upsert=False, + if ( + user_obj is not None + and user_obj.max_budget is not None + and data.max_budget > user_obj.max_budget + ): + raise HTTPException( + status_code=400, + detail={ + "error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}" + }, ) - if ( - user_obj is not None - and user_obj.max_budget is not None - and data.max_budget > user_obj.max_budget - ): - raise HTTPException( - status_code=400, - detail={ - "error": f"max budget higher than user max. User max budget={user_obj.max_budget}. User role={user_api_key_dict.user_role}" - }, - ) - # Validate team models against user's allowed models if data.models is not None and len(user_api_key_dict.models) > 0: for m in data.models: @@ -865,6 +850,45 @@ async def _check_user_team_limits( ) +def _check_team_budget_update_authority( + data: UpdateTeamRequest, + user_api_key_dict: UserAPIKeyAuth, + existing_team_max_budget: Optional[float], +) -> None: + """ + Restrict who can grow a standalone team's spend ceiling on /team/update. + + A team admin (already authorized via _verify_team_access) may keep or lower + the team budget, but only a proxy admin may grow it - by raising max_budget + above the team's current value or by removing the cap (setting it to None). + Setting a finite budget on a team that has no cap is a restriction and is + allowed. Org-scoped teams are governed by _check_org_team_limits(). + """ + if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + return + if existing_team_max_budget is None: + return + + budget_explicitly_set = "max_budget" in ( + getattr(data, "model_fields_set", None) or set() + ) + if budget_explicitly_set and data.max_budget is None: + raise HTTPException( + status_code=403, + detail={ + "error": f"Only a proxy admin can remove a team's max_budget. Team's current max_budget={existing_team_max_budget}." + }, + ) + + if data.max_budget is not None and data.max_budget > existing_team_max_budget: + raise HTTPException( + status_code=403, + detail={ + "error": f"Only a proxy admin can raise a team's max_budget. Team's current max_budget={existing_team_max_budget}, requested={data.max_budget}." + }, + ) + + #### TEAM MANAGEMENT #### @router.post( "/team/new", @@ -1827,22 +1851,14 @@ async def update_team( # noqa: PLR0915 prisma_client=prisma_client, ) - # Check user limits for standalone teams (not org-scoped) - # Skip for PROXY_ADMIN users - if ( - user_api_key_dict.user_role is None - or user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - ): - # Only validate user budget/models for standalone teams - # For org-scoped teams, validation is done by _check_org_team_limits() above - if org_id_to_check is None: - await _check_user_team_limits( - data=data, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - existing_team_max_budget=existing_team_row.max_budget, - ) + # Only a proxy admin may grow a standalone team's spend ceiling. + # Org-scoped teams are validated by _check_org_team_limits() above. + if org_id_to_check is None: + _check_team_budget_update_authority( + data=data, + user_api_key_dict=user_api_key_dict, + existing_team_max_budget=existing_team_row.max_budget, + ) updated_kv = data.json(exclude_unset=True) diff --git a/tests/proxy_behavior/management/test_team_budget_limits.py b/tests/proxy_behavior/management/test_team_budget_limits.py index 1534cee2b2..dad775370a 100644 --- a/tests/proxy_behavior/management/test_team_budget_limits.py +++ b/tests/proxy_behavior/management/test_team_budget_limits.py @@ -28,7 +28,7 @@ import pytest from litellm.proxy.utils import hash_token from .actors import Actor -from .conftest import create_scratch_org, create_scratch_team +from .conftest import MASTER_KEY, create_scratch_org, create_scratch_team pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -288,34 +288,130 @@ async def test_check_user_team_limits( # --------------------------------------------------------------------------- -# /team/update path — _check_user_team_limits on existing team, no-org. -# Pin one over-budget rejection here so the update-side wiring is also -# covered (the update path is a second call site with its own data shape). +# /team/update path — budget authority. +# +# The caller's PERSONAL limits are never applied on update (that compared the +# wrong thing). But raising a team's spend ceiling is reserved for proxy admins: +# a team admin may keep or LOWER the budget, only a proxy admin may RAISE it. +# _check_user_team_limits() only runs on /team/new. # --------------------------------------------------------------------------- -async def test_team_update_user_limit_rejected(proxy_client, prisma, scratch): +async def test_team_admin_raise_budget_blocked(proxy_client, prisma, scratch): + """A team admin cannot raise the team's budget; the block is NOT based on + their personal budget (which here is higher than the requested value).""" caller_cleartext = await _seed_scratch_actor_with_caps( prisma, scratch.prefix, - max_budget=100.0, + max_budget=100000.0, # generous personal budget; must not matter ) creator_user_id = f"{scratch.prefix}-team-creator" - # Team must exist before /team/update; seed a standalone scratch team - # owned by the same actor so the update authz gate passes. team_id = await create_scratch_team( prisma, team_id=scratch.tag("team"), admin_user_ids=[creator_user_id], max_budget=50.0, ) + # Raise the team budget 50 -> 999 as a team admin. resp = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {caller_cleartext}"}, json={"team_id": team_id, "max_budget": 999.0}, ) - assert resp.status_code == 400, resp.text + assert resp.status_code == 403, resp.text row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) assert row is not None - assert row.max_budget == 50.0, "row max_budget mutated despite rejection" + assert row.max_budget == 50.0, "team budget must not change on a blocked raise" + + +async def test_team_admin_lower_budget_allowed(proxy_client, prisma, scratch): + """A team admin may freely lower (or keep) the team's budget.""" + caller_cleartext = await _seed_scratch_actor_with_caps( + prisma, + scratch.prefix, + max_budget=10.0, # below both the old and new team budget; must not matter + ) + creator_user_id = f"{scratch.prefix}-team-creator" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[creator_user_id], + max_budget=500.0, + ) + # Lower the team budget 500 -> 300 as a team admin. + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller_cleartext}"}, + json={"team_id": team_id, "max_budget": 300.0}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 300.0, "team admin should be able to lower the budget" + + +async def test_proxy_admin_raise_budget_allowed(proxy_client, prisma, scratch): + """A proxy admin may raise a team's budget.""" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + # MASTER_KEY acts as proxy admin. + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"team_id": team_id, "max_budget": 999.0}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 999.0, "proxy admin should be able to raise the budget" + + +async def test_team_admin_remove_budget_cap_blocked(proxy_client, prisma, scratch): + """A team admin cannot strip the team's cap (max_budget=null); removing the + ceiling is the strongest possible raise -> proxy-admin only.""" + caller_cleartext = await _seed_scratch_actor_with_caps( + prisma, scratch.prefix, max_budget=100000.0 + ) + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {caller_cleartext}"}, + json={"team_id": team_id, "max_budget": None}, + ) + assert resp.status_code == 403, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget == 50.0, "team budget cap must not be removed by a team admin" + + +async def test_proxy_admin_remove_budget_cap_allowed(proxy_client, prisma, scratch): + """A proxy admin may remove a team's cap (max_budget=null).""" + team_id = await create_scratch_team( + prisma, + team_id=scratch.tag("team"), + admin_user_ids=[f"{scratch.prefix}-team-creator"], + max_budget=50.0, + ) + resp = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {MASTER_KEY}"}, + json={"team_id": team_id, "max_budget": None}, + ) + assert resp.status_code == 200, resp.text + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": team_id}) + assert row is not None + assert row.max_budget is None, "proxy admin should be able to remove the cap" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 06adcf8070..b750b6d022 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -4451,37 +4451,38 @@ async def test_new_team_org_scoped_models_not_in_org_models(): @pytest.mark.asyncio -async def test_update_team_standalone_budget_exceeds_user_limit(): +async def test_update_team_standalone_budget_raise_blocked_for_team_admin(): """ - Test that /team/update for a standalone team fails when new budget exceeds user's max_budget. + Test that /team/update for a standalone team blocks a non-proxy-admin + (team admin) from RAISING the team budget above the team's current value. + + Raising a team's spend ceiling is a budget-authority action reserved for + proxy admins. The rejection is NOT based on the caller's personal budget. Scenario: - - User has personal max_budget=$50 - - Standalone team exists (no organization_id) - - User tries to update team budget to $100 - - Expected: Should fail with error about exceeding user budget + - Team admin (internal_user) manages the team + - Standalone team exists with current budget=$30 + - Admin tries to raise team budget to $100 + - Expected: 403 (only a proxy admin may raise the team budget) """ from fastapi import Request from litellm.proxy._types import ( - LiteLLM_UserTable, ProxyException, UpdateTeamRequest, UserAPIKeyAuth, ) from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with restrictive personal budget - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="non-admin-update-test", models=[], ) - # Create update request with budget exceeding user's limit update_request = UpdateTeamRequest( team_id="standalone-team-123", - max_budget=100.0, # Exceeds user's $50 limit + max_budget=100.0, # Raise above the team's current $30 ) dummy_request = MagicMock(spec=Request) @@ -4492,13 +4493,13 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), patch( "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, + ), ): - # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-123" mock_existing_team.organization_id = None # Standalone team mock_existing_team.max_budget = 30.0 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "standalone-team-123", "organization_id": None, @@ -4510,25 +4511,253 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_cache.async_get_cache = AsyncMock(return_value=None) - # Mock user cache to return user with restrictive budget - mock_user_obj = LiteLLM_UserTable( - user_id="non-admin-update-test", - max_budget=50.0, # User's budget limit - ) - mock_cache.async_get_cache = AsyncMock(return_value=mock_user_obj) - - # Should raise ProxyException because new budget exceeds user's max_budget with pytest.raises(ProxyException) as exc_info: await update_team( data=update_request, http_request=dummy_request, - user_api_key_dict=non_admin_user, + user_api_key_dict=team_admin_user, ) - # Verify exception details - assert exc_info.value.code == "400" - assert "budget" in str(exc_info.value.message).lower() + assert exc_info.value.code == "403" + assert "proxy admin" in str(exc_info.value.message).lower() + + +@pytest.mark.asyncio +async def test_update_team_standalone_budget_raise_allowed_for_proxy_admin(): + """ + Test that a proxy admin CAN raise a standalone team's budget on /team/update. + + Scenario: + - Caller is a proxy admin + - Standalone team exists with current budget=$30 + - Proxy admin raises team budget to $100 + - Expected: Should succeed (proxy admin holds budget authority) + """ + from fastapi import Request + + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import update_team + + proxy_admin = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="proxy-admin-update-test", + models=[], + ) + + update_request = UpdateTeamRequest( + team_id="standalone-team-123", + max_budget=100.0, # Raise above the team's current $30 + ) + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-team-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = 30.0 + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 30.0, + "members_with_roles": [ + {"user_id": "proxy-admin-update-test", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-team-123" + mock_updated_team.organization_id = None + mock_updated_team.max_budget = 100.0 + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 100.0, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) + + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=proxy_admin, + ) + + assert result is not None + assert result["data"].max_budget == 100.0 + + +@pytest.mark.asyncio +async def test_update_team_standalone_budget_removal_blocked_for_team_admin(): + """ + A team admin must not be able to REMOVE a team's spend ceiling + (max_budget=null), which is the strongest possible raise (finite -> unlimited). + + Scenario: + - Team admin (internal_user) manages a team with current budget=$500 + - Admin explicitly sets max_budget=None to strip the cap + - Expected: 403 (only a proxy admin can remove the team budget) + """ + from fastapi import Request + + from litellm.proxy._types import ( + ProxyException, + UpdateTeamRequest, + UserAPIKeyAuth, + ) + from litellm.proxy.management_endpoints.team_endpoints import update_team + + team_admin_user = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="budget-removal-admin", + models=[], + ) + + # Explicitly set max_budget=None so it lands in model_fields_set and would be + # persisted by data.json(exclude_unset=True). + update_request = UpdateTeamRequest( + team_id="standalone-team-123", + max_budget=None, + ) + assert "max_budget" in update_request.model_fields_set + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-team-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = 500.0 + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-team-123", + "organization_id": None, + "max_budget": 500.0, + "members_with_roles": [ + {"user_id": "budget-removal-admin", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_cache.async_get_cache = AsyncMock(return_value=None) + + with pytest.raises(ProxyException) as exc_info: + await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert exc_info.value.code == "403" + assert "remove" in str(exc_info.value.message).lower() + + +@pytest.mark.asyncio +async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(): + """ + When a team currently has NO cap (max_budget=None / unlimited), a team admin + setting a finite max_budget is a RESTRICTION, not a raise, and is + intentionally allowed. + + Scenario: + - Team admin manages a team with current max_budget=None (unlimited) + - Admin sets max_budget=1000 (unlimited -> finite is more restrictive) + - Expected: 200 + """ + from fastapi import Request + + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import update_team + + team_admin_user = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="uncapped-team-admin", + models=[], + ) + + update_request = UpdateTeamRequest( + team_id="standalone-uncapped-123", + max_budget=1000.0, + ) + + dummy_request = MagicMock(spec=Request) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + ): + mock_existing_team = MagicMock() + mock_existing_team.team_id = "standalone-uncapped-123" + mock_existing_team.organization_id = None + mock_existing_team.max_budget = None # team has no cap + mock_existing_team.model_id = None + mock_existing_team.model_dump.return_value = { + "team_id": "standalone-uncapped-123", + "organization_id": None, + "max_budget": None, + "members_with_roles": [ + {"user_id": "uncapped-team-admin", "role": "admin"} + ], + } + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_existing_team + ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-uncapped-123" + mock_updated_team.organization_id = None + mock_updated_team.max_budget = 1000.0 + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-uncapped-123", + "organization_id": None, + "max_budget": 1000.0, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) + + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None + assert result["data"].max_budget == 1000.0 @pytest.mark.asyncio @@ -4816,32 +5045,34 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): @pytest.mark.asyncio -async def test_update_team_standalone_models_exceeds_user_limit(): +async def test_update_team_standalone_models_not_gated_by_user_limit(): """ - Test that /team/update for a standalone team fails when models are not in user's allowed models. + Test that /team/update for a standalone team does NOT gate the team's models + by the caller's personal allowed models. + + A team admin authorized via _verify_team_access() may set the team's models + independently of their own personal model list on update. Scenario: - - User has personal models=['gpt-3.5-turbo'] + - Team admin has personal models=['gpt-3.5-turbo'] - Standalone team exists (no organization_id) - - User tries to update team models to ['gpt-4'] (not in user's allowed models) - - Expected: Should fail with error about model not in user's allowed models + - Admin updates team models to ['gpt-4'] (not in their personal list) + - Expected: Should succeed (personal models are irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with restrictive personal models - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="non-admin-update-models-test", - models=["gpt-3.5-turbo"], # Restrictive model list + models=["gpt-3.5-turbo"], # Restrictive personal model list ) - # Create update request with model not in user's allowed list update_request = UpdateTeamRequest( team_id="standalone-team-models-123", - models=["gpt-4"], # Not in user's allowed models + models=["gpt-4"], # Not in the admin's personal allowed models ) dummy_request = MagicMock(spec=Request) @@ -4859,6 +5090,7 @@ async def test_update_team_standalone_models_exceeds_user_limit(): mock_existing_team.team_id = "standalone-team-models-123" mock_existing_team.organization_id = None # Standalone team mock_existing_team.models = ["gpt-3.5-turbo"] + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "standalone-team-models-123", "organization_id": None, @@ -4870,18 +5102,30 @@ async def test_update_team_standalone_models_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because model not in user's allowed models - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "standalone-team-models-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "standalone-team-models-123", + "organization_id": None, + "models": ["gpt-4"], + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "model" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio @@ -5306,32 +5550,35 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): @pytest.mark.asyncio -async def test_update_team_tpm_limit_exceeds_user_limit(): +async def test_update_team_tpm_limit_not_gated_by_user_limit(): """ - Test that /team/update fails when TPM limit exceeds user's TPM limit. + Test that /team/update does NOT gate the team's tpm_limit by the caller's + personal tpm_limit. + + A team admin authorized via _verify_team_access() may raise the team's + tpm_limit above their own personal tpm_limit on update. Scenario: - - User has tpm_limit=1000 - - User tries to update team with tpm_limit=5000 - - Expected: Should fail with error about exceeding user TPM limit + - Team admin has personal tpm_limit=1000 + - Standalone team exists with tpm_limit=500 + - Admin updates team tpm_limit to 5000 (above their personal 1000) + - Expected: Should succeed (personal tpm is irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with TPM limit - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="tpm-limit-user", models=[], - tpm_limit=1000, # User's TPM limit + tpm_limit=1000, # Restrictive personal TPM limit ) - # Create update request with TPM exceeding user's limit update_request = UpdateTeamRequest( team_id="team-tpm-test-123", - tpm_limit=5000, # Exceeds user's 1000 limit + tpm_limit=5000, # Above the admin's personal 1000 ) dummy_request = MagicMock(spec=Request) @@ -5340,12 +5587,16 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), ): # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-tpm-test-123" mock_existing_team.organization_id = None mock_existing_team.tpm_limit = 500 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "team-tpm-test-123", "organization_id": None, @@ -5355,47 +5606,59 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because new TPM exceeds user's limit - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "team-tpm-test-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "team-tpm-test-123", + "organization_id": None, + "tpm_limit": 5000, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "tpm" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio -async def test_update_team_rpm_limit_exceeds_user_limit(): +async def test_update_team_rpm_limit_not_gated_by_user_limit(): """ - Test that /team/update fails when RPM limit exceeds user's RPM limit. + Test that /team/update does NOT gate the team's rpm_limit by the caller's + personal rpm_limit. Scenario: - - User has rpm_limit=100 - - User tries to update team with rpm_limit=500 - - Expected: Should fail with error about exceeding user RPM limit + - Team admin has personal rpm_limit=100 + - Standalone team exists with rpm_limit=50 + - Admin updates team rpm_limit to 500 (above their personal 100) + - Expected: Should succeed (personal rpm is irrelevant on /team/update) """ from fastapi import Request - from litellm.proxy._types import ProxyException, UpdateTeamRequest, UserAPIKeyAuth + from litellm.proxy._types import UpdateTeamRequest, UserAPIKeyAuth from litellm.proxy.management_endpoints.team_endpoints import update_team - # Create non-admin user with RPM limit - non_admin_user = UserAPIKeyAuth( + team_admin_user = UserAPIKeyAuth( user_role=LitellmUserRoles.INTERNAL_USER, user_id="rpm-limit-user", models=[], - rpm_limit=100, # User's RPM limit + rpm_limit=100, # Restrictive personal RPM limit ) - # Create update request with RPM exceeding user's limit update_request = UpdateTeamRequest( team_id="team-rpm-test-123", - rpm_limit=500, # Exceeds user's 100 limit + rpm_limit=500, # Above the admin's personal 100 ) dummy_request = MagicMock(spec=Request) @@ -5404,12 +5667,16 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), ): # Mock existing standalone team mock_existing_team = MagicMock() mock_existing_team.team_id = "team-rpm-test-123" mock_existing_team.organization_id = None mock_existing_team.rpm_limit = 50 + mock_existing_team.model_id = None mock_existing_team.model_dump.return_value = { "team_id": "team-rpm-test-123", "organization_id": None, @@ -5419,18 +5686,30 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): mock_prisma.db.litellm_teamtable.find_unique = AsyncMock( return_value=mock_existing_team ) + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() - # Should raise ProxyException because new RPM exceeds user's limit - with pytest.raises(ProxyException) as exc_info: - await update_team( - data=update_request, - http_request=dummy_request, - user_api_key_dict=non_admin_user, - ) + mock_updated_team = MagicMock() + mock_updated_team.team_id = "team-rpm-test-123" + mock_updated_team.organization_id = None + mock_updated_team.litellm_model_table = None + mock_updated_team.model_dump.return_value = { + "team_id": "team-rpm-test-123", + "organization_id": None, + "rpm_limit": 500, + } + mock_prisma.db.litellm_teamtable.update = AsyncMock( + return_value=mock_updated_team + ) - # Verify exception details - assert exc_info.value.code == "400" - assert "rpm" in str(exc_info.value.message).lower() + result = await update_team( + data=update_request, + http_request=dummy_request, + user_api_key_dict=team_admin_user, + ) + + assert result is not None @pytest.mark.asyncio