mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-17 23:51:30 +00:00
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>
This commit is contained in:
parent
ef71222349
commit
ca04b03c2c
17 changed files with 735 additions and 374 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal file
94
litellm/proxy/management_endpoints/management_v1/teams.py
Normal file
|
|
@ -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.",
|
||||
)
|
||||
)
|
||||
101
litellm/proxy/management_endpoints/management_v1/users.py
Normal file
101
litellm/proxy/management_endpoints/management_v1/users.py
Normal file
|
|
@ -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.",
|
||||
)
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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`."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal file
137
tests/proxy_behavior/management/test_users_bulk_delete.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
314
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
314
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue