fix(team): align /team/update authority with the documented team-admin rules

This commit is contained in:
ryan-crabbe-berri 2026-08-01 12:41:46 -07:00
parent 704b9da8ab
commit 4c3afe3845
8 changed files with 975 additions and 38 deletions

View file

@ -1,4 +1,5 @@
import math
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from fastapi import HTTPException, status
@ -76,27 +77,52 @@ def require_caller_user_id_for_non_admin(
return user_api_key_dict.user_id
def _passthrough_routes_differ(requested: object, existing: Sequence[str] | None) -> bool:
"""True when `requested` asks for a different route set than what is stored.
Anything that isn't a list/tuple of route strings counts as a change, so
malformed payloads fall through to the 403 instead of silently passing the
gate (or blowing up on an unhashable element).
"""
if requested is None:
return False
if not isinstance(requested, (list, tuple)):
return True
if any(not isinstance(route, str) for route in requested):
return True
return frozenset(requested) != frozenset(existing or ())
def _check_passthrough_routes_caller_permission(
data: BaseModel,
user_api_key_dict: UserAPIKeyAuth,
*,
entity: str = "key",
existing_routes: Sequence[str] | None = None,
) -> None:
"""
Only proxy admins may set `allowed_passthrough_routes` (top-level or under
`metadata`) it short-circuits the role-based route gate, so keys and teams
must be gated identically.
Only proxy admins may CHANGE `allowed_passthrough_routes` (top-level or
under `metadata`) it short-circuits the role-based route gate, so keys and
teams must be gated identically.
Re-sending the stored routes unchanged is a no-op rather than a 403: an
update writes `metadata` wholesale, so clients have to echo the stored value
back or an unrelated edit would wipe it. Without `existing_routes` the gate
stays strict (any non-empty value is a change), which is what create paths
want.
"""
# view-only admins excluded by design; blocked upstream from writes anyway
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
if getattr(data, "allowed_passthrough_routes", None):
if _passthrough_routes_differ(getattr(data, "allowed_passthrough_routes", None), existing_routes):
raise HTTPException(
status_code=403,
detail={"error": f"Only proxy admins can set `allowed_passthrough_routes` on a {entity}."},
)
metadata = getattr(data, "metadata", None)
if isinstance(metadata, dict) and metadata.get("allowed_passthrough_routes"):
if isinstance(metadata, dict) and _passthrough_routes_differ(
metadata.get("allowed_passthrough_routes"), existing_routes
):
raise HTTPException(
status_code=403,
detail={"error": f"Only proxy admins can set `metadata.allowed_passthrough_routes` on a {entity}."},

View file

@ -15,6 +15,7 @@ import math
import traceback
from collections.abc import Sequence
from datetime import datetime, timezone
from enum import Enum
from typing import (
Annotated,
Dict,
@ -83,6 +84,7 @@ from litellm.proxy._types import (
)
from litellm.proxy.auth.auth_checks import (
_cache_team_object,
_model_matches_any_wildcard_pattern_in_list,
allowed_route_check_inside_route,
can_org_access_model,
get_org_object,
@ -315,10 +317,68 @@ async def _refresh_cached_team(
)
class TeamAccessGrant(str, Enum):
"""Which authority let a caller through `_verify_team_access`.
Callers that gate finer-grained actions (budget ceiling, model list) need to
know this: a team admin is bounded by what a proxy admin already granted the
team, while proxy/org admins hold the grant itself.
"""
PROXY_ADMIN = "proxy_admin"
ORG_ADMIN = "org_admin"
TEAM_ADMIN = "team_admin"
async def _is_org_admin_for_team_or_false(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
"""`_is_user_org_admin_for_team`, but a failed lookup means "no".
The underlying call raises when the caller's user row can't be read. Every
caller of this uses the answer to hand out authority, so degrading to "not
an org admin" is fail-closed: it can only withhold authority, never grant
it, and it keeps a lookup failure from 500-ing a request the caller is
otherwise allowed to make.
"""
if not team_obj.organization_id:
return False
try:
return await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj)
except Exception as e:
verbose_proxy_logger.warning(
"Could not resolve org-admin status for user=%s on team=%s, treating as non-org-admin. Error: %s",
user_api_key_dict.user_id,
team_obj.team_id,
e,
)
return False
async def _escalate_team_admin_grant_for_org_admin(
team_access: TeamAccessGrant,
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> TeamAccessGrant:
"""Upgrade a TEAM_ADMIN grant to ORG_ADMIN when the caller is both.
`_verify_team_access` answers the cheap team-membership question first and
stops there, so a team's own org admin (usually the team's creator) comes
back as TEAM_ADMIN. Callers that gate on the grant have to resolve the
stronger authority before refusing them.
"""
if team_access is not TeamAccessGrant.TEAM_ADMIN:
return team_access
if await _is_org_admin_for_team_or_false(team_obj=team_obj, user_api_key_dict=user_api_key_dict):
return TeamAccessGrant.ORG_ADMIN
return team_access
async def _verify_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
) -> TeamAccessGrant:
"""
Verify the caller is authorized to manage the given team.
@ -327,16 +387,17 @@ async def _verify_team_access(
- Caller is an org admin for the team's organization, OR
- Caller is a team admin of this team
Raises HTTPException(403) otherwise.
Returns the authority that granted access. Raises HTTPException(403)
otherwise.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return
return TeamAccessGrant.PROXY_ADMIN
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return
return TeamAccessGrant.TEAM_ADMIN
if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return
if await _is_org_admin_for_team_or_false(team_obj=team_obj, user_api_key_dict=user_api_key_dict):
return TeamAccessGrant.ORG_ADMIN
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -998,19 +1059,19 @@ async def _check_user_team_limits(
def _check_team_budget_update_authority(
data: UpdateTeamRequest,
user_api_key_dict: UserAPIKeyAuth,
team_access: TeamAccessGrant,
existing_team_max_budget: Optional[float],
) -> None:
"""
Restrict who can grow a standalone team's spend ceiling on /team/update.
Restrict who can grow a 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
A team admin may keep or lower the team budget, but only a proxy admin (or
the org admin who owns the team's org) 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().
allowed. Org-scoped teams are additionally capped by _check_org_team_limits().
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
if team_access is not TeamAccessGrant.TEAM_ADMIN:
return
if existing_team_max_budget is None:
return
@ -1033,6 +1094,65 @@ def _check_team_budget_update_authority(
)
def _model_list_grants_every_model(models: Sequence[str]) -> bool:
"""Whether a team.models value reaches every deployment on the proxy.
Mirrors the auth path (`_can_object_call_model`): an empty list, a bare
"*", and the all-proxy-models sentinel are all unrestricted.
"""
return len(models) == 0 or "*" in models or SpecialModelNames.all_proxy_models.value in models
def _check_team_models_update_authority(
data: UpdateTeamRequest,
team_access: TeamAccessGrant,
existing_team_models: Sequence[str],
) -> None:
"""
Restrict who can widen a team's allowed-model list on /team/update.
A team admin may drop models the team already has, but only a proxy admin
(or the org admin who owns the team's org) can grant a team access to a
model it cannot already reach. Reachability follows the auth path, so a team
holding "openai/*" may be narrowed to "openai/gpt-4o", and a team that
already reaches every model can be narrowed to anything.
"""
if team_access is not TeamAccessGrant.TEAM_ADMIN:
return
if data.models is None:
return
if _model_list_grants_every_model(existing_team_models):
return
if _model_list_grants_every_model(data.models):
raise HTTPException(
status_code=403,
detail={
"error": (
"Only a proxy admin can grant a team access to every proxy model. Team's current "
f"models={sorted(set(existing_team_models))}."
)
},
)
added_models = tuple(
model
for model in data.models
if model not in existing_team_models
and not _model_matches_any_wildcard_pattern_in_list(model=model, allowed_model_list=list(existing_team_models))
)
if added_models:
raise HTTPException(
status_code=403,
detail={
"error": (
f"Only a proxy admin can add models to a team. Models the team cannot already reach: "
f"{sorted(set(added_models))}. Team's current models={sorted(set(existing_team_models))}."
)
},
)
def _should_auto_add_team_creator(
user_api_key_dict: UserAPIKeyAuth,
general_settings: Mapping[str, object],
@ -1851,12 +1971,32 @@ async def update_team(
)
# Verify caller has access to manage this team
await _verify_team_access(
team_obj=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()),
existing_team_obj = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump())
team_access = await _escalate_team_admin_grant_for_org_admin(
team_access=await _verify_team_access(
team_obj=existing_team_obj,
user_api_key_dict=user_api_key_dict,
),
team_obj=existing_team_obj,
user_api_key_dict=user_api_key_dict,
)
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {}
stored_passthrough_routes = existing_metadata.get("allowed_passthrough_routes")
_check_passthrough_routes_caller_permission(
data,
user_api_key_dict,
entity="team",
existing_routes=(
stored_passthrough_routes if isinstance(stored_passthrough_routes, (list, tuple)) else None
),
)
_check_team_models_update_authority(
data=data,
team_access=team_access,
existing_team_models=existing_team_row.models or [],
)
if data.soft_budget is not None:
max_budget_to_check = data.max_budget if data.max_budget is not None else existing_team_row.max_budget
@ -1945,14 +2085,11 @@ async def update_team(
prisma_client=prisma_client,
)
# 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,
)
_check_team_budget_update_authority(
data=data,
team_access=team_access,
existing_team_max_budget=existing_team_row.max_budget,
)
updated_kv = data.json(exclude_unset=True)

View file

@ -715,6 +715,127 @@ class TestCheckPassthroughRoutesCallerPermission:
is None
)
def _route_data_model(self):
from pydantic import BaseModel
class _RouteData(BaseModel):
allowed_passthrough_routes: list | None = None
metadata: dict | None = None
return _RouteData
def test_unchanged_echo_of_stored_routes_is_allowed(self):
"""An update rewrites metadata wholesale, so a non-admin has to echo the
stored routes back or the save would wipe them. Echoing changes nothing
and must not 403."""
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
)
data = self._route_data_model()(
metadata={"allowed_passthrough_routes": ["/v1/bar", "/v1/foo"]}
)
assert (
_check_passthrough_routes_caller_permission(
data,
self._non_admin(),
entity="team",
existing_routes=["/v1/foo", "/v1/bar"],
)
is None
)
def test_adding_a_route_to_the_stored_set_is_rejected(self):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
)
data = self._route_data_model()(
metadata={"allowed_passthrough_routes": ["/v1/foo", "/v1/baz"]}
)
with pytest.raises(HTTPException) as exc_info:
_check_passthrough_routes_caller_permission(
data,
self._non_admin(),
entity="team",
existing_routes=["/v1/foo"],
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == {
"error": "Only proxy admins can set `metadata.allowed_passthrough_routes` on a team."
}
def test_clearing_stored_routes_is_rejected(self):
"""Wiping an admin-only field is still changing it."""
from fastapi import HTTPException
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
)
data = self._route_data_model()(metadata={"allowed_passthrough_routes": []})
with pytest.raises(HTTPException) as exc_info:
_check_passthrough_routes_caller_permission(
data,
self._non_admin(),
entity="team",
existing_routes=["/v1/foo"],
)
assert exc_info.value.status_code == 403
def test_non_list_payload_is_rejected(self):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
)
data = self._route_data_model()(
metadata={"allowed_passthrough_routes": "/v1/foo"}
)
with pytest.raises(HTTPException) as exc_info:
_check_passthrough_routes_caller_permission(
data,
self._non_admin(),
entity="team",
existing_routes=["/v1/foo"],
)
assert exc_info.value.status_code == 403
def test_top_level_echo_is_allowed_while_a_change_is_rejected(self):
from fastapi import HTTPException
from litellm.proxy.management_endpoints.common_utils import (
_check_passthrough_routes_caller_permission,
)
model = self._route_data_model()
assert (
_check_passthrough_routes_caller_permission(
model(allowed_passthrough_routes=["/v1/foo"]),
self._non_admin(),
existing_routes=["/v1/foo"],
)
is None
)
with pytest.raises(HTTPException) as exc_info:
_check_passthrough_routes_caller_permission(
model(allowed_passthrough_routes=["/v1/foo", "/v1/qux"]),
self._non_admin(),
existing_routes=["/v1/foo"],
)
assert exc_info.value.status_code == 403
class TestIsUserOrgAdminForTeam:
"""The caller must be looked up with its exact identity; a nulled or omitted

View file

@ -5410,6 +5410,10 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=mock_org),
) as mock_get_org,
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=True),
),
):
# Mock existing org-scoped team
mock_existing_team = MagicMock()
@ -5450,13 +5454,13 @@ async def test_update_team_standalone_models_not_gated_by_user_limit():
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.
A team admin authorized via _verify_team_access() may narrow the team's
models independently of their own personal model list on update.
Scenario:
- Team admin has personal models=['gpt-3.5-turbo']
- Standalone team exists (no organization_id)
- Admin updates team models to ['gpt-4'] (not in their personal list)
- Standalone team exists (no organization_id) holding both models
- Admin narrows the team to ['gpt-4'] (not in their personal list)
- Expected: Should succeed (personal models are irrelevant on /team/update)
"""
from fastapi import Request
@ -5489,12 +5493,12 @@ async def test_update_team_standalone_models_not_gated_by_user_limit():
mock_existing_team = MagicMock()
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.models = ["gpt-3.5-turbo", "gpt-4"]
mock_existing_team.model_id = None
mock_existing_team.model_dump.return_value = {
"team_id": "standalone-team-models-123",
"organization_id": None,
"models": ["gpt-3.5-turbo"],
"models": ["gpt-3.5-turbo", "gpt-4"],
"members_with_roles": [
{"user_id": "non-admin-update-models-test", "role": "admin"}
],
@ -5586,6 +5590,10 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit():
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=mock_org),
) as mock_get_org,
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=True),
),
):
# Mock existing org-scoped team
mock_existing_team = MagicMock()
@ -5696,6 +5704,10 @@ async def test_update_team_org_scoped_models_bypasses_user_limit():
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=mock_org),
) as mock_get_org,
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=True),
),
):
# Mock existing org-scoped team
mock_existing_team = MagicMock()
@ -5799,6 +5811,10 @@ async def test_update_team_org_scoped_models_not_in_org_models():
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=mock_org),
) as mock_get_org,
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=True),
),
):
# Mock existing org-scoped team
mock_existing_team = MagicMock()
@ -5887,6 +5903,10 @@ async def test_update_team_org_scoped_models_with_all_proxy_models():
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=mock_org),
) as mock_get_org,
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=True),
),
):
# Mock existing org-scoped team
mock_existing_team = MagicMock()
@ -10617,3 +10637,356 @@ def test_validate_member_user_id_provisioning_caps_the_ids_it_echoes_back():
assert f"u{_MAX_REPORTED_UNKNOWN_USER_IDS}" not in detail
assert f"and {500 - _MAX_REPORTED_UNKNOWN_USER_IDS} more" in detail
assert len(detail) < 1000
async def _run_update_team(
*,
update_request,
caller,
existing_team,
org=None,
caller_is_org_admin=False,
):
"""Drive /team/update against a mocked team row and return the handler result.
`existing_team` is the stored row as a dict; every field the handler reads
off the row has to be present, since a MagicMock attribute would otherwise
stand in as a truthy sentinel.
"""
from fastapi import Request
from litellm.proxy.management_endpoints.team_endpoints import update_team
mock_existing_team = MagicMock()
for field, value in existing_team.items():
setattr(mock_existing_team, field, value)
mock_existing_team.model_dump.return_value = dict(existing_team)
mock_updated_team = MagicMock()
mock_updated_team.team_id = existing_team["team_id"]
mock_updated_team.litellm_model_table = None
mock_updated_team.model_dump.return_value = dict(existing_team)
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()),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_org_object",
new=AsyncMock(return_value=org),
),
patch(
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
new=AsyncMock(return_value=caller_is_org_admin),
),
):
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_existing_team)
mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_updated_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()
return await update_team(
data=update_request,
http_request=MagicMock(spec=Request),
user_api_key_dict=caller,
)
def _team_admin_caller(user_id="team-admin-doc-alignment"):
return UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id=user_id, models=[])
def _stored_team(**overrides):
stored = {
"team_id": "doc-alignment-team",
"organization_id": None,
"max_budget": 30.0,
"models": ["gpt-4"],
"model_id": None,
"metadata": {},
"object_permission_id": None,
"members_with_roles": [{"user_id": "team-admin-doc-alignment", "role": "admin"}],
}
stored.update(overrides)
return stored
@pytest.mark.asyncio
async def test_update_team_model_add_blocked_for_team_admin():
"""The docs say a team admin cannot add global proxy models to their team;
/team/update has to say the same."""
from litellm.proxy._types import ProxyException, UpdateTeamRequest
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["gpt-4", "claude-opus-4-5"]),
caller=_team_admin_caller(),
existing_team=_stored_team(),
)
assert exc_info.value.code == "403"
assert "claude-opus-4-5" in str(exc_info.value.message)
assert "proxy admin" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_update_team_model_add_allowed_for_org_admin():
"""An org admin holds the grant, so widening their own team's models stays
allowed (the org's model list is the ceiling, checked separately)."""
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
UpdateTeamRequest,
)
mock_org = MagicMock(spec=LiteLLM_OrganizationTable)
mock_org.organization_id = "org-doc-alignment"
mock_org.models = ["gpt-4", "claude-opus-4-5"]
mock_org.litellm_budget_table = MagicMock(spec=LiteLLM_BudgetTable)
mock_org.litellm_budget_table.max_budget = None
mock_org.litellm_budget_table.tpm_limit = None
mock_org.litellm_budget_table.rpm_limit = None
result = await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["gpt-4", "claude-opus-4-5"]),
caller=_team_admin_caller(),
existing_team=_stored_team(organization_id="org-doc-alignment"),
org=mock_org,
caller_is_org_admin=True,
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_model_add_allowed_when_team_already_holds_every_model():
"""A team holding the all-proxy-models sentinel already reaches everything,
so naming a specific model narrows rather than widens."""
from litellm.proxy._types import UpdateTeamRequest
result = await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["claude-opus-4-5"]),
caller=_team_admin_caller(),
existing_team=_stored_team(models=["all-proxy-models"]),
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_org_scoped_budget_raise_blocked_for_team_admin():
"""Keep-or-lower applies to a team admin whether or not the team sits in an
org; staying under the org ceiling is a separate question."""
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
ProxyException,
UpdateTeamRequest,
)
mock_org = MagicMock(spec=LiteLLM_OrganizationTable)
mock_org.organization_id = "org-doc-alignment"
mock_org.models = ["gpt-4"]
mock_org.litellm_budget_table = MagicMock(spec=LiteLLM_BudgetTable)
mock_org.litellm_budget_table.max_budget = 1000.0
mock_org.litellm_budget_table.tpm_limit = None
mock_org.litellm_budget_table.rpm_limit = None
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", max_budget=900.0),
caller=_team_admin_caller(),
existing_team=_stored_team(organization_id="org-doc-alignment"),
org=mock_org,
)
assert exc_info.value.code == "403"
assert "raise" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_update_team_org_scoped_budget_removal_blocked_for_team_admin():
"""Dropping the cap is the strongest raise there is."""
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
ProxyException,
UpdateTeamRequest,
)
mock_org = MagicMock(spec=LiteLLM_OrganizationTable)
mock_org.organization_id = "org-doc-alignment"
mock_org.models = ["gpt-4"]
mock_org.litellm_budget_table = MagicMock(spec=LiteLLM_BudgetTable)
mock_org.litellm_budget_table.max_budget = 1000.0
mock_org.litellm_budget_table.tpm_limit = None
mock_org.litellm_budget_table.rpm_limit = None
request = UpdateTeamRequest(team_id="doc-alignment-team", max_budget=None)
assert "max_budget" in request.model_fields_set
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=request,
caller=_team_admin_caller(),
existing_team=_stored_team(organization_id="org-doc-alignment"),
org=mock_org,
)
assert exc_info.value.code == "403"
assert "remove" in str(exc_info.value.message).lower()
@pytest.mark.asyncio
async def test_update_team_org_scoped_budget_lower_allowed_for_team_admin():
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
UpdateTeamRequest,
)
mock_org = MagicMock(spec=LiteLLM_OrganizationTable)
mock_org.organization_id = "org-doc-alignment"
mock_org.models = ["gpt-4"]
mock_org.litellm_budget_table = MagicMock(spec=LiteLLM_BudgetTable)
mock_org.litellm_budget_table.max_budget = 1000.0
mock_org.litellm_budget_table.tpm_limit = None
mock_org.litellm_budget_table.rpm_limit = None
result = await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", max_budget=10.0),
caller=_team_admin_caller(),
existing_team=_stored_team(organization_id="org-doc-alignment"),
org=mock_org,
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_unchanged_passthrough_routes_do_not_block_a_team_admin_edit():
"""The dashboard echoes the stored routes back so an unrelated edit doesn't
wipe them; that echo must not read as an attempt to set them."""
from litellm.proxy._types import UpdateTeamRequest
result = await _run_update_team(
update_request=UpdateTeamRequest(
team_id="doc-alignment-team",
team_alias="renamed-by-team-admin",
metadata={"allowed_passthrough_routes": ["/v1/foo"]},
),
caller=_team_admin_caller(),
existing_team=_stored_team(metadata={"allowed_passthrough_routes": ["/v1/foo"]}),
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_changed_passthrough_routes_still_blocked_for_team_admin():
from litellm.proxy._types import ProxyException, UpdateTeamRequest
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(
team_id="doc-alignment-team",
metadata={"allowed_passthrough_routes": ["/v1/foo", "/v1/escalate"]},
),
caller=_team_admin_caller(),
existing_team=_stored_team(metadata={"allowed_passthrough_routes": ["/v1/foo"]}),
)
assert exc_info.value.code == "403"
assert "allowed_passthrough_routes" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_update_team_model_clear_blocked_for_team_admin():
"""An empty models list is the unrestricted sentinel at auth time, so
clearing the list is the widest possible grant, not a narrowing."""
from litellm.proxy._types import ProxyException, UpdateTeamRequest
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=[]),
caller=_team_admin_caller(),
existing_team=_stored_team(),
)
assert exc_info.value.code == "403"
assert "every proxy model" in str(exc_info.value.message)
@pytest.mark.asyncio
async def test_update_team_model_wildcard_grant_blocked_for_team_admin():
""""*" reaches everything the same way the empty list does."""
from litellm.proxy._types import ProxyException, UpdateTeamRequest
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["*"]),
caller=_team_admin_caller(),
existing_team=_stored_team(),
)
assert exc_info.value.code == "403"
@pytest.mark.asyncio
async def test_update_team_narrowing_a_wildcard_allowed_for_team_admin():
"""A team holding openai/* already reaches openai/gpt-4o, so pinning the
list to that model narrows the team's access."""
from litellm.proxy._types import UpdateTeamRequest
result = await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["openai/gpt-4o"]),
caller=_team_admin_caller(),
existing_team=_stored_team(models=["openai/*"]),
)
assert result is not None
@pytest.mark.asyncio
async def test_update_team_model_outside_a_wildcard_blocked_for_team_admin():
from litellm.proxy._types import ProxyException, UpdateTeamRequest
with pytest.raises(ProxyException) as exc_info:
await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", models=["anthropic/claude-opus-4-5"]),
caller=_team_admin_caller(),
existing_team=_stored_team(models=["openai/*"]),
)
assert exc_info.value.code == "403"
@pytest.mark.asyncio
async def test_update_team_budget_raise_allowed_for_team_admin_who_is_also_org_admin():
"""The org admin who created the team is a team admin too; the stronger
grant has to win or they lose authority over their own org's team."""
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_OrganizationTable,
UpdateTeamRequest,
)
mock_org = MagicMock(spec=LiteLLM_OrganizationTable)
mock_org.organization_id = "org-doc-alignment"
mock_org.models = ["gpt-4"]
mock_org.litellm_budget_table = MagicMock(spec=LiteLLM_BudgetTable)
mock_org.litellm_budget_table.max_budget = 1000.0
mock_org.litellm_budget_table.tpm_limit = None
mock_org.litellm_budget_table.rpm_limit = None
result = await _run_update_team(
update_request=UpdateTeamRequest(team_id="doc-alignment-team", max_budget=900.0),
caller=_team_admin_caller(),
existing_team=_stored_team(organization_id="org-doc-alignment"),
org=mock_org,
caller_is_org_admin=True,
)
assert result is not None

View file

@ -578,4 +578,94 @@ describe("ModelSelect", () => {
expect(screen.getByText(/\+5 more/)).toBeInTheDocument();
});
});
describe("restrictToCurrentTeamModels", () => {
const renderRestricted = (teamModels: string[], organizationModels?: string[]) => {
mockUseTeam.mockReturnValue({
data: { team_id: "team-1", team_alias: "Test Team", models: teamModels },
isLoading: false,
} as any);
if (organizationModels) {
mockUseOrganization.mockReturnValue({
data: createMockOrganization(organizationModels),
isLoading: false,
} as any);
}
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
context="team"
teamID="team-1"
organizationID={organizationModels ? "org-1" : undefined}
options={{ includeSpecialOptions: true, restrictToCurrentTeamModels: true }}
/>,
);
};
it("offers only the models the team already holds", async () => {
renderRestricted(["gpt-4"]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeInTheDocument();
});
expect(screen.queryByRole("option", { name: "claude-3" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "All Proxy Models" })).not.toBeInTheDocument();
});
it("hides All Proxy Models even when the team's org grants everything", async () => {
renderRestricted(["gpt-4"], ["all-proxy-models"]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeInTheDocument();
});
expect(screen.queryByRole("option", { name: "All Proxy Models" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "claude-3" })).not.toBeInTheDocument();
});
it("does not restrict a team that already reaches every model", async () => {
renderRestricted(["all-proxy-models"]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeInTheDocument();
});
expect(screen.getByRole("option", { name: "claude-3" })).toBeInTheDocument();
});
it("keeps every model a team wildcard already reaches selectable", async () => {
mockUseAllProxyModels.mockReturnValue({
data: {
data: [
{ id: "openai/gpt-4o", object: "model", created: 1, owned_by: "openai" },
{ id: "openai/*", object: "model", created: 1, owned_by: "openai" },
{ id: "anthropic/claude-opus-4-5", object: "model", created: 1, owned_by: "anthropic" },
],
},
isLoading: false,
} as any);
renderRestricted(["openai/*"]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "openai/gpt-4o" })).toBeInTheDocument();
});
expect(screen.queryByRole("option", { name: "anthropic/claude-opus-4-5" })).not.toBeInTheDocument();
});
it("does not restrict a team holding the bare * grant", async () => {
renderRestricted(["*"]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeInTheDocument();
});
expect(screen.getByRole("option", { name: "claude-3" })).toBeInTheDocument();
});
it("does not restrict a team with an empty model list", async () => {
renderRestricted([]);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeInTheDocument();
});
expect(screen.getByRole("option", { name: "claude-3" })).toBeInTheDocument();
});
});
});

