Skip to content

Commit c008d5e

Browse files
fix(scim): propagate team roster write failures on group and user writes (#37700)
SCIM roster writes were swallowed, so a group or user push returned 200 while the team roster never received the membership. Surfacing the failure fixes that, but aborting on the first failed write leaves the rest of the batch unattempted on top of unrolled-back, which is worse than what it replaces. Every roster write in a reconciliation is now attempted, and the ones that did not land are reported together, naming each failed add and remove. Rollback would be the other option and it is not safe here: the compensating write can fail too, and it can strip a membership that pre-dated the push. SCIM reconciliation is idempotent, so a named partial failure is what the IdP's next push needs to close the gap. The reported status still follows the failures, so a unanimous 404 stays a 404 and only a batch whose failures disagree falls back to 500. Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 286c75f commit c008d5e

2 files changed

Lines changed: 416 additions & 131 deletions

File tree

litellm/proxy/management_endpoints/scim/scim_v2.py

Lines changed: 148 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"""
66

77
import re
8-
from collections.abc import Iterable, Mapping, Sequence
8+
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
9+
from dataclasses import dataclass
10+
from functools import partial
911
from itertools import chain
1012
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, overload
1113

@@ -206,7 +208,6 @@ async def handle_existing_user_by_email(
206208
user_id=existing_user.user_id,
207209
existing_teams=existing_user.teams or [],
208210
new_teams=new_teams,
209-
raise_on_error=True,
210211
)
211212

212213
updated_user: Final = await _table(UserRepository(prisma_client)).update(
@@ -759,9 +760,12 @@ async def _handle_team_membership_changes(
759760
user_id: str,
760761
existing_teams: list[str],
761762
new_teams: list[str],
762-
raise_on_error: bool = False,
763763
) -> None:
764-
"""Handle adding/removing user from teams based on changes."""
764+
"""Handle adding/removing user from teams based on changes.
765+
766+
Roster write failures propagate so the SCIM endpoint returns an error the IdP
767+
retries, instead of persisting a ``teams`` array the roster never received.
768+
"""
765769
existing_teams_set: Final = set(existing_teams)
766770
new_teams_set: Final = set(new_teams)
767771

@@ -773,7 +777,7 @@ async def _handle_team_membership_changes(
773777
user_id=user_id,
774778
teams_ids_to_add_user_to=list(teams_to_add),
775779
teams_ids_to_remove_user_from=list(teams_to_remove),
776-
raise_on_error=raise_on_error,
780+
raise_on_error=True,
777781
)
778782

779783

@@ -1896,6 +1900,87 @@ def _is_user_not_in_team_error(exc: HTTPException) -> bool:
18961900
return isinstance(detail, dict) and detail.get("error") == "User not found in team"
18971901

18981902

1903+
@dataclass(frozen=True, slots=True)
1904+
class RosterWriteFailure:
1905+
description: str
1906+
status_code: int
1907+
1908+
1909+
def _roster_write_status(exc: Exception) -> int:
1910+
if isinstance(exc, HTTPException):
1911+
return exc.status_code
1912+
if isinstance(exc, ProxyException):
1913+
return int(exc.code) if exc.code.isdigit() else 500
1914+
return 500
1915+
1916+
1917+
class SCIMRosterSyncError(Exception):
1918+
"""Every roster write in the batch was attempted; these are the ones that did not land.
1919+
1920+
Rolling the successful ones back is not safe, since the compensating write can fail
1921+
too and can strip a membership that pre-dated the push. Naming the exact failures
1922+
instead lets the IdP's next push, which is idempotent, close the gap. handle_exception_on_proxy
1923+
reads ``status_code`` off this, so a unanimous failure keeps its own status and a mixed
1924+
batch reports 500.
1925+
"""
1926+
1927+
def __init__(self, failures: tuple[RosterWriteFailure, ...], attempted: int) -> None:
1928+
statuses: Final = frozenset(failure.status_code for failure in failures)
1929+
self.failures: Final[tuple[RosterWriteFailure, ...]] = failures
1930+
self.status_code: Final[int] = next(iter(statuses)) if len(statuses) == 1 else 500
1931+
super().__init__(
1932+
f"SCIM roster sync failed on {len(failures)} of {attempted} team membership writes, "
1933+
f"leaving the roster partially updated. Retry the push to reconcile it. "
1934+
f"Failed writes: {'; '.join(failure.description for failure in failures)}"
1935+
)
1936+
1937+
1938+
async def _attempt_roster_write(label: str, write: Callable[[], Awaitable[object]]) -> tuple[RosterWriteFailure, ...]:
1939+
"""Run one roster write and return what failed, so the caller can keep going."""
1940+
try:
1941+
await write()
1942+
except SCIMRosterSyncError as e:
1943+
return e.failures
1944+
except Exception as e: # noqa: BLE001 # this boundary turns any write failure into a value so the batch continues
1945+
verbose_proxy_logger.exception("SCIM roster write failed (%s): %s", label, e)
1946+
return (RosterWriteFailure(description=f"{label}: {e}", status_code=_roster_write_status(e)),)
1947+
return ()
1948+
1949+
1950+
async def _collect_roster_write_failures(
1951+
writes: Sequence[tuple[str, Callable[[], Awaitable[object]]]],
1952+
) -> tuple[RosterWriteFailure, ...]:
1953+
per_write: Final = tuple([await _attempt_roster_write(label, write) for label, write in writes])
1954+
return tuple(chain.from_iterable(per_write))
1955+
1956+
1957+
async def _add_user_to_team(user_id: str, team_id: str) -> None:
1958+
try:
1959+
await team_member_add(
1960+
data=TeamMemberAddRequest(
1961+
team_id=team_id,
1962+
member=Member(user_id=user_id, role="user"),
1963+
),
1964+
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
1965+
)
1966+
except ProxyException as e:
1967+
if e.type != ProxyErrorTypes.team_member_already_in_team:
1968+
raise
1969+
verbose_proxy_logger.debug("User %s is already in team %s, skipping add", user_id, team_id)
1970+
1971+
1972+
async def _remove_user_from_team(user_id: str, team_id: str) -> None:
1973+
try:
1974+
await team_member_delete(
1975+
data=TeamMemberDeleteRequest(team_id=team_id, user_id=user_id),
1976+
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
1977+
)
1978+
except HTTPException as e:
1979+
if not _is_user_not_in_team_error(e):
1980+
raise
1981+
verbose_proxy_logger.debug("User %s is not in team %s, skipping remove", user_id, team_id)
1982+
1983+
18991984
async def patch_team_membership(
19001985
user_id: str,
19011986
teams_ids_to_add_user_to: list[str],
@@ -1909,49 +1994,26 @@ async def patch_team_membership(
19091994
A user already being in a team (on add) or already absent from it (on
19101995
remove) is treated as a no-op, not an error.
19111996
1912-
When ``raise_on_error`` is True a genuine add or remove failure (anything
1913-
other than those idempotent no-ops) propagates instead of being swallowed,
1914-
so a caller can avoid persisting a teams array the roster never received.
1997+
Every team is attempted before anything is reported, so one failing team cannot
1998+
strand the others unattempted. When ``raise_on_error`` is True the writes that did
1999+
not land are reported together, instead of a teams array the roster never received
2000+
being persisted as a success.
19152001
"""
1916-
for _team_id in teams_ids_to_add_user_to:
1917-
try:
1918-
await team_member_add(
1919-
data=TeamMemberAddRequest(
1920-
team_id=_team_id,
1921-
member=Member(user_id=user_id, role="user"),
1922-
),
1923-
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
1924-
)
1925-
except ProxyException as e:
1926-
# Handle duplicate membership gracefully - this is idempotent
1927-
if e.type == ProxyErrorTypes.team_member_already_in_team:
1928-
verbose_proxy_logger.debug("User %s is already in team %s, skipping add", user_id, _team_id)
1929-
elif raise_on_error:
1930-
raise
1931-
else:
1932-
verbose_proxy_logger.exception("Error adding user to team %s: %s", _team_id, e)
1933-
except Exception as e:
1934-
if raise_on_error:
1935-
raise
1936-
verbose_proxy_logger.exception("Error adding user to team %s: %s", _team_id, e)
1937-
1938-
for _team_id in teams_ids_to_remove_user_from:
1939-
try:
1940-
await team_member_delete(
1941-
data=TeamMemberDeleteRequest(team_id=_team_id, user_id=user_id),
1942-
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
1943-
)
1944-
except HTTPException as e:
1945-
if _is_user_not_in_team_error(e):
1946-
verbose_proxy_logger.debug("User %s is not in team %s, skipping remove", user_id, _team_id)
1947-
elif raise_on_error:
1948-
raise
1949-
else:
1950-
verbose_proxy_logger.exception("Error removing user from team %s: %s", _team_id, e)
1951-
except Exception as e:
1952-
if raise_on_error:
1953-
raise
1954-
verbose_proxy_logger.exception("Error removing user from team %s: %s", _team_id, e)
2002+
writes: Final = tuple(
2003+
chain(
2004+
(
2005+
(f"add {user_id} to {team_id}", partial(_add_user_to_team, user_id, team_id))
2006+
for team_id in teams_ids_to_add_user_to
2007+
),
2008+
(
2009+
(f"remove {user_id} from {team_id}", partial(_remove_user_from_team, user_id, team_id))
2010+
for team_id in teams_ids_to_remove_user_from
2011+
),
2012+
)
2013+
)
2014+
failures: Final = await _collect_roster_write_failures(writes)
2015+
if failures and raise_on_error:
2016+
raise SCIMRosterSyncError(failures, attempted=len(writes))
19552017

19562018
return True
19572019

@@ -2414,35 +2476,52 @@ async def _apply_group_patch_updates(group_id: str, update_data: dict[str, objec
24142476
return await TeamRepository(prisma_client).table.find_unique(where={"team_id": group_id})
24152477

24162478

2417-
async def _handle_group_membership_changes(group_id: str, current_members: set[str], final_members: set[str]):
2418-
"""Handle adding/removing members from the group.
2479+
async def _handle_group_membership_changes(group_id: str, current_members: set[str], final_members: set[str]) -> None:
2480+
"""Reconcile the group roster, attempting every member before reporting failures.
24192481
2420-
Runs strict: a genuine add or remove failure propagates so the group request
2421-
fails and the identity provider retries, instead of reporting success for a
2422-
member the roster never received. Idempotent no-ops (already in / already out
2423-
of the team) are still swallowed by patch_team_membership.
2482+
Aborting on the first failure would leave the remaining members unattempted on top
2483+
of unrolled-back, so every member is written and the ones that failed are named for
2484+
the IdP's next push to reconcile.
24242485
"""
2425-
members_to_add: Final = final_members - current_members
2426-
members_to_remove: Final = current_members - final_members
2486+
members_to_add: Final = sorted(final_members - current_members)
2487+
members_to_remove: Final = sorted(current_members - final_members)
24272488

24282489
verbose_proxy_logger.debug("members_to_add: %s", members_to_add)
24292490
verbose_proxy_logger.debug("members_to_remove: %s", members_to_remove)
24302491

2431-
for member_id in members_to_add:
2432-
await patch_team_membership(
2433-
user_id=member_id,
2434-
teams_ids_to_add_user_to=[group_id],
2435-
teams_ids_to_remove_user_from=[],
2436-
raise_on_error=True,
2437-
)
2438-
2439-
for member_id in members_to_remove:
2440-
await patch_team_membership(
2441-
user_id=member_id,
2442-
teams_ids_to_add_user_to=[],
2443-
teams_ids_to_remove_user_from=[group_id],
2444-
raise_on_error=True,
2492+
writes: Final = tuple(
2493+
chain(
2494+
(
2495+
(
2496+
f"add {member_id} to {group_id}",
2497+
partial(
2498+
patch_team_membership,
2499+
user_id=member_id,
2500+
teams_ids_to_add_user_to=[group_id],
2501+
teams_ids_to_remove_user_from=[],
2502+
raise_on_error=True,
2503+
),
2504+
)
2505+
for member_id in members_to_add
2506+
),
2507+
(
2508+
(
2509+
f"remove {member_id} from {group_id}",
2510+
partial(
2511+
patch_team_membership,
2512+
user_id=member_id,
2513+
teams_ids_to_add_user_to=[],
2514+
teams_ids_to_remove_user_from=[group_id],
2515+
raise_on_error=True,
2516+
),
2517+
)
2518+
for member_id in members_to_remove
2519+
),
24452520
)
2521+
)
2522+
failures: Final = await _collect_roster_write_failures(writes)
2523+
if failures:
2524+
raise SCIMRosterSyncError(failures, attempted=len(writes))
24462525

24472526

24482527
@scim_router.patch(

0 commit comments

Comments
 (0)