From bd2502eeaf99111d17442929f68542639417719c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 20:34:15 -0700 Subject: [PATCH 1/5] [Feature] /v2/team/list: Add org admin access control, members_count, and indexes Add org admin support to /v2/team/list so org admins can list teams within their organizations instead of getting 401. Also enrich the response with members_count and add missing indexes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../litellm_proxy_extras/schema.prisma | 4 + .../management_endpoints/team_endpoints.py | 132 +++++++++++++++--- litellm/proxy/schema.prisma | 4 + .../management_endpoints/team_endpoints.py | 9 +- schema.prisma | 4 + 5 files changed, 136 insertions(+), 17 deletions(-) diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index ce79c2b3d52..a2c83295403 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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 diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d83ceb1b095..a7ee340282a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -101,6 +101,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberAddRequest, BulkTeamMemberAddResponse, GetTeamMemberPermissionsResponse, + TeamListItem, TeamListResponse, TeamMemberAddResult, UpdateTeamMemberPermissionsRequest, @@ -3206,6 +3207,39 @@ 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 Exception: + 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,6 +3247,7 @@ async def _build_team_list_where_conditions( organization_id: Optional[str], user_id: Optional[str], use_deleted_table: bool, + org_admin_org_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """Build where conditions for team list query.""" where_conditions: Dict[str, Any] = {} @@ -3227,7 +3262,14 @@ async def _build_team_list_where_conditions( } if organization_id: - where_conditions["organization_id"] = organization_id + # If a list is passed (org admin viewing multiple orgs), use IN + if isinstance(organization_id, list): + where_conditions["organization_id"] = {"in": organization_id} + else: + where_conditions["organization_id"] = organization_id + elif org_admin_org_ids is not None: + # Org admin without explicit org filter: scope to their orgs + where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: try: @@ -3347,7 +3389,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 +3401,58 @@ 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 not is_proxy_admin: + # First check if the standard route check passes (user querying own data) + passes_route_check = allowed_route_check_inside_route( + user_api_key_dict=user_api_key_dict, requested_user_id=user_id ) - 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 passes_route_check: + # Standard check failed — see if the caller is an org admin + 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 None: + # Not an org admin either — reject + 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 + ) + }, + ) + + 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_api_key_dict.user_id, + org_admin_org_ids, + ) + elif not is_proxy_admin: + # 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( @@ -3391,6 +3475,7 @@ 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, ) # Build order_by conditions @@ -3428,8 +3513,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, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index b68872e2ed8..46be6b31e1f 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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 diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 77816fa78cc..a69d69c6230 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel @@ -43,10 +44,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 diff --git a/schema.prisma b/schema.prisma index 939f1eb0f45..fde9a466a28 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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 From 5e2fb72f42ef10e12e8d29e9120398db9b395fd2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 20:50:58 -0700 Subject: [PATCH 2/5] fix: address review feedback on v2/team/list - Fix org admin own-query regression: always check org admin status before the standard route check so own-queries see all org teams - Clear user_id when org admin is detected so org scope replaces user-membership scope - Remove dead isinstance(organization_id, list) branch - Remove unused datetime import - Remove orphaned _convert_teams_to_response helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 79 +++++++------------ .../management_endpoints/team_endpoints.py | 1 - 2 files changed, 27 insertions(+), 53 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index a7ee340282a..35122259c3c 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3262,11 +3262,7 @@ async def _build_team_list_where_conditions( } if organization_id: - # If a list is passed (org admin viewing multiple orgs), use IN - if isinstance(organization_id, list): - where_conditions["organization_id"] = {"in": organization_id} - else: - where_conditions["organization_id"] = organization_id + where_conditions["organization_id"] = organization_id elif org_admin_org_ids is not None: # Org admin without explicit org filter: scope to their orgs where_conditions["organization_id"] = {"in": org_admin_org_ids} @@ -3304,27 +3300,6 @@ async def _build_team_list_where_conditions( 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"], @@ -3409,31 +3384,15 @@ async def list_team_v2( org_admin_org_ids: Optional[List[str]] = None if not is_proxy_admin: - # First check if the standard route check passes (user querying own data) - passes_route_check = allowed_route_check_inside_route( - user_api_key_dict=user_api_key_dict, requested_user_id=user_id - ) - - if not passes_route_check: - # Standard check failed — see if the caller is an org admin - 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 None: - # Not an org admin either — reject - 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 - ) - }, - ) + # 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 @@ -3444,12 +3403,28 @@ async def list_team_v2( "error": "You can only view teams within your organizations." }, ) + # Org admin scope replaces user_id scope — the org filter + # already returns all teams in their orgs, so we don't also + # restrict by the user's direct team memberships. + user_id = None verbose_proxy_logger.debug( "list_team_v2: org admin access for user=%s, org_ids=%s", user_api_key_dict.user_id, org_admin_org_ids, ) - elif not is_proxy_admin: + 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 diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index a69d69c6230..5055a65783f 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel From 1998571d94b366c9525f84ac96622242f4a16353 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:03:35 -0700 Subject: [PATCH 3/5] fix: address second review round on v2/team/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _get_org_admin_org_ids: catch only ValueError (user not found) instead of bare Exception — DB errors now propagate as 500s instead of silently demoting org admins to regular users - _build_team_list_where_conditions: return None (not a sentinel string) when user has no team memberships; list_team_v2 short-circuits to empty response without hitting the DB - Org admin + team_id + user_id: use exact team_id match with org scope instead of OR expansion that effectively ignored the team_id filter - Org admin + user_id (no team_id): OR(org teams, direct memberships) now matches legacy _authorize_and_filter_teams behaviour Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 82 ++++++++++++------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 35122259c3c..b32206a4542 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3226,7 +3226,8 @@ async def _get_org_admin_org_ids( user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) - except Exception: + except ValueError: + # get_user_object raises ValueError when the user doesn't exist return None if caller_user is None: @@ -3248,8 +3249,13 @@ async def _build_team_list_where_conditions( user_id: Optional[str], use_deleted_table: bool, org_admin_org_ids: Optional[List[str]] = None, -) -> Dict[str, Any]: - """Build where conditions for team list query.""" +) -> 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: @@ -3263,39 +3269,50 @@ async def _build_team_list_where_conditions( if organization_id: where_conditions["organization_id"] = organization_id - elif org_admin_org_ids is not None: - # Org admin without explicit org filter: scope to their orgs + elif org_admin_org_ids is not None and not user_id: + # Org admin without explicit org or user filter: scope to their orgs 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} - ) - except Exception: - raise HTTPException( - status_code=404, - detail={"error": f"User not found, passed user_id={user_id}"}, - ) + user_object = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) if user_object 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 + elif org_admin_org_ids is not None: + # Org admin with user_id filter: show teams in their orgs + # OR teams the user is a direct member of (matches legacy + # _authorize_and_filter_teams behaviour). + # When team_id is also provided, the exact match is already in + # where_conditions and the org scope just needs to be added — + # no OR expansion needed. + if team_id is not None: + where_conditions["organization_id"] = {"in": org_admin_org_ids} + elif user_team_ids: + org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}} + where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}] else: - raise HTTPException( - status_code=404, - detail={"error": f"User is not a member of team_id={team_id}"}, - ) + where_conditions["organization_id"] = {"in": org_admin_org_ids} + else: + 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: + where_conditions["team_id"] = {"in": user_team_ids} return where_conditions @@ -3403,14 +3420,11 @@ async def list_team_v2( "error": "You can only view teams within your organizations." }, ) - # Org admin scope replaces user_id scope — the org filter - # already returns all teams in their orgs, so we don't also - # restrict by the user's direct team memberships. - user_id = None verbose_proxy_logger.debug( - "list_team_v2: org admin access for user=%s, org_ids=%s", + "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 @@ -3442,7 +3456,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, @@ -3453,6 +3468,15 @@ async def list_team_v2( org_admin_org_ids=org_admin_org_ids, ) + 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 From 0485a1859a94520ff55148aeab313a102d2b50be Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:19:42 -0700 Subject: [PATCH 4/5] fix: use get_user_object helper, preserve caller org_id filter - Replace raw find_unique with get_user_object in _build_team_list_where_conditions for cache/metrics consistency - Remove over-complex OR clause for org admin + user_id: when user_id is provided, filter by that user's direct team memberships (same as regular users) since the access control gate already verified the org admin's authority - Preserve caller-supplied organization_id instead of overwriting with org_admin_org_ids - Update test mock to match get_user_object call path Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 41 +++++++------- .../test_team_endpoints.py | 55 +++++++++++-------- 2 files changed, 53 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index b32206a4542..531cd675892 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3249,6 +3249,8 @@ async def _build_team_list_where_conditions( user_id: Optional[str], use_deleted_table: bool, 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. @@ -3274,34 +3276,33 @@ async def _build_team_list_where_conditions( where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: - user_object = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_id} - ) - if user_object is None: + try: + 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 ValueError: + raise HTTPException( + status_code=404, + detail={"error": f"User not found, passed user_id={user_id}"}, + ) + 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} - elif org_admin_org_ids is not None: - # Org admin with user_id filter: show teams in their orgs - # OR teams the user is a direct member of (matches legacy - # _authorize_and_filter_teams behaviour). - # When team_id is also provided, the exact match is already in - # where_conditions and the org scope just needs to be added — - # no OR expansion needed. - if team_id is not None: - where_conditions["organization_id"] = {"in": org_admin_org_ids} - elif user_team_ids: - org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}} - where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}] - else: - where_conditions["organization_id"] = {"in": org_admin_org_ids} else: + # 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: @@ -3466,6 +3467,8 @@ async def list_team_v2( 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: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 41a724c271d..122ac749f2b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2142,19 +2142,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 +2165,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 From 41a7747e8cc99b133dd72ac6de6f55aaa9708e0d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 17 Mar 2026 21:56:57 -0700 Subject: [PATCH 5/5] fix: document org scope behavior, fix test mocks, add org admin tests - Document intentional legacy-matching behavior: when user_id is provided to an org admin, no org filter is applied (returns all of that user's teams across all orgs, same as legacy endpoint) - Fix two existing security tests to properly patch user_api_key_cache, proxy_logging_obj, and get_user_object instead of relying on incidental error handling - Add three new org admin test cases: - Org admin sees org-scoped teams (200 with correct where clause) - Org admin rejected when filtering by other org (403) - Org admin with user_id filter returns target user's teams Co-Authored-By: Claude Opus 4.6 (1M context) --- .../management_endpoints/team_endpoints.py | 7 +- .../test_team_endpoints.py | 281 +++++++++++++++++- 2 files changed, 280 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 531cd675892..e7cd6c1748f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3272,7 +3272,12 @@ 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 + # 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: diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 122ac749f2b..c325b0b6fce 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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 @@ -2300,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