diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a2e084f265a..6b11847f09d 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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, ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 91e803d6f29..8626c073e55 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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") diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index f2ac8259fa5..4d3387aacb7 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -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, diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 6952de9c1cc..1825813ae9c 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -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`.""" 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 91ce7e56a40..a8a09518326 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 @@ -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" + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 14aa5b081bd..23c989a3220 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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;