Merge pull request #38703 from BerriAI/litellm_fix_stale_team_on_user_row

fix(team_endpoints): let member_delete clear a team left on the user row
This commit is contained in:
ryan-crabbe-berri 2026-09-04 18:48:07 -07:00 committed by GitHub
commit 53d7b45e87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 322 additions and 19 deletions

View file

@ -3326,9 +3326,6 @@ async def team_member_delete(
data=data,
)
if not removed_team_members:
raise HTTPException(status_code=400, detail={"error": "User not found in team"})
existing_team_row.members_with_roles = new_team_members
_db_new_team_members: Final[list[dict]] = [m.model_dump() for m in new_team_members]
@ -3336,17 +3333,27 @@ async def team_member_delete(
## DELETE TEAM ID from USER ROW, IF EXISTS ##
# get user row
removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None)
addressed_user_ids: Final = (
removed_user_ids if removed_team_members else frozenset((data.user_id,) if data.user_id is not None else ())
)
key_val: Final[Mapping[str, object]] = (
{"user_id": {"in": sorted(removed_user_ids)}} if removed_user_ids else {"user_email": data.user_email}
{"user_id": {"in": sorted(addressed_user_ids)}} if addressed_user_ids else {"user_email": data.user_email}
)
member_tx: Final[_MemberDeleteTx] = tx
existing_user_rows: Final = await member_tx.litellm_usertable.find_many(where=key_val)
# Also clean up any existing team membership rows for this user and team
user_ids_to_delete: Final = removed_user_ids.union(
(data.user_id,) if data.user_id is not None else (),
(user.user_id for user in existing_user_rows if user.user_id),
)
# A user row can outlive its roster entry, and until the team is off user.teams the user
# still sees it and still fails key creation against it, so removal has to clear it too
stale_user_rows: Final = tuple(user for user in existing_user_rows if data.team_id in user.teams)
# Also clean up any existing team membership rows for this user and team. An email can
# match several user rows, so with no roster entry to name the member, only the rows
# actually carrying the team are the ones this request is allowed to touch
cleanup_user_rows: Final = existing_user_rows if removed_team_members else stale_user_rows
user_ids_to_delete: Final = addressed_user_ids.union(user.user_id for user in cleanup_user_rows if user.user_id)
if not removed_team_members and not stale_user_rows:
raise HTTPException(status_code=400, detail={"error": "User not found in team"})
## DELETE KEYS CREATED BY USER FOR THIS TEAM
# Fetch keys before deletion so their audit records can be persisted alongside the delete.
@ -3358,17 +3365,17 @@ async def team_member_delete(
}
)
await _team_tx_db(tx).update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_new_team_members)},
)
if removed_team_members:
await _team_tx_db(tx).update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_new_team_members)},
)
for existing_user in existing_user_rows:
if data.team_id in existing_user.teams:
await tx.litellm_usertable.update(
where={"user_id": existing_user.user_id},
data={"teams": {"set": [team for team in existing_user.teams if team != data.team_id]}},
)
for existing_user in stale_user_rows:
await tx.litellm_usertable.update(
where={"user_id": existing_user.user_id},
data={"teams": {"set": [team for team in existing_user.teams if team != data.team_id]}},
)
for _uid in sorted(user_ids_to_delete):
await tx.litellm_teammembership.delete_many(where={"team_id": data.team_id, "user_id": _uid})

View file

