fix(scim)!: delete a group through the team delete's bulk path instead of one member at a time (#47544)

* fix(scim): write a group's roster in one locked transaction instead of one member at a time

POST, PUT and PATCH /scim/v2/Groups resolve every member in one query and write the roster, the
membership rows and the users' team tags in one transaction under the team's advisory lock, so a
500-member push runs a fixed number of statements instead of thousands. PUT now answers with the
roster it just wrote

* test(scim): assert the roster the IdP gets back and boot the bulk roster cell with two workers

* fix(scim): skip the team lock for a rename-only PATCH and lock member rows in id order before the roster write

A PATCH naming no members opened the roster transaction and took the team advisory lock for nothing. A SIGKILLed worker then left its orphaned query engine holding that lock until the 60 s transaction timeout, and /team/delete, which takes the same lock, timed out behind it. An empty delta is now answered from a plain team read, with no transaction and no lock

Two groups trading members in parallel locked user rows in opposite orders, and one request died with a Postgres deadlock. Every user row a push touches is now locked with SELECT ... ORDER BY user_id FOR UPDATE before the first write, so the two requests serialize

The bulk roster cell gets a 120 s client through a new owned_proxy_process client_timeout, so an unfixed push fails on the statement count instead of the harness clock, requires positive counts on both pushes, and gains a parallel roster-trade test that answered 500 before the lock

* fix(scim): evict the cached team on a group rename and read a no-op roster from the writer

A rename-only PUT or PATCH wrote the new alias and left the team cached under
its id and old alias on every worker, so keys kept logging spend against the
old team alias until the TTL expired. The group write now evicts the cached
team (and broadcasts the eviction) right after the alias or metadata update.

sync_team_roster reads the team from the writer when the plan names nobody,
so a replica cannot hand back the pre-rename row; a plan already matching the
roster returns inside the lock without writing, evicting, or emitting the
members metric; the response is built from the written row, so lastModified
reflects the roster write; and detaching a removed member bumps the user's
updated_at like every other user write.

* fix(scim): bump detached members' updated_at in UTC

The detach UPDATE wrote updated_at = CURRENT_TIMESTAMP, which Postgres casts
into Prisma's naive timestamp column in the session timezone, so on a database
whose session is not UTC the removed member's row landed hours away from the
UTC instants Prisma itself writes. Write NOW() AT TIME ZONE 'UTC' instead, and
have the bulk roster cell assert the bumped row sits between two reads of the
database's own UTC clock taken around the replace.

* fix(scim): delete a group through the team delete's bulk path instead of one member at a time

* fix(scim): chunk the group write's member lookups under the bind cap

Every Prisma "in" filter the group write issues bound one parameter per
member with nothing bounding the list, which check_unbounded_in_lists
reports as 11 new findings. The single-field lookups now go through
litellm.repositories.chunked_in (caller_transaction inside the roster
transaction, per_chunk_ok for the role recompute), and the one
cross-field OR read slices its values at IN_LIST_CHUNK_SIZE and records
the bound. A push of IN_LIST_CHUNK_SIZE + 1 members now reads in two
slices, and a member named only in the last slice still resolves.

* fix(scim): demote a deleted admin group's members before the team delete so a failed delete stays retryable

* fix(scim): recompute member roles again after the team delete so a concurrent group write cannot regrant admin

* fix(proxy): send bulk cache evictions in slices so the broadcaster drops none

Veria pointed out that the invalidation publisher caps its pending tasks at
1,024 and drops the excess with a warning, and that a group push now detaches
every removed member's keys in one call instead of one member at a time, so a
removal of more than roughly 340 members (three cache keys each) silently left
stale key objects on the other replicas. The merge base never hit the cap
because each member was a separate request.

Bulk evictions (team, user, organization, and session key deletes, plus the
roster settle) now publish in slices of 256 and wait, bounded at two seconds,
for the backlog already handed to the event loop before each slice. A Redis
that never answers costs a bulk caller at most that wait per slice instead of
wedging the request, and single-key callers are untouched. Regression tests
push 1,500 keys and a 500-member removal through a slow publisher and assert
every eviction reaches Redis; both fail against the previous tip.

* fix(proxy): clear this worker's cache before pacing bulk eviction broadcasts

Greptile pointed out that pacing the broadcasts in slices also paced the
local eviction: a bulk delete waited for the publish backlog before dropping
the next slice from this worker's own memory, so on a Redis that accepts
connections but never answers a key deleted from the database kept
authenticating on the handling worker for up to two seconds per slice. The
merge base cleared every key locally at once.

Bulk evictions now drop every affected entry from this worker's memory first,
through the cache's own partition lookup since key objects live apart from
the other auth objects, and only then publish in paced slices. The roster
settle does the same for the deleted tokens, the JWT mapping keys and every
changed member's user and membership entries before any step that can wait.
Regression tests run both paths against a publisher that never answers and
check that nothing is still cached on this worker before the first bound
elapses; both fail with the local eviction removed.

* fix(scim): write recomputed member roles only where the membership still backs them

* fix(team): report the users a team delete detaches so SCIM demotes late-added members

* test(scim): audit cells for the bulk group delete

Move the SCIM group helpers into tests/integration/_support/scim.py and add the delete cells: a member's other
group survives, the admin group's members are demoted and the audit row names the SCIM caller, a member a
concurrent PUT adds while the delete waits on the team lock is demoted too, unknown, repeated, non-admin and
hostile ids, an empty group, the peer proxy, a 20-delete burst across both proxies, and a delete after a worker
was SIGKILLed

* fix(proxy): drop the Redis copies of bulk-evicted auth objects before pacing the broadcasts

* docs(proxy): drop the module docstring that repeated the PR description

* fix(team): sweep deleted team references only under the lock so a late-added member is demoted

The unlocked sweep that ran before the team's advisory lock stripped a member a
concurrent group write added after the roster was read, so neither the roster
snapshot nor the locked sweep's RETURNING named them and the SCIM recompute
left them proxy_admin after the admin group was gone. The locked sweep does the
same strip and reports whom it stripped, so it is the only sweep now.

The integration cell holds the delete on a member's user row so a PATCH lands
between the roster read and the lock, then checks the late member is demoted.
Both waiter queries in the SCIM support module return their count as text so
the first poll parses.

* chore(team): carry the unbounded-IN baseline entries to delete_validated_teams

The delete pass moved from delete_team and the unlocked sweep into delete_validated_teams, so the checker's baseline names the function the two team_id IN lists now live in

* test(scim): expect the master key alias in the audit row and send post-kill requests on a fresh connection

* fix(scim): bound bulk cache evictions to one Redis round trip and close lock-order gaps

Bulk key evictions now drop entries from memory, delete them from Redis in one
chunked DEL, and broadcast every key in one pipelined publish, so a large roster
removal no longer overflows the publisher's pending cap or costs a Redis round
trip per member. A push waits for in-flight membership loads before evicting.

User rows are locked in byte order to match the spend writer, a bulk user delete
takes every team lock before its first user write, and a POST whose roster write
fails deletes the empty team so the IdP retry creates it again.

* test(team): observe delete broadcasts at the Redis layer

* fix(scim): lock user rows in id order on every multi-row writer and create unprovisioned members concurrently

* fix(team): chunk the email lookup behind the member row lock

* fix(scim): create a member deleted mid-push again instead of failing the group write

* fix(scim): refuse strict-mode mid-push deletes with 400 and check license seats once per batch

A member deleted mid-push is created again only when scim_upsert_user allows it,
under the verb's own created_via. New group members are checked against the
license seat limit as one batch before any is created. Auth cache publish drops
log the key count instead of the key.

* fix(scim): lock member rows while recomputing admin roles after a group push

* fix(users): re-read teams under the row locks in bulk user delete so a racing group push keeps no deleted member

* fix(users): take /user/delete and SCIM DELETE /Users users off their current teams under the row locks so a racing group push keeps no deleted member

* fix(scim): keep retrying a push while deletes race it, lock rows in member bulk delete, evict removed keys from Redis first

A group push gave up after one retry when SCIM user deletes kept landing between attempts, answering 500 in 9 of 10 race rounds. It now retries while each attempt finds newly deleted members, and gives up only when a member it created again goes missing a second time.

The team member bulk delete now locks the removed members' user rows in the shared order and takes the team off their teams arrays in one array_remove statement, so it no longer writes back a teams array read before a concurrent push added another team.

A push that removes members now drops their deleted team keys from memory and Redis together with the members' other cache entries before the team eviction, so a removed key cannot refill from Redis mid-cleanup.

* fix(user-delete): keep the team alias label on the team members gauge after removals

* test(integration): count relay statements over plaintext so an SSL-enabled Postgres does not hide them

* fix(user-delete): skip a team deleted while the user delete waited on its lock instead of answering 404

* fix(scim): answer 404 and roll back when the team is deleted during a roster write

* test(integration): audit cells for SCIM bulk roster writes, user delete locking and the eviction broadcast

* test(scim): cover deleting a SCIM group through the team delete's bulk path

* fix(scim): evict a deleted group's member caches in bulk and drop cached roles after a recompute

* fix(scim): answer 404 to a group delete that lost a race to another delete

* fix(scim): chunk the locked team lookup and delete in the team delete transaction

* fix(scim): keep service-account keys and team models on a group delete and write its records once under the lock

* fix(scim): demote a deleted group's members inside the delete transaction

The admin-group role write now runs on the team delete's transaction, over the
roster read under the team lock, so a failed delete leaves roles untouched and
a member added while the delete waited is demoted with the rest. The delete
transaction also waits for a free connection as long as it may run, so a burst
of deletes queues instead of failing after Prisma's two-second default. Every
member in the 5-vs-500 statement test now holds a key.

* fix(audit): write deleted keys' audit rows in one insert

A delete that removes many keys wrote one audit insert task per key, and those tasks held pool connections the delete's follow-up writes waited on. The race test now waits on any lock wait behind the delete.
This commit is contained in:
Mateo Wang 2026-10-21 00:59:23 +00:00 • committed by GitHub
parent 3c28f4bf1e
commit e333ccabdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1744 additions and 460 deletions

View file

@ -254,39 +254,45 @@ class KeyManagementEventHooks:
litellm_changed_by: str | None = None,
) -> None:
from litellm.proxy.management_helpers.audit_logs import (
create_audit_log_for_update,
create_audit_logs_for_updates,
get_audit_log_changed_by,
is_audit_logging_enabled,
track_audit_task,
)
from litellm.proxy.proxy_server import litellm_proxy_admin_name
if not is_audit_logging_enabled():
return
for key in keys_being_deleted:
key_row = LiteLLM_VerificationToken.model_validate(key, from_attributes=True)
if key_row.token is None:
continue
_key_row = key_row.model_dump_json(exclude_none=True)
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=get_audit_log_changed_by(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.token,
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=key_row.token,
action="deleted",
updated_values="{}",
before_value=_key_row,
)
)
changed_by: Final = get_audit_log_changed_by(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
key_rows: Final = (
LiteLLM_VerificationToken.model_validate(key, from_attributes=True) for key in keys_being_deleted
)
audit_rows: Final = tuple(
LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=changed_by,
changed_by_api_key=user_api_key_dict.token,
table_name=LitellmTableNames.KEY_TABLE_NAME,
object_id=key_row.token,
action="deleted",
updated_values="{}",
before_value=key_row.model_dump_json(exclude_none=True),
)
for key_row in key_rows
if key_row.token is not None
)
if not audit_rows:
return
track_audit_task(
asyncio.create_task(
create_audit_logs_for_updates(audit_rows), name=f"{len(audit_rows)} deleted key audit logs"
)
)
@staticmethod
async def _store_virtual_key_in_secret_manager(secret_name: str, secret_token: str, team_id: str | None = None):

View file

@ -11,7 +11,7 @@ from dataclasses import dataclass
from functools import partial, reduce
from itertools import chain, groupby
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, NamedTuple, NoReturn, Protocol, TypeAlias, overload
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeAlias, overload
from fastapi import (
APIRouter,
@ -53,7 +53,12 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
delete_cache_team_object,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
broadcast_evictions,
evict_and_broadcast,
evict_local,
evict_shared,
)
from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports
_safe_get_request_headers, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
safe_get_request_headers,
@ -64,6 +69,7 @@ from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
from litellm.proxy.management_endpoints.team_endpoints import (
delete_validated_team_rows,
new_team,
team_member_add,
team_member_delete,
@ -104,6 +110,7 @@ from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.proxy.management_endpoints.scim_v2 import *
if TYPE_CHECKING:
from prisma import Prisma
from prisma.models import LiteLLM_VerificationToken as PrismaVerificationToken
@ -317,11 +324,15 @@ async def _check_team_exists(team_id: str) -> LiteLLM_TeamTable:
team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id})
if not team:
raise HTTPException(status_code=404, detail={"error": f"Group not found with ID: {team_id}"})
raise _group_not_found(team_id)
return team
def _group_not_found(team_id: str) -> HTTPException:
return HTTPException(status_code=404, detail={"error": f"Group not found with ID: {team_id}"})
def _extract_scim_user_data(user: SCIMUser) -> ScimUserData:
"""Extract common data from SCIMUser object."""
user_email = None
@ -482,10 +493,7 @@ async def _recompute_scim_member_roles(prisma_client: PrismaClient, user_ids: It
Recompute and persist each user's global proxy role from their resulting team
membership. No-op unless scim_admin_group is configured, so a SCIM group write
that drops a member from the admin group demotes them just like the user
endpoints do, and the role is left untouched when the feature is off. The users'
rows are locked through ``USER_ROWS_LOCK_SQL`` before their teams are read, the
same lock a roster write takes, so a concurrent group push cannot change a
member's teams between the read and the role write.
endpoints do, and the role is left untouched when the feature is off.
"""
admin_group: Final = await _get_scim_admin_group()
if admin_group is None:
@ -494,28 +502,46 @@ async def _recompute_scim_member_roles(prisma_client: PrismaClient, user_ids: It
ids: Final = tuple(dict.fromkeys(user_ids))
if not ids:
return
default_role: Final = _default_scim_user_role()
async with prisma_client.tx(timeout=ROSTER_TX_TIMEOUT) as tx:
await tx.query_raw(USER_ROWS_LOCK_SQL, sorted(ids))
rows: Final = await find_many_in(user_tx_db(tx), "user_id", ids)
team_ids: Final = tuple(dict.fromkeys(chain.from_iterable(row.teams for row in rows)))
teams: Final = await find_many_in(team_tx_db(tx), "team_id", team_ids)
alias_of: Final = MappingProxyType({team.team_id: team.team_alias for team in teams})
resolved: Final = tuple(
(
row.user_id,
_resolve_scim_user_role(
[SCIMUserGroup(value=team_id, display=alias_of.get(team_id)) for team_id in row.teams],
admin_group,
default_role,
),
)
for row in rows
await _write_scim_member_roles_tx(tx, ids, admin_group=admin_group)
from litellm.proxy.proxy_server import user_api_key_cache
evict_local(cache_keys=ids, user_api_key_cache=user_api_key_cache)
await evict_shared(cache_keys=ids, cache=user_api_key_cache)
await broadcast_evictions(cache_keys=ids)
async def _write_scim_member_roles_tx(tx: "Prisma", user_ids: Sequence[str], *, admin_group: str) -> None:
"""
Write each user's role resolved from the teams they are in, on ``tx``. The users'
rows are locked through ``USER_ROWS_LOCK_SQL`` before their teams are read, the
same lock a roster write takes, so a concurrent group push cannot change a
member's teams between the read and the role write.
"""
if not user_ids:
return
default_role: Final = _default_scim_user_role()
await tx.query_raw(USER_ROWS_LOCK_SQL, sorted(user_ids))
rows: Final = await find_many_in(user_tx_db(tx), "user_id", user_ids)
team_ids: Final = tuple(dict.fromkeys(chain.from_iterable(row.teams for row in rows)))
teams: Final = await find_many_in(team_tx_db(tx), "team_id", team_ids)
alias_of: Final = MappingProxyType({team.team_id: team.team_alias for team in teams})
resolved: Final = tuple(
(
row.user_id,
_resolve_scim_user_role(
[SCIMUserGroup(value=team_id, display=alias_of.get(team_id)) for team_id in row.teams],
admin_group,
default_role,
),
)
for row in rows
)
for role in dict.fromkeys(role for _, role in resolved):
await tx.execute_raw(
_SET_USER_ROLE_SQL, role, sorted(user_id for user_id, user_role in resolved if user_role == role)
)
for role in dict.fromkeys(role for _, role in resolved):
await tx.execute_raw(
_SET_USER_ROLE_SQL, role, sorted(user_id for user_id, user_role in resolved if user_role == role)
)
class _ResolvedUserMember(NamedTuple):
@ -3051,7 +3077,8 @@ async def update_group(
dependencies=[Depends(user_api_key_auth)],
)
async def delete_group(
group_id: str = Path(..., title="Group ID"),
group_id: Annotated[str, Path(title="Group ID")],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Delete a group according to SCIM v2 protocol
@ -3064,23 +3091,22 @@ async def delete_group(
existing_team: Final = await _check_team_exists(group_id)
raise_if_teams_hold_ptu_shares((group_id,), llm_router)
member_ids: Final = await _get_team_member_user_ids_from_team(existing_team)
# For each member, remove this team from their teams list
for member_id in member_ids:
user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": member_id})
if user:
current_teams = user.teams or []
if group_id in current_teams:
new_teams = [t for t in current_teams if t != group_id]
await _table(UserRepository(prisma_client)).update(
where={"user_id": member_id}, data={"teams": new_teams}
)
await _recompute_scim_member_roles(prisma_client, member_ids)
# Delete team
await _table(TeamRepository(prisma_client)).delete(where={"team_id": group_id})
admin_group: Final = await _get_scim_admin_group()
deleted: Final = await delete_validated_team_rows(
teams=(LiteLLM_TeamTable.model_validate(existing_team.model_dump()),),
prisma_client=prisma_client,
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN,
api_key=user_api_key_dict.api_key,
user_id=user_api_key_dict.user_id,
),
litellm_changed_by=None,
before_commit=(
None if admin_group is None else partial(_write_scim_member_roles_tx, admin_group=admin_group)
),
)
if group_id not in deleted.team_ids:
raise _group_not_found(group_id)
return Response(status_code=204)

View file

@ -14,7 +14,7 @@ import copy
import json
import math
import traceback
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@ -34,7 +34,7 @@ from typing import (
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, Field, JsonValue, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
@ -102,6 +102,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
OrganizationNotFoundError,
_cache_team_object, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
allowed_route_check_inside_route,
await_inflight_team_membership_load,
cache_team_object,
can_org_access_model,
delete_cache_key_objects,
@ -112,6 +113,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo
get_team_object,
get_user_object,
invalidate_team_member_spend_state,
jwt_key_mapping_cache_key,
)
from litellm.proxy.auth.auth_utils import (
enforce_batch_limits_are_admin_only,
@ -119,12 +121,19 @@ from litellm.proxy.auth.auth_utils import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
broadcast_evictions,
evict_and_broadcast,
evict_local,
evict_shared,
publish_auth_cache_invalidation,
)
from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_auth_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.db.db_span import db_span
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.hooks.model_max_budget_limiter import (
@ -207,7 +216,7 @@ from litellm.proxy.management_helpers.utils import (
)
from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.chunked_in import delete_many_in, find_many_in
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
@ -417,6 +426,23 @@ class _TeamDeleteTx(AccessGroupSyncTx, Protocol):
@property
def litellm_teammembership(self) -> "TableActions[prisma_models.LiteLLM_TeamMembership]": ...
@property
def litellm_deletedteamtable(self) -> "TableActions[prisma_models.LiteLLM_DeletedTeamTable]": ...
@property
def litellm_verificationtoken(self) -> "TableActions[prisma_models.LiteLLM_VerificationToken]": ...
@property
def litellm_jwtkeymapping(self) -> "TableActions[prisma_models.LiteLLM_JWTKeyMapping]": ...
def _team_delete_tx(tx: "Prisma") -> _TeamDeleteTx:
return tx # pyright: ignore[reportReturnType] # TableActions widens the generated inputs to Mapping, as the repositories do
_LOCK_DELETED_TEAM_MEMBERS_SQL: Final = """
SELECT user_id, teams FROM "LiteLLM_UserTable" WHERE teams && $1::text[] ORDER BY user_id COLLATE "C" FOR UPDATE
"""
_STRIP_DELETED_TEAMS_FROM_USERS_SQL: Final = """
UPDATE "LiteLLM_UserTable"
@ -430,6 +456,22 @@ WHERE user_id IN (
)
"""
class _DetachedMember(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
user_id: str
teams: tuple[str, ...]
_DetachedMembers: Final = TypeAdapter(tuple[_DetachedMember, ...])
@dataclass(frozen=True, slots=True)
class DeletedTeams:
team_ids: tuple[str, ...]
_INCLUDE_MODEL_TABLE: Final = MappingProxyType({"litellm_model_table": True})
@ -4526,18 +4568,7 @@ async def delete_team(
}'
```
"""
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
litellm_proxy_admin_name,
llm_router,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
from litellm.proxy.proxy_server import llm_router, prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": "No db connected"})
@ -4567,47 +4598,32 @@ async def delete_team(
raise_if_teams_hold_ptu_shares(data.team_ids, llm_router)
await _persist_deleted_team_records(
await delete_validated_teams(
teams=team_rows,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
deleted_teams: Final[_DeletedTeamsResult] = {"deleted_teams": data.team_ids}
return deleted_teams
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
if is_audit_logging_enabled():
# make an audit log for each team deleted
for team_id in data.team_ids:
team_row: LiteLLM_TeamTable | None = await prisma_client.get_data(
team_id=team_id, table_name="team", query_type="find_unique"
)
if team_row is None:
continue
async def delete_validated_teams(
teams: Sequence[LiteLLM_TeamTable],
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> DeletedTeams:
"""
What ``/team/delete`` does once the teams exist and the caller may delete them: every key
and BYOK model of the teams, then ``delete_validated_team_rows``.
"""
from litellm.proxy.proxy_server import (
proxy_logging_obj,
user_api_key_cache,
)
_team_row = team_row.json(exclude_none=True)
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=get_audit_log_changed_by(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id=team_id,
action="deleted",
updated_values="{}",
before_value=_team_row,
)
)
)
# End of Audit logging
team_ids: Final = [team.team_id for team in teams]
## DELETE ASSOCIATED KEYS
# Fetch keys before deletion to persist them
@ -4615,7 +4631,7 @@ async def delete_team(
persist_deleted_verification_tokens,
)
keys_to_delete: Final = await _tokens_db(prisma_client).find_many(where={"team_id": {"in": data.team_ids}})
keys_to_delete: Final = await _tokens_db(prisma_client).find_many(where={"team_id": {"in": team_ids}})
jwt_mapping_cache_keys: Final = await get_jwt_key_mapping_cache_keys_for_tokens(
hashed_tokens=tuple(key.token for key in keys_to_delete),
prisma_client=prisma_client,
@ -4629,7 +4645,7 @@ async def delete_team(
litellm_changed_by=litellm_changed_by,
)
await prisma_client.delete_data(team_id_list=data.team_ids, table_name="key")
await prisma_client.delete_data(team_id_list=team_ids, table_name="key")
if keys_to_delete:
KeyManagementEventHooks.create_key_deleted_audit_logs(
@ -4654,18 +4670,41 @@ async def delete_team(
from litellm.proxy.proxy_server import llm_router
await delete_team_models(
team_ids=data.team_ids,
team_ids=team_ids,
prisma_client=prisma_client,
llm_router=llm_router,
)
await _sweep_deleted_team_references(team_ids=data.team_ids, prisma_client=prisma_client)
member_ids_per_team: Final = await _resolve_deleted_team_member_user_ids(
teams=team_rows,
return await delete_validated_team_rows(
teams=teams,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
async def delete_validated_team_rows(
teams: Sequence[LiteLLM_TeamTable],
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
before_commit: Callable[["Prisma", tuple[str, ...]], Awaitable[None]] | None = None,
) -> DeletedTeams:
"""
Under every team's advisory lock, delete the teams still there with the keys their rostered
members hold on them, their membership rows and user references, and write their deleted-team
records; then evict caches and write audit rows. Keys of users off the roster and the teams'
models stay. ``team_ids`` names only the teams this call deleted. ``before_commit`` runs on the
delete's transaction with the members of the rosters read under the lock, so its writes commit
or roll back with the delete.
"""
from litellm.proxy.proxy_server import (
proxy_logging_obj,
user_api_key_cache,
)
team_ids: Final = [team.team_id for team in teams]
## DELETE TEAMS
# Both the delete and the reconcile sweep run under every team's advisory lock
# (TEAM_ADVISORY_LOCK_SQL, the same one /team/member_add takes before its own writes),
@ -4674,14 +4713,28 @@ async def delete_team(
# the lock before this transaction starts, in which case this sweep reaches what it wrote,
# or is still waiting on the lock, in which case its own re-read happens after this commits
# and sees the row gone before it writes anything.
delete_filter: Final[_TeamIdInFilter] = {"team_id": {"in": data.team_ids}}
async with prisma_client.tx(timeout=timedelta(seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS)) as tx:
for team_id in sorted(data.team_ids):
tx_budget: Final = timedelta(seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS)
async with prisma_client.tx(max_wait=tx_budget, timeout=tx_budget) as tx:
for team_id in sorted(team_ids):
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
await tx.litellm_teamtable.delete_many(where=delete_filter)
await _sweep_deleted_team_references_tx(team_ids=data.team_ids, tx=tx)
team_tx: Final = _team_delete_tx(tx)
locked: Final = await _locked_teams_tx(team_ids=team_ids, tx=team_tx)
member_keys, jwt_mapping_cache_keys = await _delete_roster_member_keys_tx(
locked=locked,
tx=tx,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
await _delete_locked_teams_tx(
locked=locked, tx=team_tx, user_api_key_dict=user_api_key_dict, litellm_changed_by=litellm_changed_by
)
detached_per_team: Final = await _sweep_deleted_team_references_tx(team_ids=team_ids, tx=team_tx)
if before_commit is not None:
await before_commit(tx, _locked_roster_user_ids(locked))
deleted_teams: Final[_DeletedTeamsResult] = {"deleted_teams": data.team_ids}
rostered_per_team: Final = await _resolve_deleted_team_member_user_ids(teams=locked, prisma_client=prisma_client)
member_ids_per_team: Final = _members_per_deleted_team(rostered_per_team, detached_per_team)
# Evict AFTER the rows are gone. Both writers of these keys (`_cache_team_object` and
# `get_team_object_by_alias`) hydrate from the db, so evicting first leaves a window where a
@ -4689,7 +4742,7 @@ async def delete_team(
# invalidated anything. Nothing fallible runs between the delete and this, or a failure there
# would strand the deleted team in cache.
await _invalidate_deleted_team_cache(
teams=team_rows,
teams=locked,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
@ -4697,44 +4750,167 @@ async def delete_team(
member_ids_per_team=member_ids_per_team,
user_api_key_cache=user_api_key_cache,
)
await _invalidate_deleted_key_cache(
keys=member_keys,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
await evict_and_broadcast(cache_keys=jwt_mapping_cache_keys, user_api_key_cache=user_api_key_cache)
for deleted_team in team_rows:
_schedule_deleted_team_audit_rows(
teams=locked, user_api_key_dict=user_api_key_dict, litellm_changed_by=litellm_changed_by
)
if member_keys:
KeyManagementEventHooks.create_key_deleted_audit_logs(
keys_being_deleted=member_keys,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
for deleted_team in locked:
_emit_team_members_metric(deleted_team.model_copy(update={"members_with_roles": ()}))
await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id)
return deleted_teams
return DeletedTeams(team_ids=tuple(team.team_id for team in locked))
async def _sweep_deleted_team_references(team_ids: Sequence[str], prisma_client: PrismaClient) -> None:
def _locked_roster_user_ids(locked: Sequence[LiteLLM_TeamTable]) -> tuple[str, ...]:
return tuple(dict.fromkeys(chain.from_iterable(_roster_user_ids(team) for team in locked)))
def _members_per_deleted_team(
rostered: Sequence[tuple[str, Sequence[str]]], detached: Sequence[tuple[str, Sequence[str]]]
) -> tuple[tuple[str, tuple[str, ...]], ...]:
detached_by_team: Final = MappingProxyType({team_id: user_ids for team_id, user_ids in detached})
return tuple(
(team_id, tuple(dict.fromkeys((*user_ids, *detached_by_team[team_id])))) for team_id, user_ids in rostered
)
def _roster_user_ids(team: LiteLLM_TeamTable) -> tuple[str, ...]:
return tuple(member.user_id for member in team.members_with_roles if member.user_id is not None)
def _is_roster_member_key(
key: "prisma_models.LiteLLM_VerificationToken", roster_by_team: Mapping[str, frozenset[str]]
) -> bool:
return key.team_id is not None and key.user_id in roster_by_team.get(key.team_id, frozenset())
async def _locked_teams_tx(team_ids: Sequence[str], tx: _TeamDeleteTx) -> tuple[LiteLLM_TeamTable, ...]:
rows: Final = await find_many_in(tx.litellm_teamtable, "team_id", team_ids)
return tuple(LiteLLM_TeamTable.model_validate(row.model_dump()) for row in rows)
async def _delete_roster_member_keys_tx(
locked: Sequence[LiteLLM_TeamTable],
tx: "Prisma",
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> tuple[tuple["prisma_models.LiteLLM_VerificationToken", ...], tuple[str, ...]]:
from litellm.proxy.management_endpoints.key_management_endpoints import (
persist_deleted_verification_tokens,
)
team_tx: Final = _team_delete_tx(tx)
roster_by_team: Final = MappingProxyType({team.team_id: frozenset(_roster_user_ids(team)) for team in locked})
team_keys: Final = await find_many_in(team_tx.litellm_verificationtoken, "team_id", tuple(roster_by_team))
member_keys: Final = tuple(key for key in team_keys if _is_roster_member_key(key, roster_by_team))
if not member_keys:
return (), ()
tokens: Final = tuple(key.token for key in member_keys)
jwt_mappings: Final = await find_many_in(team_tx.litellm_jwtkeymapping, "token", tokens)
await persist_deleted_verification_tokens(
keys=member_keys, # pyright: ignore[reportArgumentType] # generated row model carries the same columns as LiteLLM_VerificationToken
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
tx=tx,
)
await delete_many_in(team_tx.litellm_verificationtoken, "token", tokens, atomicity="caller_transaction")
return member_keys, tuple(
jwt_key_mapping_cache_key(mapping.jwt_claim_name, mapping.jwt_claim_value, mapping.jwt_issuer)
for mapping in jwt_mappings
)
async def _delete_locked_teams_tx(
locked: Sequence[LiteLLM_TeamTable],
tx: _TeamDeleteTx,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> None:
await _persist_deleted_team_records(
teams=locked,
deleted_teams=tx.litellm_deletedteamtable,
user_api_key_dict=user_api_key_dict,
litellm_changed_by=litellm_changed_by,
)
await delete_many_in(
tx.litellm_teamtable,
"team_id",
tuple(team.team_id for team in locked),
atomicity="caller_transaction",
)
def _schedule_deleted_team_audit_rows(
teams: Sequence[LiteLLM_TeamTable],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None,
) -> None:
from litellm.proxy.management_helpers.audit_logs import (
get_audit_log_changed_by,
is_audit_logging_enabled,
)
from litellm.proxy.proxy_server import (
create_audit_log_for_update,
litellm_proxy_admin_name,
)
if not is_audit_logging_enabled():
return
for team in teams:
asyncio.create_task(
create_audit_log_for_update(
request_data=LiteLLM_AuditLogs(
id=str(uuid.uuid4()),
updated_at=datetime.now(timezone.utc),
changed_by=get_audit_log_changed_by(
litellm_changed_by=litellm_changed_by,
user_api_key_dict=user_api_key_dict,
litellm_proxy_admin_name=litellm_proxy_admin_name,
),
changed_by_api_key=user_api_key_dict.api_key,
table_name=LitellmTableNames.TEAM_TABLE_NAME,
object_id=team.team_id,
action="deleted",
updated_values="{}",
before_value=team.model_dump_json(exclude_none=True),
)
)
)
async def _sweep_deleted_team_references_tx(
team_ids: Sequence[str], tx: _TeamDeleteTx
) -> tuple[tuple[str, tuple[str, ...]], ...]:
"""
Strip the deleted team ids from every user row and team-membership row that still references them.
The per-member `team_member_delete` pass above only reaches users listed in the team's
`members_with_roles`, so a user row that outlived its roster entry is invisible to it and keeps
surfacing the team on `/user/info` after the team is gone.
#36839 closed the route that created that drift, by resolving member removal off the roster
entry's `user_id` rather than the identifier the caller happened to pass. It does not backfill
rows that already drifted, which is the state this was reported against, so the sweep still has
to run on delete.
One statement filters the array in place rather than read-filter-write: rewriting the whole
array from a snapshot read outside a transaction drops any team a concurrent `/team/member_add`
appended in between. It locks the user rows in the order `USER_ROWS_LOCK_SQL` takes them, so it
never waits on a SCIM group push in a cycle.
Strip the deleted team ids from every user and team-membership row still referencing them, on
the transaction holding the teams' advisory locks. User rows are locked first in
``USER_ROWS_LOCK_SQL`` order so the sweep never deadlocks with a SCIM group push, and one
statement filters each array in place so a team a concurrent ``/team/member_add`` appended is
kept. Returns, per team, the users read under the lock as referencing it.
"""
_ = await prisma_client.db.execute_raw(_STRIP_DELETED_TEAMS_FROM_USERS_SQL, list(team_ids))
_ = await _team_membership_db(prisma_client).delete_many(where=_TeamIdInFilter(team_id={"in": tuple(team_ids)}))
async def _sweep_deleted_team_references_tx(team_ids: Sequence[str], tx: _TeamDeleteTx) -> None:
"""Same sweep as `_sweep_deleted_team_references`, run on the transaction that holds
every id's advisory lock and deletes the team rows, so it commits or rolls back with them."""
locked: Final = _DetachedMembers.validate_python(await tx.query_raw(_LOCK_DELETED_TEAM_MEMBERS_SQL, list(team_ids)))
_ = await tx.execute_raw(_STRIP_DELETED_TEAMS_FROM_USERS_SQL, list(team_ids))
membership_filter: Final[_TeamIdInFilter] = {"team_id": {"in": tuple(team_ids)}}
_ = await tx.litellm_teammembership.delete_many(where=membership_filter)
return tuple(
(team_id, tuple(member.user_id for member in locked if team_id in member.teams)) for team_id in team_ids
)
async def _invalidate_deleted_key_cache(
@ -4791,17 +4967,20 @@ async def _evict_deleted_team_member_cache(
member_user_ids: Sequence[str],
user_api_key_cache: UserApiKeyCache,
) -> None:
await evict_and_broadcast(cache_keys=tuple(member_user_ids), user_api_key_cache=user_api_key_cache)
await asyncio.gather(
*(
invalidate_team_member_spend_state(
user_id=user_id,
team_id=team_id,
user_api_key_cache=user_api_key_cache,
)
for user_id in member_user_ids
)
_ = await asyncio.gather(
*(await_inflight_team_membership_load(user_id=user_id, team_id=team_id) for user_id in member_user_ids)
)
cache_keys: Final = tuple(member_cache_keys(team_id, member_user_ids))
evict_local(cache_keys=cache_keys, user_api_key_cache=user_api_key_cache)
await evict_shared(cache_keys=cache_keys, cache=user_api_key_cache)
await broadcast_evictions(cache_keys=cache_keys)
def member_cache_keys(team_id: str, user_ids: Sequence[str]) -> Iterator[str]:
for user_id in user_ids:
yield user_id
yield team_membership_auth_cache_key(team_id=team_id, user_id=user_id)
yield team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
async def _resolve_deleted_team_member_user_ids(
@ -4832,7 +5011,7 @@ async def _deleted_team_member_user_ids(team: LiteLLM_TeamTable, prisma_client:
def _transform_teams_to_deleted_records(
teams: list[LiteLLM_TeamTable],
teams: Sequence[LiteLLM_TeamTable],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
) -> list[dict[str, object]]:
@ -4881,17 +5060,17 @@ def _transform_teams_to_deleted_records(
async def _save_deleted_team_records(
records: list[dict[str, object]],
prisma_client: PrismaClient,
deleted_teams: "TableActions[prisma_models.LiteLLM_DeletedTeamTable]",
) -> None:
"""Save deleted team records to the database."""
if not records:
return
await _deleted_team_db(prisma_client).create_many(data=records)
await deleted_teams.create_many(data=records)
async def _persist_deleted_team_records(
teams: list[LiteLLM_TeamTable],
prisma_client: PrismaClient,
teams: Sequence[LiteLLM_TeamTable],
deleted_teams: "TableActions[prisma_models.LiteLLM_DeletedTeamTable]",
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
) -> None:
@ -4903,7 +5082,7 @@ async def _persist_deleted_team_records(
)
await _save_deleted_team_records(
records=records,
prisma_client=prisma_client,
deleted_teams=deleted_teams,
)

View file

@ -4,6 +4,7 @@ Functions to create audit logs for LiteLLM Proxy
import asyncio
import json
from collections.abc import Awaitable, Sequence
from datetime import datetime, timezone
from typing import Final, TypeVar
@ -258,12 +259,19 @@ async def create_object_audit_log(
)
async def _insert_audit_log_row(repository: AuditLogRepository, request_data: LiteLLM_AuditLogs) -> None:
async def _insert_audit_logs(insert: Awaitable[object], description: str) -> None:
try:
await repository.table.create(data={**request_data.model_dump(exclude_none=True)})
await insert
except Exception as e:
# [Non-Blocking Exception. Do not allow blocking LLM API call]
verbose_proxy_logger.error("Failed Creating audit log %s", e)
verbose_proxy_logger.error("Failed Creating %s %s", description, e)
def _json_encoded(request_data: LiteLLM_AuditLogs) -> LiteLLM_AuditLogs:
values: Final = (("updated_values", request_data.updated_values), ("before_value", request_data.before_value))
return request_data.model_copy(
update={field: json.dumps(value) for field, value in values if isinstance(value, dict)}
)
async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
@ -279,15 +287,10 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
return
verbose_proxy_logger.debug("creating audit log for %s", request_data)
if isinstance(request_data.updated_values, dict):
request_data.updated_values = json.dumps(request_data.updated_values)
if isinstance(request_data.before_value, dict):
request_data.before_value = json.dumps(request_data.before_value)
encoded: Final = _json_encoded(request_data)
# Dispatch to external audit log callbacks regardless of DB availability
await _dispatch_audit_log_to_callbacks(request_data)
await _dispatch_audit_log_to_callbacks(encoded)
if prisma_client is None:
verbose_proxy_logger.error("prisma_client is None, cannot write audit log to DB")
@ -295,7 +298,41 @@ async def create_audit_log_for_update(request_data: LiteLLM_AuditLogs):
await track_audit_task(
asyncio.create_task(
_insert_audit_log_row(AuditLogRepository(prisma_client), request_data),
name=_audit_task_name(request_data, "the database"),
_insert_audit_logs(
AuditLogRepository(prisma_client).table.create(data={**encoded.model_dump(exclude_none=True)}),
"audit log",
),
name=_audit_task_name(encoded, "the database"),
)
)
async def create_audit_logs_for_updates(rows: Sequence[LiteLLM_AuditLogs]) -> None:
"""Create audit logs for many objects with one insert."""
if not rows or not is_audit_logging_enabled():
return
from litellm.proxy.proxy_server import premium_user, prisma_client
if premium_user is not True:
return
encoded: Final = tuple(_json_encoded(row) for row in rows)
for row in encoded:
await _dispatch_audit_log_to_callbacks(row)
if prisma_client is None:
verbose_proxy_logger.error("prisma_client is None, cannot write audit log to DB")
return
await track_audit_task(
asyncio.create_task(
_insert_audit_logs(
AuditLogRepository(prisma_client).table.create_many(
data=tuple(row.model_dump(exclude_none=True) for row in encoded)
),
f"{len(encoded)} audit logs",
),
name=f"{len(encoded)} audit logs to the database",
)
)

View file

@ -1,6 +1,6 @@
import asyncio
import json
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import timedelta
from types import MappingProxyType
@ -23,8 +23,6 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
)
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_auth_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.management_endpoints.key_management_endpoints import (
@ -33,6 +31,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
from litellm.proxy.management_endpoints.team_endpoints import (
_emit_team_members_metric, # pyright: ignore[reportPrivateUsage] # same gauge /team/member_add moves
_schedule_team_membership_audit_log, # pyright: ignore[reportPrivateUsage] # same audit row /team/member_add writes
member_cache_keys,
)
from litellm.proxy.management_helpers.access_group_team_sync import (
DETACH_TEAM_FROM_USERS_SQL,
@ -277,7 +276,7 @@ async def _settle_caches(
evicted: Final = (
*deleted_tokens,
*removal.jwt_mapping_cache_keys,
*_member_cache_keys(team.team_id, changed_user_ids),
*member_cache_keys(team.team_id, changed_user_ids),
)
evict_local(cache_keys=evicted, user_api_key_cache=user_api_key_cache)
await evict_shared(cache_keys=evicted, cache=user_api_key_cache)
@ -292,13 +291,6 @@ async def _settle_caches(
await broadcast_evictions(cache_keys=evicted)
def _member_cache_keys(team_id: str, user_ids: Sequence[str]) -> Iterator[str]:
for user_id in user_ids:
yield user_id
yield team_membership_auth_cache_key(team_id=team_id, user_id=user_id)
yield team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
async def sync_team_roster(
prisma_client: PrismaClient,
team_id: str,

View file

@ -4832,6 +4832,7 @@ def jsonify_object(data: dict) -> dict:
_deprecated_key_cache: Final[LimitedSizeOrderedDict] = LimitedSizeOrderedDict(max_size=1000)
_DEPRECATED_KEY_CACHE_TTL_SECONDS: Final = 60
_PRISMA_DEFAULT_TX_TIMEOUT: Final = timedelta(seconds=5)
_PRISMA_DEFAULT_TX_MAX_WAIT: Final = timedelta(seconds=2)
async def _lookup_deprecated_key(
@ -5238,13 +5239,17 @@ class PrismaClient:
return self.db.read_target
return self.db
def tx(self, *, timeout: timedelta = _PRISMA_DEFAULT_TX_TIMEOUT) -> "TransactionManager":
def tx(
self, *, timeout: timedelta = _PRISMA_DEFAULT_TX_TIMEOUT, max_wait: timedelta = _PRISMA_DEFAULT_TX_MAX_WAIT
) -> "TransactionManager":
"""Open an interactive transaction on the writer.
Callers go through this instead of reaching into ``self.db`` so writer
selection and read-replica routing stay encapsulated in the wrapper.
"""
return cast("TransactionManager", self.db.tx(timeout=timeout)) # cast-ok: untyped __getattr__ delegate
return cast( # cast-ok: untyped __getattr__ delegate
"TransactionManager", self.db.tx(max_wait=max_wait, timeout=timeout)
)
def get_request_status(self, payload: dict | SpendLogsPayload) -> Literal["success", "failure"]:
"""

View file

@ -80,14 +80,12 @@ litellm/proxy/management_endpoints/team_endpoints.py _get_keys_count_by_team pri
litellm/proxy/management_endpoints/team_endpoints.py _hydrate_member_user_details prisma user_id.in `sorted(user_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _resolve_existing_member_user_ids prisma user_id.in `sorted(requested_user_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py resolve_team_daily_activity_scope prisma team_id.in `list(team_ids_list)` 0
litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references prisma team_id.in `tuple(team_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references_tx prisma team_id.in `tuple(team_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _team_member_delete prisma user_id.in `sorted(addressed_user_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _team_member_delete prisma user_id.in `sorted(user_ids_to_delete)` 0
litellm/proxy/management_endpoints/team_endpoints.py _team_member_delete prisma user_id.in `sorted(user_ids_to_delete)` 1
litellm/proxy/management_endpoints/team_endpoints.py _team_user_spend_sql raw-sql sl.team_id.IN `IN ({team_placeholders})` 0
litellm/proxy/management_endpoints/team_endpoints.py delete_team prisma team_id.in `data.team_ids` 0
litellm/proxy/management_endpoints/team_endpoints.py delete_team prisma team_id.in `data.team_ids` 1
litellm/proxy/management_endpoints/team_endpoints.py delete_validated_teams prisma team_id.in `team_ids` 0
litellm/proxy/management_endpoints/team_endpoints.py get_all_team_memberships prisma team_id.in `team_ids` 0
litellm/proxy/management_endpoints/team_endpoints.py list_available_teams prisma team_id.in `available_teams` 0
litellm/proxy/management_endpoints/tool_management_endpoints.py get_tool_spend prisma tool_name.in `[row.tool_name for row in top_tools]` 0

View file

@ -0,0 +1,207 @@
"""SCIM v2 group helpers for the management cells: seeded users, group pushes, the team lock, and what a group
leaves behind in Postgres."""
import os
import uuid
from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager
from typing import Final
import httpx
import psycopg
from integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
delete_key_if_present,
object_value,
string_value,
)
from integration._support.database import read_rows, write_rows
from pydantic import JsonValue
GROUP_SCHEMA: Final = "urn:ietf:params:scim:schemas:core:2.0:Group"
PATCH_OP_SCHEMA: Final = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
SEED_USERS_SQL: Final = """
INSERT INTO "LiteLLM_UserTable" (user_id, user_role, teams, models)
SELECT %s || '-' || lpad(n::text, 3, '0'), 'internal_user', '{}'::text[], '{}'::text[]
FROM generate_series(1, %s::int) AS n
"""
TAKE_TEAM_LOCK_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext(%s))"
WAITERS_ON_HELD_LOCK_SQL: Final = """
SELECT count(*)::text AS waiting
FROM pg_locks waiter
JOIN pg_stat_activity session ON session.pid = waiter.pid
WHERE waiter.locktype = 'advisory'
AND NOT waiter.granted
AND session.wait_event_type = 'Lock'
AND (waiter.classid, waiter.objid, waiter.objsubid) IN (
SELECT held.classid, held.objid, held.objsubid
FROM pg_locks held
WHERE held.locktype = 'advisory' AND held.granted AND held.pid = %s::int
)
"""
TAKE_USER_ROW_LOCK_SQL: Final = 'SELECT user_id FROM "LiteLLM_UserTable" WHERE user_id = %s FOR UPDATE'
WAITERS_ON_ROWS_LOCKED_BY_SQL: Final = """
SELECT count(*)::text AS waiting
FROM pg_locks waiter
WHERE waiter.locktype = 'transactionid'
AND NOT waiter.granted
AND waiter.transactionid IN (
SELECT held.transactionid
FROM pg_locks held
WHERE held.locktype = 'transactionid' AND held.granted AND held.pid = %s::int
)
"""
def seed_users(scenario: Scenario, count: int) -> tuple[str, ...]:
prefix: Final = f"integration-scim-{uuid.uuid4().hex}"
write_rows(SEED_USERS_SQL, (prefix, str(count)))
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_UserTable" WHERE user_id LIKE %s', (f"{prefix}-%",))
return tuple(f"{prefix}-{index:03d}" for index in range(1, count + 1))
def members(users: Sequence[str]) -> list[JsonValue]:
return [{"value": user} for user in users]
def group_name() -> str:
return f"integration-{uuid.uuid4().hex}"
def create_group(candidate: Gateway, users: Sequence[str], *, display_name: str | None = None) -> httpx.Response:
return candidate.request(
"POST",
"/scim/v2/Groups",
{"schemas": [GROUP_SCHEMA], "displayName": display_name or group_name(), "members": members(users)},
)
def replace_group(
candidate: Gateway, team: str, users: Sequence[str], *, display_name: str | None = None
) -> httpx.Response:
return candidate.request(
"PUT",
f"/scim/v2/Groups/{team}",
{"schemas": [GROUP_SCHEMA], "id": team, "displayName": display_name or group_name(), "members": members(users)},
)
def add_to_group(candidate: Gateway, team: str, users: Sequence[str]) -> httpx.Response:
return candidate.request(
"PATCH",
f"/scim/v2/Groups/{team}",
{"schemas": [PATCH_OP_SCHEMA], "Operations": [{"op": "add", "path": "members", "value": members(users)}]},
)
def body(response: httpx.Response) -> dict[str, JsonValue]:
return JSON_OBJECT.validate_json(response.content)
def member_ids(group: Mapping[str, JsonValue]) -> frozenset[str]:
listed: Final = group.get("members") or []
assert isinstance(listed, list), listed
return frozenset(string_value(object_value(member)["value"]) for member in listed)
def delete_group(candidate: Gateway, team: str, *, key: str | None = None) -> httpx.Response:
return candidate.request("DELETE", f"/scim/v2/Groups/{team}", key=key)
def delete_team_if_present(scenario: Scenario, team: str) -> None:
if read_rows('SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team,)):
scenario.delete_team(team)
def created_team(scenario: Scenario, created: httpx.Response) -> str:
assert created.status_code == 201, created.text
team: Final = string_value(body(created)["id"])
scenario.cleanups.callback(delete_team_if_present, scenario, team)
return team
def team_key(candidate: Gateway, team: str, user: str) -> str:
created: Final = candidate.post(
"/key/generate", {"team_id": team, "user_id": user, "key_alias": f"integration-{uuid.uuid4().hex}"}
)
return string_value(created["key"])
def service_account_key(candidate: Gateway, scenario: Scenario, team: str) -> str:
created: Final = candidate.post("/key/generate", {"team_id": team, "key_alias": f"integration-{uuid.uuid4().hex}"})
key: Final = string_value(created["key"])
scenario.cleanups.callback(delete_key_if_present, candidate, key)
return key
def membership_user_ids(team: str) -> frozenset[str]:
rows: Final = read_rows('SELECT user_id FROM "LiteLLM_TeamMembership" WHERE team_id = %s', (team,))
return frozenset(string_value(row["user_id"]) for row in rows)
def keys_of(team: str) -> frozenset[str]:
rows: Final = read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team,))
return frozenset(string_value(row["token"]) for row in rows)
def users_referencing(team: str) -> frozenset[str]:
rows: Final = read_rows('SELECT user_id FROM "LiteLLM_UserTable" WHERE %s = ANY(teams)', (team,))
return frozenset(string_value(row["user_id"]) for row in rows)
def user_role(candidate: Gateway, user: str) -> str:
return string_value(object_value(candidate.get("/user/info", {"user_id": user})["user_info"])["user_role"])
def groups_of(candidate: Gateway, user: str) -> frozenset[str]:
groups: Final = candidate.get(f"/scim/v2/Users/{user}").get("groups") or []
assert isinstance(groups, list), groups
return frozenset(string_value(object_value(group)["value"]) for group in groups)
def assert_landed(candidate: Gateway, team: str, users: Sequence[str]) -> None:
assert member_ids(candidate.get(f"/scim/v2/Groups/{team}")) == frozenset(users)
assert membership_user_ids(team) == frozenset(users)
assert users_referencing(team) == frozenset(users)
def assert_gone(candidate: Gateway, team: str, users: Sequence[str], key: str) -> None:
assert candidate.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
assert team not in groups_of(candidate, users[0])
assert membership_user_ids(team) == frozenset()
assert users_referencing(team) == frozenset()
assert keys_of(team) == frozenset()
assert candidate.request("GET", "/v1/models", key=key).status_code == 401
@contextmanager
def held_team_lock(team: str) -> Generator[int]:
"""Hold the team's advisory lock from a session of our own and yield its backend pid; leaving the block
commits, which releases the lock to whoever is queued on it."""
with psycopg.connect(os.environ["DATABASE_URL"]) as holder:
holder.execute(TAKE_TEAM_LOCK_SQL, (team,))
yield holder.info.backend_pid
@contextmanager
def held_user_row(user: str) -> Generator[int]:
"""Hold a row lock on the user's row from a session of our own and yield its backend pid, so a role write
that reaches the row waits until leaving the block commits."""
with psycopg.connect(os.environ["DATABASE_URL"]) as holder:
holder.execute(TAKE_USER_ROW_LOCK_SQL, (user,))
yield holder.info.backend_pid
def waiters_on_lock_held_by(backend_pid: int) -> int:
return _waiting(WAITERS_ON_HELD_LOCK_SQL, backend_pid)
def waiters_on_rows_locked_by(backend_pid: int) -> int:
return _waiting(WAITERS_ON_ROWS_LOCKED_BY_SQL, backend_pid)
def _waiting(query: str, backend_pid: int) -> int:
rows: Final = read_rows(query, (str(backend_pid),))
return int(string_value(rows[0]["waiting"]))

View file

@ -3,7 +3,10 @@
POST /scim/v2/Groups, PUT and PATCH /scim/v2/Groups/{id} used to run /team/member_add once per member, so a
500-member push was thousands of statements and outlasted the edge in front of the proxy. One locked
transaction now resolves every member, writes the roster and the membership rows and tags the users, so a
push of 500 members runs as many statements as a push of 5.
push of 500 members runs as many statements as a push of 5. DELETE /scim/v2/Groups/{id} used to detach the
members one at a time and leave their membership rows and keys behind; it now detaches them in one locked pass,
so deleting 500 members who each hold a key costs as many statements as deleting 5 and leaves no membership rows
or member keys.
"""
import os
@ -13,6 +16,7 @@ from collections.abc import Callable, Iterator, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from functools import partial
from hashlib import sha256
from pathlib import Path
from typing import Final, LiteralString
@ -22,7 +26,6 @@ import psycopg
import pytest
from integration._support.budget import case_timeout_seconds
from integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
delete_key_if_present,
@ -32,14 +35,30 @@ from integration._support.client import (
)
from integration._support.database import read_rows, write_rows
from integration._support.database_relay import StatementCountingRelay, statement_counting_relay
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.scim import (
GROUP_SCHEMA,
PATCH_OP_SCHEMA,
add_to_group,
assert_gone,
assert_landed,
body,
create_group,
created_team,
delete_group,
member_ids,
members,
membership_user_ids,
replace_group,
seed_users,
team_key,
users_referencing,
)
from psycopg import sql
from pydantic import JsonValue
pytestmark: Final = pytest.mark.shares_worker_state
GROUP_SCHEMA: Final = "urn:ietf:params:scim:schemas:core:2.0:Group"
PATCH_OP_SCHEMA: Final = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
SMALL_PUSH: Final = 5
LARGE_PUSH: Final = 500
# Background work on an idle proxy (the cron-job leader poll, spend flushes) lands a few statements inside a
@ -48,7 +67,9 @@ NOISE_ALLOWANCE: Final = 100
UNFIXED_PUSH_SECONDS: Final = 120
SWAP_GROUP: Final = 200
SWAP_ROUNDS: Final = 10
OWNED_PROXY_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 2 * UNFIXED_PUSH_SECONDS)
CHAOS_GROUP: Final = 100
KEY_WRITERS: Final = 16
ROLLBACK_SECONDS: Final = 90
TEAM_ROW_SQL: Final = 'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = %s'
LOCK_USER_ROW_SQL: Final = 'SELECT user_id FROM "LiteLLM_UserTable" WHERE user_id = %s FOR UPDATE'
@ -56,16 +77,12 @@ LOCK_KEY_ROW_SQL: Final = 'SELECT token FROM "LiteLLM_VerificationToken" WHERE t
SESSIONS_BLOCKED_BY_SQL: Final = (
"SELECT count(*)::int AS waiting FROM pg_stat_activity WHERE %s::int = ANY(pg_blocking_pids(pid))"
)
BACKENDS_BLOCKED_BY_SQL: Final = "SELECT pid FROM pg_stat_activity WHERE %s::int = ANY(pg_blocking_pids(pid))"
TRANSACTIONS_OPEN_SINCE_SQL: Final = """
SELECT count(*)::int AS open FROM pg_stat_activity
WHERE datname = current_database() AND backend_type = 'client backend' AND pid <> pg_backend_pid()
AND xact_start < %s::timestamptz
"""
SEED_USERS_SQL: Final = """
INSERT INTO "LiteLLM_UserTable" (user_id, user_role, teams, models)
SELECT %s || '-' || lpad(n::text, 3, '0'), 'internal_user', '{}'::text[], '{}'::text[]
FROM generate_series(1, %s::int) AS n
"""
@dataclass(frozen=True, slots=True)
@ -82,71 +99,6 @@ def _measured(relay: StatementCountingRelay, send: Callable[[], httpx.Response])
return Measured(response=response, statements=relay.statements - before, seconds=time.perf_counter() - started)
def _seed_users(scenario: Scenario, count: int) -> tuple[str, ...]:
prefix: Final = f"integration-scim-{uuid.uuid4().hex}"
write_rows(SEED_USERS_SQL, (prefix, str(count)))
scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_UserTable" WHERE user_id LIKE %s', (f"{prefix}-%",))
return tuple(f"{prefix}-{index:03d}" for index in range(1, count + 1))
def _members(users: Sequence[str]) -> list[JsonValue]:
return [{"value": user} for user in users]
def _create_group(candidate: Gateway, users: Sequence[str]) -> httpx.Response:
return candidate.request(
"POST",
"/scim/v2/Groups",
{"schemas": [GROUP_SCHEMA], "displayName": f"integration-{uuid.uuid4().hex}", "members": _members(users)},
)
def _replace_group(candidate: Gateway, team: str, users: Sequence[str]) -> httpx.Response:
return candidate.request(
"PUT",
f"/scim/v2/Groups/{team}",
{
"schemas": [GROUP_SCHEMA],
"id": team,
"displayName": f"integration-{uuid.uuid4().hex}",
"members": _members(users),
},
)
def _add_to_group(candidate: Gateway, team: str, users: Sequence[str]) -> httpx.Response:
return candidate.request(
"PATCH",
f"/scim/v2/Groups/{team}",
{
"schemas": [PATCH_OP_SCHEMA],
"Operations": [{"op": "add", "path": "members", "value": _members(users)}],
},
)
def _body(response: httpx.Response) -> dict[str, JsonValue]:
return JSON_OBJECT.validate_json(response.content)
def _member_ids(body: Mapping[str, JsonValue]) -> frozenset[str]:
members: Final = body.get("members") or []
assert isinstance(members, list), members
return frozenset(string_value(object_value(member)["value"]) for member in members)
def _created_team(scenario: Scenario, created: httpx.Response) -> str:
assert created.status_code == 201, created.text
team: Final = string_value(_body(created)["id"])
scenario.cleanups.callback(scenario.delete_team, team)
return team
def _membership_user_ids(team: str) -> frozenset[str]:
rows: Final = read_rows('SELECT user_id FROM "LiteLLM_TeamMembership" WHERE team_id = %s', (team,))
return frozenset(string_value(row["user_id"]) for row in rows)
_UTC_MICROSECONDS: Final = "YYYY-MM-DD HH24:MI:SS.US"
@ -163,18 +115,7 @@ def _database_utc_now() -> str:
return string_value(rows[0]["utc_now"])
def _users_referencing(team: str) -> frozenset[str]:
rows: Final = read_rows('SELECT user_id FROM "LiteLLM_UserTable" WHERE %s = ANY(teams)', (team,))
return frozenset(string_value(row["user_id"]) for row in rows)
def _assert_landed(candidate: Gateway, team: str, users: Sequence[str]) -> None:
assert _member_ids(candidate.get(f"/scim/v2/Groups/{team}")) == frozenset(users)
assert _membership_user_ids(team) == frozenset(users)
assert _users_referencing(team) == frozenset(users)
@pytest.mark.timeout(300) # owned proxy boot plus three 500-member pushes
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_group_pushes_of_500_members_run_as_many_statements_as_pushes_of_5(
gateway: Gateway, tmp_path: Path, record_property: Callable[[str, object], None]
) -> None:
@ -191,39 +132,50 @@ def test_group_pushes_of_500_members_run_as_many_statements_as_pushes_of_5(
) as owned,
):
candidate: Final = owned.gateway
small_a: Final = _seed_users(scenario, SMALL_PUSH)
small_b: Final = _seed_users(scenario, SMALL_PUSH)
large: Final = _seed_users(scenario, LARGE_PUSH)
small_a: Final = seed_users(scenario, SMALL_PUSH)
small_b: Final = seed_users(scenario, SMALL_PUSH)
large: Final = seed_users(scenario, LARGE_PUSH)
created_small: Final = _measured(relay, lambda: _create_group(candidate, small_a))
small_team: Final = _created_team(scenario, created_small.response)
created_large: Final = _measured(relay, lambda: _create_group(candidate, large))
large_team: Final = _created_team(scenario, created_large.response)
assert _member_ids(_body(created_large.response)) == frozenset(large)
_assert_landed(candidate, small_team, small_a)
_assert_landed(candidate, large_team, large)
created_small: Final = _measured(relay, lambda: create_group(candidate, small_a))
small_team: Final = created_team(scenario, created_small.response)
created_large: Final = _measured(relay, lambda: create_group(candidate, large))
large_team: Final = created_team(scenario, created_large.response)
assert member_ids(body(created_large.response)) == frozenset(large)
assert_landed(candidate, small_team, small_a)
assert_landed(candidate, large_team, large)
detach_window_open: Final = _database_utc_now()
replaced_small: Final = _measured(relay, lambda: _replace_group(candidate, small_team, small_b))
replaced_large: Final = _measured(relay, lambda: _replace_group(candidate, large_team, small_a))
replaced_small: Final = _measured(relay, lambda: replace_group(candidate, small_team, small_b))
replaced_large: Final = _measured(relay, lambda: replace_group(candidate, large_team, small_a))
assert replaced_small.response.status_code == 200, replaced_small.response.text
assert replaced_large.response.status_code == 200, replaced_large.response.text
assert _member_ids(_body(replaced_small.response)) == frozenset(small_b)
_assert_landed(candidate, small_team, small_b)
_assert_landed(candidate, large_team, small_a)
assert member_ids(body(replaced_small.response)) == frozenset(small_b)
assert_landed(candidate, small_team, small_b)
assert_landed(candidate, large_team, small_a)
assert detach_window_open <= _updated_at(large[0]) <= _database_utc_now()
patched_small: Final = _measured(relay, lambda: _add_to_group(candidate, small_team, small_a))
patched_large: Final = _measured(relay, lambda: _add_to_group(candidate, large_team, large))
patched_small: Final = _measured(relay, lambda: add_to_group(candidate, small_team, small_a))
patched_large: Final = _measured(relay, lambda: add_to_group(candidate, large_team, large))
assert patched_small.response.status_code == 200, patched_small.response.text
assert patched_large.response.status_code == 200, patched_large.response.text
_assert_landed(candidate, small_team, [*small_a, *small_b])
_assert_landed(candidate, large_team, [*small_a, *large])
assert_landed(candidate, small_team, [*small_a, *small_b])
assert_landed(candidate, large_team, [*small_a, *large])
with ThreadPoolExecutor(max_workers=KEY_WRITERS) as pool:
small_keys: Final = tuple(pool.map(partial(team_key, candidate, small_team), (*small_a, *small_b)))
large_keys: Final = tuple(pool.map(partial(team_key, candidate, large_team), (*small_a, *large)))
deleted_small: Final = _measured(relay, lambda: delete_group(candidate, small_team))
deleted_large: Final = _measured(relay, lambda: delete_group(candidate, large_team))
assert deleted_small.response.status_code == 204, deleted_small.response.text
assert deleted_large.response.status_code == 204, deleted_large.response.text
assert_gone(candidate, small_team, [*small_a, *small_b], small_keys[0])
assert_gone(candidate, large_team, [*small_a, *large], large_keys[-1])
pushes: Final = (
("POST", created_small, created_large),
("PUT", replaced_small, replaced_large),
("PATCH", patched_small, patched_large),
("DELETE", deleted_small, deleted_large),
)
for verb, small, big in pushes:
record_property(
@ -238,13 +190,13 @@ def test_group_pushes_of_500_members_run_as_many_statements_as_pushes_of_5(
def _trade_rosters(candidate: Gateway, teams: tuple[str, str], rosters: tuple[Sequence[str], Sequence[str]]) -> None:
with ThreadPoolExecutor(max_workers=2) as pool:
left: Final = pool.submit(_replace_group, candidate, teams[0], rosters[1])
right: Final = pool.submit(_replace_group, candidate, teams[1], rosters[0])
left: Final = pool.submit(replace_group, candidate, teams[0], rosters[1])
right: Final = pool.submit(replace_group, candidate, teams[1], rosters[0])
for response in (left.result(), right.result()):
assert response.status_code == 200, response.text
@pytest.mark.timeout(300)
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_two_groups_trading_rosters_in_parallel_both_land(gateway: Gateway, tmp_path: Path) -> None:
with (
gateway.scenario() as scenario,
@ -258,15 +210,18 @@ def test_two_groups_trading_rosters_in_parallel_both_land(gateway: Gateway, tmp_
) as owned,
):
candidate: Final = owned.gateway
left_users: Final = _seed_users(scenario, SWAP_GROUP)
right_users: Final = _seed_users(scenario, SWAP_GROUP)
left: Final = _created_team(scenario, _create_group(candidate, left_users))
right: Final = _created_team(scenario, _create_group(candidate, right_users))
for round_index in range(SWAP_ROUNDS):
held: Final = (left_users, right_users) if round_index % 2 == 0 else (right_users, left_users)
left_users: Final = seed_users(scenario, SWAP_GROUP)
right_users: Final = seed_users(scenario, SWAP_GROUP)
left: Final = created_team(scenario, create_group(candidate, left_users))
right: Final = created_team(scenario, create_group(candidate, right_users))
rounds: Final = tuple(
(left_users, right_users) if round_index % 2 == 0 else (right_users, left_users)
for round_index in range(SWAP_ROUNDS)
)
for held in rounds:
_trade_rosters(candidate, (left, right), held)
_assert_landed(candidate, left, left_users)
_assert_landed(candidate, right, right_users)
assert_landed(candidate, left, left_users)
assert_landed(candidate, right, right_users)
def _int_value(rows: Sequence[Mapping[str, JsonValue]], column: str) -> int:
@ -286,6 +241,10 @@ def _sessions_blocked_by(backend_pid: int) -> int:
return _int_value(read_rows(SESSIONS_BLOCKED_BY_SQL, (str(backend_pid),)), "waiting")
def _backend_blocked_by(backend_pid: int) -> int:
return _int_value(read_rows(BACKENDS_BLOCKED_BY_SQL, (str(backend_pid),)), "pid")
def _database_now() -> str:
return string_value(read_rows("SELECT now()::text AS now", ())[0]["now"])
@ -302,36 +261,25 @@ def _roster_ids(team: str) -> tuple[str, ...]:
return tuple(string_value(object_value(member)["user_id"]) for member in roster)
def _delete_team_if_present(candidate: Gateway, team: str) -> None:
if read_rows(TEAM_ROW_SQL, (team,)):
_ = candidate.post("/team/delete", {"team_ids": [team]})
def _group_the_test_deletes(candidate: Gateway, scenario: Scenario) -> str:
created: Final = _create_group(candidate, ())
assert created.status_code == 201, created.text
team: Final = string_value(_body(created)["id"])
scenario.cleanups.callback(_delete_team_if_present, candidate, team)
return team
def test_a_group_deleted_while_its_push_waits_on_a_member_row_answers_404_and_writes_nothing(
def test_a_group_deleted_while_its_push_waits_on_a_member_row_waits_for_the_push_then_removes_every_trace(
gateway: Gateway,
) -> None:
with gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=1) as pool:
users: Final = _seed_users(scenario, SMALL_PUSH)
team: Final = _group_the_test_deletes(gateway, scenario)
with gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=2) as pool:
users: Final = seed_users(scenario, SMALL_PUSH)
team: Final = created_team(scenario, create_group(gateway, ()))
with _holding_row_lock(LOCK_USER_ROW_SQL, min(users)) as holder_pid:
push: Final = pool.submit(_replace_group, gateway, team, users)
push: Final = pool.submit(replace_group, gateway, team, users)
_ = eventually(lambda: _sessions_blocked_by(holder_pid), lambda waiting: waiting >= 1, seconds=30)
deleted: Final = gateway.request("DELETE", f"/scim/v2/Groups/{team}")
assert deleted.status_code == 204, deleted.text
push_pid: Final = _backend_blocked_by(holder_pid)
deleted: Final = pool.submit(delete_group, gateway, team)
_ = eventually(lambda: _sessions_blocked_by(push_pid), lambda waiting: waiting >= 1, seconds=30)
assert not push.done(), push.result().text
pushed: Final = push.result()
assert pushed.status_code == 404, pushed.text
assert not deleted.done(), deleted.result().text
assert push.result().status_code == 200, push.result().text
assert deleted.result().status_code == 204, deleted.result().text
assert read_rows(TEAM_ROW_SQL, (team,)) == []
assert _membership_user_ids(team) == frozenset()
assert _users_referencing(team) == frozenset()
assert membership_user_ids(team) == frozenset()
assert users_referencing(team) == frozenset()
@contextmanager
@ -361,17 +309,17 @@ def test_a_push_whose_membership_write_fails_answers_5xx_keeps_the_old_roster_an
gateway: Gateway,
) -> None:
with gateway.scenario() as scenario:
kept: Final = _seed_users(scenario, SMALL_PUSH)
arriving: Final = _seed_users(scenario, SMALL_PUSH)
team: Final = _created_team(scenario, _create_group(gateway, kept))
kept: Final = seed_users(scenario, SMALL_PUSH)
arriving: Final = seed_users(scenario, SMALL_PUSH)
team: Final = created_team(scenario, create_group(gateway, kept))
replacement: Final = (*kept[:2], *arriving)
with _refusing_memberships_for(max(arriving)):
refused: Final = _replace_group(gateway, team, replacement)
refused: Final = replace_group(gateway, team, replacement)
assert 500 <= refused.status_code < 600, refused.text
_assert_landed(gateway, team, kept)
retried: Final = _replace_group(gateway, team, replacement)
assert_landed(gateway, team, kept)
retried: Final = replace_group(gateway, team, replacement)
assert retried.status_code == 200, retried.text
_assert_landed(gateway, team, replacement)
assert_landed(gateway, team, replacement)
def _five_kilobyte_member(scenario: Scenario) -> str:
@ -394,8 +342,8 @@ def test_a_push_with_an_odd_member_value_never_answers_500_and_keeps_the_roster_
gateway: Gateway, shape: str
) -> None:
with gateway.scenario() as scenario:
kept: Final = _seed_users(scenario, SMALL_PUSH)
team: Final = _created_team(scenario, _create_group(gateway, kept))
kept: Final = seed_users(scenario, SMALL_PUSH)
team: Final = created_team(scenario, create_group(gateway, kept))
odd: Final = ODD_MEMBER_VALUES[shape](scenario, kept)
pushed: Final = gateway.request(
"PUT",
@ -404,14 +352,14 @@ def test_a_push_with_an_odd_member_value_never_answers_500_and_keeps_the_roster_
"schemas": [GROUP_SCHEMA],
"id": team,
"displayName": f"integration-{uuid.uuid4().hex}",
"members": [*_members(kept), {"value": odd}],
"members": [*members(kept), {"value": odd}],
},
)
assert pushed.status_code < 500, pushed.text
roster: Final = _roster_ids(team)
assert len(roster) == len(frozenset(roster)), roster
assert frozenset(kept) <= frozenset(roster), roster
_assert_landed(gateway, team, roster)
assert_landed(gateway, team, roster)
if pushed.status_code >= 400:
assert frozenset(roster) == frozenset(kept), (pushed.text, roster)
@ -443,15 +391,15 @@ PATCH_SHAPES: Final[Mapping[str, PatchShape]] = {
@pytest.mark.parametrize("shape", tuple(PATCH_SHAPES))
def test_patch_member_operation_shapes_land_the_expected_roster(gateway: Gateway, shape: str) -> None:
with gateway.scenario() as scenario:
users: Final = _seed_users(scenario, 4)
team: Final = _created_team(scenario, _create_group(gateway, users[:3]))
users: Final = seed_users(scenario, 4)
team: Final = created_team(scenario, create_group(gateway, users[:3]))
patched: Final = gateway.request(
"PATCH",
f"/scim/v2/Groups/{team}",
{"schemas": [PATCH_OP_SCHEMA], "Operations": PATCH_SHAPES[shape].operations(users)},
)
assert patched.status_code == 200, patched.text
_assert_landed(gateway, team, PATCH_SHAPES[shape].expected(users))
assert_landed(gateway, team, PATCH_SHAPES[shape].expected(users))
def _team_key_for(scenario: Scenario, team: str, user_id: str) -> str:
@ -462,7 +410,7 @@ def _team_key_for(scenario: Scenario, team: str, user_id: str) -> str:
def _push_outcome(candidate: Gateway, team: str, users: Sequence[str]) -> str:
try:
return str(_replace_group(candidate, team, users).status_code)
return str(replace_group(candidate, team, users).status_code)
except httpx.TransportError as error:
return type(error).__name__
@ -484,9 +432,9 @@ def test_a_worker_killed_mid_push_leaves_the_old_roster_and_a_retry_lands_the_ne
ThreadPoolExecutor(max_workers=1) as pool,
):
candidate: Final = owned.gateway
leaving: Final = _seed_users(scenario, CHAOS_GROUP)
arriving: Final = _seed_users(scenario, CHAOS_GROUP)
team: Final = _created_team(scenario, _create_group(candidate, leaving))
leaving: Final = seed_users(scenario, CHAOS_GROUP)
arriving: Final = seed_users(scenario, CHAOS_GROUP)
team: Final = created_team(scenario, create_group(candidate, leaving))
pinned_key: Final = _team_key_for(scenario, team, max(leaving))
with _holding_row_lock(LOCK_KEY_ROW_SQL, sha256(pinned_key.encode()).hexdigest()) as holder_pid:
push: Final = pool.submit(_push_outcome, candidate, team, arriving)
@ -508,11 +456,11 @@ def test_a_worker_killed_mid_push_leaves_the_old_roster_and_a_retry_lands_the_ne
{"leaving": len(settled & frozenset(leaving)), "arriving": len(settled & frozenset(arriving))},
)
assert settled in (frozenset(leaving), frozenset(arriving)), sorted(settled)
_assert_landed(candidate, team, sorted(settled))
assert_landed(candidate, team, sorted(settled))
retried: Final = _replace_group(candidate, team, arriving)
retried: Final = replace_group(candidate, team, arriving)
assert retried.status_code == 200, retried.text
_assert_landed(candidate, team, arriving)
assert_landed(candidate, team, arriving)
assert (
read_rows(
'SELECT token FROM "LiteLLM_VerificationToken" WHERE token = %s',

View file

@ -0,0 +1,431 @@
"""DELETE /scim/v2/Groups/{id} deletes the group's team the way /team/delete does.
The members' group entries, their membership rows and the keys the members hold on the team go in one locked
pass whatever the roster size, and the configured admin group's members are demoted in that same pass, so they stay
admins until the group is gone. A member a concurrent group write added while the delete waited on the team lock is
demoted with the rest, and a group write that arrives once the delete holds the lock finds the group gone. The group
is 404 after, on every worker and on the peer proxy, and an unrelated key keeps serving.
A service-account key and a model on the team stay, so re-creating the group with the same id brings them back.
"""
import os
import re
import uuid
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from functools import partial
from hashlib import sha256
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import GATEWAY_LIMITS, JSON_OBJECT, Gateway, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.process import graceful_stop_seconds, owned_proxy_process
from integration._support.scim import (
GROUP_SCHEMA,
add_to_group,
assert_gone,
assert_landed,
body,
create_group,
created_team,
delete_group,
groups_of,
held_team_lock,
held_user_row,
keys_of,
member_ids,
membership_user_ids,
replace_group,
seed_users,
service_account_key,
team_key,
user_role,
waiters_on_lock_held_by,
waiters_on_rows_locked_by,
)
from pydantic import JsonValue, TypeAdapter
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
pytestmark: Final = pytest.mark.shares_worker_state
PROXY_ADMIN: Final = "proxy_admin"
DEMOTED_ROLE: Final = "internal_user_viewer"
MASTER_KEY_USER: Final = "default_user_id"
BURST_GROUPS: Final = 20
OWNED_PROXY_TIMEOUT: Final = int(2 * graceful_stop_seconds() + 120)
DELETED_TEAM_RECORDS_SQL: Final = 'SELECT team_id FROM "LiteLLM_DeletedTeamTable" WHERE team_id = %s'
DELETED_KEY_RECORDS_SQL: Final = 'SELECT token FROM "LiteLLM_DeletedVerificationToken" WHERE token = %s'
TEAM_MODEL_IDS_SQL: Final = """SELECT model_id FROM "LiteLLM_ProxyModelTable" WHERE model_info->>'team_id' = %s"""
BACKENDS_BLOCKED_BY_SQL: Final = "SELECT pid FROM pg_stat_activity WHERE %s::int = ANY(pg_blocking_pids(pid))"
USER_ROLE_SQL: Final = 'SELECT user_role FROM "LiteLLM_UserTable" WHERE user_id = %s'
DELETED_AUDIT_ROWS_SQL: Final = """
SELECT table_name, action, object_id, changed_by, changed_by_api_key
FROM "LiteLLM_AuditLog"
WHERE object_id IN (%s, %s) AND action = 'deleted'
ORDER BY table_name
"""
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_PIDS: Final = TypeAdapter(tuple[int, ...])
def _hashed(key: str) -> str:
return sha256(key.encode()).hexdigest()
def _admin_group_config(tmp_path: Path, admin_alias: str) -> Path:
config: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
settings: Final = {**object_value(config["litellm_settings"]), "scim_admin_group": admin_alias}
path: Final = tmp_path / "scim-admin-group.yaml"
path.write_text(yaml.safe_dump({**config, "litellm_settings": settings}))
return path
def _roles_of(candidate: Gateway, users: tuple[str, ...]) -> frozenset[str]:
return frozenset(user_role(candidate, user) for user in users)
def _backends_blocked_by(holder: int) -> tuple[int, ...]:
return _PIDS.validate_python([row["pid"] for row in read_rows(BACKENDS_BLOCKED_BY_SQL, (str(holder),))])
def _stored_roles(users: tuple[str, ...]) -> frozenset[str]:
return frozenset(string_value(read_rows(USER_ROLE_SQL, (user,))[0]["user_role"]) for user in users)
def _models_status(candidate: Gateway, key: str) -> int:
return candidate.request("GET", "/v1/models", key=key).status_code
def _team_model_ids(team: str) -> frozenset[str]:
return frozenset(string_value(row["model_id"]) for row in read_rows(TEAM_MODEL_IDS_SQL, (team,)))
def _assert_revoked_everywhere(proxies: tuple[Gateway, ...], key: str) -> None:
for candidate in proxies:
eventually(partial(_models_status, candidate, key), lambda code: code == 401, seconds=30)
def _deleted_audit_rows(team: str, hashed_key: str) -> list[dict[str, JsonValue]]:
return read_rows(DELETED_AUDIT_ROWS_SQL, (team, hashed_key))
def _worker_startups(log: Path) -> tuple[tuple[int, ...], int]:
text: Final = log.read_text()
return _PIDS.validate_python(_STARTED_WORKER.findall(text)), text.count("Application startup complete.")
@contextmanager
def _fresh_connection(candidate: Gateway) -> Iterator[Gateway]:
with httpx.Client(
base_url=candidate.client.base_url, timeout=candidate.client.timeout, trust_env=False, limits=GATEWAY_LIMITS
) as client:
yield Gateway(client, candidate.key, candidate.upstream_url)
def test_deleting_one_group_leaves_the_members_other_group_intact(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
users: Final = seed_users(scenario, 3)
doomed: Final = created_team(scenario, create_group(gateway, users))
kept: Final = created_team(scenario, create_group(gateway, users))
kept_key: Final = team_key(gateway, kept, users[0])
deleted: Final = delete_group(gateway, doomed)
assert deleted.status_code == 204, deleted.text
assert gateway.request("GET", f"/scim/v2/Groups/{doomed}").status_code == 404
assert_landed(gateway, kept, users)
assert groups_of(gateway, users[0]) == frozenset({kept})
assert keys_of(kept) == frozenset({_hashed(kept_key)})
assert _models_status(gateway, kept_key) == 200
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_deleting_the_admin_group_demotes_its_members_and_writes_an_audit_row_naming_the_scim_caller(
gateway: Gateway, tmp_path: Path
) -> None:
admin_alias: Final = f"integration-admins-{uuid.uuid4().hex}"
with (
owned_proxy_process(
gateway,
tmp_path,
{"DATABASE_URL": os.environ["DATABASE_URL"], "LITELLM_STORE_AUDIT_LOGS": "true"},
config=_admin_group_config(tmp_path, admin_alias),
remove_environment=("DATABASE_URL_READ_REPLICA",),
workers=2,
) as owned,
owned.gateway.scenario() as scenario,
):
candidate: Final = owned.gateway
users: Final = seed_users(scenario, 3)
team: Final = created_team(scenario, create_group(candidate, users, display_name=admin_alias))
assert _roles_of(candidate, users) == frozenset({PROXY_ADMIN})
key: Final = team_key(candidate, team, users[0])
deleted: Final = delete_group(candidate, team)
assert deleted.status_code == 204, deleted.text
assert_gone(candidate, team, users, key)
assert _roles_of(candidate, users) == frozenset({DEMOTED_ROLE})
rows: Final = eventually(lambda: _deleted_audit_rows(team, _hashed(key)), lambda found: len(found) >= 2, 30)
caller: Final = {"changed_by": MASTER_KEY_USER, "changed_by_api_key": LITELLM_PROXY_MASTER_KEY_ALIAS}
assert rows == [
{"table_name": "LiteLLM_TeamTable", "action": "deleted", "object_id": team, **caller},
{"table_name": "LiteLLM_VerificationToken", "action": "deleted", "object_id": _hashed(key), **caller},
]
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_a_member_added_while_the_admin_group_delete_waits_on_the_team_lock_is_demoted_too(
gateway: Gateway, tmp_path: Path
) -> None:
admin_alias: Final = f"integration-admins-{uuid.uuid4().hex}"
with (
owned_proxy_process(
gateway,
tmp_path,
{"DATABASE_URL": os.environ["DATABASE_URL"]},
config=_admin_group_config(tmp_path, admin_alias),
remove_environment=("DATABASE_URL_READ_REPLICA",),
workers=2,
client_timeout=60,
) as owned,
owned.gateway.scenario() as scenario,
ThreadPoolExecutor(max_workers=2) as pool,
):
candidate: Final = owned.gateway
users: Final = seed_users(scenario, 5)
(late,) = seed_users(scenario, 1)
team: Final = created_team(scenario, create_group(candidate, users, display_name=admin_alias))
assert _roles_of(candidate, users) == frozenset({PROXY_ADMIN})
key: Final = team_key(candidate, team, users[0])
with held_team_lock(team) as holder:
put: Final = pool.submit(replace_group, candidate, team, (*users, late), display_name=admin_alias)
eventually(lambda: waiters_on_lock_held_by(holder), lambda waiting: waiting == 1, seconds=30)
delete: Final = pool.submit(delete_group, candidate, team)
eventually(lambda: waiters_on_lock_held_by(holder), lambda waiting: waiting == 2, seconds=30)
assert _stored_roles(users) == frozenset({PROXY_ADMIN})
replaced: Final = put.result()
deleted: Final = delete.result()
assert replaced.status_code == 200, replaced.text
assert member_ids(body(replaced)) == frozenset((*users, late))
assert deleted.status_code == 204, deleted.text
assert_gone(candidate, team, (*users, late), key)
assert _roles_of(candidate, (*users, late)) == frozenset({DEMOTED_ROLE})
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_a_member_added_while_the_admin_group_delete_holds_the_team_lock_is_refused_and_never_made_admin(
gateway: Gateway, tmp_path: Path
) -> None:
admin_alias: Final = f"integration-admins-{uuid.uuid4().hex}"
with (
owned_proxy_process(
gateway,
tmp_path,
{"DATABASE_URL": os.environ["DATABASE_URL"]},
config=_admin_group_config(tmp_path, admin_alias),
remove_environment=("DATABASE_URL_READ_REPLICA",),
workers=2,
client_timeout=60,
) as owned,
owned.gateway.scenario() as scenario,
ThreadPoolExecutor(max_workers=2) as pool,
):
candidate: Final = owned.gateway
users: Final = seed_users(scenario, 5)
(late,) = seed_users(scenario, 1)
team: Final = created_team(scenario, create_group(candidate, users, display_name=admin_alias))
assert _roles_of(candidate, users) == frozenset({PROXY_ADMIN})
key: Final = team_key(candidate, team, users[0])
with held_user_row(users[0]) as holder:
delete: Final = pool.submit(delete_group, candidate, team)
eventually(lambda: waiters_on_rows_locked_by(holder), lambda waiting: waiting == 1, seconds=30)
(deleting,) = _backends_blocked_by(holder)
added: Final = pool.submit(add_to_group, candidate, team, (late,))
eventually(lambda: _backends_blocked_by(deleting), lambda blocked: len(blocked) == 1, seconds=30)
assert _stored_roles(users) == frozenset({PROXY_ADMIN})
deleted: Final = delete.result()
assert deleted.status_code == 204, deleted.text
assert added.result().status_code == 404, added.result().text
assert_gone(candidate, team, users, key)
assert _roles_of(candidate, users) == frozenset({DEMOTED_ROLE})
assert user_role(candidate, late) != PROXY_ADMIN
def test_a_group_delete_revokes_member_keys_and_a_re_created_group_gets_its_service_account_key_and_model_back(
gateway: Gateway,
) -> None:
with gateway.scenario() as scenario:
users: Final = seed_users(scenario, 2)
team: Final = created_team(scenario, create_group(gateway, users))
member_key: Final = team_key(gateway, team, users[0])
service_key: Final = service_account_key(gateway, scenario, team)
scenario.model(model_info={"team_id": team})
team_models: Final = _team_model_ids(team)
assert team_models != frozenset()
deleted: Final = delete_group(gateway, team)
assert deleted.status_code == 204, deleted.text
assert gateway.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
assert membership_user_ids(team) == frozenset()
assert keys_of(team) == frozenset({_hashed(service_key)})
assert _team_model_ids(team) == team_models
_assert_revoked_everywhere((gateway,), member_key)
recreated: Final = gateway.request(
"POST",
"/scim/v2/Groups",
{"schemas": [GROUP_SCHEMA], "externalId": team, "displayName": f"integration-{uuid.uuid4().hex}"},
)
assert recreated.status_code == 201, recreated.text
assert string_value(body(recreated)["id"]) == team
eventually(partial(_models_status, gateway, service_key), lambda code: code == 200, seconds=90)
assert _models_status(gateway, member_key) == 401
def test_deleting_an_unknown_group_is_404(gateway: Gateway) -> None:
missing: Final = f"missing-{uuid.uuid4().hex}"
response: Final = delete_group(gateway, missing)
assert response.status_code == 404, response.text
assert "error" in body(response), response.text
assert missing in response.text
def test_deleting_a_group_twice_is_404_the_second_time(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
users: Final = seed_users(scenario, 2)
team: Final = created_team(scenario, create_group(gateway, users))
first: Final = delete_group(gateway, team)
assert first.status_code == 204, first.text
second: Final = delete_group(gateway, team)
assert second.status_code == 404, second.text
assert "error" in body(second), second.text
assert gateway.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
assert membership_user_ids(team) == frozenset()
def test_two_deletes_racing_on_one_group_answer_204_then_404_and_record_the_team_once(gateway: Gateway) -> None:
with gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=2) as pool:
users: Final = seed_users(scenario, 2)
team: Final = created_team(scenario, create_group(gateway, users))
key: Final = team_key(gateway, team, users[0])
with held_team_lock(team) as holder:
deletes: Final = (pool.submit(delete_group, gateway, team), pool.submit(delete_group, gateway, team))
eventually(lambda: waiters_on_lock_held_by(holder), lambda waiting: waiting == 2, seconds=30)
assert sorted(delete.result().status_code for delete in deletes) == [204, 404]
assert len(read_rows(DELETED_TEAM_RECORDS_SQL, (team,))) == 1
assert len(read_rows(DELETED_KEY_RECORDS_SQL, (_hashed(key),))) == 1
assert gateway.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
def test_deleting_a_group_with_a_non_admin_key_is_refused(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
users: Final = seed_users(scenario, 2)
team: Final = created_team(scenario, create_group(gateway, users))
outsider: Final = scenario.key()
response: Final = delete_group(gateway, team, key=outsider)
assert response.status_code in (401, 403), response.text
assert "error" in body(response), response.text
assert_landed(gateway, team, users)
@pytest.mark.parametrize("group_id", ("123", "x" * 5000, "a%2Fb"), ids=("int_like", "five_kb", "encoded_slash"))
def test_deleting_a_group_with_a_hostile_id_is_404(gateway: Gateway, group_id: str) -> None:
response: Final = delete_group(gateway, group_id)
assert response.status_code == 404, response.text
assert isinstance(response.json(), dict), response.text
assert gateway.request("GET", "/health/liveliness").status_code == 200
def test_deleting_an_empty_group_is_204_then_404(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
team: Final = created_team(scenario, create_group(gateway, ()))
assert member_ids(gateway.get(f"/scim/v2/Groups/{team}")) == frozenset()
deleted: Final = delete_group(gateway, team)
assert deleted.status_code == 204, deleted.text
assert gateway.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
assert membership_user_ids(team) == frozenset()
def test_group_delete_on_one_proxy_revokes_the_team_key_and_the_group_on_the_peer(
gateway: Gateway, peer: Gateway
) -> None:
with gateway.scenario() as scenario:
users: Final = seed_users(scenario, 3)
team: Final = created_team(scenario, create_group(gateway, users))
key: Final = team_key(gateway, team, users[0])
assert _models_status(peer, key) == 200
assert member_ids(peer.get(f"/scim/v2/Groups/{team}")) == frozenset(users)
deleted: Final = delete_group(gateway, team)
assert deleted.status_code == 204, deleted.text
assert peer.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
_assert_revoked_everywhere((peer, gateway), key)
assert_gone(peer, team, users, key)
def test_twenty_group_deletes_split_across_two_proxies_all_land(gateway: Gateway, peer: Gateway) -> None:
proxies: Final = (gateway, peer)
with gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=10) as pool:
bystander: Final = scenario.key()
rosters: Final = tuple(seed_users(scenario, 3) for _ in range(BURST_GROUPS))
teams: Final = tuple(created_team(scenario, create_group(gateway, users)) for users in rosters)
keys: Final = tuple(team_key(gateway, team, users[0]) for team, users in zip(teams, rosters))
assigned: Final = tuple(proxies[index % 2] for index in range(BURST_GROUPS))
responses: Final = tuple(pool.map(delete_group, assigned, teams))
for response in responses:
assert response.status_code == 204, response.text
for team, users, key in zip(teams, rosters, keys):
_assert_revoked_everywhere(proxies, key)
assert_gone(gateway, team, users, key)
assert peer.request("GET", f"/scim/v2/Groups/{team}").status_code == 404
assert _models_status(gateway, bystander) == 200
assert _models_status(peer, bystander) == 200
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_group_delete_after_a_worker_was_killed_still_completes(gateway: Gateway, tmp_path: Path) -> None:
with (
owned_proxy_process(
gateway, tmp_path, {}, remove_environment=("DATABASE_URL_READ_REPLICA",), workers=2
) as owned,
owned.gateway.scenario() as scenario,
):
candidate: Final = owned.gateway
workers, _ = eventually(
lambda: _worker_startups(owned.log), lambda found: len(found[0]) == 2 and found[1] == 2, seconds=120
)
first_users: Final = seed_users(scenario, 3)
second_users: Final = seed_users(scenario, 3)
first: Final = created_team(scenario, create_group(candidate, first_users))
second: Final = created_team(scenario, create_group(candidate, second_users))
first_key: Final = team_key(candidate, first, first_users[0])
second_key: Final = team_key(candidate, second, second_users[0])
with _fresh_connection(candidate) as survivor:
with owned.replacing_worker(workers[0]) as kill:
kill.freeze()
kill()
deleted_first: Final = delete_group(survivor, first)
assert deleted_first.status_code == 204, deleted_first.text
assert_gone(survivor, first, first_users, first_key)
deleted_second: Final = delete_group(survivor, second)
assert deleted_second.status_code == 204, deleted_second.text
assert_gone(survivor, second, second_users, second_key)

View file

@ -6,6 +6,7 @@ Validates that email and secret manager operations are independent and non-block
import asyncio
import json
from collections.abc import Sequence
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -715,14 +716,14 @@ async def test_key_deleted_hook_writes_audit_log_for_alias_deletion():
captured: Final[list[LiteLLM_AuditLogs]] = []
async def capture_audit_log(request_data: LiteLLM_AuditLogs) -> None:
captured.append(request_data)
async def capture_audit_logs(rows: Sequence[LiteLLM_AuditLogs]) -> None:
captured.extend(rows)
with (
patch("litellm.store_audit_logs", True),
patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
new=capture_audit_log,
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=capture_audit_logs,
),
patch.object(
KeyManagementEventHooks,
@ -747,3 +748,34 @@ async def test_key_deleted_hook_writes_audit_log_for_alias_deletion():
assert audit_row.object_id == "hashed"
assert audit_row.table_name == LitellmTableNames.KEY_TABLE_NAME
assert audit_row.changed_by == "admin"
@pytest.mark.asyncio
async def test_deleting_many_keys_writes_every_audit_row_in_one_insert():
from litellm.proxy._types import LiteLLM_VerificationToken, LitellmTableNames, UserAPIKeyAuth
from litellm.proxy.management_helpers.audit_logs import drain_audit_tasks
tokens: Final = tuple(f"hashed-{index}" for index in range(50))
prisma_client: Final = MagicMock()
prisma_client.db.litellm_auditlog.create = AsyncMock()
prisma_client.db.litellm_auditlog.create_many = AsyncMock(return_value=len(tokens))
with (
patch("litellm.store_audit_logs", True),
patch("litellm.proxy.proxy_server.premium_user", True),
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
):
KeyManagementEventHooks.create_key_deleted_audit_logs(
keys_being_deleted=[LiteLLM_VerificationToken(token=token) for token in tokens],
user_api_key_dict=UserAPIKeyAuth(user_id="admin", token="callertok"),
)
await drain_audit_tasks()
prisma_client.db.litellm_auditlog.create.assert_not_called()
prisma_client.db.litellm_auditlog.create_many.assert_awaited_once()
rows: Final = prisma_client.db.litellm_auditlog.create_many.await_args.kwargs["data"]
assert [row["object_id"] for row in rows] == list(tokens)
assert [json.loads(row["before_value"])["token"] for row in rows] == list(tokens)
assert {(row["table_name"], row["action"], row["changed_by"]) for row in rows} == {
(LitellmTableNames.KEY_TABLE_NAME, "deleted", "admin")
}

View file

@ -3,7 +3,7 @@ import asyncio
import json
import logging
import time
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from itertools import chain
from types import MappingProxyType
from typing import Final, assert_never
@ -58,6 +58,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
update_user,
user_api_key_auth,
)
from litellm.proxy.management_endpoints.team_endpoints import DeletedTeams
from litellm.proxy.management_helpers.bulk_user_creation import BULK_NEW_USER_CONCURRENCY
from litellm.proxy.management_helpers.team_roster_sync import (
MembersMissing,
@ -2831,16 +2832,24 @@ async def test_patch_user_grants_admin_by_team_display_name(mocker, monkeypatch)
assert call_args[1]["data"]["user_role"] == LitellmUserRoles.PROXY_ADMIN
def _scim_admin_prisma(mocker, *, user_teams):
"""Prisma double and its one transaction, in which the user resolves to user_teams
and teams expose an alias equal to their id. Only the transaction answers reads,
so a role written outside it fails the role-recompute helper tests."""
user = mocker.MagicMock()
user.user_id = "member-1"
user.teams = user_teams
def _member_row(mocker, user_id: str, teams: Sequence[str]) -> MagicMock:
row = mocker.MagicMock()
row.user_id = user_id
row.teams = list(teams)
return row
def _scim_admin_prisma(mocker, *, user_teams, teammates: Sequence[tuple[str, Sequence[str]]] = ()):
"""Prisma double and its one transaction, in which the user resolves to user_teams,
``teammates`` are further (user_id, teams) rows, and teams expose an alias equal to
their id. Only the transaction answers reads, so a role written outside it fails the
role-recompute helper tests."""
user = _member_row(mocker, "member-1", user_teams)
members = (user, *(_member_row(mocker, user_id, teams) for user_id, teams in teammates))
def _team_find_unique(where):
team = mocker.MagicMock()
team.team_id = where["team_id"]
team.team_alias = where["team_id"]
return team
@ -2848,7 +2857,7 @@ def _scim_admin_prisma(mocker, *, user_teams):
return tuple(_team_find_unique({"team_id": team_id}) for team_id in where["team_id"]["in"])
def _users_find_many(where):
return (user,) if user.user_id in where["user_id"]["in"] else ()
return tuple(member for member in members if member.user_id in where["user_id"]["in"])
tx = mocker.MagicMock()
tx.query_raw = AsyncMock(return_value=[])
@ -2904,6 +2913,29 @@ async def test_recompute_scim_member_roles_grants_when_in_admin_group(mocker, mo
tx.execute_raw.assert_awaited_once_with(ANY, LitellmUserRoles.PROXY_ADMIN, ["member-1"])
@pytest.mark.asyncio
async def test_recompute_scim_member_roles_stops_serving_a_demoted_members_cached_admin_role(mocker, monkeypatch):
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import proxy_config
async def mock_get_config():
return {"litellm_settings": {"scim_admin_group": "litellm-admins"}}
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False)
cache = UserApiKeyCache()
await cache.async_set_cache(
key="member-1", value={"user_id": "member-1", "user_role": LitellmUserRoles.PROXY_ADMIN.value}
)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", cache)
prisma, tx = _scim_admin_prisma(mocker, user_teams=["engineering"])
await _recompute_scim_member_roles(prisma, ["member-1"])
tx.execute_raw.assert_awaited_once_with(ANY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, ["member-1"])
assert await cache.async_get_cache(key="member-1") is None
@pytest.mark.asyncio
async def test_recompute_scim_member_roles_noop_when_admin_group_unset(mocker, monkeypatch):
"""With scim_admin_group unset the recompute helper must not touch any role,
@ -3043,44 +3075,128 @@ async def test_patch_group_recomputes_roles_for_changed_members(mocker):
assert set(recompute_mock.call_args[0][1]) == {"user1"}
@pytest.mark.asyncio
async def test_delete_group_recomputes_roles_for_members(mocker):
"""DELETE /Groups must recompute the global role for the team's members, so
deleting the admin group demotes everyone who was only admin through it."""
from litellm.proxy._types import Member
_SCIM_CALLER: Final = UserAPIKeyAuth(api_key="hashed-scim-token", user_id="idp-service-user")
_ADMIN_GROUP: Final = "litellm-admins"
_DEMOTED: Final = call(ANY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, ["member-1"])
existing_team = mocker.MagicMock()
existing_team.members_with_roles = [
Member(user_id="user1", role="user"),
Member(user_id="user2", role="user"),
]
member = mocker.MagicMock()
member.teams = ["test-team-123"]
def _group_delete_mocks(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
team: LiteLLM_TeamTable | None,
*,
teammates: Sequence[tuple[str, Sequence[str]]] = (),
) -> tuple[MagicMock, MagicMock, AsyncMock]:
"""Prisma and its transaction, whose users are left in no team once the delete detached
them and whose group lookup answers ``team``, plus the patched team delete, which hands
its transaction and the roster it read under the lock, member-1 and every teammate, to
the caller's write."""
from litellm.proxy.proxy_server import proxy_config
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team)
mock_prisma_client.db.litellm_teamtable.delete = AsyncMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=member)
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=())
mock_prisma_client.db.litellm_usertable.update = AsyncMock()
async def mock_get_config():
return {"litellm_settings": {"scim_admin_group": _ADMIN_GROUP}}
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False)
prisma, tx = _scim_admin_prisma(mocker, user_teams=[], teammates=teammates)
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
AsyncMock(return_value=prisma),
)
recompute_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
AsyncMock(),
roster: Final = ("member-1", *(user_id for user_id, _ in teammates))
async def delete_rows(
*, before_commit: Callable[[object, tuple[str, ...]], Awaitable[None]] | None = None, **_: object
) -> DeletedTeams:
if before_commit is not None:
await before_commit(tx, roster)
return DeletedTeams(team_ids=(_ADMIN_GROUP,))
delete_team_mock: Final = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.delete_validated_team_rows",
AsyncMock(side_effect=delete_rows),
)
return prisma, tx, delete_team_mock
await delete_group(group_id="test-team-123")
recompute_mock.assert_awaited_once()
assert list(recompute_mock.call_args[0][1]) == ["user1", "user2"]
def _admin_group_team() -> LiteLLM_TeamTable:
return LiteLLM_TeamTable(team_id=_ADMIN_GROUP, members_with_roles=[Member(user_id="member-1", role="user")])
@pytest.mark.asyncio
async def test_delete_group_demotes_a_member_who_was_admin_only_through_the_deleted_group(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
"""DELETE /Groups answers 204 and writes the non-admin role for a member who was
admin only through the deleted group, once, inside the team delete."""
_, tx, _ = _group_delete_mocks(mocker, monkeypatch, _admin_group_team())
response: Final = await delete_group(group_id=_ADMIN_GROUP, user_api_key_dict=_SCIM_CALLER)
assert response.status_code == 204
assert tx.execute_raw.await_args_list == [_DEMOTED]
@pytest.mark.asyncio
async def test_delete_group_demotes_every_member_on_the_roster_the_team_delete_read_under_the_lock(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
"""A member a concurrent PUT added to the admin group after this delete read the
group is on the roster the team delete read under the lock, so the one role write
covers that late member too."""
_, tx, _ = _group_delete_mocks(mocker, monkeypatch, _admin_group_team(), teammates=(("member-2", ()),))
response: Final = await delete_group(group_id=_ADMIN_GROUP, user_api_key_dict=_SCIM_CALLER)
assert response.status_code == 204
assert tx.execute_raw.await_args_list == [
call(ANY, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, ["member-1", "member-2"]),
]
@pytest.mark.asyncio
async def test_delete_group_fails_when_the_role_writes_fail(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch):
"""The role writes run on the team delete's transaction, so when they fail the
request fails with them and the identity provider's retry finds the group."""
_, tx, _ = _group_delete_mocks(mocker, monkeypatch, _admin_group_team())
tx.execute_raw.side_effect = RuntimeError("database unavailable")
with pytest.raises(ProxyException) as raised:
await delete_group(group_id=_ADMIN_GROUP, user_api_key_dict=_SCIM_CALLER)
assert raised.value.code == "500"
@pytest.mark.asyncio
async def test_delete_group_answers_404_when_a_concurrent_delete_removed_the_team_first(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
"""A concurrent delete that removed the group while this one waited on the team lock
leaves the team delete nothing to delete or report, so this request answers 404, the
same as a group that never existed."""
_, _, delete_team_mock = _group_delete_mocks(mocker, monkeypatch, _admin_group_team())
delete_team_mock.side_effect = None
delete_team_mock.return_value = DeletedTeams(team_ids=())
with pytest.raises(ProxyException) as raised:
await delete_group(group_id=_ADMIN_GROUP, user_api_key_dict=_SCIM_CALLER)
assert raised.value.code == "404"
@pytest.mark.asyncio
async def test_delete_group_of_an_unknown_group_answers_404_without_deleting_or_touching_roles(
mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
):
_, tx, delete_team_mock = _group_delete_mocks(mocker, monkeypatch, None)
with pytest.raises(ProxyException) as raised:
await delete_group(group_id="missing-team", user_api_key_dict=_SCIM_CALLER)
assert raised.value.code == "404"
delete_team_mock.assert_not_awaited()
tx.execute_raw.assert_not_called()
@pytest.mark.asyncio
@ -3121,12 +3237,10 @@ async def test_delete_group_is_refused_while_the_team_holds_a_ptu_share(mocker,
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", shared_router)
with pytest.raises(ProxyException) as refused:
await delete_group(group_id="test-team-123")
await delete_group(group_id="test-team-123", user_api_key_dict=_SCIM_CALLER)
assert refused.value.code == "400"
assert "test-team-123" in str(refused.value.message) and "shared-ptu" in str(refused.value.message)
mock_prisma_client.db.litellm_teamtable.delete.assert_not_called()
mock_prisma_client.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio

View file

@ -5123,11 +5123,11 @@ async def test_delete_user_writes_deleted_audit_log_for_user_keys(mocker):
mocker.patch("litellm.store_audit_logs", True)
captured: Final[list] = []
async def _capture(request_data):
captured.append(request_data)
async def _capture(rows):
captured.extend(rows)
mocker.patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=_capture,
)

View file

@ -29,6 +29,7 @@ from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LiteLLM_VerificationToken,
LitellmTableNames,
LitellmUserRoles,
Member,
@ -50,7 +51,9 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
)
from litellm.proxy.management_endpoints.team_endpoints import (
_LOCK_DELETED_TEAM_MEMBERS_SQL,
_STRIP_DELETED_TEAMS_FROM_USERS_SQL,
DeletedTeams,
GetTeamMemberPermissionsResponse,
UpdateTeamMemberPermissionsRequest,
_build_team_list_where_conditions,
@ -63,6 +66,8 @@ from litellm.proxy.management_endpoints.team_endpoints import (
_validate_team_member_reset_spend_value,
aggregated_date_range_error,
delete_team,
delete_validated_team_rows,
delete_validated_teams,
list_available_teams,
new_team,
reset_team_member_budget_fn,
@ -212,21 +217,50 @@ def _wire_member_delete_tx(prisma_client):
return tx
def _wire_team_delete_tx(prisma_client):
"""`/team/delete` deletes the team rows and runs its post-delete reference sweep under
every team's advisory lock in one transaction, so a mocked client has to hand its own
table mocks (and db-level execute_raw) back out of `tx()` for existing per-table
assertions on `prisma_client.db.*` to keep seeing those calls."""
def _wire_team_delete_tx(
prisma_client,
*,
present_team_ids=None,
locked_teams: tuple[LiteLLM_TeamTable, ...] = (),
team_keys: tuple[LiteLLM_VerificationToken, ...] = (),
):
"""`/team/delete` records, deletes and sweeps the team rows under every team's advisory
lock in one transaction, so a mocked client has to hand its own table mocks back out of
`tx()` for existing per-table assertions on `prisma_client.db.*` to keep seeing those
calls. Under the lock every requested team still exists unless `present_team_ids` names
the ones left, and reads as the matching `locked_teams` entry or a bare row. The keys the
lock finds on those teams are `team_keys`. The locked sweep reads the users it detaches
through a `query_raw` whose rows name them and their teams, so the default answers none."""
locked_by_id = {team.team_id: team for team in locked_teams}
async def teams_still_there(where: dict[str, dict[str, list[str]]]) -> list[LiteLLM_TeamTable]:
return [
locked_by_id.get(team_id, LiteLLM_TeamTable(team_id=team_id))
for team_id in where["team_id"]["in"]
if present_team_ids is None or team_id in present_team_ids
]
async def keys_on_teams(where: dict[str, dict[str, list[str]]]) -> list[LiteLLM_VerificationToken]:
return [key for key in team_keys if key.team_id in where["team_id"]["in"]]
tx = SimpleNamespace(
litellm_teamtable=prisma_client.db.litellm_teamtable,
litellm_teamtable=SimpleNamespace(
find_many=teams_still_there,
delete_many=prisma_client.db.litellm_teamtable.delete_many,
),
litellm_verificationtoken=SimpleNamespace(find_many=keys_on_teams, delete_many=AsyncMock(return_value=0)),
litellm_deletedverificationtoken=SimpleNamespace(create_many=AsyncMock()),
litellm_jwtkeymapping=SimpleNamespace(find_many=AsyncMock(return_value=[])),
litellm_teammembership=prisma_client.db.litellm_teammembership,
litellm_deletedteamtable=prisma_client.db.litellm_deletedteamtable,
query_raw=AsyncMock(return_value=[]),
execute_raw=prisma_client.db.execute_raw,
execute_raw=AsyncMock(return_value=0),
)
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx)
tx_cm.__aexit__ = AsyncMock(return_value=None)
prisma_client.tx = MagicMock(return_value=tx_cm)
return tx
# Mock prisma_client
@ -5489,13 +5523,13 @@ async def test_team_member_delete_writes_deleted_audit_log_for_member_keys(
captured: Final[list] = []
async def _capture(request_data):
captured.append(request_data)
async def _capture(rows):
captured.extend(rows)
with (
patch("litellm.store_audit_logs", True),
patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=_capture,
),
):
@ -5564,12 +5598,12 @@ async def test_delete_team_writes_deleted_audit_log_for_team_keys(
)
captured: Final[list] = []
async def _capture(request_data):
captured.append(request_data)
async def _capture(rows):
captured.extend(rows)
monkeypatch.setattr("litellm.store_audit_logs", True)
monkeypatch.setattr(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
_capture,
)
await delete_team(
@ -9050,7 +9084,7 @@ async def test_save_deleted_team_records():
},
]
await _save_deleted_team_records(records=records, prisma_client=mock_prisma_client)
await _save_deleted_team_records(records=records, deleted_teams=mock_prisma_client.db.litellm_deletedteamtable)
mock_create_many.assert_called_once_with(data=records)
@ -9061,7 +9095,7 @@ async def test_save_deleted_team_records_empty_list():
mock_create_many = AsyncMock()
mock_prisma_client.db.litellm_deletedteamtable.create_many = mock_create_many
await _save_deleted_team_records(records=[], prisma_client=mock_prisma_client)
await _save_deleted_team_records(records=[], deleted_teams=mock_prisma_client.db.litellm_deletedteamtable)
mock_create_many.assert_not_called()
@ -9091,7 +9125,7 @@ async def test_persist_deleted_team_records():
await _persist_deleted_team_records(
teams=[team],
prisma_client=mock_prisma_client,
deleted_teams=mock_prisma_client.db.litellm_deletedteamtable,
user_api_key_dict=user_api_key_dict,
litellm_changed_by="admin-user",
)
@ -9295,7 +9329,7 @@ async def test_delete_team_sweeps_references_outside_members_with_roles(
# The locked delete-and-sweep transaction /team/member_add serializes against, kept
# separate from mock_tx above (the BYOK-model-cleanup transaction, unrelated to this lock).
_wire_team_delete_tx(mock_prisma_client)
_wire_team_delete_tx(mock_prisma_client, locked_teams=(doomed_team,))
mock_lock_tx = mock_prisma_client.tx.return_value.__aenter__.return_value
fresh_cache = UserApiKeyCache()
@ -9325,24 +9359,23 @@ async def test_delete_team_sweeps_references_outside_members_with_roles(
litellm_changed_by="admin-user",
)
assert mock_execute_raw.await_args_list == [
call(_STRIP_DELETED_TEAMS_FROM_USERS_SQL, ["team-doomed"]),
call(_STRIP_DELETED_TEAMS_FROM_USERS_SQL, ["team-doomed"]),
assert mock_execute_raw.await_args_list == [], (
"no sweep runs outside the lock: one there strips a member a concurrent roster write added "
"after the caller read the team, before the locked sweep can report them"
)
# one pass for the membership rows, under the lock alongside the delete
assert mock_membership_delete_many.await_args_list == [call(where={"team_id": {"in": ("team-doomed",)}})]
assert mock_lock_tx.query_raw.await_args_list == [
call(TEAM_ADVISORY_LOCK_SQL, "team-doomed"),
call(_LOCK_DELETED_TEAM_MEMBERS_SQL, ["team-doomed"]),
], (
"the unlocked sweep must run once to catch pre-existing drift, and the locked sweep "
"(alongside the delete, under the same advisory lock member_add takes) must run again "
"so a member_add that wrote its reference just before losing the lock is still reaped"
)
# same two passes for the membership rows, the second under the lock alongside the delete
assert mock_membership_delete_many.await_args_list == [
call(where={"team_id": {"in": ("team-doomed",)}}),
call(where={"team_id": {"in": ("team-doomed",)}}),
]
assert mock_lock_tx.query_raw.await_args_list == [call(TEAM_ADVISORY_LOCK_SQL, "team-doomed")], (
"the advisory lock must be acquired before the team row is deleted"
"the advisory lock must be acquired before the team row is deleted, and the user sweep "
"runs under it (the same lock member_add takes) so a member_add that wrote its "
"reference just before losing the lock is still reaped"
)
assert mock_lock_tx.execute_raw.await_args_list == [call(_STRIP_DELETED_TEAMS_FROM_USERS_SQL, ["team-doomed"])]
assert fresh_cache.get_cache(key="team_id:team-doomed") is None
assert fresh_cache.get_cache(key="team_alias:doomed-team") is None
@ -9417,7 +9450,7 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx)
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm)
_wire_team_delete_tx(mock_prisma_client)
_wire_team_delete_tx(mock_prisma_client, locked_teams=(team,))
fresh_cache = UserApiKeyCache()
for user_id in member_user_ids:
@ -9461,6 +9494,234 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
)
@pytest.mark.asyncio
async def test_delete_validated_team_rows_records_the_roster_the_lock_read_and_writes_its_users_before_commit(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
A roster write that lands between the caller's read of the team and the locked delete adds
a member the caller never saw, and a user row can reference the team without a roster entry.
The deleted-team record and the caller's write, which runs on the delete's transaction before
it commits, both take the roster read under the lock, so they name the late member and not the
stray user, while both lose their cached user object.
"""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
read_by_caller = LiteLLM_TeamTable(
team_id="team-doomed",
team_alias="doomed-team",
members_with_roles=[Member(user_id="member-1", role="user")],
metadata={},
model_max_budget={},
model_spend={},
)
read_under_lock = read_by_caller.model_copy(
update={
"members_with_roles": [
Member(user_id="member-1", role="user"),
Member(user_id="late-member", role="user"),
]
}
)
mock_prisma_client = AsyncMock()
mock_prisma_client.delete_data = AsyncMock(return_value={"deleted_keys": 0})
mock_prisma_client.db.litellm_deletedteamtable.create_many = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.execute_raw = AsyncMock()
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
mock_tx = AsyncMock()
mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
mock_tx_cm = MagicMock()
mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx)
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm)
tx = _wire_team_delete_tx(mock_prisma_client, locked_teams=(read_under_lock,))
tx.query_raw = AsyncMock(
side_effect=[
[],
[
{"user_id": "member-1", "teams": ["team-kept", "team-doomed"]},
{"user_id": "late-member", "teams": ["team-doomed"]},
{"user_id": "stray-user", "teams": ["team-doomed"]},
],
]
)
fresh_cache = UserApiKeyCache()
for user_id in ("member-1", "late-member", "stray-user", "bystander-user"):
fresh_cache.set_cache(key=user_id, value=UserAPIKeyAuth(user_id=user_id))
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", fresh_cache)
monkeypatch.setattr("litellm.proxy.proxy_server.create_audit_log_for_update", AsyncMock())
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
async def write_before_commit(_tx: object, _user_ids: tuple[str, ...]) -> None:
mock_prisma_client.tx.return_value.__aexit__.assert_not_awaited()
before_commit = AsyncMock(side_effect=write_before_commit)
deleted = await delete_validated_team_rows(
teams=(read_by_caller,),
prisma_client=mock_prisma_client,
user_api_key_dict=UserAPIKeyAuth(
user_id="admin-user",
api_key="sk-admin",
user_role=LitellmUserRoles.PROXY_ADMIN.value,
),
litellm_changed_by="admin-user",
before_commit=before_commit,
)
assert deleted == DeletedTeams(team_ids=("team-doomed",))
before_commit.assert_awaited_once_with(tx, ("member-1", "late-member"))
(written,) = mock_prisma_client.db.litellm_deletedteamtable.create_many.await_args_list
(record,) = written.kwargs["data"]
assert [member["user_id"] for member in json.loads(record["members_with_roles"])] == ["member-1", "late-member"]
for user_id in ("member-1", "late-member", "stray-user"):
assert fresh_cache.get_cache(key=user_id) is None, user_id
assert fresh_cache.get_cache(key="bystander-user") is not None
@pytest.mark.asyncio
async def test_delete_validated_team_rows_deletes_only_the_keys_its_rostered_members_hold_on_the_team(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
The SCIM group delete removes the team the way a SCIM member removal removes each member:
the keys rostered members hold on the team are tombstoned and deleted, and a key with no
user or held by someone off the roster stays.
"""
team = LiteLLM_TeamTable(
team_id="team-doomed",
members_with_roles=[Member(user_id="member-1", role="user")],
metadata={},
model_max_budget={},
model_spend={},
)
team_keys = tuple(
LiteLLM_VerificationToken(token=token, team_id="team-doomed", user_id=user_id)
for token, user_id in (("hashed-member", "member-1"), ("hashed-service-account", None), ("hashed-outsider", "admin-made"))
)
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
mock_access_group_tx_cm = MagicMock()
mock_access_group_tx_cm.__aenter__ = AsyncMock(return_value=AsyncMock())
mock_access_group_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_access_group_tx_cm)
tx = _wire_team_delete_tx(mock_prisma_client, locked_teams=(team,), team_keys=team_keys)
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
await delete_validated_team_rows(
teams=(team,),
prisma_client=mock_prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="hashed-scim-token"),
litellm_changed_by=None,
)
(tombstones,) = tx.litellm_deletedverificationtoken.create_many.await_args_list
assert [record["token"] for record in tombstones.kwargs["data"]] == ["hashed-member"]
(deleted_keys,) = tx.litellm_verificationtoken.delete_many.await_args_list
assert deleted_keys.kwargs["where"] == {"token": {"in": ["hashed-member"]}}
@pytest.mark.asyncio
async def test_delete_validated_teams_writes_the_deleted_audit_row_only_for_a_team_still_there_under_the_lock(
monkeypatch: pytest.MonkeyPatch,
):
"""Two deletes racing on one team both read it before the lock, and only the one that still
finds it under the lock deletes it, so only that one writes its deleted audit row."""
audit_logger = _wire_audit_log_callback(monkeypatch)
teams = tuple(
LiteLLM_TeamTable(team_id=team_id, metadata={}, model_max_budget={}, model_spend={})
for team_id in ("team-raced", "team-kept-for-us")
)
mock_prisma = AsyncMock()
mock_prisma.delete_data = AsyncMock(return_value={"deleted_keys": 0})
mock_prisma.db.litellm_deletedteamtable.create_many = AsyncMock()
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma.db.litellm_auditlog.create = AsyncMock()
mock_tx = AsyncMock()
mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
mock_tx_cm = MagicMock()
mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx)
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma.db.tx = MagicMock(return_value=mock_tx_cm)
_wire_team_delete_tx(mock_prisma, present_team_ids={"team-kept-for-us"})
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
await delete_validated_teams(
teams=teams,
prisma_client=mock_prisma,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1", api_key="sk-a"),
litellm_changed_by=None,
)
await _settle_audit_log_tasks()
assert [
(p["object_id"], p["action"])
for p in audit_logger.payloads
if p["table_name"] == LitellmTableNames.TEAM_TABLE_NAME
] == [("team-kept-for-us", "deleted")]
@pytest.mark.asyncio
async def test_delete_validated_teams_records_and_reports_only_the_teams_still_there_under_the_lock(
monkeypatch,
disable_audit_logging_for_mocked_team,
):
"""
A concurrent delete that removed one of the teams while this one waited on the lock
already wrote that team's deleted-team record, so this delete writes records only for
the teams it still finds under the lock and reports only those as deleted.
"""
teams = tuple(
LiteLLM_TeamTable(
team_id=team_id,
team_alias=team_id,
members_with_roles=[],
metadata={},
model_max_budget={},
model_spend={},
)
for team_id in ("team-raced", "team-kept-for-us")
)
mock_prisma_client = AsyncMock()
mock_prisma_client.delete_data = AsyncMock(return_value={"deleted_keys": 0})
mock_prisma_client.db.litellm_deletedteamtable.create_many = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.execute_raw = AsyncMock()
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
mock_tx = AsyncMock()
mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
mock_tx_cm = MagicMock()
mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx)
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm)
_wire_team_delete_tx(mock_prisma_client, present_team_ids={"team-kept-for-us"})
monkeypatch.setattr("litellm.proxy.proxy_server.create_audit_log_for_update", AsyncMock())
monkeypatch.setattr("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
deleted = await delete_validated_teams(
teams=teams,
prisma_client=mock_prisma_client,
user_api_key_dict=UserAPIKeyAuth(
user_id="admin-user",
api_key="sk-admin",
user_role=LitellmUserRoles.PROXY_ADMIN.value,
),
litellm_changed_by=None,
)
assert deleted.team_ids == ("team-kept-for-us",)
(written,) = mock_prisma_client.db.litellm_deletedteamtable.create_many.await_args_list
assert [record["team_id"] for record in written.kwargs["data"]] == ["team-kept-for-us"]
@pytest.mark.asyncio
async def test_delete_team_evicts_the_auth_cache_of_the_keys_it_deletes(
monkeypatch,
@ -9558,8 +9819,7 @@ async def test_delete_team_failing_locked_sweep_rolls_back_the_delete_and_leaves
mock_prisma_client.db.litellm_deletedteamtable.create_many = AsyncMock()
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
# the unlocked pre-delete sweep succeeds, the locked post-delete sweep blows up
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[None, ConnectionError("db went away")])
mock_prisma_client.db.execute_raw = AsyncMock()
mock_tx = AsyncMock()
mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
@ -9568,6 +9828,10 @@ async def test_delete_team_failing_locked_sweep_rolls_back_the_delete_and_leaves
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm)
_wire_team_delete_tx(mock_prisma_client)
# the advisory lock is taken, then the locked post-delete user sweep blows up
mock_prisma_client.tx.return_value.__aenter__.return_value.query_raw = AsyncMock(
side_effect=[[], ConnectionError("db went away")]
)
fresh_cache = UserApiKeyCache()
cached_obj = LiteLLM_TeamTableCachedObj(team_id="team-doomed", team_alias="doomed-team")
@ -9607,21 +9871,26 @@ class _BroadcastPipeline:
return self
async def execute(self) -> None:
for message in self._messages:
await self._redis.publish("", message)
await self._redis.send(tuple(self._messages))
class _BroadcastRecordingRedis:
namespace = None
def __init__(self) -> None:
def __init__(self, round_trip_seconds: float = 0.0) -> None:
self.broadcast_keys: list[str] = []
self._round_trip_seconds = round_trip_seconds
def init_pubsub_client(self) -> "_BroadcastRecordingRedis":
return self
async def send(self, messages: Sequence[str]) -> None:
if self._round_trip_seconds:
await asyncio.sleep(self._round_trip_seconds)
self.broadcast_keys.extend(json.loads(message)["cache_key"] for message in messages)
async def publish(self, channel: str, message: str) -> int:
self.broadcast_keys.append(json.loads(message)["cache_key"])
await self.send((message,))
return 1
def pipeline(self, transaction: bool = True) -> _BroadcastPipeline:
@ -9667,7 +9936,7 @@ async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(
mock_tx_cm.__aenter__ = AsyncMock(return_value=mock_tx)
mock_tx_cm.__aexit__ = AsyncMock(return_value=False)
mock_prisma_client.db.tx = MagicMock(return_value=mock_tx_cm)
_wire_team_delete_tx(mock_prisma_client)
_wire_team_delete_tx(mock_prisma_client, locked_teams=(team,))
import litellm.proxy.common_utils.auth_cache_invalidation_pubsub as pubsub_module
@ -9697,6 +9966,44 @@ async def test_delete_team_broadcasts_cache_invalidation_to_other_workers(
assert redis.broadcast_keys == ["hashed-doomed-key", "team_id:team-doomed", "team_alias:doomed-team"]
@pytest.mark.asyncio
async def test_deleting_a_500_member_team_reaches_every_member_cache_on_other_workers(
monkeypatch: pytest.MonkeyPatch,
):
from itertools import chain
import litellm.proxy.common_utils.auth_cache_invalidation_pubsub as pubsub_module
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_auth_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.management_endpoints.team_endpoints import _evict_deleted_team_member_cache
redis: Final = _BroadcastRecordingRedis(round_trip_seconds=0.001)
monkeypatch.setattr(pubsub_module, "coordination_redis_cache", lambda: redis)
monkeypatch.setattr(pubsub_module, "_pending_publishes", set())
monkeypatch.setattr(pubsub_module, "_in_flight_publishes", asyncio.Semaphore(16))
members: Final = tuple(f"member-{index:03d}" for index in range(500))
await _evict_deleted_team_member_cache(
team_id="team-large", member_user_ids=members, user_api_key_cache=UserApiKeyCache()
)
await asyncio.gather(*pubsub_module._pending_publishes) # pyright: ignore[reportPrivateUsage] # drain module-level tasks
expected: Final = frozenset(
chain.from_iterable(
(
member,
team_membership_auth_cache_key(team_id="team-large", user_id=member),
team_membership_reservation_cache_key(user_id=member, team_id="team-large"),
)
for member in members
)
)
assert expected - frozenset(redis.broadcast_keys) == frozenset()
@pytest.mark.asyncio
async def test_delete_team_survives_a_failing_cache_backend(
monkeypatch,
@ -15783,6 +16090,8 @@ async def test_new_team_and_delete_team_both_drive_the_mirror(
prisma.delete_data = AsyncMock(return_value=[team_row])
prisma.db.execute_raw = AsyncMock(return_value=0)
prisma.db.litellm_teammembership.delete_many = AsyncMock(return_value=0)
prisma.db.litellm_teamtable.delete_many = AsyncMock(return_value=1)
_wire_team_delete_tx(prisma, locked_teams=(team_row,))
await delete_team(
data=DeleteTeamRequest(team_ids=["team-gone"]),

View file

@ -1007,12 +1007,12 @@ async def test_bulk_delete_writes_deleted_audit_log_for_deleted_keys(mocker):
captured: Final[list] = []
async def _capture(request_data):
captured.append(request_data)
async def _capture(rows):
captured.extend(rows)
mocker.patch("litellm.store_audit_logs", True)
mocker.patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=_capture,
)
await _delete(prisma, ["u1"])
@ -1041,12 +1041,12 @@ async def test_bulk_member_delete_writes_deleted_audit_log_for_removed_team_keys
captured: Final[list] = []
async def _capture(request_data):
captured.append(request_data)
async def _capture(rows):
captured.extend(rows)
mocker.patch("litellm.store_audit_logs", True)
mocker.patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=_capture,
)
await _remove(prisma, "t1", [{"user_id": "u1"}])
@ -1076,7 +1076,7 @@ async def test_bulk_delete_skips_the_key_audit_log_when_the_tx_rolls_back(mocker
mocker.patch("litellm.store_audit_logs", True)
mock_audit_write = mocker.patch(
"litellm.proxy.management_helpers.audit_logs.create_audit_log_for_update",
"litellm.proxy.management_helpers.audit_logs.create_audit_logs_for_updates",
new=mocker.AsyncMock(),
)
results = await _delete(prisma, ["u1", "u2"], cache=cache)