litellm/tests/integration/management/test_scim_group_delete.py
zoveki 38b4d31447
test(scim): audit #47544 (#48791)
* test(scim): audit #47544

Add integration cells for the SCIM group delete that now runs through the team delete's bulk path: an admin group delete revokes its members' admin key, a delete sent with the IdP's SCIM token revokes member keys and audits them under that token, a delete that times out on a locked member row changes nothing so a retry works, and two racing team deletes write one tombstone and one team audit row

* test(scim): read the racing team delete's audit rows after the proxy drains them

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-28 06:08:30 -07:00

531 lines
24 KiB
Python

"""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, TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS
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
"""
AUDIT_ROWS_NAMING_SQL: Final = """
SELECT table_name, object_id, changed_by_api_key
FROM "LiteLLM_AuditLog"
WHERE object_id = ANY(%s::text[]) AND action = 'deleted'
ORDER BY table_name, object_id
"""
OLDEST_TRANSACTION_BLOCKED_BY_SQL: Final = """
SELECT coalesce(max(extract(epoch FROM clock_timestamp() - xact_start)), 0)::float AS age
FROM pg_stat_activity
WHERE %s::int = ANY(pg_blocking_pids(pid))
"""
_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 _audit_rows_naming(object_ids: tuple[str, ...]) -> list[dict[str, JsonValue]]:
return read_rows(AUDIT_ROWS_NAMING_SQL, ("{" + ",".join(object_ids) + "}",))
def _oldest_transaction_blocked_by(holder: int) -> float:
age: Final = read_rows(OLDEST_TRANSACTION_BLOCKED_BY_SQL, (str(holder),))[0]["age"]
assert isinstance(age, float), age
return age
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, timeout: httpx.Timeout | float | None = None) -> Iterator[Gateway]:
with httpx.Client(
base_url=candidate.client.base_url,
timeout=candidate.client.timeout if timeout is None else 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
@pytest.mark.timeout(OWNED_PROXY_TIMEOUT)
def test_a_group_delete_sent_with_the_idps_scim_token_revokes_member_keys_and_audits_them_under_that_token(
gateway: Gateway, tmp_path: Path
) -> None:
with (
owned_proxy_process(
gateway,
tmp_path,
{"DATABASE_URL": os.environ["DATABASE_URL"], "LITELLM_STORE_AUDIT_LOGS": "true"},
remove_environment=("DATABASE_URL_READ_REPLICA",),
) as owned,
owned.gateway.scenario() as scenario,
):
candidate: Final = owned.gateway
scim_token: Final = scenario.key(allowed_routes=["/scim/*"], models=[])
users: Final = seed_users(scenario, 3)
team: Final = created_team(scenario, create_group(candidate, users))
member_keys: Final = (team_key(candidate, team, users[0]), team_key(candidate, team, users[1]))
service_key: Final = service_account_key(candidate, scenario, team)
deleted: Final = delete_group(candidate, team, key=scim_token)
assert deleted.status_code == 204, deleted.text
assert keys_of(team) == frozenset({_hashed(service_key)})
for key in member_keys:
_assert_revoked_everywhere((candidate,), key)
audited: Final = (team, *(_hashed(key) for key in member_keys), _hashed(service_key))
rows: Final = eventually(lambda: _audit_rows_naming(audited), lambda found: len(found) >= 3, seconds=30)
token: Final = _hashed(scim_token)
assert rows == [
{"table_name": "LiteLLM_TeamTable", "object_id": team, "changed_by_api_key": token},
*(
{"table_name": "LiteLLM_VerificationToken", "object_id": hashed, "changed_by_api_key": token}
for hashed in sorted(_hashed(key) for key in member_keys)
),
]
@pytest.mark.timeout(TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS + 120)
def test_a_group_delete_that_outlives_its_transaction_on_a_locked_member_row_changes_nothing_and_a_retry_lands(
gateway: Gateway,
) -> None:
patient_timeout: Final = TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS + 90
with (
gateway.scenario() as scenario,
_fresh_connection(gateway, timeout=patient_timeout) as patient,
ThreadPoolExecutor(max_workers=1) as pool,
):
users: Final = seed_users(scenario, 3)
team: Final = created_team(scenario, create_group(gateway, users))
key: Final = team_key(gateway, team, users[0])
with held_user_row(users[0]) as holder:
delete: Final = pool.submit(delete_group, patient, team)
eventually(
lambda: (
delete.done()
or _oldest_transaction_blocked_by(holder) > TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS + 1
),
lambda outlived: outlived,
seconds=TEAM_DELETE_TRANSACTION_TIMEOUT_SECONDS + 30,
)
failed: Final = delete.result(timeout=patient_timeout)
assert failed.status_code >= 500, failed.text
assert_landed(gateway, team, users)
assert keys_of(team) == frozenset({_hashed(key)})
assert read_rows(DELETED_TEAM_RECORDS_SQL, (team,)) == []
assert read_rows(DELETED_KEY_RECORDS_SQL, (_hashed(key),)) == []
assert _models_status(gateway, key) == 200
retried: Final = delete_group(gateway, team)
assert retried.status_code == 204, retried.text
assert_gone(gateway, team, users, key)
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)