mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
beaa96fc8f
commit
595aba3cb0
6 changed files with 303 additions and 113 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`."""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
47
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
47
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue