diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 066c685c117..65bac09151a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3839,6 +3839,28 @@ async def _get_org_admin_org_ids( return org_ids if org_ids else None +def _apply_org_admin_membership_union( + where_conditions: dict[str, Any], + org_admin_org_ids: Optional[list[str]], + user_team_ids: list[str], +) -> None: + """Union (teams in the admin's orgs) with (teams the caller is a member of). + + Mutates ``where_conditions`` in place for a self/bare org-admin query so + teams in orgs where the caller is only a member stay visible (LIT-3723). + An org admin with no memberships still sees their org teams, so this never + collapses to an empty result. + """ + union_or: list[dict[str, Any]] = [{"organization_id": {"in": org_admin_org_ids}}] + if user_team_ids: + union_or.append({"team_id": {"in": user_team_ids}}) + existing_or = where_conditions.pop("OR", None) # set above from `search` + if existing_or is not None: + where_conditions["AND"] = [{"OR": existing_or}, {"OR": union_or}] + else: + where_conditions["OR"] = union_or + + async def _build_team_list_where_conditions( prisma_client: PrismaClient, team_id: Optional[str], @@ -3917,14 +3939,7 @@ async def _build_team_list_where_conditions( # of), so teams in orgs where I'm only a member stay visible # (LIT-3723). An org admin with no memberships must still see org # teams, so do NOT early-return None here. - union_or: List[Dict[str, Any]] = [{"organization_id": {"in": org_admin_org_ids}}] - if user_team_ids: - union_or.append({"team_id": {"in": user_team_ids}}) - existing_or = where_conditions.pop("OR", None) # set above from `search` - if existing_or is not None: - where_conditions["AND"] = [{"OR": existing_or}, {"OR": union_or}] - else: - where_conditions["OR"] = union_or + _apply_org_admin_membership_union(where_conditions, org_admin_org_ids, user_team_ids) else: # When user_id is provided, filter by that user's direct team # memberships. For org admins the access control gate in 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 5f3974b46fb..96776cf4782 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -3137,6 +3137,7 @@ async def test_list_team_v2_org_admin_sees_org_teams(): organization_id=None, team_id=None, team_alias=None, + search=None, user_api_key_dict=mock_user_api_key_dict, page=1, page_size=10, @@ -3149,9 +3150,12 @@ async def test_list_team_v2_org_admin_sees_org_teams(): assert len(result["teams"]) == 1 assert result["teams"][0].members_count == 1 - # Verify org-scoped where clause + # Bare org-admin query is scoped to the admin's org via the union + # branch (LIT-3723). With no direct memberships the union collapses to + # the org-scope branch only. where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"] - assert where["organization_id"] == {"in": ["org_A"]} + assert where["OR"] == [{"organization_id": {"in": ["org_A"]}}] + assert "organization_id" not in where @pytest.mark.asyncio @@ -3237,6 +3241,7 @@ async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams(): organization_id=None, team_id=None, team_alias=None, + search=None, user_api_key_dict=mock_user_api_key_dict, page=1, page_size=10, @@ -3248,9 +3253,18 @@ async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams(): assert result["total"] == 2 assert len(result["teams"]) == 2 - # Verify the where clause scopes by org only — no team_id filter + # Self/bare org-admin query unions org-scoped teams with the caller's + # own memberships (LIT-3723). The org-scope branch still guarantees the + # admin sees *all* teams in their org (regression #30215); the team_id + # branch only widens the result and never clamps it. No top-level + # organization_id/team_id key that could hide an org team the admin + # isn't a direct member of. where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"] - assert where["organization_id"] == {"in": ["org_A"]} + assert where["OR"] == [ + {"organization_id": {"in": ["org_A"]}}, + {"team_id": {"in": ["team_1"]}}, + ] + assert "organization_id" not in where assert "team_id" not in where