View file

@ -29,6 +29,7 @@ export interface ModelSelectProps {
showAllTeamModelsOption?: boolean;
showAllProxyModelsOverride?: boolean;
includeSpecialOptions?: boolean;
restrictToCurrentTeamModels?: boolean;
};
context: "team" | "organization" | "user" | "global";
dataTestId?: string;
@ -45,6 +46,28 @@ type FilterContextArgs = {
options?: ModelSelectProps["options"];
};
/**
* The team's own models, when the caller may only narrow that list (a team
* admin can drop a model but not grant a new one /team/update rejects it).
* Returns null when the restriction doesn't apply, including when the team
* already reaches every model, since nothing can widen it further.
*/
const keepOnlyCurrentTeamModels = (
selectedTeam: Team | undefined,
options: ModelSelectProps["options"],
allProxyModels: string[],
): string[] | null => {
if (!options?.restrictToCurrentTeamModels) return null;
const teamModels = selectedTeam?.models ?? [];
if (teamModels.length === 0) return null;
if (teamModels.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) || teamModels.includes("*")) return null;
const wildcardPrefixes = teamModels.filter((m) => m.endsWith("/*")).map((m) => m.slice(0, -1));
const reachableProxyModels = allProxyModels.filter((model) =>
wildcardPrefixes.some((prefix) => model.startsWith(prefix)),
);
return Array.from(new Set([...teamModels, ...reachableProxyModels]));
};
const contextFilters: Record<ModelSelectProps["context"], (args: FilterContextArgs) => string[]> = {
user: ({ allProxyModels, userModels, options }) => {
if (!userModels) return [];
@ -52,7 +75,10 @@ const contextFilters: Record<ModelSelectProps["context"], (args: FilterContextAr
return [];
},
team: ({ allProxyModels, selectedOrganization, userModels }) => {
team: ({ allProxyModels, selectedTeam, selectedOrganization, userModels, options }) => {
const currentTeamModels = keepOnlyCurrentTeamModels(selectedTeam, options, allProxyModels);
if (currentTeamModels) return currentTeamModels;
if (selectedOrganization) {
if (
selectedOrganization.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) ||
@ -107,7 +133,8 @@ export const ModelSelect = (props: ModelSelectProps) => {
organization?.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) ||
organization?.models.length === 0;
const shouldShowAllProxyModels =
showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global";
keepOnlyCurrentTeamModels(team, options, []) === null &&
(showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global");
if (isLoading) {
return <Skeleton.Input active block />;

View file

@ -3,7 +3,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import TeamInfoView from "./TeamInfo";
import TeamInfoView, { validateTeamMaxBudget } from "./TeamInfo";
vi.mock("@/components/networking", () => ({
teamInfoCall: vi.fn(),
@ -43,6 +43,21 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
let mockUserRole = "Admin";
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
token: "123",
accessToken: "test-token",
userId: "user-1",
userEmail: "user@example.com",
userRole: mockUserRole,
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
}),
}));
vi.mock("@/components/team/TeamMemberTab", () => ({
default: vi.fn(({ setIsAddMemberModalVisible }) => (
<div>
@ -1193,4 +1208,119 @@ describe("TeamInfoView", () => {
});
});
});
describe("team-admin budget authority", () => {
const teamAdminProps = { ...defaultProps, is_proxy_admin: false, is_team_admin: true };
const openSettingsForm = async (user: ReturnType<typeof userEvent.setup>) => {
await waitFor(() => {
expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
});
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
beforeEach(() => {
testQueryClient.clear();
mockUserRole = "Internal User";
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({ models: ["gpt-4"], max_budget: 30 }),
);
});
afterEach(() => {
mockUserRole = "Admin";
});
it("blocks a raise before the request leaves the browser", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await openSettingsForm(user);
const budgetInput = screen.getByLabelText("Max Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "100");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/Only a proxy admin can raise this team's budget above \$30/)).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
it("blocks clearing the cap", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await openSettingsForm(user);
await user.clear(screen.getByLabelText("Max Budget (USD)"));
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/Only a proxy admin can remove this team's budget/)).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
it("lets a team admin lower the cap", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await openSettingsForm(user);
const budgetInput = screen.getByLabelText("Max Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "10");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
const [accessToken, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
expect(accessToken).toBe("test-token");
expect(payload.team_id).toBe("123");
expect(Number(payload.max_budget)).toBe(10);
});
it("leaves a proxy admin free to raise the cap", async () => {
const user = userEvent.setup({ delay: null });
mockUserRole = "Admin";
renderWithProviders(<TeamInfoView {...defaultProps} />);
await openSettingsForm(user);
const budgetInput = screen.getByLabelText("Max Budget (USD)");
await user.clear(budgetInput);
await user.type(budgetInput, "100");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(networking.teamUpdateCall).toHaveBeenCalled();
});
const [accessToken, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
expect(accessToken).toBe("test-token");
expect(payload.team_id).toBe("123");
expect(Number(payload.max_budget)).toBe(100);
});
});
describe("validateTeamMaxBudget", () => {
it("is inert for callers who hold the grant, or when the team has no cap", async () => {
await expect(validateTeamMaxBudget(true, 30)(null, 1000)).resolves.toBeUndefined();
await expect(validateTeamMaxBudget(false, null)(null, 1000)).resolves.toBeUndefined();
await expect(validateTeamMaxBudget(false, undefined)(null, "")).resolves.toBeUndefined();
});
it("allows keeping or lowering, rejects raising or clearing", async () => {
const validate = validateTeamMaxBudget(false, 30);
await expect(validate(null, 30)).resolves.toBeUndefined();
await expect(validate(null, 29.99)).resolves.toBeUndefined();
await expect(validate(null, 30.01)).rejects.toThrow(/raise/);
await expect(validate(null, "")).rejects.toThrow(/remove/);
await expect(validate(null, null)).rejects.toThrow(/remove/);
});
it("treats a zero cap as a real ceiling", async () => {
const validate = validateTeamMaxBudget(false, 0);
await expect(validate(null, 0)).resolves.toBeUndefined();
await expect(validate(null, 5)).rejects.toThrow(/raise/);
});
});
});

View file

@ -144,6 +144,25 @@ export interface TeamInfoProps {
premiumUser?: boolean;
}
/**
* Mirrors the /team/update budget-authority gate so a team admin sees why the
* save is refused before the round-trip: they may keep or lower the team's
* ceiling, never raise or remove it.
*/
export const validateTeamMaxBudget =
(canWidenTeamGrants: boolean, currentMaxBudget: number | null | undefined) =>
async (_rule: unknown, value: unknown): Promise<void> => {
if (canWidenTeamGrants || currentMaxBudget == null) return;
if (value === null || value === undefined || value === "") {
throw new Error(`Only a proxy admin can remove this team's budget (currently $${currentMaxBudget})`);
}
const requested = Number(value);
if (Number.isNaN(requested)) return;
if (requested > currentMaxBudget) {
throw new Error(`Only a proxy admin can raise this team's budget above $${currentMaxBudget}`);
}
};
const getOrganizationModels = (organization: Organization | null, userModels: string[]) => {
let tempModelsToPick = [];
@ -234,6 +253,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
const isTeamAdminForThisTeam = is_team_admin || isTeamAdminFromTeamData;
const holdsAuthorityOverTeam =
isProxyAdminRole(userRole) || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
const canWidenTeamGrants = holdsAuthorityOverTeam || !isTeamAdminForThisTeam;
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
@ -1042,6 +1065,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
includeUserModels: !teamData?.team_info?.organization_id,
showAllProxyModelsOverride:
isProxyAdminRole(userRole) && !teamData?.team_info?.organization_id,
restrictToCurrentTeamModels: !canWidenTeamGrants,
}}
context="team"
dataTestId="models-select"
@ -1066,7 +1090,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
<Form.Item
label="Max Budget (USD)"
name="max_budget"
tooltip={
canWidenTeamGrants || info.max_budget == null
? undefined
: `Only a proxy admin can raise this team's budget above $${info.max_budget} or remove it`
}
rules={[{ validator: validateTeamMaxBudget(canWidenTeamGrants, info.max_budget) }]}
>
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
</Form.Item>