From b4b6f585c5417aa9860c370056b2940f295d7c28 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Mon, 29 Jun 2026 19:55:46 +0200 Subject: [PATCH 1/2] fix(proxy): keep org admins' team memberships in other orgs visible --- .../management_endpoints/team_endpoints.py | 73 ++++- .../test_team_list_org_admin_scope.py | 301 ++++++++++++++++++ 2 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_team_list_org_admin_scope.py diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0ea2b9e05f9..066c685c117 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3848,6 +3848,7 @@ async def _build_team_list_where_conditions( use_deleted_table: bool, search: Optional[str] = None, org_admin_org_ids: Optional[List[str]] = None, + union_org_admin_membership: bool = False, user_api_key_cache: Optional[Any] = None, proxy_logging_obj: Optional[Any] = None, ) -> Optional[Dict[str, Any]]: @@ -3874,10 +3875,18 @@ async def _build_team_list_where_conditions( {"team_alias": {"contains": search, "mode": "insensitive"}}, ] + # Self/bare org-admin query: union org teams with the caller's own + # memberships (LIT-3723) instead of intersecting. Gated off when an + # explicit org filter is supplied (it takes precedence) or for the + # deleted-table path (keeps current behavior). + org_admin_union = ( + union_org_admin_membership and org_admin_org_ids is not None and not organization_id and not use_deleted_table + ) + if organization_id: where_conditions["organization_id"] = organization_id - elif org_admin_org_ids is not None: - # Org admin: always scope to their orgs, even when filtering by user_id. + elif org_admin_org_ids is not None and not org_admin_union: + # Org admin cross-user query: scope strictly to their orgs. where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: @@ -3903,6 +3912,19 @@ async def _build_team_list_where_conditions( if use_deleted_table: where_conditions["members"] = {"has": user_id} + elif org_admin_union: + # Self/bare org admin: (teams in my orgs) OR (teams I'm a member + # 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 else: # When user_id is provided, filter by that user's direct team # memberships. For org admins the access control gate in @@ -4015,20 +4037,24 @@ async def _enforce_list_team_v2_access( prisma_client: Any, user_api_key_cache: Any, proxy_logging_obj: Any, -) -> Tuple[Optional[str], Optional[List[str]]]: +) -> Tuple[Optional[str], Optional[List[str]], bool]: """Enforce access control for list_team_v2. - 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. - Returns the (possibly overridden) user_id and org_admin_org_ids. + Returns the (possibly overridden) user_id, org_admin_org_ids, and + union_org_admin_membership — a flag set for a self/bare org-admin query + that tells the where-builder to union org teams with the caller's own + memberships instead of intersecting them. """ is_proxy_admin = _user_has_admin_view(user_api_key_dict) org_admin_org_ids: Optional[List[str]] = None + union_org_admin_membership = False if is_proxy_admin: - return user_id, org_admin_org_ids + return user_id, org_admin_org_ids, union_org_admin_membership # Always check org admin status so that even own-queries see # the full set of organisation teams, not just direct memberships. @@ -4047,14 +4073,17 @@ async def _enforce_list_team_v2_access( status_code=403, detail={"error": "You can only view teams within your organizations."}, ) - # When the caller is an org admin querying their own teams (or no - # specific user), null out user_id so that - # _build_team_list_where_conditions scopes only by organization_id - # — org admins should see all teams in their orgs, not just teams - # they are a direct member of. Keep user_id when the org admin - # explicitly queries a *different* user's teams. + # Self/bare org-admin query: union the admin's org teams with the + # caller's own memberships, so teams in orgs where they are only a + # member (not org admin) stay visible (LIT-3723). Keep user_id = + # caller for the membership lookup instead of nulling it; the + # where-builder unions on the flag. Keep user_id (and leave the flag + # False) when the org admin explicitly queries a *different* user's + # teams — that path stays org-scoped (intersection), the #25904 + # cross-user boundary. if user_id is None or user_id == user_api_key_dict.user_id: - user_id = None + user_id = user_api_key_dict.user_id + union_org_admin_membership = True 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, @@ -4076,7 +4105,7 @@ async def _enforce_list_team_v2_access( if user_id is None: user_id = user_api_key_dict.user_id - return user_id, org_admin_org_ids + return user_id, org_admin_org_ids, union_org_admin_membership @router.get( @@ -4152,7 +4181,7 @@ async def list_team_v2( ) # --- Access control --- - user_id, org_admin_org_ids = await _enforce_list_team_v2_access( + user_id, org_admin_org_ids, union_org_admin_membership = await _enforce_list_team_v2_access( user_api_key_dict=user_api_key_dict, user_id=user_id, organization_id=organization_id, @@ -4183,6 +4212,7 @@ async def list_team_v2( use_deleted_table=use_deleted_table, search=search, org_admin_org_ids=org_admin_org_ids, + union_org_admin_membership=union_org_admin_membership, user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) @@ -4314,14 +4344,25 @@ async def _authorize_and_filter_teams( ) if allowed_org_ids is not None: - # Org admin: query DB for teams in their orgs + if user_id and is_own_query: + # Self-query: return every team the caller is a member of, across + # ALL orgs. Org-admin status must not clamp a user's own + # membership list to their admin orgs, or teams in orgs where they + # are only a member disappear (LIT-3723). + response = await TeamRepository(prisma_client).table.find_many(include={"litellm_model_table": True}) + return [ + team + for team in response + if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles) + ] + # Org admin bare or cross-user query: query DB for teams in their orgs org_teams = await TeamRepository(prisma_client).table.find_many( where={"organization_id": {"in": allowed_org_ids}}, include={"litellm_model_table": True}, ) if not user_id: return list(org_teams) - # Filter org teams to only those where the target user is a member + # Cross-user query: keep the org boundary (intersection) — #25904. return [ team for team in org_teams diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_list_org_admin_scope.py b/tests/test_litellm/proxy/management_endpoints/test_team_list_org_admin_scope.py new file mode 100644 index 00000000000..475c8ae8a43 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_list_org_admin_scope.py @@ -0,0 +1,301 @@ +""" +Tests for org-admin team-list scoping (LIT-3723). + +A user who is org admin of one org must still see teams they belong to in +*other* orgs (where they are only a member). PR #25904 clamped a self-query to +the caller's org-admin orgs, dropping those memberships. The fix is strictly +additive: a self/bare org-admin query unions org teams with the caller's own +memberships, while a cross-user org-admin query keeps the org-boundary +intersection (#25904). + +These tests are self-contained — they mock `get_user_object`, `TeamRepository`, +and the access-gate helpers rather than touching the shared seed fixture in +tests/proxy_behavior/management/ (which ~8 suites parametrize over). +""" + +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_UserTable, + LitellmUserRoles, + UserAPIKeyAuth, +) + +_MOD = "litellm.proxy.management_endpoints.team_endpoints" +_NOW = datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + + +class _FakeTeam: + """Lightweight stand-in for a Prisma team row. + + `members_with_roles` is a list of plain dicts so `m.get("user_id")` (the + production filter) works without pydantic coercion. + """ + + def __init__(self, team_id, organization_id, member_user_ids): + self.team_id = team_id + self.organization_id = organization_id + self.members_with_roles = [{"user_id": uid, "role": "user"} for uid in member_user_ids] + + +def _user_with_teams(user_id="x", teams=None) -> LiteLLM_UserTable: + return LiteLLM_UserTable(user_id=user_id, teams=teams or []) + + +def _org_admin_caller(user_id="x", org_id="org-a") -> LiteLLM_UserTable: + return LiteLLM_UserTable( + user_id=user_id, + organization_memberships=[ + LiteLLM_OrganizationMembershipTable( + user_id=user_id, + organization_id=org_id, + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=_NOW, + updated_at=_NOW, + ) + ], + ) + + +def _internal_key(user_id="x") -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER.value) + + +def _make_team_repo(all_teams): + """Return a TeamRepository factory whose `.table.find_many` honors `where`. + + No `where` (self-query full scan) returns every team; an + `organization_id: {in: [...]}` filter returns only teams in those orgs. + """ + + async def _find_many(*args, **kwargs): + where = kwargs.get("where") + if not where: + return list(all_teams) + org_ids = where.get("organization_id", {}).get("in", []) + return [t for t in all_teams if t.organization_id in org_ids] + + instance = MagicMock() + instance.table.find_many = AsyncMock(side_effect=_find_many) + return MagicMock(return_value=instance) + + +async def _build_where(**overrides): + from litellm.proxy.management_endpoints.team_endpoints import ( + _build_team_list_where_conditions, + ) + + kwargs = dict( + prisma_client=MagicMock(), + team_id=None, + team_alias=None, + organization_id=None, + user_id="x", + use_deleted_table=False, + search=None, + org_admin_org_ids=["org-a"], + union_org_admin_membership=True, + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + kwargs.update(overrides) + return await _build_team_list_where_conditions(**kwargs) + + +# --------------------------------------------------------------------------- +# _build_team_list_where_conditions — union semantics +# --------------------------------------------------------------------------- + + +class TestBuildWhereOrgAdminUnion: + @pytest.mark.asyncio + async def test_self_query_emits_union(self): + """Self/bare org admin → OR(org teams, memberships), no sibling org clamp.""" + user = _user_with_teams("x", ["team-2"]) # team-2 lives in org-b + with patch(f"{_MOD}.get_user_object", new_callable=AsyncMock, return_value=user): + where = await _build_where() + + assert where is not None + assert where["OR"] == [ + {"organization_id": {"in": ["org-a"]}}, + {"team_id": {"in": ["team-2"]}}, + ] + # No sibling organization_id clamp — that would re-AND the intersection. + assert "organization_id" not in where + + @pytest.mark.asyncio + async def test_self_query_no_memberships_keeps_org_teams(self): + """Org admin with no memberships still sees org teams; does NOT return None.""" + user = _user_with_teams("x", []) + with patch(f"{_MOD}.get_user_object", new_callable=AsyncMock, return_value=user): + where = await _build_where() + + assert where is not None + assert where["OR"] == [{"organization_id": {"in": ["org-a"]}}] + + @pytest.mark.asyncio + async def test_cross_user_query_keeps_intersection(self): + """union flag off → sibling org clamp AND membership filter (the #25904 boundary).""" + target = _user_with_teams("target", ["team-2"]) + with patch(f"{_MOD}.get_user_object", new_callable=AsyncMock, return_value=target): + where = await _build_where(user_id="target", union_org_admin_membership=False) + + assert where is not None + assert where["organization_id"] == {"in": ["org-a"]} + assert where["team_id"] == {"in": ["team-2"]} + assert "OR" not in where + + @pytest.mark.asyncio + async def test_search_co_exists_with_union(self): + """search OR and union OR are both preserved, ANDed at the top level.""" + user = _user_with_teams("x", ["team-2"]) + with patch(f"{_MOD}.get_user_object", new_callable=AsyncMock, return_value=user): + where = await _build_where(search="foo") + + assert where is not None + assert where["AND"] == [ + { + "OR": [ + {"team_id": "foo"}, + {"team_alias": {"contains": "foo", "mode": "insensitive"}}, + ] + }, + { + "OR": [ + {"organization_id": {"in": ["org-a"]}}, + {"team_id": {"in": ["team-2"]}}, + ] + }, + ] + assert "OR" not in where + + +# --------------------------------------------------------------------------- +# _enforce_list_team_v2_access — flag + boundary gates +# --------------------------------------------------------------------------- + + +class TestEnforceListTeamV2Access: + async def _call(self, user_id, organization_id=None, org_admin_org_ids=["org-a"]): + from litellm.proxy.management_endpoints.team_endpoints import ( + _enforce_list_team_v2_access, + ) + + with patch( + f"{_MOD}._get_org_admin_org_ids", + new_callable=AsyncMock, + return_value=org_admin_org_ids, + ): + return await _enforce_list_team_v2_access( + user_api_key_dict=_internal_key("x"), + user_id=user_id, + organization_id=organization_id, + prisma_client=MagicMock(), + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + + @pytest.mark.asyncio + async def test_bare_self_query_sets_flag(self): + uid, orgs, union = await self._call(user_id=None) + assert (uid, orgs, union) == ("x", ["org-a"], True) + + @pytest.mark.asyncio + async def test_explicit_self_query_sets_flag(self): + uid, orgs, union = await self._call(user_id="x") + assert (uid, orgs, union) == ("x", ["org-a"], True) + + @pytest.mark.asyncio + async def test_cross_user_query_clears_flag(self): + uid, orgs, union = await self._call(user_id="other") + assert (uid, orgs, union) == ("other", ["org-a"], False) + + @pytest.mark.asyncio + async def test_foreign_org_filter_is_403(self): + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc: + await self._call(user_id=None, organization_id="org-z") + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + async def test_non_org_admin_bare_is_401(self): + from fastapi import HTTPException + + with patch(f"{_MOD}.allowed_route_check_inside_route", return_value=False): + with pytest.raises(HTTPException) as exc: + await self._call(user_id=None, org_admin_org_ids=None) + assert exc.value.status_code == 401 + + +# --------------------------------------------------------------------------- +# _authorize_and_filter_teams (v1) — self-query union vs cross-user intersection +# --------------------------------------------------------------------------- + + +class TestAuthorizeAndFilterTeams: + async def _call(self, user_id, all_teams): + from litellm.proxy.management_endpoints.team_endpoints import ( + _authorize_and_filter_teams, + ) + + caller = _org_admin_caller("x", "org-a") + with ( + patch(f"{_MOD}.get_user_object", new_callable=AsyncMock, return_value=caller), + patch(f"{_MOD}.TeamRepository", _make_team_repo(all_teams)), + ): + return await _authorize_and_filter_teams( + user_api_key_dict=_internal_key("x"), + user_id=user_id, + prisma_client=MagicMock(), + user_api_key_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + ) + + @pytest.mark.asyncio + async def test_self_query_includes_cross_org_membership(self): + """Org admin of org-a, member of a team in org-b, self-query → both teams.""" + team_a = _FakeTeam("team-1", "org-a", member_user_ids=["x"]) + team_b = _FakeTeam("team-2", "org-b", member_user_ids=["x"]) + other = _FakeTeam("team-3", "org-c", member_user_ids=["someone-else"]) + + result = await self._call(user_id="x", all_teams=[team_a, team_b, other]) + + ids = {t.team_id for t in result} + assert ids == {"team-1", "team-2"} # org-b membership survives + + @pytest.mark.asyncio + async def test_cross_user_query_keeps_org_boundary(self): + """Cross-user query → only org-a teams the target is a member of.""" + team_a = _FakeTeam("team-1", "org-a", member_user_ids=["target"]) + team_b = _FakeTeam("team-2", "org-b", member_user_ids=["target"]) + + result = await self._call(user_id="target", all_teams=[team_a, team_b]) + + ids = {t.team_id for t in result} + assert ids == {"team-1"} # team-2 (org-b) is NOT leaked + + @pytest.mark.asyncio + async def test_bare_query_returns_all_org_teams(self): + """Bare org-admin query (no user_id) → every team in the admin's orgs.""" + team_a1 = _FakeTeam("team-1", "org-a", member_user_ids=["someone"]) + team_a2 = _FakeTeam("team-2", "org-a", member_user_ids=[]) + team_b = _FakeTeam("team-3", "org-b", member_user_ids=["x"]) + + result = await self._call(user_id=None, all_teams=[team_a1, team_a2, team_b]) + + ids = {t.team_id for t in result} + assert ids == {"team-1", "team-2"} # all org-a teams, no org-b From 919923d2bde2726089bbab9bd1000c04ae9c8f59 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Mon, 29 Jun 2026 21:24:21 +0200 Subject: [PATCH 2/2] fix(proxy): reduce team-list where-builder complexity and update org-admin scope tests --- .../management_endpoints/team_endpoints.py | 31 ++++++++++++++----- .../test_team_endpoints.py | 22 ++++++++++--- 2 files changed, 41 insertions(+), 12 deletions(-) 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