55"""
66
77import 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
911from itertools import chain
1012from 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+
18991984async 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