Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions litellm/proxy/management_endpoints/team_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4244,6 +4244,14 @@ async def _enforce_list_team_v2_access(
status_code=403,
detail={"error": "You can only view teams within your organizations."},
)
# When the caller is an org admin querying their own teams (or no
# specific user), null out user_id so that
# _build_team_list_where_conditions scopes only by organization_id
# — org admins should see all teams in their orgs, not just teams
# they are a direct member of. Keep user_id when the org admin
# explicitly queries a *different* user's teams.
if user_id is None or user_id == user_api_key_dict.user_id:
user_id = None
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
user_api_key_dict.user_id,
Expand Down
100 changes: 100 additions & 0 deletions tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -3063,6 +3063,106 @@ async def test_list_team_v2_org_admin_sees_org_teams():
assert where["organization_id"] == {"in": ["org_A"]}


@pytest.mark.asyncio
async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams():
"""
Test that an org admin whose own user_id is sent (as the UI does for
non-Admin roles) still sees all teams in their organization, not just
teams they are a direct member of.

Regression test for https://github.com/BerriAI/litellm/issues/30215
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch

from fastapi import Request

from litellm.proxy._types import (
LiteLLM_OrganizationMembershipTable,
LiteLLM_UserTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2

mock_request = Mock(spec=Request)
mock_user_api_key_dict = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER,
user_id="org_admin_user",
)

mock_user = LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_1"], # direct member of only 1 team
organization_memberships=[
LiteLLM_OrganizationMembershipTable(
user_id="org_admin_user",
organization_id="org_A",
user_role="org_admin",
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
),
],
)

with (
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
patch("litellm.proxy.proxy_server.user_api_key_cache"),
patch("litellm.proxy.proxy_server.proxy_logging_obj"),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
),
):
mock_db = Mock()
mock_prisma.db = mock_db

mock_team_1 = Mock()
mock_team_1.model_dump.return_value = {
"team_id": "team_1",
"team_alias": "Team One",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "org_admin_user", "role": "admin"}],
}
mock_team_2 = Mock()
mock_team_2.model_dump.return_value = {
"team_id": "team_2",
"team_alias": "Team Two",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "other_user", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(
return_value=[mock_team_1, mock_team_2]
)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])

# UI sends the caller's own user_id for non-Admin roles
result = await list_team_v2(
http_request=mock_request,
user_id="org_admin_user", # same as caller — UI sends this
organization_id=None,
team_id=None,
team_alias=None,
user_api_key_dict=mock_user_api_key_dict,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)

assert result["total"] == 2
assert len(result["teams"]) == 2

# Verify the where clause scopes by org only — no team_id filter
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["organization_id"] == {"in": ["org_A"]}
assert "team_id" not in where


@pytest.mark.asyncio
async def test_list_team_v2_org_admin_cannot_view_other_orgs():
"""
Expand Down