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>
This commit is contained in:
ryan 2026-09-14 23:51:15 +00:00
parent b0acda2825
commit 5ace6fa731
3 changed files with 72 additions and 11 deletions

View file

@ -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))

View file

@ -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])

View file

@ -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")])