From 98ea14758fd670809d7bc0c61cd636e3db479c7d Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 15 Sep 2026 00:09:57 +0000 Subject: [PATCH] fix(proxy): only widen an id-only bulk member delete to its email when the user lists the team Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_helpers/bulk_user_deletion.py | 4 +++- .../test_bulk_user_deletion.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 337b3cdeabc..1ae83b0004a 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -218,7 +218,9 @@ async def _remove_members_from_team( 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)) ) - email_of: Final = MappingProxyType({u.user_id: u.user_email for u in requested_rows if u.user_email is not None}) + email_of: Final = MappingProxyType( + {u.user_id: u.user_email for u in requested_rows if u.user_email is not None and team_id in u.teams} + ) 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)) 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 6736f1de08b..fc972ccbb75 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 @@ -610,6 +610,26 @@ async def test_bulk_member_delete_by_id_removes_the_members_email_only_roster_en assert users["u1"].teams == [] and users["twin"].teams == ["t1"] +@pytest.mark.asyncio +async def test_bulk_member_delete_by_id_of_a_non_member_leaves_a_same_email_users_roster_entry(): + team = LiteLLM_TeamTable( + team_id="t1", + members_with_roles=[ + Member(user_id=None, user_email="shared@example.com", role="user"), + Member(user_id="keep", user_email="keep@example.com", role="user"), + ], + ) + outsider = _UserRow(user_id="outsider", user_email="shared@example.com", teams=[]) + member = _UserRow(user_id="member", user_email="shared@example.com", teams=["t1"]) + prisma = _FakePrisma(users=[outsider, member, _user("keep", "t1")], teams=[team]) + + results = await _remove(prisma, "t1", [{"user_id": "outsider"}]) + + assert [(r.success, r.error) for r in results] == [(False, "User not found in team")] + assert _roster(prisma, "t1") == [None, "keep"] + assert prisma.db.litellm_usertable.rows["member"].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")])