Merge pull request #23938 from BerriAI/litellm_/sweet-austin

[Feature] /v2/team/list: Add org admin access control, members_count, and indexes
This commit is contained in:
yuneng-jiang 2026-03-17 23:01:41 -07:00 • committed by GitHub
commit d2e77e74ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 484 additions and 85 deletions

View file

@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management

View file

@ -101,6 +101,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
BulkTeamMemberAddRequest,
BulkTeamMemberAddResponse,
GetTeamMemberPermissionsResponse,
TeamListItem,
TeamListResponse,
TeamMemberAddResult,
UpdateTeamMemberPermissionsRequest,
@ -3206,6 +3207,40 @@ async def list_available_teams(
return available_teams_correct_type
async def _get_org_admin_org_ids(
user_id: str,
prisma_client: Any,
user_api_key_cache: Any,
proxy_logging_obj: Any,
) -> Optional[List[str]]:
"""
Return the list of organization IDs where the user is an org admin.
Returns None if the user is not an org admin of any organization or if
the user cannot be found.
"""
try:
caller_user = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
)
except ValueError:
# get_user_object raises ValueError when the user doesn't exist
return None
if caller_user is None:
return None
org_ids = [
m.organization_id
for m in (caller_user.organization_memberships or [])
if m.user_role == LitellmUserRoles.ORG_ADMIN.value
]
return org_ids if org_ids else None
async def _build_team_list_where_conditions(
prisma_client: PrismaClient,
team_id: Optional[str],
@ -3213,8 +3248,16 @@ async def _build_team_list_where_conditions(
organization_id: Optional[str],
user_id: Optional[str],
use_deleted_table: bool,
) -> Dict[str, Any]:
"""Build where conditions for team list query."""
org_admin_org_ids: Optional[List[str]] = None,
user_api_key_cache: Optional[Any] = None,
proxy_logging_obj: Optional[Any] = None,
) -> Optional[Dict[str, Any]]:
"""
Build where conditions for team list query.
Returns None when the query is guaranteed to yield no results (e.g. user
has no team memberships), allowing the caller to skip the DB round-trip.
"""
where_conditions: Dict[str, Any] = {}
if team_id:
@ -3228,61 +3271,58 @@ async def _build_team_list_where_conditions(
if organization_id:
where_conditions["organization_id"] = organization_id
elif org_admin_org_ids is not None and not user_id:
# Org admin without explicit org or user filter: scope to their orgs.
# NOTE: when user_id is provided, no org filter is applied — the
# query returns all teams the target user belongs to across all
# organisations. This matches the legacy /team/list behaviour in
# _authorize_and_filter_teams which fetches direct-membership teams
# without an org constraint.
where_conditions["organization_id"] = {"in": org_admin_org_ids}
if user_id:
try:
user_object = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_id}
user_object_correct_type = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
)
except Exception:
except ValueError:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
if user_object is None:
if user_object_correct_type is None:
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)
user_object_correct_type = LiteLLM_UserTable(**user_object.model_dump())
user_team_ids = user_object_correct_type.teams or []
if use_deleted_table:
where_conditions["members"] = {"has": user_id}
else:
if team_id is None:
where_conditions["team_id"] = {"in": user_object_correct_type.teams}
elif team_id in user_object_correct_type.teams:
where_conditions["team_id"] = team_id
# When user_id is provided, filter by that user's direct team
# memberships. For org admins the access control gate in
# list_team_v2 already verified the caller's authority — the
# filter logic is the same as for regular users.
if not user_team_ids:
return None # no memberships — skip the DB query
elif team_id is not None:
# team_id exact-match already in where_conditions; verify membership
if team_id not in user_team_ids:
raise HTTPException(
status_code=404,
detail={"error": f"User is not a member of team_id={team_id}"},
)
else:
raise HTTPException(
status_code=404,
detail={"error": f"User is not a member of team_id={team_id}"},
)
where_conditions["team_id"] = {"in": user_team_ids}
return where_conditions
def _convert_teams_to_response(
teams: List[Any], use_deleted_table: bool
) -> List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]:
"""Convert Prisma models to Pydantic models."""
team_list: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
if teams:
for team in teams:
# Convert Prisma model to dict (supports both Pydantic v1 and v2)
try:
team_dict = team.model_dump()
except Exception:
# Fallback for Pydantic v1 compatibility
team_dict = team.dict()
if use_deleted_table:
# Use deleted team type to preserve deleted_at, deleted_by, etc.
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
else:
team_list.append(LiteLLM_TeamTable(**team_dict))
return team_list
@router.get(
"/v2/team/list",
tags=["team management"],
@ -3347,7 +3387,11 @@ async def list_team_v2(
status: Optional[str]
Filter by status. Currently supports "deleted" to query deleted teams.
"""
from litellm.proxy.proxy_server import prisma_client
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise HTTPException(
@ -3355,20 +3399,55 @@ async def list_team_v2(
detail={"error": f"No db connected. prisma client={prisma_client}"},
)
if not allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
):
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
# --- Access control ---
# Proxy admins and admin viewers can query any teams.
# Org admins can query teams within their organizations.
# Regular users can only query their own teams.
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
org_admin_org_ids: Optional[List[str]] = None
if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
user_id = user_api_key_dict.user_id
if not is_proxy_admin:
# Always check org admin status so that even own-queries see
# the full set of organisation teams, not just direct memberships.
if user_api_key_dict.user_id:
org_admin_org_ids = await _get_org_admin_org_ids(
user_id=user_api_key_dict.user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if org_admin_org_ids is not None:
# Org admin: validate org_id filter if provided
if organization_id and organization_id not in org_admin_org_ids:
raise HTTPException(
status_code=403,
detail={
"error": "You can only view teams within your organizations."
},
)
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,
org_admin_org_ids,
user_id,
)
else:
# Not an org admin — fall back to standard route check
if not allowed_route_check_inside_route(
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
):
raise HTTPException(
status_code=401,
detail={
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
user_api_key_dict.user_role
)
},
)
# Regular user — auto-inject caller's user_id
if user_id is None:
user_id = user_api_key_dict.user_id
if status is not None and status != "deleted":
raise HTTPException(
@ -3383,7 +3462,8 @@ async def list_team_v2(
# Calculate skip and take for pagination
skip = (page - 1) * page_size
# Build where conditions based on provided parameters
# Build where conditions based on provided parameters.
# Returns None when the query is guaranteed to yield no results.
where_conditions = await _build_team_list_where_conditions(
prisma_client=prisma_client,
team_id=team_id,
@ -3391,8 +3471,20 @@ async def list_team_v2(
organization_id=organization_id,
user_id=user_id,
use_deleted_table=use_deleted_table,
org_admin_org_ids=org_admin_org_ids,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if where_conditions is None:
return {
"teams": [],
"total": 0,
"page": page,
"page_size": page_size,
"total_pages": 0,
}
# Build order_by conditions
valid_sort_columns = ["team_id", "team_alias", "created_at"]
order_by = None
@ -3428,8 +3520,23 @@ async def list_team_v2(
# Calculate total pages
total_pages = -(-total_count // page_size) # Ceiling division
# Convert Prisma models to Pydantic models, preserving deleted fields when applicable
team_list = _convert_teams_to_response(teams, use_deleted_table)
# Convert Prisma models to response models with members_count
team_list: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]] = []
for team in teams:
try:
team_dict = team.model_dump()
except Exception:
team_dict = team.dict()
if use_deleted_table:
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
else:
members_with_roles = team_dict.get("members_with_roles")
if not isinstance(members_with_roles, list):
members_with_roles = []
team_dict["members_with_roles"] = members_with_roles
members_count = len(members_with_roles)
team_list.append(TeamListItem(**team_dict, members_count=members_count))
return {
"teams": team_list,

View file

@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management

View file

@ -43,10 +43,16 @@ class UpdateTeamMemberPermissionsRequest(BaseModel):
team_member_permissions: List[str]
class TeamListItem(LiteLLM_TeamTable):
"""A team item in the paginated list response, enriched with computed fields."""
members_count: int = 0
class TeamListResponse(BaseModel):
"""Response to get the list of teams"""
teams: List[Union[LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
teams: List[Union[TeamListItem, LiteLLM_TeamTable, LiteLLM_DeletedTeamTable]]
total: int
page: int
page_size: int

View file

@ -146,6 +146,10 @@ model LiteLLM_TeamTable {
litellm_model_table LiteLLM_ModelTable? @relation(fields: [model_id], references: [id])
object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id])
projects LiteLLM_ProjectTable[]
@@index([organization_id])
@@index([team_alias])
@@index([created_at])
}
// Projects sit between teams and keys for use-case management

View file

@ -2062,7 +2062,14 @@ async def test_list_team_v2_security_check_non_admin_user():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
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=None,
):
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
# Should raise HTTPException with 401 status
@ -2103,7 +2110,14 @@ async def test_list_team_v2_security_check_non_admin_user_other_user():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
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=None,
):
mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client
# Should raise HTTPException with 401 status
@ -2142,19 +2156,21 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
user_id="non_admin_user_123",
)
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, \
patch("litellm.proxy.proxy_server.user_api_key_cache"), \
patch("litellm.proxy.proxy_server.proxy_logging_obj"):
# Mock prisma client and database operations
mock_db = Mock()
mock_prisma_client.db = mock_db
# Mock user lookup
mock_user_object = Mock()
mock_user_object.model_dump.return_value = {
"user_id": "non_admin_user_123",
"teams": ["team_1", "team_2"],
}
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user_object)
# Mock get_user_object to return a user with teams
from litellm.proxy._types import LiteLLM_UserTable
mock_user = LiteLLM_UserTable(
user_id="non_admin_user_123",
teams=["team_1", "team_2"],
)
# Mock team lookup
mock_teams = [
Mock(model_dump=lambda: {"team_id": "team_1", "team_alias": "Team 1"}),
@ -2163,21 +2179,26 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams():
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=mock_teams)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
# Should NOT raise an exception
result = await list_team_v2(
http_request=mock_request,
user_id="non_admin_user_123", # Non-admin querying their own teams
user_api_key_dict=mock_user_api_key_dict_non_admin,
team_id=None,
page=1,
page_size=10,
status=None,
)
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
return_value=mock_user,
):
# Should NOT raise an exception
result = await list_team_v2(
http_request=mock_request,
user_id="non_admin_user_123", # Non-admin querying their own teams
user_api_key_dict=mock_user_api_key_dict_non_admin,
team_id=None,
page=1,
page_size=10,
status=None,
)
# Should return results without error
assert "teams" in result
assert "total" in result
assert result["total"] == 2
# Should return results without error
assert "teams" in result
assert "total" in result
assert result["total"] == 2
@pytest.mark.asyncio
@ -2293,27 +2314,280 @@ async def test_list_team_v2_with_status_deleted():
assert len(result["teams"]) == 2
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_sees_org_teams():
"""
Test that an org admin (internal_user role with org_admin membership)
can list teams scoped to their organisations without getting a 401.
"""
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=[],
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 = Mock()
mock_team.model_dump.return_value = {
"team_id": "team_in_org_A",
"team_alias": "Org A Team",
"organization_id": "org_A",
"members_with_roles": [{"user_id": "u1", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
result = await list_team_v2(
http_request=mock_request,
user_id=None,
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"] == 1
assert len(result["teams"]) == 1
assert result["teams"][0].members_count == 1
# Verify org-scoped where clause
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["organization_id"] == {"in": ["org_A"]}
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_cannot_view_other_orgs():
"""
Test that an org admin is rejected with 403 when filtering by an
organisation they do not administer.
"""
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from fastapi import HTTPException, 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=[],
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_prisma.db = Mock()
with pytest.raises(HTTPException) as exc_info:
await list_team_v2(
http_request=mock_request,
user_id=None,
organization_id="org_B", # not their org
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 exc_info.value.status_code == 403
assert "only view teams within your organizations" in str(
exc_info.value.detail
).lower()
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_with_user_id_returns_user_teams():
"""
Test that an org admin passing user_id gets that user's direct team
memberships (not all org teams).
"""
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_org_admin = LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_1"],
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(),
),
],
)
# The target user whose teams we want to list
mock_target_user = LiteLLM_UserTable(
user_id="target_user",
teams=["team_X", "team_Y"],
)
call_count = 0
async def mock_get_user_object(**kwargs):
nonlocal call_count
call_count += 1
# First call: org admin lookup in list_team_v2
# Second call: target user lookup in _build_team_list_where_conditions
if call_count == 1:
return mock_org_admin
return mock_target_user
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",
side_effect=mock_get_user_object,
):
mock_db = Mock()
mock_prisma.db = mock_db
mock_team = Mock()
mock_team.model_dump.return_value = {
"team_id": "team_X",
"team_alias": "Target Team",
"members_with_roles": [{"user_id": "target_user", "role": "user"}],
}
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
result = await list_team_v2(
http_request=mock_request,
user_id="target_user",
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"] == 1
# Verify the where clause filters by user's teams, not org scope
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["team_id"] == {"in": ["team_X", "team_Y"]}
assert "organization_id" not in where
@pytest.mark.asyncio
async def test_list_team_v2_with_invalid_status():
"""
Test that invalid status parameter raises HTTPException.
"""
from unittest.mock import Mock, patch
from fastapi import HTTPException, Request
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
# Mock request
mock_request = Mock(spec=Request)
# Mock admin user
mock_user_api_key_dict_admin = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
user_id="admin_user_123",
)
mock_prisma_client = Mock()
# Mock prisma_client to be non-None