mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
parent
9cdfe311e9
commit
2d3b63fb5a
3 changed files with 76 additions and 22 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue