fix(proxy): evict the cached user row when SCIM or /user/delete removes a user

This commit is contained in:
mateo-berri 2026-09-21 13:51:26 -07:00
parent 662e5b6e32
commit d8ce49de06
4 changed files with 83 additions and 0 deletions

View file

@ -2518,6 +2518,7 @@ async def delete_user(
## DELETE USERS
deleted_users: Final = await _user_table(prisma_client).delete_many(where={"user_id": {"in": data.user_ids}})
await evict_and_broadcast(cache_keys=tuple(data.user_ids), user_api_key_cache=user_api_key_cache)
return deleted_users

View file

@ -1871,6 +1871,10 @@ async def delete_user(
# Delete user
await _table(UserRepository(prisma_client)).delete(where={"user_id": user_id})
from litellm.proxy.proxy_server import user_api_key_cache
await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache)
return Response(status_code=204)
except Exception as e:
raise handle_exception_on_proxy(e)

View file

@ -601,3 +601,42 @@ async def test_scim_status_write_refreshes_user_cache(
else:
assert cached is None
broadcast.assert_awaited_once_with(cache_key=user_id)
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [None, "delete"])
async def test_scim_delete_user_evicts_cached_user_row(failure: str | None) -> None:
from typing import Final
from litellm.proxy._types import ProxyException
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
user_id: Final = "scim-deleted-user"
saved: Final = LiteLLM_UserTable(user_id=user_id, user_email="x@example.com", teams=[], metadata={})
client, db = _build_prisma_with_keys([], mock_user=saved.model_copy(deep=True))
if failure == "delete":
db.litellm_usertable.delete.side_effect = RuntimeError("user delete failed")
cache: Final = UserApiKeyCache()
await cache.async_set_cache(key=user_id, value=saved, model_type=LiteLLM_UserTable)
with (
patch("litellm.proxy.proxy_server.prisma_client", client), # test-quality-ok: substitute the database dependency
patch("litellm.proxy.proxy_server.user_api_key_cache", cache), # test-quality-ok: exercise a real isolated cache
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), # test-quality-ok: isolate the logging dependency
patch( # test-quality-ok: observe the Redis publication boundary
"litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
new_callable=AsyncMock,
) as broadcast,
):
if failure == "delete":
with pytest.raises(ProxyException, match="user delete failed"):
await delete_user(user_id=user_id)
else:
response: Final = await delete_user(user_id=user_id)
assert response.status_code == 204
cached: Final = await cache.async_get_cache(key=user_id, model_type=LiteLLM_UserTable)
if failure == "delete":
assert cached == saved
broadcast.assert_not_awaited()
else:
assert cached is None
broadcast.assert_awaited_once_with(cache_key=user_id)

View file

@ -4691,3 +4691,42 @@ async def test_user_update_hashes_and_persists_strong_password(_admin_prisma, mo
written_data = mock_prisma_client.update_data.call_args.kwargs["data"]
assert written_data.get("password") is not None
assert written_data["password"] != strong_password
@pytest.mark.asyncio
async def test_delete_user_evicts_cached_user_rows(mocker: MockerFixture) -> None:
from litellm.proxy._types import DeleteUserRequest, LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.internal_user_endpoints import delete_user
deleted: Final = LiteLLM_UserTable(user_id="user-gone", user_email="gone@example.test", teams=[])
survivor: Final = LiteLLM_UserTable(user_id="user-stays", user_email="stays@example.test", teams=[])
prisma_client: Final = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock(return_value=deleted)
prisma_client.db.litellm_teamtable.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_jwtkeymapping.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_verificationtoken.find_many = mocker.AsyncMock(return_value=[])
prisma_client.db.litellm_verificationtoken.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_invitationlink.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_organizationmembership.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_teammembership.delete_many = mocker.AsyncMock(return_value=0)
prisma_client.db.litellm_usertable.delete_many = mocker.AsyncMock(return_value=1)
mocker.patch("litellm.proxy.proxy_server.prisma_client", prisma_client) # test-quality-ok: substitute the database dependency
cache: Final = UserApiKeyCache()
for row in (deleted, survivor):
await cache.async_set_cache(key=row.user_id, value=row, model_type=LiteLLM_UserTable)
mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", cache) # test-quality-ok: exercise a real isolated cache
mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", None) # test-quality-ok: delete_user reads it off proxy_server at call time
broadcast: Final = mocker.patch( # test-quality-ok: observe the Redis publication boundary
"litellm.proxy.common_utils.auth_cache_invalidation_pubsub.publish_auth_cache_invalidation",
new_callable=mocker.AsyncMock,
)
await delete_user(
data=DeleteUserRequest(user_ids=[deleted.user_id]),
user_api_key_dict=UserAPIKeyAuth(user_id="proxy-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert await cache.async_get_cache(key=deleted.user_id, model_type=LiteLLM_UserTable) is None
assert await cache.async_get_cache(key=survivor.user_id, model_type=LiteLLM_UserTable) == survivor
broadcast.assert_awaited_once_with(cache_key=deleted.user_id)