From ca04b03c2c4af8844199adc46315f043d06b92a3 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 14 Sep 2026 22:31:07 +0000 Subject: [PATCH] feat(proxy): move bulk user delete and team member delete under /management/v1 Replaces POST /user/bulk_delete and POST /team/bulk_member_delete with POST /management/v1/users/bulk_delete and POST /management/v1/teams/{team_id}/members/bulk_delete per the Management API modernization one-pager: {data} envelopes, application/problem+json errors with urn:litellm:error:* types, 422 on unknown body fields, 400 on unknown query params, 403 on authorization failures, 404 on unknown team. Route checks now match parametrized management/v1 paths so team-scoped callers reach the endpoint's own authorization and get a 403 problem instead of the generic 401. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 5 +- litellm/proxy/auth/route_checks.py | 20 +- .../internal_user_endpoints.py | 44 --- .../management_v1/__init__.py | 8 + .../management_v1/teams.py | 94 ++++++ .../management_v1/users.py | 101 ++++++ .../management_endpoints/team_endpoints.py | 32 -- .../management_helpers/bulk_user_deletion.py | 72 ++-- litellm/proxy/proxy_server.py | 25 +- .../internal_user_endpoints.py | 16 +- .../management_endpoints/management_v1.py | 6 + .../management_endpoints/team_endpoints.py | 37 ++- .../endpointaudit/coverage_allowlist.txt | 2 - .../test_team_bulk_member_delete.py | 76 ++++- .../management/test_users_bulk_delete.py | 137 ++++++++ .../test_bulk_user_deletion.py | 120 +++---- ui/litellm-dashboard/src/lib/http/schema.d.ts | 314 ++++++++++-------- 17 files changed, 735 insertions(+), 374 deletions(-) create mode 100644 litellm/proxy/management_endpoints/management_v1/teams.py create mode 100644 litellm/proxy/management_endpoints/management_v1/users.py create mode 100644 tests/proxy_behavior/management/test_users_bulk_delete.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 95ca0ad352b..9d6fff0fa37 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -659,7 +659,7 @@ class LiteLLMRoutes(enum.Enum): "/user/update", "/user/bulk_update", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/info", "/user/list", "/user/daily/activity", @@ -839,7 +839,7 @@ class LiteLLMRoutes(enum.Enum): self_managed_routes = [ "/team/member_add", "/team/member_delete", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/member_update", "/team/{team_id}/member/{user_id}/reset_spend", "/team/permissions_list", @@ -866,6 +866,7 @@ class LiteLLMRoutes(enum.Enum): "/organization/daily/activity", "/user/available_roles", # read-only role metadata; any authenticated user may read "/user/list", # org admins checked in endpoint; non-admins get 403 + "/management/v1/users/bulk_delete", # proxy admins delete anyone, org admins only their orgs' users; others 403 "/model/{model_id}/update", "/prompt/list", "/prompt/info", diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 38cabcd0944..4c64d4d7e23 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -1,5 +1,5 @@ import re -from collections.abc import Sequence +from collections.abc import Collection from typing import Final from fastapi import HTTPException, Request, status @@ -25,11 +25,11 @@ _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES: Final = frozenset( # user "/user/new", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/bulk_update", # team "/team/new", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/update", "/team/delete", "/team/block", @@ -589,7 +589,7 @@ class RouteChecks: return False @staticmethod - def check_route_access(route: str, allowed_routes: Sequence[str]) -> bool: + def check_route_access(route: str, allowed_routes: Collection[str]) -> bool: """ Check if a route has access by checking both exact matches and patterns @@ -761,10 +761,10 @@ class RouteChecks: [ "/user/new", "/user/delete", - "/user/bulk_delete", + "/management/v1/users/bulk_delete", "/user/bulk_update", "/team/new", - "/team/bulk_member_delete", + "/management/v1/teams/{team_id}/members/bulk_delete", "/team/update", "/team/delete", "/model/new", @@ -828,7 +828,7 @@ class RouteChecks: status_code=status.HTTP_403_FORBIDDEN, detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email and password can be updated", ) - elif route in _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES or ( + elif RouteChecks.check_route_access(route=route, allowed_routes=_PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES) or ( route.startswith("/key/") and route.endswith(_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES) ): # Block write operations for PROXY_ADMIN_VIEW_ONLY @@ -863,9 +863,9 @@ class RouteChecks: # Hard-block known write routes regardless of HTTP method (defensive # — these are POSTs in practice, but pinning them here protects # against future GET-shaped writes). - if route in RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES or ( - route.startswith("/key/") and route.endswith("/regenerate") - ): + if RouteChecks.check_route_access( + route=route, allowed_routes=RouteChecks._ADMIN_VIEWER_BLOCKED_WRITE_ROUTES + ) or (route.startswith("/key/") and route.endswith("/regenerate")): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route}", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c9fc6720490..e3efda507f6 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -8,7 +8,6 @@ These are members of a Team on LiteLLM /user/update /user/bulk_update /user/delete -/user/bulk_delete /user/info /user/list """ @@ -56,7 +55,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, prepare_metadata_fields, ) -from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, @@ -79,8 +77,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( - BulkDeleteUserRequest, - BulkDeleteUserResponse, BulkUpdateUserRequest, BulkUpdateUserResponse, UserListResponse, @@ -2500,46 +2496,6 @@ async def delete_user( return deleted_users -@router.post( - "/user/bulk_delete", - tags=["Internal User management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence - dependencies=(Depends(user_api_key_auth),), - response_model=BulkDeleteUserResponse, -) -@management_endpoint_wrapper -async def bulk_delete_user( - data: BulkDeleteUserRequest, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection - litellm_changed_by: str | None = Header( - None, - description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", - ), -) -> BulkDeleteUserResponse: - """Delete up to 500 users, removing each from every team; same authorization as `/user/delete`.""" - 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) - try: - return await bulk_delete_users( - 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, - ) - except Exception as e: # noqa: BLE001 # normalize every failure to the proxy exception contract - verbose_proxy_logger.exception("/user/bulk_delete: Exception occured") - raise handle_exception_on_proxy(e) - - async def add_internal_user_to_organization( user_id: str, organization_id: str, diff --git a/litellm/proxy/management_endpoints/management_v1/__init__.py b/litellm/proxy/management_endpoints/management_v1/__init__.py index 342e6525cda..b29b7fe5dd5 100644 --- a/litellm/proxy/management_endpoints/management_v1/__init__.py +++ b/litellm/proxy/management_endpoints/management_v1/__init__.py @@ -10,9 +10,17 @@ from litellm.proxy.management_endpoints.management_v1.budgets import ( from litellm.proxy.management_endpoints.management_v1.spend_logs import ( router as spend_logs_router, ) +from litellm.proxy.management_endpoints.management_v1.teams import ( + router as teams_router, +) +from litellm.proxy.management_endpoints.management_v1.users import ( + router as users_router, +) router: Final = APIRouter() router.include_router(budgets_router) router.include_router(spend_logs_router) +router.include_router(teams_router) +router.include_router(users_router) __all__ = ["router"] diff --git a/litellm/proxy/management_endpoints/management_v1/teams.py b/litellm/proxy/management_endpoints/management_v1/teams.py new file mode 100644 index 00000000000..ba384bfb028 --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/teams.py @@ -0,0 +1,94 @@ +"""`POST /management/v1/teams/{team_id}/members/bulk_delete`.""" + +from typing import Annotated, Final + +from fastapi import APIRouter, Depends + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params +from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX +from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members +from litellm.proxy.management_helpers.utils import ( + management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail +from litellm.types.proxy.management_endpoints.team_endpoints import ( + BulkTeamMemberDeleteRequest, + BulkTeamMemberDeleteResponse, +) + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +@router.post( + "/teams/{team_id}/members/bulk_delete", + tags=["team management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence + dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)), + response_model=BulkTeamMemberDeleteResponse, +) +@management_endpoint_wrapper +async def bulk_delete_team_members_action( + team_id: str, + data: BulkTeamMemberDeleteRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], +) -> BulkTeamMemberDeleteResponse: + """ + Remove up to 500 members from one team in one call. Same authorization as + `/team/member_delete`: proxy admins, the team's admins, and admins of the team's + organization. Each member is named by exactly one of `user_id` or `user_email`; + unknown body fields are a 422 and an unknown team is a 404. + + `data` holds one result per requested member, in request order. A row is + `success: false` with an `error` when it names nobody on the team or repeats an + earlier row. The roster is rewritten once, under the team's advisory lock, so a + concurrent member_add is never overwritten from a stale read. + + Example curl: + ``` + curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache + + if prisma_client is None: + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + results: Final = await bulk_remove_team_members( + team_id=team_id, + 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, + ) + return BulkTeamMemberDeleteResponse(data=results) + + except ManagementProblem: + raise + except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.teams.bulk_delete_team_members_action(): " + "Exception occured - %s", + e, + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to remove team members.", + ) + ) diff --git a/litellm/proxy/management_endpoints/management_v1/users.py b/litellm/proxy/management_endpoints/management_v1/users.py new file mode 100644 index 00000000000..fa2a5cf3c3d --- /dev/null +++ b/litellm/proxy/management_endpoints/management_v1/users.py @@ -0,0 +1,101 @@ +"""`POST /management/v1/users/bulk_delete`.""" + +from typing import Annotated, Final + +from fastapi import APIRouter, Depends, Header + +from litellm._logging import verbose_proxy_logger +from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem, reject_unknown_query_params +from litellm.proxy.management_endpoints.management_v1.common import MANAGEMENT_V1_PREFIX +from litellm.proxy.management_helpers.bulk_user_deletion import bulk_delete_users +from litellm.proxy.management_helpers.utils import ( + management_endpoint_wrapper, # pyright: ignore[reportUnknownVariableType] # legacy decorator is untyped +) +from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( + BulkDeleteUserRequest, + BulkDeleteUsersResponse, +) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail + +router: Final = APIRouter(prefix=MANAGEMENT_V1_PREFIX) + + +@router.post( + "/users/bulk_delete", + tags=["Internal User management"], # mutable-ok: FastAPI types `tags` as list[str], not Sequence + dependencies=(Depends(user_api_key_auth), Depends(reject_unknown_query_params)), + response_model=BulkDeleteUsersResponse, +) +@management_endpoint_wrapper +async def bulk_delete_users_action( + data: BulkDeleteUserRequest, + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + litellm_changed_by: Annotated[ + str | None, + Header(description="Who the caller is acting for; recorded on the audit log entries this call writes."), + ] = None, +) -> BulkDeleteUsersResponse: + """ + Delete up to 500 users in one call, taking each out of every team it belongs to. + Same authorization as `/user/delete`: proxy admins may delete anyone, org admins + only users inside organizations they administer. Unknown body fields are a 422. + + `data` holds one result per requested `user_id`, in request order. A row is + `success: false` with an `error` when the id is unknown, repeated in the request, + or outside the caller's scope. Rows that pass those checks are deleted together, + in one transaction, so either all of them go or none does. + + Example curl: + ``` + curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' \ + --header 'Authorization: Bearer sk-1234' \ + --header 'Content-Type: application/json' \ + --data '{"user_ids": ["user-1", "user-2"]}' + ``` + """ + try: + 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 ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}database-not-connected", + title="Database not connected", + status=503, + detail=CommonProxyErrors.db_not_connected_error.value, + ) + ) + + results: Final = await bulk_delete_users( + 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, + ) + return BulkDeleteUsersResponse(data=results) + + except ManagementProblem: + raise + except Exception as e: # noqa: BLE001 # a driver error answers as a problem document, not the OpenAI error shape + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.management_v1.users.bulk_delete_users_action(): Exception occured - %s", + e, + ) + raise ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}internal-server-error", + title="Internal server error", + status=500, + detail="Failed to delete users.", + ) + ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index fbcde2bc032..6b16692f7ad 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -163,8 +163,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberAddRequest, BulkTeamMemberAddResponse, - BulkTeamMemberDeleteRequest, - BulkTeamMemberDeleteResponse, BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, @@ -3455,36 +3453,6 @@ async def team_member_delete( return existing_team_row -@router.post( - "/team/bulk_member_delete", - tags=["team management"], # mutable-ok: FastAPI's `tags` param is typed as list[str], not Sequence - dependencies=(Depends(user_api_key_auth),), - response_model=BulkTeamMemberDeleteResponse, -) -@management_endpoint_wrapper -async def bulk_team_member_delete( - data: BulkTeamMemberDeleteRequest, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection -) -> BulkTeamMemberDeleteResponse: - """Remove up to 500 members from one team; same authorization as `/team/member_delete`.""" - from litellm.proxy.management_helpers.bulk_user_deletion import bulk_remove_team_members - 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) - try: - return await bulk_remove_team_members( - 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") - raise handle_exception_on_proxy(e) - - _MEMBER_BUDGET_PATCH_FIELDS: Final = { "max_budget_in_team": "max_budget", "tpm_limit": "tpm_limit", diff --git a/litellm/proxy/management_helpers/bulk_user_deletion.py b/litellm/proxy/management_helpers/bulk_user_deletion.py index 92e21a3803b..53527c42c25 100644 --- a/litellm/proxy/management_helpers/bulk_user_deletion.py +++ b/litellm/proxy/management_helpers/bulk_user_deletion.py @@ -1,4 +1,5 @@ -"""Batched deletes behind `POST /user/bulk_delete` and `POST /team/bulk_member_delete`. +"""Batched deletes behind `POST /management/v1/users/bulk_delete` and +`POST /management/v1/teams/{team_id}/members/bulk_delete`. Each team a batch touches is rewritten exactly once, under the same advisory lock `/team/member_delete` takes and from a roster re-read under that lock, so a concurrent @@ -31,6 +32,7 @@ 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.list_api.common import PROBLEM_TYPE_BASE, ManagementProblem from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses _is_user_team_admin, # pyright: ignore[reportPrivateUsage] # same check /team/member_delete uses @@ -48,12 +50,11 @@ from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkDeleteUserRequest, - BulkDeleteUserResponse, UserDeleteResult, ) +from litellm.types.proxy.management_endpoints.management_v1 import ProblemDetail from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkTeamMemberDeleteRequest, - BulkTeamMemberDeleteResponse, TeamMemberDeleteResult, ) @@ -67,10 +68,6 @@ _AUDIT_LOG_CONCURRENCY: Final = 10 _BATCH_TX_TIMEOUT: Final = timedelta(seconds=60) -class _ErrorDetail(TypedDict): - error: ReadOnly[str] - - class _OrgAdminFilter(TypedDict): user_id: ReadOnly[str] user_role: ReadOnly[str] @@ -105,9 +102,21 @@ class _UserBatchDeletion: deleted_key_tokens: tuple[str, ...] -def _http_error(status_code: int, message: str) -> HTTPException: - detail: Final[_ErrorDetail] = {"error": message} - return HTTPException(status_code=status_code, detail=detail) +def _team_not_found(team_id: str) -> ManagementProblem: + return ManagementProblem( + ProblemDetail( + type=f"{PROBLEM_TYPE_BASE}team-not-found", + title="Team not found", + status=404, + detail=f"Team id={team_id} does not exist in db", + ) + ) + + +def _forbidden(detail: str) -> ManagementProblem: + return ManagementProblem( + ProblemDetail(type=f"{PROBLEM_TYPE_BASE}forbidden", title="Forbidden", status=403, detail=detail) + ) def _in_filter(field: str, values: Iterable[str]) -> Mapping[str, object]: @@ -167,6 +176,8 @@ def _addresses_user(user: "prisma_models.LiteLLM_UserTable", request: MemberDele def _error_message(exc: BaseException) -> str: + if isinstance(exc, ManagementProblem): + return exc.problem.detail if isinstance(exc, HTTPException) and isinstance(exc.detail, dict): return str(exc.detail.get("error", exc.detail)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # HTTPException.detail is untyped if isinstance(exc, HTTPException): @@ -194,7 +205,7 @@ async def _remove_members_from_team( await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id) roster: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, team_id) if roster is None: - raise _http_error(400, f"Team id={team_id} does not exist in db") + raise _team_not_found(team_id) removed_members: Final = tuple(m for m in roster if any(_addresses_member(m, r) for r in members)) kept_members: Final = tuple(m for m in roster if not any(_addresses_member(m, r) for r in members)) @@ -269,32 +280,32 @@ def _duplicate_member_indexes(members: Sequence[MemberDeleteRequest]) -> frozens async def bulk_remove_team_members( + team_id: str, 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) +) -> tuple[TeamMemberDeleteResult, ...]: + team: Final = await TeamRepository(prisma_client).find_by_id(team_id) if team is None: - raise _http_error(400, f"Team id={data.team_id} does not exist in db") + raise _team_not_found(team_id) if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team) and not await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team) ): - raise _http_error( - 403, + raise _forbidden( "Call not allowed. User not proxy admin OR team admin OR org admin for this team. " - f"route='/team/bulk_member_delete', team_id={data.team_id}", + f"route='/management/v1/teams/{team_id}/members/bulk_delete'" ) 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) async with prisma_client.tx(timeout=_BATCH_TX_TIMEOUT) as tx: - removal: Final = await _remove_members_from_team(prisma_client, tx, data.team_id, members, user_api_key_dict) + removal: Final = await _remove_members_from_team(prisma_client, tx, 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, @@ -309,7 +320,7 @@ async def bulk_remove_team_members( return "Duplicate member in request" return None if index in matched else "User not found in team" - results: Final = tuple( + return tuple( TeamMemberDeleteResult( user_id=member.user_id, user_email=member.user_email, @@ -318,14 +329,6 @@ async def bulk_remove_team_members( ) for i, member in enumerate(data.members) ) - successful: Final = sum(1 for r in results if r.success) - return BulkTeamMemberDeleteResponse( - team_id=data.team_id, - results=results, - total_requested=len(results), - successful_deletions=successful, - failed_deletions=len(results) - successful, - ) async def _caller_admin_org_ids(prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth) -> frozenset[str]: @@ -433,7 +436,7 @@ async def _delete_users( try: deletion: Final = await _delete_users_tx(prisma_client, users, teams_of, 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) + verbose_proxy_logger.error("users/bulk_delete: failed to delete users %s: %s", sorted(user_ids), e) return _error_message(e) await delete_cache_key_objects( hashed_tokens=deletion.deleted_key_tokens, @@ -468,11 +471,11 @@ async def bulk_delete_users( proxy_logging_obj: ProxyLogging | None, litellm_proxy_admin_name: str | None, litellm_changed_by: str | None, -) -> BulkDeleteUserResponse: +) -> tuple[UserDeleteResult, ...]: caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value caller_admin_org_ids: Final = await _caller_admin_org_ids(prisma_client, user_api_key_dict) if not caller_is_proxy_admin and not caller_admin_org_ids: - raise _http_error(403, "Only PROXY_ADMIN or ORG_ADMIN users may delete users.") + raise _forbidden("Only PROXY_ADMIN or ORG_ADMIN users may delete users.") unique_ids: Final = frozenset(data.user_ids) rows: Final = await UserRepository(prisma_client).table.find_many(where=_in_filter("user_id", unique_ids)) @@ -543,11 +546,4 @@ async def bulk_delete_users( teams_removed=tuple(tid for tid, r in deletion.removals.items() if user_id in r.removed), ) - results: Final = tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) - successful: Final = sum(1 for r in results if r.success) - return BulkDeleteUserResponse( - results=results, - total_requested=len(results), - successful_deletions=successful, - failed_deletions=len(results) - successful, - ) + return tuple(result(i, uid) for i, uid in enumerate(data.user_ids)) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1c931863a2f..88844c04e74 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1788,24 +1788,37 @@ class _ExceptionRow(TypedDict, total=False): class _ValidationErrorDetail(TypedDict): + type: str loc: tuple[int | str, ...] msg: str +def _is_length_error_of_rejected_items(error: _ValidationErrorDetail, errors: Sequence[_ValidationErrorDetail]) -> bool: + """pydantic counts only items that validated, so a bad item also trips the parent's min_length.""" + return error["type"] == "too_short" and any( + len(other["loc"]) > len(error["loc"]) and other["loc"][: len(error["loc"])] == error["loc"] for other in errors + ) + + @app.exception_handler(RequestValidationError) async def otel_request_validation_exception_handler(request: Request, exc: RequestValidationError): if request.url.path.startswith(MANAGEMENT_V1_PREFIX): - _close_dangling_otel_server_span(request, 400, exc=exc) - validation_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() + raw_errors: Final[Sequence[_ValidationErrorDetail]] = exc.errors() + validation_errors: Final = tuple( + error for error in raw_errors if not _is_length_error_of_rejected_items(error, raw_errors) + ) + in_body: Final = any(error["loc"] and error["loc"][0] == "body" for error in validation_errors) + status: Final = 422 if in_body else 400 + _close_dangling_otel_server_span(request, status, exc=exc) return problem_response( ProblemDetail( - type=f"{PROBLEM_TYPE_BASE}invalid-query-parameter", - title="Invalid query parameter", - status=400, + type=f"{PROBLEM_TYPE_BASE}{'invalid-request-body' if in_body else 'invalid-query-parameter'}", + title="Invalid request body" if in_body else "Invalid query parameter", + status=status, detail="; ".join( f"{'.'.join(str(part) for part in error['loc'][1:])}: {error['msg']}" for error in validation_errors ) - or "The request query parameters are invalid.", + or "The request is invalid.", ) ) _close_dangling_otel_server_span(request, 422, exc=exc) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 7d9f2bd2a85..2b41893663d 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import Any, Final, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from typing_extensions import ReadOnly, TypedDict from litellm.proxy._types import ( @@ -9,6 +9,7 @@ from litellm.proxy._types import ( UpdateUserRequest, UpdateUserRequestNoUserIDorEmail, ) +from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse MAX_BULK_DELETE_USERS: Final = 500 @@ -88,11 +89,15 @@ class BulkUpdateUserResponse(BaseModel): class BulkDeleteUserRequest(BaseModel): + """Body of `POST /management/v1/users/bulk_delete`.""" + + model_config = ConfigDict(extra="forbid") + user_ids: tuple[str, ...] = Field(min_length=1, max_length=MAX_BULK_DELETE_USERS) class UserDeleteResult(BaseModel): - """Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of.""" + """Outcome for one requested user, in request order. `teams_removed` lists the teams the user left.""" user_id: str user_email: str | None = None @@ -101,8 +106,5 @@ class UserDeleteResult(BaseModel): error: str | None = None -class BulkDeleteUserResponse(BaseModel): - results: tuple[UserDeleteResult, ...] - total_requested: int - successful_deletions: int - failed_deletions: int +class BulkDeleteUsersResponse(ResourceResponse[tuple[UserDeleteResult, ...]]): + """`{data: [...]}` with one `UserDeleteResult` per requested user, in request order.""" diff --git a/litellm/types/proxy/management_endpoints/management_v1.py b/litellm/types/proxy/management_endpoints/management_v1.py index aa82138110d..c23d0ecfb54 100644 --- a/litellm/types/proxy/management_endpoints/management_v1.py +++ b/litellm/types/proxy/management_endpoints/management_v1.py @@ -65,6 +65,12 @@ class ListLinks(BaseModel): last: str +class ResourceResponse(BaseModel, Generic[TOut]): + """Envelope for a single resource or an action's result: `{data: ...}`, no `meta` or `links`.""" + + data: TOut + + class ListResponse(BaseModel, Generic[TOut]): """Rows stay flat: JSON:API's `{type, id, attributes}` wrapper is a deliberate deviation, so every dashboard column accessor would otherwise have to go through `.attributes`.""" diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 1825813ae9c..5f5be81ee4b 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, field_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from litellm.proxy._types import ( KeyManagementRoutes, @@ -10,6 +10,7 @@ from litellm.proxy._types import ( Member, MemberDeleteRequest, ) +from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse TeamIdSearchMatch = Literal["exact", "prefix"] @@ -121,20 +122,28 @@ class BulkTeamMemberAddResponse(BaseModel): updated_team: dict[str, Any] | None = None -class BulkTeamMemberDeleteRequest(BaseModel): - team_id: str - members: tuple[MemberDeleteRequest, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) +class TeamMemberRef(MemberDeleteRequest): + """One member to remove, named by exactly one of `user_id` or `user_email`.""" - @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): + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def one_identifier(self) -> "TeamMemberRef": + if self.user_id is not None and self.user_email is not None: raise ValueError("Each member must be identified by exactly one of user_id or user_email") - return members + return self + + +class BulkTeamMemberDeleteRequest(BaseModel): + """Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`.""" + + model_config = ConfigDict(extra="forbid") + + members: tuple[TeamMemberRef, ...] = Field(min_length=1, max_length=MAX_BULK_TEAM_MEMBER_DELETES) class TeamMemberDeleteResult(BaseModel): - """Outcome for one row of `/team/bulk_member_delete`.""" + """Outcome for one requested member, in request order.""" user_id: str | None = None user_email: str | None = None @@ -142,12 +151,8 @@ class TeamMemberDeleteResult(BaseModel): error: str | None = None -class BulkTeamMemberDeleteResponse(BaseModel): - team_id: str - results: tuple[TeamMemberDeleteResult, ...] - total_requested: int - successful_deletions: int - failed_deletions: int +class BulkTeamMemberDeleteResponse(ResourceResponse[tuple[TeamMemberDeleteResult, ...]]): + """`{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order.""" class TeamMemberInfoResponse(LiteLLM_TeamMembership): diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 53a6bd4f8c6..6bc8947e89f 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -80,12 +80,10 @@ POST /model/unblock POST /prompts/test POST /search_tools/test_connection POST /team/bulk_member_add -POST /team/bulk_member_delete POST /team/{team_id}/member/{user_id}/reset_spend POST /team/key/bulk_update POST /team/permissions_bulk_update POST /team/{team_id}/disable_logging -POST /user/bulk_delete POST /user/bulk_update # Alternate method or path for functionality the provider already manages elsewhere diff --git a/tests/proxy_behavior/management/test_team_bulk_member_delete.py b/tests/proxy_behavior/management/test_team_bulk_member_delete.py index 09a3cd54783..5283497c6bf 100644 --- a/tests/proxy_behavior/management/test_team_bulk_member_delete.py +++ b/tests/proxy_behavior/management/test_team_bulk_member_delete.py @@ -71,17 +71,20 @@ async def test_team_bulk_member_delete_authz_matrix( caller = world.keys[actor] resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {caller.cleartext}"}, - json={"team_id": scratch.prefix, "members": [{"user_id": v} for v in victims]}, + json={"members": [{"user_id": v} for v in victims]}, ) assert resp.status_code == expected_status, f"{actor.value} {shape}: {resp.status_code} {resp.text}" + if expected_status == 403: + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:forbidden" row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None assert keep in _member_ids(row), "unrelated member removed" if expected_status == 200: - assert [(r["user_id"], r["success"]) for r in resp.json()["results"]] == [(v, True) for v in victims] + assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [(v, True) for v in victims] assert not set(victims) & set(_member_ids(row)) else: assert set(victims) <= set(_member_ids(row)), "denied but members removed" @@ -94,18 +97,18 @@ async def test_team_bulk_member_delete_reports_each_row_in_order(proxy_client, p await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim, keep]) resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, - json={"team_id": scratch.prefix, "members": [{"user_id": stranger}, {"user_id": victim}]}, + json={"members": [{"user_id": stranger}, {"user_id": victim}]}, ) assert resp.status_code == 200, resp.text body = resp.json() - assert [(r["user_id"], r["success"]) for r in body["results"]] == [ + assert set(body) == {"data"} + assert [(r["user_id"], r["success"]) for r in body["data"]] == [ (stranger, False), (victim, True), ] - assert body["results"][0]["error"] == "User not found in team" - assert (body["successful_deletions"], body["failed_deletions"]) == (1, 1) + assert body["data"][0]["error"] == "User not found in team" row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None and _member_ids(row) == [keep] @@ -116,14 +119,61 @@ async def test_team_bulk_member_delete_row_naming_both_identifiers_is_422(proxy_ await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) resp = await proxy_client.post( - "/team/bulk_member_delete", + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, - json={ - "team_id": scratch.prefix, - "members": [{"user_id": victim, "user_email": f"{victim}@example.com"}], - }, + json={"members": [{"user_id": victim, "user_email": f"{victim}@example.com"}]}, ) assert resp.status_code == 422, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:invalid-request-body" + assert ( + resp.json()["detail"] + == "members.0: Value error, Each member must be identified by exactly one of user_id or user_email" + ) row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_query_param_is_400(proxy_client, prisma, scratch, world): + victim = scratch.tag("victim") + await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) + + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete?dry_run=1", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"members": [{"user_id": victim}]}, + ) + assert resp.status_code == 400, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert "dry_run" in resp.json()["detail"] + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_body_field_is_422(proxy_client, prisma, scratch, world): + victim = scratch.tag("victim") + await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=[victim]) + + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.prefix}/members/bulk_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "members": [{"user_id": victim}]}, + ) + assert resp.status_code == 422, resp.text + assert "team_id" in resp.json()["detail"] + + row = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert row is not None and victim in _member_ids(row) + + +async def test_team_bulk_member_delete_unknown_team_is_404_problem(proxy_client, scratch, world): + resp = await proxy_client.post( + f"/management/v1/teams/{scratch.tag('missing')}/members/bulk_delete", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"members": [{"user_id": scratch.tag("victim")}]}, + ) + assert resp.status_code == 404, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:team-not-found" diff --git a/tests/proxy_behavior/management/test_users_bulk_delete.py b/tests/proxy_behavior/management/test_users_bulk_delete.py new file mode 100644 index 00000000000..049234e737a --- /dev/null +++ b/tests/proxy_behavior/management/test_users_bulk_delete.py @@ -0,0 +1,137 @@ +import pytest + +from .actors import Actor +from .conftest import create_scratch_team, create_scratch_user + +pytestmark = pytest.mark.asyncio(loop_scope="session") + +_URL = "/management/v1/users/bulk_delete" + +# (id, actor, victims' org, expected status, whether the victims are gone afterwards) +_MATRIX = [ + ("org_a/proxy_admin", Actor.PROXY_ADMIN, "a", 200, True), + ("org_a/org_admin", Actor.ORG_ADMIN, "a", 200, True), + ("org_a/org_b_admin", Actor.ORG_B_ADMIN, "a", 200, False), + ("org_a/team_admin", Actor.TEAM_ADMIN, "a", 403, False), + ("org_a/internal_user", Actor.INTERNAL_USER, "a", 403, False), + ("org_a/owner", Actor.OWNER, "a", 403, False), + ("org_a/service_account", Actor.SERVICE_ACCOUNT, "a", 403, False), + ("no_org/proxy_admin", Actor.PROXY_ADMIN, None, 200, True), + ("no_org/org_admin", Actor.ORG_ADMIN, None, 200, False), +] + + +def _member_ids(row) -> list: + return [m["user_id"] for m in (row.members_with_roles or [])] + + +async def _seed_team_members(prisma, scratch, world, member_ids: list, org_id) -> None: + """Leave behind what /team/member_add would: roster entry, `teams` array, and org membership.""" + await create_scratch_team(prisma, scratch.prefix, organization_id=world.org_a_id, member_user_ids=member_ids) + await prisma.db.litellm_usertable.update_many( + where={"user_id": {"in": member_ids}}, data={"teams": {"set": [scratch.prefix]}} + ) + if org_id is None: + return + for uid in member_ids: + await prisma.db.litellm_organizationmembership.create( + data={"user_id": uid, "organization_id": org_id, "user_role": "internal_user"} + ) + + +@pytest.mark.parametrize( + "actor,org,expected_status,expect_deleted", + [(a, o, s, d) for (_id, a, o, s, d) in _MATRIX], + ids=[s[0] for s in _MATRIX], +) +async def test_users_bulk_delete_authz_matrix( + actor: Actor, + org, + expected_status: int, + expect_deleted: bool, + proxy_client, + prisma, + scratch, + world, +): + victims = [await create_scratch_user(prisma, scratch.prefix, suffix=s) for s in ("v1", "v2")] + keep = await create_scratch_user(prisma, scratch.prefix, suffix="keep") + await _seed_team_members(prisma, scratch, world, victims + [keep], world.org_a_id if org == "a" else None) + + resp = await proxy_client.post( + _URL, + headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"}, + json={"user_ids": victims}, + ) + assert resp.status_code == expected_status, f"{actor.value}: {resp.status_code} {resp.text}" + + team = await prisma.db.litellm_teamtable.find_unique(where={"team_id": scratch.prefix}) + assert team is not None and keep in _member_ids(team), "unrelated member removed" + remaining = {u.user_id for u in await prisma.db.litellm_usertable.find_many(where={"user_id": {"in": victims}})} + if expected_status == 403: + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:forbidden" + assert remaining == set(victims), "denied but users deleted" + assert set(victims) <= set(_member_ids(team)), "denied but members removed" + return + + body = resp.json() + assert set(body) == {"data"} + rows = [(r["user_id"], r["success"], r["teams_removed"]) for r in body["data"]] + if expect_deleted: + assert rows == [(v, True, [scratch.prefix]) for v in victims] + assert remaining == set() + assert not set(victims) & set(_member_ids(team)) + return + assert rows == [(v, False, []) for v in victims] + assert all("not within your admin scope" in r["error"] for r in body["data"]) + assert remaining == set(victims), "out-of-scope rows reported failed but users deleted" + assert set(victims) <= set(_member_ids(team)) + + +async def test_users_bulk_delete_reports_each_row_in_order(proxy_client, prisma, scratch, world): + victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim") + ghost = scratch.tag("ghost") + + resp = await proxy_client.post( + _URL, + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"user_ids": [ghost, victim, victim]}, + ) + assert resp.status_code == 200, resp.text + assert [(r["user_id"], r["success"]) for r in resp.json()["data"]] == [ + (ghost, False), + (victim, True), + (victim, False), + ] + assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is None + + +async def test_users_bulk_delete_unknown_query_param_is_400_problem(proxy_client, prisma, scratch, world): + victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim") + + resp = await proxy_client.post( + f"{_URL}?dry_run=1", + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"user_ids": [victim]}, + ) + assert resp.status_code == 400, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:unknown-query-parameter" + assert "dry_run" in resp.json()["detail"] + assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None + + +async def test_users_bulk_delete_unknown_body_field_is_422_problem(proxy_client, prisma, scratch, world): + victim = await create_scratch_user(prisma, scratch.prefix, suffix="victim") + + resp = await proxy_client.post( + _URL, + headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, + json={"user_ids": [victim], "dry_run": True}, + ) + assert resp.status_code == 422, resp.text + assert resp.headers["content-type"] == "application/problem+json" + assert resp.json()["type"] == "urn:litellm:error:invalid-request-body" + assert "dry_run" in resp.json()["detail"] + assert await prisma.db.litellm_usertable.find_unique(where={"user_id": victim}) is not None 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 49aed00177a..077eb90ddce 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 @@ -5,14 +5,14 @@ from contextlib import asynccontextmanager from typing import Final import pytest -from fastapi import HTTPException from pydantic import BaseModel, ConfigDict, ValidationError -from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, MemberDeleteRequest, UserAPIKeyAuth +from litellm.proxy._types import LiteLLM_TeamTable, LitellmUserRoles, Member, UserAPIKeyAuth from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache +from litellm.proxy.list_api.common import ManagementProblem 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 +from litellm.types.proxy.management_endpoints.team_endpoints import BulkTeamMemberDeleteRequest, TeamMemberRef ADMIN: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin") INTERNAL: Final = UserAPIKeyAuth(user_id="someone", user_role=LitellmUserRoles.INTERNAL_USER) @@ -237,7 +237,8 @@ async def _remove( cache: UserApiKeyCache | None = None, ): return await bulk_remove_team_members( - data=BulkTeamMemberDeleteRequest(team_id=team_id, members=tuple(MemberDeleteRequest(**m) for m in members)), + team_id=team_id, + data=BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(**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(), @@ -260,10 +261,10 @@ async def test_bulk_delete_removes_users_from_every_team_and_store(): org_memberships=[{"user_id": "u1", "organization_id": "o1", "user_role": "internal_user"}], ) - response = await _delete(prisma, ["u1", "u2"]) + results = await _delete(prisma, ["u1", "u2"]) - assert (response.total_requested, response.successful_deletions, response.failed_deletions) == (2, 2, 0) - assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in response.results] == [ + assert len(results) == 2 + assert [(r.user_id, r.user_email, r.success, r.teams_removed) for r in results] == [ ("u1", "u1@example.com", True, ("t1", "t2")), ("u2", "u2@example.com", True, ("t1",)), ] @@ -297,9 +298,9 @@ async def test_bulk_delete_leaves_teammates_who_share_the_deleted_users_email_al ], ) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert _roster(prisma, "t1") == ["twin"] assert set(prisma.db.litellm_usertable.rows) == {"twin"} and prisma.db.litellm_usertable.rows["twin"].teams == [ "t1" @@ -319,9 +320,9 @@ async def test_bulk_delete_removes_the_deleted_users_email_only_roster_entry(): ) prisma = _FakePrisma(users=[_user("u1", "t1"), _user("keep", "t1")], teams=[team]) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert _roster(prisma, "t1") == ["keep"] assert set(prisma.db.litellm_usertable.rows) == {"keep"} @@ -334,9 +335,9 @@ async def test_bulk_delete_finds_teams_through_membership_rows_when_user_teams_a memberships=[("t1", "u1")], ) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert response.results[0].teams_removed == ("t1",) + assert results[0].teams_removed == ("t1",) assert _roster(prisma, "t1") == ["keep"] assert prisma.db.litellm_teammembership.rows == [] @@ -350,9 +351,9 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives( prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[team], on_lock=concurrent_member_add) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert response.results[0].success is True + assert results[0].success is True assert _roster(prisma, "t1") == ["late"] @@ -360,10 +361,10 @@ async def test_bulk_delete_reads_roster_under_lock_so_a_concurrent_add_survives( async def test_bulk_delete_reports_missing_and_duplicate_ids_per_item_and_still_deletes_the_rest(): prisma = _FakePrisma(users=[_user("u1")]) - response = await _delete(prisma, ["u1", "ghost", "u1"]) + results = await _delete(prisma, ["u1", "ghost", "u1"]) - assert (response.successful_deletions, response.failed_deletions) == (1, 2) - assert [(r.user_id, r.success, r.error) for r in response.results] == [ + assert [r.success for r in results].count(True) == 1 + assert [(r.user_id, r.success, r.error) for r in results] == [ ("u1", True, None), ("ghost", False, "User id=ghost not found"), ("u1", False, "Duplicate user_id in request: u1"), @@ -381,9 +382,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_ ) cache = _cache_with("k1") - response = await _delete(prisma, ["u1", "u2"], cache=cache) + results = await _delete(prisma, ["u1", "u2"], cache=cache) - assert [(r.user_id, r.success, r.teams_removed, r.error) for r in response.results] == [ + assert [(r.user_id, r.success, r.teams_removed, r.error) for r in results] == [ ("u1", False, (), "Failed to delete user: lock timeout"), ("u2", False, (), "Failed to delete user: lock timeout"), ] @@ -397,9 +398,9 @@ async def test_bulk_delete_rolls_back_every_team_and_user_when_one_team_rewrite_ async def test_bulk_delete_skips_teams_the_user_still_names_but_which_no_longer_exist(): prisma = _FakePrisma(users=[_user("u1", "gone", "t1")], teams=[_team("t1", "u1", "keep")]) - response = await _delete(prisma, ["u1"]) + results = await _delete(prisma, ["u1"]) - assert [(r.success, r.teams_removed) for r in response.results] == [(True, ("t1",))] + assert [(r.success, r.teams_removed) for r in results] == [(True, ("t1",))] assert prisma.db.litellm_usertable.rows == {} and _roster(prisma, "t1") == ["keep"] assert prisma.locks == ["t1"] @@ -414,9 +415,9 @@ async def test_bulk_delete_rolls_back_every_user_row_and_reports_it_per_row_when ) cache = _cache_with("k1") - response = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache) + results = await _delete(prisma, ["u1", "u2", "ghost"], cache=cache) - assert [(r.user_id, r.success, r.error) for r in response.results] == [ + assert [(r.user_id, r.success, r.error) for r in results] == [ ("u1", False, "Failed to delete user: connection reset"), ("u2", False, "Failed to delete user: connection reset"), ("ghost", False, "User id=ghost not found"), @@ -452,10 +453,10 @@ async def test_bulk_delete_evicts_deleted_keys_and_users_from_the_auth_cache(): async def test_bulk_delete_rejects_non_admin_callers_before_touching_the_db(): prisma = _FakePrisma(users=[_user("u1")]) - with pytest.raises(HTTPException) as exc: + with pytest.raises(ManagementProblem) as exc: await _delete(prisma, ["u1"], caller=INTERNAL) - assert exc.value.status_code == 403 + assert exc.value.problem.status == 403 assert set(prisma.db.litellm_usertable.rows) == {"u1"} @@ -471,10 +472,10 @@ async def test_org_admin_deletes_only_users_fully_inside_their_orgs(): ], ) - response = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN) + results = await _delete(prisma, ["inside", "straddles", "orgless"], caller=ORG_ADMIN) - assert [r.success for r in response.results] == [True, False, False] - assert all("not within your admin scope" in (r.error or "") for r in response.results[1:]) + assert [r.success for r in results] == [True, False, False] + assert all("not within your admin scope" in (r.error or "") for r in results[1:]) assert set(prisma.db.litellm_usertable.rows) == {"straddles", "orgless"} assert {(m["user_id"], m["organization_id"]) for m in prisma.db.litellm_organizationmembership.rows} == { ("org-admin", "o1"), @@ -496,10 +497,9 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest(): ], ) - response = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}]) + results = await _remove(prisma, "t1", [{"user_id": "u1"}, {"user_email": "u2@example.com"}]) - assert (response.team_id, response.successful_deletions, response.failed_deletions) == ("t1", 2, 0) - assert [(r.user_id, r.user_email, r.success) for r in response.results] == [ + assert [(r.user_id, r.user_email, r.success) for r in results] == [ ("u1", None, True), (None, "u2@example.com", True), ] @@ -516,13 +516,12 @@ async def test_bulk_member_delete_removes_by_id_and_email_and_keeps_the_rest(): async def test_bulk_member_delete_reports_members_not_on_the_team_without_rewriting_the_roster(): prisma = _FakePrisma(users=[_user("u1", "t1"), _user("elsewhere")], teams=[_team("t1", "u1")]) - response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}]) + results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}, {"user_email": "nobody@example.com"}]) - assert [(r.success, r.error) for r in response.results] == [ + assert [(r.success, r.error) for r in results] == [ (False, "User not found in team"), (False, "User not found in team"), ] - assert (response.successful_deletions, response.failed_deletions) == (0, 2) assert prisma.db.litellm_teamtable.update_calls == 0 assert _roster(prisma, "t1") == ["u1"] @@ -536,9 +535,9 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe tokens=[{"token": "orphan-key", "user_id": "elsewhere", "team_id": "t1"}], ) - response = await _remove(prisma, "t1", [{"user_id": "elsewhere"}]) + results = await _remove(prisma, "t1", [{"user_id": "elsewhere"}]) - assert response.results[0].success is False + assert 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"] @@ -547,17 +546,16 @@ async def test_bulk_member_delete_leaves_keys_and_memberships_of_unmatched_membe 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( + results = 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] == [ + assert [(r.success, r.error) for r in 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"] @@ -583,9 +581,9 @@ async def test_bulk_member_delete_evicts_the_removed_team_keys_from_the_auth_cac 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")]) - response = await _remove(prisma, "t1", [{"user_id": "stale"}]) + results = await _remove(prisma, "t1", [{"user_id": "stale"}]) - assert response.results[0].success is True + assert results[0].success is True assert prisma.db.litellm_usertable.rows["stale"].teams == [] assert prisma.db.litellm_teammembership.rows == [] assert _roster(prisma, "t1") == ["other"] and prisma.db.litellm_teamtable.update_calls == 0 @@ -595,13 +593,13 @@ async def test_bulk_member_delete_cleans_a_user_whose_teams_array_still_names_th async def test_bulk_member_delete_rejects_unknown_team_and_unauthorized_callers(): prisma = _FakePrisma(users=[_user("u1", "t1")], teams=[_team("t1", "u1")]) - with pytest.raises(HTTPException) as missing: + with pytest.raises(ManagementProblem) as missing: await _remove(prisma, "nope", [{"user_id": "u1"}]) - with pytest.raises(HTTPException) as forbidden: + with pytest.raises(ManagementProblem) as forbidden: await _remove(prisma, "t1", [{"user_id": "u1"}], caller=INTERNAL) - assert missing.value.status_code == 400 - assert forbidden.value.status_code == 403 + assert missing.value.problem.status == 404 + assert forbidden.value.problem.status == 403 assert _roster(prisma, "t1") == ["u1"] and prisma.locks == [] @@ -611,9 +609,9 @@ async def test_team_admin_may_bulk_remove_members(): team.members_with_roles[0].role = "admin" prisma = _FakePrisma(users=[_user("lead", "t1"), _user("u1", "t1")], teams=[team]) - response = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead")) + results = await _remove(prisma, "t1", [{"user_id": "u1"}], caller=UserAPIKeyAuth(user_id="lead")) - assert response.results[0].success is True + assert results[0].success is True assert _roster(prisma, "t1") == ["lead"] @@ -623,22 +621,24 @@ def test_request_models_enforce_batch_bounds(): with pytest.raises(ValidationError): BulkDeleteUserRequest(user_ids=tuple(f"u{i}" for i in range(501))) with pytest.raises(ValidationError): - BulkTeamMemberDeleteRequest(team_id="t1", members=()) + BulkTeamMemberDeleteRequest(members=()) with pytest.raises(ValidationError): - BulkTeamMemberDeleteRequest( - team_id="t1", members=tuple(MemberDeleteRequest(user_id=f"u{i}") for i in range(501)) - ) + BulkTeamMemberDeleteRequest(members=tuple(TeamMemberRef(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"),) - ) + BulkTeamMemberDeleteRequest.model_validate({"members": [{"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" - ) + BulkTeamMemberDeleteRequest.model_validate({"members": [{}]}) + assert BulkTeamMemberDeleteRequest(members=(TeamMemberRef(user_id="u1"),)).members[0].user_id == "u1" + + +def test_request_models_reject_unknown_fields(): + with pytest.raises(ValidationError, match="team_id"): + BulkTeamMemberDeleteRequest.model_validate({"team_id": "t1", "members": [{"user_id": "u1"}]}) + with pytest.raises(ValidationError, match="role"): + BulkTeamMemberDeleteRequest.model_validate({"members": [{"user_id": "u1", "role": "admin"}]}) + with pytest.raises(ValidationError, match="dry_run"): + BulkDeleteUserRequest.model_validate({"user_ids": ["u1"], "dry_run": True}) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 0c8913d9c6c..b068565e697 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -8470,6 +8470,71 @@ export interface paths { patch?: never; trace?: never; }; + "/management/v1/teams/{team_id}/members/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Team Members Action + * @description Remove up to 500 members from one team in one call. Same authorization as + * `/team/member_delete`: proxy admins, the team's admins, and admins of the team's + * organization. Each member is named by exactly one of `user_id` or `user_email`; + * unknown body fields are a 422 and an unknown team is a 404. + * + * `data` holds one result per requested member, in request order. A row is + * `success: false` with an `error` when it names nobody on the team or repeats an + * earlier row. The roster is rewritten once, under the team's advisory lock, so a + * concurrent member_add is never overwritten from a stale read. + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/management/v1/teams/team-1/members/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"members": [{"user_id": "user-1"}, {"user_email": "user-2@example.com"}]}' + * ``` + */ + post: operations["bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/management/v1/users/bulk_delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Users Action + * @description Delete up to 500 users in one call, taking each out of every team it belongs to. + * Same authorization as `/user/delete`: proxy admins may delete anyone, org admins + * only users inside organizations they administer. Unknown body fields are a 422. + * + * `data` holds one result per requested `user_id`, in request order. A row is + * `success: false` with an `error` when the id is unknown, repeated in the request, + * or outside the caller's scope. Rows that pass those checks are deleted together, + * in one transaction, so either all of them go or none does. + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/management/v1/users/bulk_delete' --header 'Authorization: Bearer sk-1234' --header 'Content-Type: application/json' --data '{"user_ids": ["user-1", "user-2"]}' + * ``` + */ + post: operations["bulk_delete_users_action_management_v1_users_bulk_delete_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/mcp": { parameters: { query?: never; @@ -15056,26 +15121,6 @@ export interface paths { patch?: never; trace?: never; }; - "/team/bulk_member_delete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Bulk Team Member Delete - * @description Remove up to 500 members from one team; same authorization as `/team/member_delete`. - */ - post: operations["bulk_team_member_delete_team_bulk_member_delete_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/team/daily/activity": { parameters: { query?: never; @@ -16498,26 +16543,6 @@ export interface paths { patch?: never; trace?: never; }; - "/user/bulk_delete": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Bulk Delete User - * @description Delete up to 500 users, removing each from every team; same authorization as `/user/delete`. - */ - post: operations["bulk_delete_user_user_bulk_delete_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/user/bulk_update": { parameters: { query?: never; @@ -24543,21 +24568,21 @@ export interface components { /** Budgets */ budgets: string[]; }; - /** BulkDeleteUserRequest */ + /** + * BulkDeleteUserRequest + * @description Body of `POST /management/v1/users/bulk_delete`. + */ BulkDeleteUserRequest: { /** User Ids */ user_ids: string[]; }; - /** BulkDeleteUserResponse */ - BulkDeleteUserResponse: { - /** Failed Deletions */ - failed_deletions: number; - /** Results */ - results: components["schemas"]["UserDeleteResult"][]; - /** Successful Deletions */ - successful_deletions: number; - /** Total Requested */ - total_requested: number; + /** + * BulkDeleteUsersResponse + * @description `{data: [...]}` with one `UserDeleteResult` per requested user, in request order. + */ + BulkDeleteUsersResponse: { + /** Data */ + data: components["schemas"]["UserDeleteResult"][]; }; /** * BulkTeamMemberAddRequest @@ -24596,25 +24621,21 @@ export interface components { [key: string]: unknown; } | null; }; - /** BulkTeamMemberDeleteRequest */ + /** + * BulkTeamMemberDeleteRequest + * @description Body of `POST /management/v1/teams/{team_id}/members/bulk_delete`. + */ BulkTeamMemberDeleteRequest: { /** Members */ - members: components["schemas"]["MemberDeleteRequest"][]; - /** Team Id */ - team_id: string; + members: components["schemas"]["TeamMemberRef"][]; }; - /** BulkTeamMemberDeleteResponse */ + /** + * BulkTeamMemberDeleteResponse + * @description `{data: [...]}` with one `TeamMemberDeleteResult` per requested member, in request order. + */ BulkTeamMemberDeleteResponse: { - /** Failed Deletions */ - failed_deletions: number; - /** Results */ - results: components["schemas"]["TeamMemberDeleteResult"][]; - /** Successful Deletions */ - successful_deletions: number; - /** Team Id */ - team_id: string; - /** Total Requested */ - total_requested: number; + /** Data */ + data: components["schemas"]["TeamMemberDeleteResult"][]; }; /** * BulkUpdateKeyRequest @@ -31982,13 +32003,6 @@ export interface components { */ user_id?: string | null; }; - /** MemberDeleteRequest */ - MemberDeleteRequest: { - /** User Email */ - user_email?: string | null; - /** User Id */ - user_id?: string | null; - }; /** MemoryCreateRequest */ MemoryCreateRequest: { /** @@ -37254,7 +37268,7 @@ export interface components { }; /** * TeamMemberDeleteResult - * @description Outcome for one row of `/team/bulk_member_delete`. + * @description Outcome for one requested member, in request order. */ TeamMemberDeleteResult: { /** Error */ @@ -37296,6 +37310,16 @@ export interface components { /** User Id */ user_id: string; }; + /** + * TeamMemberRef + * @description One member to remove, named by exactly one of `user_id` or `user_email`. + */ + TeamMemberRef: { + /** User Email */ + user_email?: string | null; + /** User Id */ + user_id?: string | null; + }; /** TeamMemberUpdateRequest */ TeamMemberUpdateRequest: { /** @@ -39447,7 +39471,7 @@ export interface components { }; /** * UserDeleteResult - * @description Outcome for one row of `/user/bulk_delete`. `teams_removed` lists the teams the user was taken out of. + * @description Outcome for one requested user, in request order. `teams_removed` lists the teams the user left. */ UserDeleteResult: { /** Error */ @@ -51236,6 +51260,77 @@ export interface operations { }; }; }; + bulk_delete_team_members_action_management_v1_teams__team_id__members_bulk_delete_post: { + parameters: { + query?: never; + header?: never; + path: { + team_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkTeamMemberDeleteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + bulk_delete_users_action_management_v1_users_bulk_delete_post: { + parameters: { + query?: never; + header?: { + /** @description Who the caller is acting for; recorded on the audit log entries this call writes. */ + "litellm-changed-by"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkDeleteUserRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkDeleteUsersResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; aggregate_mcp_route_mcp_get: { parameters: { query?: never; @@ -59006,39 +59101,6 @@ export interface operations { }; }; }; - bulk_team_member_delete_team_bulk_member_delete_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BulkTeamMemberDeleteRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BulkTeamMemberDeleteResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_team_daily_activity_team_daily_activity_get: { parameters: { query?: { @@ -60783,42 +60845,6 @@ export interface operations { }; }; }; - bulk_delete_user_user_bulk_delete_post: { - parameters: { - query?: never; - header?: { - /** @description The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability */ - "litellm-changed-by"?: string | null; - }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["BulkDeleteUserRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BulkDeleteUserResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; bulk_user_update_user_bulk_update_post: { parameters: { query?: never;