fix(proxy): evict deleted keys from the auth cache and make bulk user deletion transactional

/user/bulk_delete now deletes the users' keys, invitation links, org and team
memberships and user rows in one transaction and reports a rolled-back batch
per row instead of leaving partial deletes behind. Both bulk endpoints evict
the deleted keys (and deleted user objects) from the auth cache, so a deleted
key stops authenticating immediately rather than at TTL expiry.

/team/bulk_member_delete rejects member rows that carry both user_id and
user_email, reports repeated rows as duplicates, and only cleans up keys and
memberships of members it actually matched.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-14 05:12:34 +00:00
parent beaa96fc8f
commit 595aba3cb0
6 changed files with 303 additions and 113 deletions

View file

@ -2516,29 +2516,20 @@ async def bulk_delete_user(
),
) -> BulkDeleteUserResponse:
"""
Delete up to 500 internal users in one request and remove each one from every team they belong to.
Delete up to 500 users and remove each one from every team they belong to. Same authorization as
`/user/delete`. Returns one result per user id, in order.
Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose
organizations they all administer. Each team a deleted user was on is rewritten once under the team
lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards.
Then the users' keys, invitation links, organization memberships and user rows are deleted.
Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with
`success: false` and an `error`, and the other users are still deleted.
Usage Example
```shell
curl -X POST "http://localhost:4000/user/bulk_delete" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer sk-1234" \\
-d '{"user_ids": ["user-1", "user-2"]}'
```bash
curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \\
-H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}'
```
Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`,
`teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
"""
from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client
from litellm.proxy.proxy_server import (
litellm_proxy_admin_name,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
@ -2547,6 +2538,8 @@ async def bulk_delete_user(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
litellm_proxy_admin_name=litellm_proxy_admin_name,
litellm_changed_by=litellm_changed_by,
)

View file

@ -3465,30 +3465,17 @@ async def bulk_team_member_delete(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection
) -> BulkTeamMemberDeleteResponse:
"""
Remove up to 500 members from one team in a single request.
Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization
as `/team/member_delete`. Returns one result per member, in order.
Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's
organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under
the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and
their team-scoped keys are all cleaned up together. Members that are not on the team are reported in
`results` with `success: false` and the rest are still removed.
Example request:
```bash
curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \\
--header 'Authorization: Bearer sk-1234' \\
--header 'Content-Type: application/json' \\
--data '{
"team_id": "team-1234",
"members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]
}'
curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \\
-H 'Content-Type: application/json' \\
-d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}'
```
Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`,
`success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
"""
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members
from litellm.proxy.proxy_server import prisma_client
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if prisma_client is None:
raise HTTPException(status_code=400, detail=CommonProxyErrors.db_not_connected_error.value)
@ -3497,6 +3484,8 @@ async def bulk_team_member_delete(
data=data,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract
verbose_proxy_logger.exception("/team/bulk_member_delete: Exception occured")

View file

@ -24,6 +24,9 @@ from litellm.proxy._types import (
MemberDeleteRequest,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import delete_cache_key_objects
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
from litellm.proxy.management_endpoints.common_utils import (
_is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses
@ -33,15 +36,13 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
_persist_deleted_verification_tokens, # pyright: ignore[reportPrivateUsage] # same audit path /key/delete uses
)
from litellm.proxy.management_helpers.access_group_team_sync import TEAM_ADVISORY_LOCK_SQL
from litellm.proxy.utils import PrismaClient
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.table_repositories import (
InvitationLinkRepository,
OrganizationMembershipRepository,
TeamMembershipRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.repositories.verification_token_repository import VerificationTokenRepository
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
BulkDeleteUserRequest,
BulkDeleteUserResponse,
@ -91,6 +92,7 @@ class _TeamRemoval:
team: LiteLLM_TeamTable
removed: frozenset[str]
matched: frozenset[int]
deleted_key_tokens: tuple[str, ...]
def _http_error(status_code: int, message: str) -> HTTPException:
@ -130,6 +132,14 @@ def _token_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_Verificati
return tx.litellm_verificationtoken # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _invitation_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_InvitationLink]":
return tx.litellm_invitationlink # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _org_membership_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]":
return tx.litellm_organizationmembership # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
def _addresses_member(member: Member, request: MemberDeleteRequest) -> bool:
return (request.user_id is not None and request.user_id == member.user_id) or (
request.user_email is not None and request.user_email == member.user_email
@ -184,7 +194,7 @@ async def _remove_members_from_team(
)
)
stale_rows: Final = tuple(u for u in user_rows if team_id in u.teams)
cleanup_ids: Final = removed_ids | requested_ids | frozenset(u.user_id for u in stale_rows)
cleanup_ids: Final = removed_ids | frozenset(u.user_id for u in stale_rows)
matched: Final = frozenset(
i
for i, r in enumerate(members)
@ -216,8 +226,9 @@ async def _remove_members_from_team(
team_id=team_id,
members_with_roles=kept_members, # pyright: ignore[reportArgumentType] # pydantic coerces the tuple into the list field
),
removed=removed_ids | frozenset(u.user_id for u in stale_rows),
removed=cleanup_ids,
matched=matched,
deleted_key_tokens=tuple(k.token for k in keys),
)
@ -231,10 +242,24 @@ def _emit_team_members_metric(team: LiteLLM_TeamTable) -> None:
verbose_proxy_logger.debug("Prometheus: failed to emit team members metric: %s", str(e))
def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozenset[int]:
return frozenset(
i
for i, m in enumerate(members)
if any(
(m.user_id is not None and m.user_id == earlier.user_id)
or (m.user_email is not None and m.user_email == earlier.user_email)
for earlier in members[:i]
)
)
async def bulk_remove_team_members(
data: BulkTeamMemberDeleteRequest,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
) -> BulkTeamMemberDeleteResponse:
team: Final = await TeamRepository(prisma_client).find_by_id(data.team_id)
if team is None:
@ -251,15 +276,30 @@ async def bulk_remove_team_members(
f"route='/team/bulk_member_delete', team_id={data.team_id}",
)
removal: Final = await _remove_members_from_team(prisma_client, data.team_id, data.members, user_api_key_dict)
duplicates: Final = _duplicate_member_indexes(data.members)
kept_indexes: Final = tuple(i for i in range(len(data.members)) if i not in duplicates)
members: Final = tuple(data.members[i] for i in kept_indexes)
removal: Final = await _remove_members_from_team(prisma_client, data.team_id, members, user_api_key_dict)
await delete_cache_key_objects(
hashed_tokens=removal.deleted_key_tokens,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
_emit_team_members_metric(removal.team)
matched: Final = frozenset(kept_indexes[j] for j in removal.matched)
def error(index: int) -> str | None:
if index in duplicates:
return "Duplicate member in request"
return None if index in matched else "User not found in team"
results: Final = tuple(
TeamMemberDeleteResult(
user_id=member.user_id,
user_email=member.user_email,
success=i in removal.matched,
error=None if i in removal.matched else "User not found in team",
success=i in matched,
error=error(i),
)
for i, member in enumerate(data.members)
)
@ -293,14 +333,60 @@ def _scope_error(user_id: str, target_org_ids: frozenset[str], caller_admin_org_
)
async def _delete_user_rows_tx(
prisma_client: PrismaClient,
user_ids: frozenset[str],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> tuple[str, ...]:
async with prisma_client.tx() as tx:
keys: Final = await _token_tx_db(tx).find_many(where=_in_filter("user_id", user_ids))
if keys:
await _persist_deleted_verification_tokens(
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
tx=tx,
)
await _token_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
await _invitation_tx_db(tx).delete_many(
where=_any_filter(
_in_filter("user_id", user_ids),
_in_filter("created_by", user_ids),
_in_filter("updated_by", user_ids),
)
)
await _org_membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
await _membership_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
await _user_tx_db(tx).delete_many(where=_in_filter("user_id", user_ids))
return tuple(k.token for k in keys)
async def _delete_user_rows(
prisma_client: PrismaClient,
users: Sequence["prisma_models.LiteLLM_UserTable"],
user_api_key_dict: UserAPIKeyAuth,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
litellm_proxy_admin_name: str | None,
litellm_changed_by: str | None,
) -> None:
) -> str | None:
"""Returns the error message when the transaction rolled back, in which case no row was touched."""
user_ids: Final = frozenset(u.user_id for u in users)
try:
deleted_key_tokens: Final = await _delete_user_rows_tx(
prisma_client, user_ids, user_api_key_dict, litellm_changed_by
)
except Exception as e: # noqa: BLE001 # the rolled-back batch is reported per row, not as a request failure
verbose_proxy_logger.error("/user/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e)
return _error_message(e)
await delete_cache_key_objects(
hashed_tokens=deleted_key_tokens,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
await evict_and_broadcast(cache_keys=sorted(user_ids), user_api_key_cache=user_api_key_cache)
audit_outcomes: Final = await _bounded(
UserManagementEventHooks.create_internal_user_audit_log(
user_id=u.user_id,
@ -315,33 +401,15 @@ async def _delete_user_rows(
for u, outcome in zip(users, audit_outcomes, strict=True):
if isinstance(outcome, BaseException):
verbose_proxy_logger.warning("Failed to create audit log for user %s: %s", u.user_id, outcome)
keys: Final = await VerificationTokenRepository(prisma_client).table.find_many(
where=_in_filter("user_id", user_ids)
)
if keys:
await _persist_deleted_verification_tokens(
keys=keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
await VerificationTokenRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await InvitationLinkRepository(prisma_client).table.delete_many(
where=_any_filter(
_in_filter("user_id", user_ids),
_in_filter("created_by", user_ids),
_in_filter("updated_by", user_ids),
)
)
await OrganizationMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await TeamMembershipRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
await UserRepository(prisma_client).table.delete_many(where=_in_filter("user_id", user_ids))
return None
async def bulk_delete_users(
data: BulkDeleteUserRequest,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
litellm_proxy_admin_name: str | None,
litellm_changed_by: str | None,
) -> BulkDeleteUserResponse:
@ -406,6 +474,11 @@ async def bulk_delete_users(
)
for tid, err in team_failures.items():
verbose_proxy_logger.error("/user/bulk_delete: failed to remove users from team %s: %s", tid, err)
await delete_cache_key_objects(
hashed_tokens=tuple(t for removal in removals.values() for t in removal.deleted_key_tokens),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
for removal in removals.values():
_emit_team_members_metric(removal.team)
@ -415,10 +488,19 @@ async def bulk_delete_users(
)
deletable: Final = tuple(u for u in candidates if not team_errors(u.user_id))
if deletable:
delete_error: Final = (
await _delete_user_rows(
prisma_client, deletable, user_api_key_dict, litellm_proxy_admin_name, litellm_changed_by
prisma_client,
deletable,
user_api_key_dict,
user_api_key_cache,
proxy_logging_obj,
litellm_proxy_admin_name,
litellm_changed_by,
)
if deletable
else None
)
def result(index: int, user_id: str) -> UserDeleteResult:
if user_id in data.user_ids[:index]:
@ -426,7 +508,9 @@ async def bulk_delete_users(
error: Final = precheck_errors[user_id]
if error is not None:
return UserDeleteResult(user_id=user_id, success=False, error=error)
errors: Final = team_errors(user_id)
errors: Final = team_errors(user_id) or (
(f"Failed to delete user: {delete_error}",) if delete_error is not None else ()
)
return UserDeleteResult(
user_id=user_id,
user_email=rows_by_id[user_id].user_email,

View file

@ -1,6 +1,6 @@
from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from litellm.proxy._types import (
KeyManagementRoutes,
@ -125,6 +125,13 @@ class BulkTeamMemberDeleteRequest(BaseModel):
team_id: str
members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES)
@field_validator("members")
@classmethod
def one_identifier_per_member(cls, members: tuple[MemberDeleteRequest, ...]) -> tuple[MemberDeleteRequest, ...]:
if any(m.user_id is not None and m.user_email is not None for m in members):
raise ValueError("Each member must be identified by exactly one of user_id or user_email")
return members
class TeamMemberDeleteResult(BaseModel):
"""Outcome for one row of `/team/bulk_member_delete`."""

View file

@ -1,3 +1,4 @@
import copy
import json
from collections.abc import Callable, Mapping, Sequence
from contextlib import asynccontextmanager
@ -8,6 +9,7 @@ from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users, bulk_remove_team_members
from litellm.types.proxy.management_endpoints.internal_user_endpoints import BulkDeleteUserRequest
from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest
@ -126,6 +128,8 @@ class _Tx:
self.litellm_teammembership = db.litellm_teammembership
self.litellm_verificationtoken = db.litellm_verificationtoken
self.litellm_deletedverificationtoken = db.litellm_deletedverificationtoken
self.litellm_invitationlink = db.litellm_invitationlink
self.litellm_organizationmembership = db.litellm_organizationmembership
self._on_lock = on_lock
self._fail_locks = fail_locks
self.locks: list[str] = []
@ -158,17 +162,26 @@ class _FakePrisma:
org_memberships: Sequence[Mapping[str, object]] = (),
on_lock: Callable[[str], None] = lambda _: None,
fail_locks: frozenset[str] = frozenset(),
fail_user_delete: bool = False,
) -> None:
self.db = _Db(users, teams, memberships, tokens, invitations, org_memberships)
self._on_lock = on_lock
self._fail_locks = fail_locks
self._fail_user_delete = fail_user_delete
self.locks: list[str] = []
self.roster_reads: list[str] = []
@asynccontextmanager
async def tx(self):
snapshot = copy.deepcopy(self.db)
tx = _Tx(self.db, self._on_lock, self._fail_locks)
yield tx
try:
yield tx
if self._fail_user_delete and tx.locks == []:
raise RuntimeError("connection reset")
except BaseException:
self.db.__dict__.update(snapshot.__dict__)
raise
self.locks.extend(tx.locks)
self.roster_reads.extend(tx.roster_reads)
@ -189,21 +202,43 @@ def _roster(prisma: _FakePrisma, team_id: str) -> list[str | None]:
return [m.user_id for m in prisma.db.litellm_teamtable.rows[team_id].members_with_roles]
async def _delete(prisma: _FakePrisma, user_ids: Sequence[str], caller: UserAPIKeyAuth = ADMIN):
def _cache_with(*hashed_tokens: str) -> UserApiKeyCache:
cache = UserApiKeyCache()
for token in hashed_tokens:
cache.set_cache(key=token, value=UserAPIKeyAuth(token=token))
return cache
async def _delete(
prisma: _FakePrisma,
user_ids: Sequence[str],
caller: UserAPIKeyAuth = ADMIN,
cache: UserApiKeyCache | None = None,
):
return await bulk_delete_users(
data=BulkDeleteUserRequest(user_ids=tuple(user_ids)),
user_api_key_dict=caller,
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
user_api_key_cache=cache or UserApiKeyCache(),
proxy_logging_obj=None,
litellm_proxy_admin_name="default_user_id",
litellm_changed_by=None,
)
async def _remove(prisma: _FakePrisma, team_id: str, members: Sequence[Mapping[str, str]], caller=ADMIN):
async def _remove(
prisma: _FakePrisma,
team_id: str,
members: Sequence[Mapping[str, str]],
caller: UserAPIKeyAuth = ADMIN,
cache: UserApiKeyCache | None = None,
):
return await bulk_remove_team_members(
data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)),
user_api_key_dict=caller,
prisma_client=prisma, # pyright: ignore[reportArgumentType] # fake stands in for PrismaClient
user_api_key_cache=cache or UserApiKeyCache(),
proxy_logging_obj=None,
)
@ -303,6 +338,50 @@ async def test_bulk_delete_keeps_user_when_a_team_rewrite_fails_and_deletes_the_
assert _roster(prisma, "bad") == ["u1"] and _roster(prisma, "good") == []
@pytest.mark.asyncio
async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when_the_delete_fails():
prisma = _FakePrisma(
users=[_user("u1", "t1"), _user("u2")],
teams=[_team("t1", "u1")],
tokens=[{"token": "k1", "user_id": "u1"}],
fail_user_delete=True,
)
cache = _cache_with("k1")
response = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache)
assert [(r.user_id, r.success, r.error) for r in response.results] == [
("u1", False, "Failed to delete user: connection reset"),
("u2", False, "Failed to delete user: connection reset"),
("ghost", False, "User id=ghost not found"),
]
assert set(prisma.db.litellm_usertable.rows) == {"u1", "u2"}
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["k1"]
assert prisma.db.litellm_deletedverificationtoken.rows == []
assert cache.get_cache(key="k1") is not None
@pytest.mark.asyncio
async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache():
prisma = _FakePrisma(
users=[_user("u1", "t1"), _user("keep", "t1")],
teams=[_team("t1", "u1", "keep")],
tokens=[
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
{"token": "personal-key", "user_id": "u1"},
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
],
)
cache = _cache_with("team-key", "personal-key", "keep-key")
cache.set_cache(key="u1", value={"user_id": "u1"})
await _delete(prisma, ["u1"], cache=cache)
assert cache.get_cache(key="team-key") is None and cache.get_cache(key="personal-key") is None
assert cache.get_cache(key="u1") is None
assert cache.get_cache(key="keep-key") is not None
@pytest.mark.asyncio
async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db():
prisma = _FakePrisma(users=[_user("u1")])
@ -382,6 +461,58 @@ async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewrit
assert _roster(prisma, "t1") == ["u1"]
@pytest.mark.asyncio
async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_members_alone():
prisma = _FakePrisma(
users=[_user("u1", "t1"), _user("elsewhere")],
teams=[_team("t1", "u1")],
memberships=[("t1", "elsewhere")],
tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}],
)
response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}])
assert response.results[0].success is False
assert prisma.db.litellm_teammembership.rows == [{"team_id": "t1", "user_id": "elsewhere"}]
assert [t["token"] for t in prisma.db.litellm_verificationtoken.rows] == ["orphan-key"]
@pytest.mark.asyncio
async def test_bulk_member_delete_reports_repeated_members_as_duplicates_and_removes_them_once():
prisma = _FakePrisma(users=[_user("u1", "t1"), _user("u2", "t1")], teams=[_team("t1", "u1", "u2", "keep")])
response = await _remove(
prisma, "t1", [{"user_id": "u1"}, {"user_id": "u1"}, {"user_email": "u1@example.com"}, {"user_id": "u2"}]
)
assert [(r.success, r.error) for r in response.results] == [
(True, None),
(False, "Duplicate member in request"),
(True, None),
(True, None),
]
assert (response.successful_deletions, response.failed_deletions) == (3, 1)
assert _roster(prisma, "t1") == ["keep"]
@pytest.mark.asyncio
async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cache():
prisma = _FakePrisma(
users=[_user("u1", "t1"), _user("keep", "t1")],
teams=[_team("t1", "u1", "keep")],
tokens=[
{"token": "team-key", "user_id": "u1", "team_id": "t1"},
{"token": "keep-key", "user_id": "keep", "team_id": "t1"},
],
)
cache = _cache_with("team-key", "keep-key")
await _remove(prisma, "t1", [{"user_id": "u1"}], cache=cache)
assert cache.get_cache(key="team-key") is None
assert cache.get_cache(key="keep-key") is not None
@pytest.mark.asyncio
async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_the_team():
prisma = _FakePrisma(users=[_user("stale", "t1")], teams=[_team("t1", "other")], memberships=[("t1", "stale")])
@ -432,3 +563,16 @@ def test_request_models_enforce_batch_bounds():
team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501))
)
assert len(BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(500))).user_ids) == 500
def test_bulk_member_delete_request_requires_exactly_one_identifier_per_member():
with pytest.raises(ValidationError, match="exactly one of user_id or user_email"):
BulkTeamMemberDeleteRequest(
team_id="t1", members=(MemberDeleteRequest(user_id="u1", user_email="other@example.com"),)
)
with pytest.raises(ValidationError):
BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{}]})
assert (
BulkTeamMemberDeleteRequest(team_id="t1", members=(MemberDeleteRequest(user_id="u1"),)).members[0].user_id
== "u1"
)

View file

@ -15067,27 +15067,14 @@ export interface paths {
put?: never;
/**
* Bulk Team Member Delete
* @description Remove up to 500 members from one team in a single request.
* @description Remove up to 500 members (each named by `user_id` or `user_email`) from one team. Same authorization
* as `/team/member_delete`. Returns one result per member, in order.
*
* Same authorization as `/team/member_delete` (proxy admin, team admin, or org admin of the team's
* organization). Each member is named by `user_id` or `user_email`. The team is rewritten once under
* the team lock: the roster, every removed user's `teams` array, their `LiteLLM_TeamMembership` rows and
* their team-scoped keys are all cleaned up together. Members that are not on the team are reported in
* `results` with `success: false` and the rest are still removed.
*
* Example request:
* ```bash
* curl --location 'http://0.0.0.0:4000/team/bulk_member_delete' \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "team_id": "team-1234",
* "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]
* }'
* curl -X POST 'http://0.0.0.0:4000/team/bulk_member_delete' -H 'Authorization: Bearer sk-1234' \
* -H 'Content-Type: application/json' \
* -d '{"team_id": "team-1234", "members": [{"user_id": "user1"}, {"user_email": "user2@example.com"}]}'
* ```
*
* Returns `team_id`, `results` (one entry per input member, in order, with `user_id`, `user_email`,
* `success`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
*/
post: operations["bulk_team_member_delete_team_bulk_member_delete_post"];
delete?: never;
@ -16529,27 +16516,13 @@ export interface paths {
put?: never;
/**
* Bulk Delete User
* @description Delete up to 500 internal users in one request and remove each one from every team they belong to.
* @description Delete up to 500 users and remove each one from every team they belong to. Same authorization as
* `/user/delete`. Returns one result per user id, in order.
*
* Same authorization as `/user/delete`: proxy admins may delete anyone, org admins only users whose
* organizations they all administer. Each team a deleted user was on is rewritten once under the team
* lock, so the roster, the user's `teams` array and the `LiteLLM_TeamMembership` rows all agree afterwards.
* Then the users' keys, invitation links, organization memberships and user rows are deleted.
*
* Rows fail independently: unknown, duplicate or out-of-scope ids are reported in `results` with
* `success: false` and an `error`, and the other users are still deleted.
*
* Usage Example
*
* ```shell
* curl -X POST "http://localhost:4000/user/bulk_delete" \
* -H "Content-Type: application/json" \
* -H "Authorization: Bearer sk-1234" \
* -d '{"user_ids": ["user-1", "user-2"]}'
* ```bash
* curl -X POST 'http://localhost:4000/user/bulk_delete' -H 'Authorization: Bearer sk-1234' \
* -H 'Content-Type: application/json' -d '{"user_ids": ["user-1", "user-2"]}'
* ```
*
* Returns `results` (one entry per input id, in order, with `user_id`, `user_email`, `success`,
* `teams_removed`, `error`), `total_requested`, `successful_deletions` and `failed_deletions`.
*/
post: operations["bulk_delete_user_user_bulk_delete_post"];
delete?: never;