fix(proxy): report parent org models on /team/info only to team managers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-14 20:01:47 +00:00
parent 9cdfe311e9
commit 2d3b63fb5a
3 changed files with 76 additions and 22 deletions

View file

@ -4414,7 +4414,8 @@ class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable):
access_group_mcp_server_ids: list[str] | None = None
access_group_agent_ids: list[str] | None = None
access_group_details: tuple[TeamAccessGroupModelGrant, ...] | None = None
# Parent org's model ceiling. None = no org; [] or ["all-proxy-models"] = no ceiling.
# Parent org's model ceiling, reported only to callers who can manage the team.
# None = no org or not a manager; [] or ["all-proxy-models"] = no ceiling.
organization_models: list[str] | None = None

View file

@ -429,27 +429,26 @@ async def _refresh_cached_team(
)
async def _can_manage_team(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> bool:
"""True for a proxy admin, an admin of this team, or an org admin for the team's organization."""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return True
return await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj)
async def _verify_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
Verify the caller is authorized to manage the given team.
Access is granted if:
- Caller is a proxy admin, OR
- Caller is an org admin for the team's organization, OR
- Caller is a team admin of this team
Raises HTTPException(403) otherwise.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return
if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
"""Raise HTTPException(403) unless the caller can manage the given team."""
if await _can_manage_team(team_obj=team_obj, user_api_key_dict=user_api_key_dict):
return
raise HTTPException(
@ -4466,10 +4465,12 @@ async def team_info(
status_code=status.HTTP_404_NOT_FOUND,
detail={"message": f"Team not found, passed team id: {team_id}."},
)
organization_models: Final[list[str] | None] = _parent_organization_models(team_info)
await validate_membership(
user_api_key_dict=user_api_key_dict,
team_table=LiteLLM_TeamTable.model_validate(team_info.model_dump()),
team_table: Final = LiteLLM_TeamTable.model_validate(team_info.model_dump())
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_table)
organization_models: Final[list[str] | None] = (
_parent_organization_models(team_info)
if await _can_manage_team(team_obj=team_table, user_api_key_dict=user_api_key_dict)
else None
)
## GET ALL KEYS ##

View file

@ -14225,3 +14225,55 @@ async def test_team_info_returns_parent_organization_models(organization, expect
)
assert response["team_info"].organization_models == expected_models
@pytest.mark.parametrize(
"caller, expected_models",
[
(UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.INTERNAL_USER), ["gpt-4o"]),
(UserAPIKeyAuth(user_id="member-1", user_role=LitellmUserRoles.INTERNAL_USER), None),
(UserAPIKeyAuth(team_id="team-1"), None),
],
)
@pytest.mark.asyncio
async def test_team_info_reports_parent_organization_models_only_to_team_managers(caller, expected_models):
"""Plain members and team keys can read their team, but not the org's wider allow-list."""
from fastapi import Request
from litellm.proxy.management_endpoints import team_endpoints
team_row = _TeamRowWithOrganization(
team_id="team-1",
organization_id="org-1",
members_with_roles=[
Member(user_id="admin-1", role="admin"),
Member(user_id="member-1", role="user"),
],
litellm_organization_table=LiteLLM_OrganizationTable(
organization_id="org-1",
budget_id="budget-1",
models=["gpt-4o"],
created_by="admin",
updated_by="admin",
),
)
mock_prisma = MagicMock()
mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[])
mock_prisma.get_data = AsyncMock(return_value=[])
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: no seam on team_info
patch.object(team_endpoints, "get_all_team_memberships", AsyncMock(return_value=[])), # test-quality-ok: no seam on team_info
patch.object( # test-quality-ok: no seam on team_info
team_endpoints, "_is_user_org_admin_for_team", AsyncMock(return_value=False)
),
):
response = await team_endpoints.team_info(
http_request=MagicMock(spec=Request),
team_id="team-1",
user_api_key_dict=caller,
)
assert response["team_info"].organization_models == expected_models