mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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:
parent
b0acda2825
commit
5ace6fa731
3 changed files with 72 additions and 11 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue