From b7db48c7c1f162efda73ccd3bde1386c912c0786 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 23:57:38 +0000 Subject: [PATCH] fix(team): 404 a role update whose target left the roster before the locked read Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 3 ++ .../test_team_endpoints.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 6eaed62013e..5917f219fde 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3069,6 +3069,9 @@ async def _update_team_member_role( raise HTTPException(status_code=404, detail={"error": f"Team id={team_id} does not exist in db"}) before: Final = tuple(locked_members) + if all(member.user_id != user_id for member in before): + raise HTTPException(status_code=404, detail={"error": f"User {user_id} is not a member of team {team_id}"}) + after: Final = tuple( Member(user_id=member.user_id, role=role, user_email=user_email or member.user_email) if member.user_id == user_id diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index dc72a6a2256..155f03e6d3e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -13675,6 +13675,45 @@ async def test_team_member_update_role_change_404s_when_the_team_is_gone_under_t mock_prisma_client.db.litellm_teamtable.update.assert_not_awaited() +@pytest.mark.asyncio +async def test_team_member_update_role_change_404s_when_the_member_left_before_the_locked_read(monkeypatch): + """Regression: a member removed between the pre-lock read and the locked read was reported as updated.""" + audit_logger = _wire_audit_log_callback(monkeypatch) + snapshot = LiteLLM_TeamTable( + team_id="team-member-gone-race", + team_alias="member-gone-race", + metadata={}, + members_with_roles=[Member(user_id="alice", role="admin"), Member(user_id="bob", role="user")], + ) + locked_row = LiteLLM_TeamTable( + team_id="team-member-gone-race", + team_alias="member-gone-race", + metadata={}, + members_with_roles=[Member(user_id="alice", role="admin")], + ) + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=[snapshot, locked_row]) + mock_prisma_client.db.litellm_teamtable.update = AsyncMock() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + _wire_member_delete_tx(mock_prisma_client) + + team_info_patch, upsert_patch = _member_update_patches(snapshot) + with team_info_patch, upsert_patch, pytest.raises(HTTPException) as exc_info: + await team_member_update( + data=TeamMemberUpdateRequest(team_id="team-member-gone-race", user_id="bob", role="admin"), + http_request=MagicMock(), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin-user" + ), + ) + + assert exc_info.value.status_code == 404 + assert "bob" in str(exc_info.value.detail) + mock_prisma_client.db.litellm_teamtable.update.assert_not_awaited() + await _settle_audit_log_tasks() + assert audit_logger.payloads == [] + + @pytest.mark.asyncio async def test_team_member_delete_response_does_not_wait_for_the_audit_insert( monkeypatch, mock_db_client, mock_admin_auth