@ -4863,6 +4863,302 @@ async def test_team_member_delete_by_email_the_user_row_does_not_carry(
)
@pytest.mark.asyncio
async def test_team_member_delete_clears_team_left_on_the_user_row_without_a_roster_entry(
mock_db_client, mock_admin_auth
):
"""
A user row can keep a team (several times over, from older duplicate-prone adds) after the
roster entry is gone, which leaves the team listed on the user, offered in the key creation
dropdown, and rejected by key creation itself. Reporting "User not found in team" left that
residue unremovable, so the delete now cleans every copy of the team off the user row.
"""
from litellm.proxy._types import TeamMemberDeleteRequest
from litellm.proxy.management_endpoints.team_endpoints import team_member_delete
test_team_id = "team-del-orphan-123"
test_user_id = "user-del-orphan-123"
mock_team_row = MagicMock()
mock_team_row.model_dump.return_value = {
"team_id": test_team_id,
"members_with_roles": [],
"team_member_permissions": [],
"metadata": {},
"models": [],
"spend": 0.0,
}
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_team_row
)
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row)
mock_user_row = MagicMock()
mock_user_row.user_id = test_user_id
mock_user_row.user_email = None
mock_user_row.teams = [test_team_id, "other-team", test_team_id]
mock_db_client.db.litellm_usertable.find_many = AsyncMock(
return_value=[mock_user_row]
)
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
mock_db_client.db.litellm_teammembership = MagicMock()
mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(
return_value=MagicMock()
)
mock_db_client.db.litellm_verificationtoken = MagicMock()
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(
return_value=MagicMock()
)
_wire_member_delete_tx(mock_db_client)
await team_member_delete(
data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id),
user_api_key_dict=mock_admin_auth,
)
mock_db_client.db.litellm_usertable.update.assert_awaited_once_with(
where={"user_id": test_user_id},
data={"teams": {"set": ["other-team"]}},
)
mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with(
where={"team_id": test_team_id, "user_id": test_user_id}
)
@pytest.mark.asyncio
async def test_team_member_delete_still_rejects_a_user_the_team_has_no_trace_of(
mock_db_client, mock_admin_auth
):
from litellm.proxy._types import TeamMemberDeleteRequest
from litellm.proxy.management_endpoints.team_endpoints import team_member_delete
test_team_id = "team-del-absent-123"
test_user_id = "user-del-absent-123"
mock_team_row = MagicMock()
mock_team_row.model_dump.return_value = {
"team_id": test_team_id,
"members_with_roles": [],
"team_member_permissions": [],
"metadata": {},
"models": [],
"spend": 0.0,
}
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_team_row
)
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row)
mock_user_row = MagicMock()
mock_user_row.user_id = test_user_id
mock_user_row.user_email = None
mock_user_row.teams = ["other-team"]
mock_db_client.db.litellm_usertable.find_many = AsyncMock(
return_value=[mock_user_row]
)
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
mock_db_client.db.litellm_teammembership = MagicMock()
mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(
return_value=MagicMock()
)
_wire_member_delete_tx(mock_db_client)
with pytest.raises(HTTPException) as exc_info:
await team_member_delete(
data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id),
user_api_key_dict=mock_admin_auth,
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == {"error": "User not found in team"}
mock_db_client.db.litellm_usertable.update.assert_not_awaited()
mock_db_client.db.litellm_teammembership.delete_many.assert_not_awaited()
@pytest.mark.asyncio
async def test_team_member_delete_leaves_a_bystander_named_by_a_conflicting_user_id_alone(
mock_db_client, mock_admin_auth
):
"""
A request can carry a user_id and a user_email that point at two different people, and only the
email matches a roster entry. Cleaning up both ids would strip the team, the membership row and
the keys off the bystander the roster never listed, so the user_id only widens the cleanup when
the roster came back empty.
"""
from litellm.proxy._types import TeamMemberDeleteRequest
from litellm.proxy.management_endpoints.team_endpoints import team_member_delete
test_team_id = "team-del-conflict-123"
roster_user_id = "user-del-conflict-roster"
bystander_user_id = "user-del-conflict-bystander"
roster_email = "roster@example.com"
mock_team_row = MagicMock()
mock_team_row.model_dump.return_value = {
"team_id": test_team_id,
"members_with_roles": [
{"user_id": roster_user_id, "user_email": roster_email, "role": "user"}
],
"team_member_permissions": [],
"metadata": {},
"models": [],
"spend": 0.0,
}
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_team_row
)
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row)
roster_user_row = MagicMock()
roster_user_row.user_id = roster_user_id
roster_user_row.user_email = roster_email
roster_user_row.teams = [test_team_id]
bystander_user_row = MagicMock()
bystander_user_row.user_id = bystander_user_id
bystander_user_row.user_email = "bystander@example.com"
bystander_user_row.teams = [test_team_id]
rows_by_user_id = {
roster_user_id: roster_user_row,
bystander_user_id: bystander_user_row,
}
async def find_user_rows(where):
user_id_filter = where.get("user_id")
if isinstance(user_id_filter, dict):
return [
rows_by_user_id[uid]
for uid in user_id_filter.get("in", [])
if uid in rows_by_user_id
]
return [
row
for row in rows_by_user_id.values()
if row.user_email == where.get("user_email")
]
mock_db_client.db.litellm_usertable.find_many = AsyncMock(
side_effect=find_user_rows
)
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
mock_db_client.db.litellm_teammembership = MagicMock()
mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(
return_value=MagicMock()
)
mock_db_client.db.litellm_verificationtoken = MagicMock()
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(
return_value=MagicMock()
)
_wire_member_delete_tx(mock_db_client)
await team_member_delete(
data=TeamMemberDeleteRequest(
team_id=test_team_id,
user_id=bystander_user_id,
user_email=roster_email,
),
user_api_key_dict=mock_admin_auth,
)
mock_db_client.db.litellm_usertable.update.assert_awaited_once_with(
where={"user_id": roster_user_id},
data={"teams": {"set": []}},
)
mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with(
where={"team_id": test_team_id, "user_id": roster_user_id}
)
mock_db_client.db.litellm_verificationtoken.delete_many.assert_awaited_once_with(
where={"user_id": {"in": [roster_user_id]}, "team_id": test_team_id}
)
@pytest.mark.asyncio
async def test_team_member_delete_by_email_only_touches_the_row_carrying_the_stale_team(
mock_db_client, mock_admin_auth
):
"""
user_email is not unique, so an email delete against an empty roster can match several user
rows. Only the row that actually carries the team is stale; the namesake keeps its team, its
membership row and its keys.
"""
from litellm.proxy._types import TeamMemberDeleteRequest
from litellm.proxy.management_endpoints.team_endpoints import team_member_delete
test_team_id = "team-del-shared-email-123"
stale_user_id = "user-del-shared-email-stale"
namesake_user_id = "user-del-shared-email-namesake"
shared_email = "shared@example.com"
mock_team_row = MagicMock()
mock_team_row.model_dump.return_value = {
"team_id": test_team_id,
"members_with_roles": [],
"team_member_permissions": [],
"metadata": {},
"models": [],
"spend": 0.0,
}
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(
return_value=mock_team_row
)
mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row)
stale_user_row = MagicMock()
stale_user_row.user_id = stale_user_id
stale_user_row.user_email = shared_email
stale_user_row.teams = [test_team_id]
namesake_user_row = MagicMock()
namesake_user_row.user_id = namesake_user_id
namesake_user_row.user_email = shared_email
namesake_user_row.teams = ["other-team"]
mock_db_client.db.litellm_usertable.find_many = AsyncMock(
return_value=[stale_user_row, namesake_user_row]
)
mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock())
mock_db_client.db.litellm_teammembership = MagicMock()
mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(
return_value=MagicMock()
)
mock_db_client.db.litellm_verificationtoken = MagicMock()
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(
return_value=MagicMock()
)
_wire_member_delete_tx(mock_db_client)
await team_member_delete(
data=TeamMemberDeleteRequest(team_id=test_team_id, user_email=shared_email),
user_api_key_dict=mock_admin_auth,
)
mock_db_client.db.litellm_usertable.update.assert_awaited_once_with(
where={"user_id": stale_user_id},
data={"teams": {"set": []}},
)
mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with(
where={"team_id": test_team_id, "user_id": stale_user_id}
)
mock_db_client.db.litellm_verificationtoken.delete_many.assert_awaited_once_with(
where={"user_id": {"in": [stale_user_id]}, "team_id": test_team_id}
)
class _InjectedMemberDeleteFailure(Exception):
pass