From 5ace6fa731b0672db095ca64241bc2f4138305de Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 23:51:15 +0000 Subject: [PATCH] fix(proxy): match an id-only bulk member delete against a legacy email-only roster entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 29 ++++++++++------ .../test_team_bulk_member_delete.py | 33 ++++++++++++++++++- .../test_bulk_user_deletion.py | 21 ++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 53527c42c25..337b3cdeabc 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -169,6 +169,12 @@ def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool: return request.user_id == member.user_id or (member.user_id is None and _same_email(member.user_email, request)) +def _with_row_email(request: MemberDeleteRequest, email_of: Mapping[str, str]) -> MemberDeleteRequest: + if request.user_id is None or request.user_email is not None: + return request + return MemberDeleteRequest(user_id=request.user_id, user_email=email_of.get(request.user_id)) + + def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDeleteRequest) -> bool: if request.user_id is None: return _same_email(user.user_email, request) @@ -207,22 +213,25 @@ async def _remove_members_from_team( if roster is None: raise _team_not_found(team_id) - removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) - kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) - removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) requested_ids: Final = frozenset(r.user_id for r in members if r.user_id is not None) requested_emails: Final = frozenset(r.user_email for r in members if r.user_id is None and r.user_email) - user_rows: Final = await _user_tx_db(tx).find_many( - where=_any_filter( - _in_filter("user_id", removed_ids | requested_ids), - _in_filter("user_email", requested_emails), - ) + requested_rows: Final = await _user_tx_db(tx).find_many( + where=_any_filter(_in_filter("user_id", requested_ids), _in_filter("user_email", requested_emails)) ) - stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams) + email_of: Final = MappingProxyType({u.user_id: u.user_email for u in requested_rows if u.user_email is not None}) + requests: Final = tuple(_with_row_email(r, email_of) for r in members) + removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in requests)) + kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in requests)) + removed_ids: Final = frozenset(m.user_id for m in removed_members if m.user_id is not None) + unfetched_ids: Final = removed_ids - frozenset(u.user_id for u in requested_rows) + removed_rows: Final = ( + await _user_tx_db(tx).find_many(where=_in_filter("user_id", unfetched_ids)) if unfetched_ids else () + ) + stale_rows: Final = tuple(u for u in (*requested_rows, *removed_rows) if team_id in u.teams) cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows) matched: Final = frozenset( i - for i, r in enumerate(members) + for i, r in enumerate(requests) if any(_addresses_member(m, r) for m in removed_members) or any(_addresses_user(u, r) for u in stale_rows) ) keys: Final = await _token_tx_db(tx).find_many(where=_team_users_filter(team_id, cleanup_ids)) diff --git a/tests/proxy_behavior/management/test_team_bulk_member_delete.py b/tests/proxy_behavior/management/test_team_bulk_member_delete.py index 5283497c6bf..0818fb25dfe 100644 --- a/tests/proxy_behavior/management/test_team_bulk_member_delete.py +++ b/tests/proxy_behavior/management/test_team_bulk_member_delete.py @@ -1,7 +1,8 @@ import pytest +from prisma import Json from .actors import Actor -from .conftest import create_scratch_team +from .conftest import create_scratch_team, create_scratch_user pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -114,6 +115,36 @@ async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, p assert row is not None and _member_ids(row) == [keep] +async def test_team_bulk_member_delete_by_id_removes_a_legacy_email_only_roster_entry( + proxy_client, prisma, scratch, world +): + email = f"{scratch.prefix}@example.com" + victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim", user_email=email) + keep = scratch.tag("keep") + await prisma.db.litellm_teamtable.create( + data={ + "team_id": scratch.prefix, + "team_alias": scratch.prefix, + "organization_id": world.org_a_id, + "members_with_roles": Json([{"user_email": email, "role": "user"}, {"user_id": keep, "role": "user"}]), + } + ) + await prisma.db.litellm_usertable.update(where={"user_id": victim}, data={"teams": [scratch.prefix]}) + + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"members": [{"user_id": victim}]}, + ) + assert resp.status_code == 200, resp.text + assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(victim, True)] + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and [(m["user_id"], m.get("user_email")) for m in row.members_with_roles] == [(keep, None)] + user = await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) + assert user is not None and user.teams == [] + + async def test_team_bulk_member_delete_row_naming_both_identifiers_is_422(proxy_client, prisma, scratch, world): victim = scratch.tag("victim") await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) diff --git a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py index 077eb90ddce..6736f1de08b 100644 --- a/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py +++ b/tests/test_litellm/proxy/management_helpers/test_bulk_user_deletion.py @@ -589,6 +589,27 @@ async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_th assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0 +@pytest.mark.asyncio +async def test_bulk_member_delete_by_id_removes_the_members_email_only_roster_entry(): + team = LiteLLM_TeamTable( + team_id="t1", + members_with_roles=[ + Member(user_id=None, user_email="u1@example.com", role="user"), + Member(user_id="twin", user_email="u1@example.com", role="user"), + Member(user_id="keep", user_email="keep@example.com", role="user"), + ], + ) + twin = _UserRow(user_id="twin", user_email="u1@example.com", teams=["t1"]) + prisma = _FakePrisma(users=[_user("u1", "t1"), twin, _user("keep", "t1")], teams=[team]) + + results = await _remove(prisma, "t1", [{"user_id": "u1"}]) + + assert [(r.success, r.error) for r in results] == [(True, None)] + assert _roster(prisma, "t1") == ["twin", "keep"] + users = prisma.db.litellm_usertable.rows + assert users["u1"].teams == [] and users["twin"].teams == ["t1"] + + @pytest.mark.asyncio async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers(): prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")])