mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
commit
d2e77e74ee
6 changed files with 484 additions and 85 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue