@@ -487,13 +487,18 @@ class _UnknownMember(NamedTuple):
487487 value : str
488488
489489
490- _ClassifiedGroupMember = Union [_ResolvedUserMember , _SkippedGroupMember , _UnknownMember ]
490+ class _AmbiguousMember (NamedTuple ):
491+ value : str
492+
493+
494+ _ClassifiedGroupMember = Union [_ResolvedUserMember , _SkippedGroupMember , _UnknownMember , _AmbiguousMember ]
491495
492496
493497class _PartitionedMembers (NamedTuple ):
494498 resolved_ids : tuple [str , ...]
495499 skipped : tuple [_SkippedGroupMember , ...]
496500 unknown_ids : tuple [str , ...]
501+ ambiguous_values : tuple [str , ...]
497502
498503
499504def _member_value (member : SCIMMember ) -> str :
@@ -536,6 +541,44 @@ def _team_metadata_has_scim_provenance(team_metadata: object) -> bool:
536541 return bool (fields .get (SCIM_MANAGED_TEAM_METADATA_KEY )) or fields .get (SCIM_TEAM_DATA_METADATA_KEY ) is not None
537542
538543
544+ class _CaseInsensitiveMatch (TypedDict ):
545+ equals : ReadOnly [str ]
546+ mode : ReadOnly [str ]
547+
548+
549+ async def _users_named_by_member_value (
550+ value : str , prisma_client : PrismaClient , * , take : int | None = 2
551+ ) -> tuple [str , ...]:
552+ """Every user id this member value names, by SSO identity or by email.
553+
554+ Both fields are searched in one pass, because searching either first would hide a
555+ value that names one account by its SSO identity and another by its email, and
556+ hand the group to whichever field was searched first.
557+
558+ They are not compared alike. An email is matched the way ``new_user`` matches one
559+ before it accepts a new account, case-insensitively: matching more strictly than
560+ the layer that would reject the placeholder is what turned a member id whose
561+ casing differed from the stored email into a 500 on the whole push. An SSO
562+ identity is matched exactly, because OIDC defines ``sub`` as case-sensitive and
563+ nothing folds its case on the way in, so treating two subjects that differ in case
564+ as one would hand the group to an account the provider never named.
565+
566+ ``take`` bounds the read for a caller that only needs to know whether the value
567+ names one account or several; ``user_email`` carries no index, so letting the scan
568+ stop early is worth the two rows. A caller that has to know *which* accounts, as a
569+ removal does, passes None. That set is the accounts sharing one identity, which is
570+ a handful at worst.
571+ """
572+ subject : Final = value .strip ()
573+ email : Final [_CaseInsensitiveMatch ] = {"equals" : subject , "mode" : "insensitive" }
574+ rows : Final = await _table (UserRepository (prisma_client )).find_many (
575+ # mutable-ok: the Prisma serializer requires concrete dicts and a concrete list
576+ where = {"OR" : [{"sso_user_id" : subject }, {"user_email" : email }]},
577+ take = take ,
578+ )
579+ return tuple (dict .fromkeys (row .user_id for row in rows ))
580+
581+
539582async def _classify_group_member (member : SCIMMember , prisma_client : PrismaClient ) -> _ClassifiedGroupMember :
540583 """
541584 Decide what a single SCIM group member refers to.
@@ -557,6 +600,20 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient
557600 one the identity provider writes. An id the IdP called a User is a user
558601 even if some team happens to share the id, and a team created here rather
559602 than through SCIM is not evidence of anything about the member.
603+
604+ When those checks miss on an otherwise user-shaped member, its value is looked
605+ up as an SSO identity or an email, and a match resolves to that user's
606+ ``user_id``. A value that names more than one account is ambiguous rather than
607+ unknown: it names a real person we cannot identify, so it is neither guessed at
608+ nor provisioned.
609+
610+ An exact ``user_id`` hit is checked the same way rather than trusted outright. A
611+ value can be one account's id and another's SSO identity or email, and taking the
612+ id on sight would hand the group to whichever account happened to be keyed by it.
613+ The placeholders this bug provisioned are that shape exactly, since they are keyed
614+ by the very id the provider keeps pushing, so on a tenant that already has them
615+ the membership is refused and named rather than silently landing on the
616+ placeholder again.
560617 """
561618 value : Final = _member_value (member )
562619 member_type : Final = _normalized_member_type (member )
@@ -566,6 +623,18 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient
566623
567624 user : Final = await _table (UserRepository (prisma_client )).find_unique (where = {"user_id" : value })
568625 if user is not None :
626+ shared_with : Final = tuple (
627+ other for other in await _users_named_by_member_value (value , prisma_client ) if other != value
628+ )
629+ if shared_with :
630+ verbose_proxy_logger .warning (
631+ "SCIM: group member '%s' is one account's user id and is also account '%s' by SSO identity or email, "
632+ "so the membership cannot be attributed. A placeholder an earlier release provisioned under this id "
633+ "looks exactly like this and should be deleted so the real account can be matched" ,
634+ value ,
635+ shared_with [0 ],
636+ )
637+ return _AmbiguousMember (value = value )
569638 return _ResolvedUserMember (user_id = value )
570639
571640 if member_type is not None and member_type != "user" :
@@ -576,18 +645,36 @@ async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient
576645 if team is not None and _team_metadata_has_scim_provenance (team .metadata ):
577646 return _SkippedGroupMember (value = value , reason = "existing_team" )
578647
648+ named : Final = await _users_named_by_member_value (value , prisma_client )
649+ if len (named ) == 1 :
650+ verbose_proxy_logger .info (
651+ "SCIM: group member '%s' matched user_id '%s' by SSO identity or email" ,
652+ value ,
653+ named [0 ],
654+ )
655+ return _ResolvedUserMember (user_id = named [0 ])
656+ if len (named ) > 1 :
657+ verbose_proxy_logger .warning (
658+ "SCIM: group member '%s' names more than one account by SSO identity or email and cannot be resolved "
659+ "unambiguously" ,
660+ value ,
661+ )
662+ return _AmbiguousMember (value = value )
663+
579664 return _UnknownMember (value = value )
580665
581666
582667def _bucketed_member (entry : _ClassifiedGroupMember ) -> _PartitionedMembers :
583668 """The single-member partition one classified entry contributes."""
584669 match entry :
585670 case _ResolvedUserMember (user_id = user_id ):
586- return _PartitionedMembers (resolved_ids = (user_id ,), skipped = (), unknown_ids = ())
671+ return _PartitionedMembers (resolved_ids = (user_id ,), skipped = (), unknown_ids = (), ambiguous_values = () )
587672 case _SkippedGroupMember ():
588- return _PartitionedMembers (resolved_ids = (), skipped = (entry ,), unknown_ids = ())
673+ return _PartitionedMembers (resolved_ids = (), skipped = (entry ,), unknown_ids = (), ambiguous_values = () )
589674 case _UnknownMember (value = value ):
590- return _PartitionedMembers (resolved_ids = (), skipped = (), unknown_ids = (value ,))
675+ return _PartitionedMembers (resolved_ids = (), skipped = (), unknown_ids = (value ,), ambiguous_values = ())
676+ case _AmbiguousMember (value = value ):
677+ return _PartitionedMembers (resolved_ids = (), skipped = (), unknown_ids = (), ambiguous_values = (value ,))
591678 case _:
592679 assert_never (entry )
593680
@@ -599,6 +686,7 @@ def _partition_classified_members(classified: Iterable[_ClassifiedGroupMember])
599686 resolved_ids = tuple (chain .from_iterable (bucket .resolved_ids for bucket in bucketed )),
600687 skipped = tuple (chain .from_iterable (bucket .skipped for bucket in bucketed )),
601688 unknown_ids = tuple (chain .from_iterable (bucket .unknown_ids for bucket in bucketed )),
689+ ambiguous_values = tuple (chain .from_iterable (bucket .ambiguous_values for bucket in bucketed )),
602690 )
603691
604692
@@ -608,7 +696,7 @@ def _admitted_member_id(entry: _ClassifiedGroupMember, created_ids: frozenset[st
608696 return user_id
609697 case _UnknownMember (value = value ):
610698 return value if value in created_ids else None
611- case _SkippedGroupMember ():
699+ case _SkippedGroupMember () | _AmbiguousMember () :
612700 return None
613701 case _:
614702 assert_never (entry )
@@ -662,6 +750,70 @@ async def _ensure_group_member_user(
662750 raise HTTPException (status_code = 500 , detail = detail )
663751
664752
753+ def _roster_entries_named_by (value : str , roster : frozenset [str ], resolved : tuple [str , ...]) -> tuple [str , ...]:
754+ """The members of this group a removal value names.
755+
756+ Both ways of naming one count together. The id as written counts when the roster
757+ holds it verbatim, which is how an earlier release recorded a member it could not
758+ match, and the accounts it resolves to count when they are on the roster. Counting
759+ only the resolved ones would let a value that is one member's canonical id and
760+ another member's email revoke both, since each looks singular on its own.
761+ """
762+ return tuple (
763+ dict .fromkeys (
764+ chain (
765+ (value ,) if value in roster else (),
766+ (user_id for user_id in resolved if user_id in roster ),
767+ )
768+ )
769+ )
770+
771+
772+ async def _member_ids_to_drop (
773+ members : Sequence [SCIMMember ], roster : frozenset [str ], prisma_client : PrismaClient
774+ ) -> frozenset [str ]:
775+ """The members a ``remove`` clears, one per id the request names.
776+
777+ The roster holds canonical user ids, so a directory that added someone by their
778+ email or SSO identity has to be able to remove them by that same value, and a
779+ member an earlier release recorded under the raw id has to stay removable by it.
780+
781+ Ambiguity is a property of the table as it stands, not of the value, so a value
782+ that named one person when they were admitted can name two later. Resolving a
783+ removal against the whole table would then drop nobody while answering 200, and
784+ the person the directory just took out of the group would keep the team. So a
785+ removal keeps only the accounts already on the roster: one is unambiguous however
786+ many strangers share the address, none means there is nothing to revoke, and only
787+ a value naming two of this group's own members is genuinely undecidable. That last
788+ case fails rather than reporting a removal it did not perform, or revoking both.
789+
790+ Raises:
791+ HTTPException: 400 when a member id names more than one current member.
792+ """
793+ written : Final = frozenset (_member_value (member ) for member in members )
794+ matched : Final = tuple (
795+ [
796+ (
797+ value ,
798+ _roster_entries_named_by (
799+ value , roster , await _users_named_by_member_value (value , prisma_client , take = None )
800+ ),
801+ )
802+ for value in sorted (written )
803+ ]
804+ )
805+ undecidable : Final = tuple (value for value , entries in matched if len (entries ) > 1 )
806+ if undecidable :
807+ raise HTTPException (
808+ status_code = 400 ,
809+ detail = {
810+ "error" : f"Member ID '{ undecidable [0 ]} ' names more than one member of this group, so the removal "
811+ "cannot be attributed. Send the LiteLLM user ID as the member value, or resolve the duplicate."
812+ },
813+ )
814+ return frozenset (chain .from_iterable (entries for _ , entries in matched ))
815+
816+
665817async def _resolve_group_member_ids (
666818 members : Sequence [SCIMMember ],
667819 created_via : str ,
@@ -670,17 +822,18 @@ async def _resolve_group_member_ids(
670822 """
671823 Resolve SCIM group members to LiteLLM user ids, dropping members that are not users.
672824
673- Only the operations that put ids onto a roster resolve their members: an id
674- that resolves to nothing is created when litellm_settings.scim_upsert_user is
675- True (default) and rejected per SCIM 2.0 otherwise. Removals do not come
676- through here; dropping an id is idempotent, so it needs neither a lookup nor a
677- user to drop .
825+ Member ids are matched by ``user_id`` first, then by SSO identity or email. An
826+ id that resolves to nothing is created when litellm_settings.scim_upsert_user is
827+ True (default) and rejected per SCIM 2.0 otherwise. Removals do not come through
828+ here: they resolve through ``_member_ids_to_drop`` instead, which neither creates
829+ a user nor fails on an id it cannot place .
678830
679831 Raises:
680- HTTPException: 400 when a member id is empty, or when scim_upsert_user is
681- False and a member id is neither an existing user, an existing team, nor a
682- member declared to be something other than a user. 500 when a member's
683- user row can neither be created nor found.
832+ HTTPException: 400 when a member id is empty, when a member id names more
833+ than one user, or when scim_upsert_user is False and a member id is neither
834+ an existing user, an existing team, nor a member declared to be something
835+ other than a user. 500 when a member's user row can neither be created nor
836+ found.
684837 """
685838 classified : Final = tuple ([await _classify_group_member (member , prisma_client ) for member in members ])
686839 partition : Final = _partition_classified_members (classified )
@@ -692,6 +845,16 @@ async def _resolve_group_member_ids(
692845 skipped .reason ,
693846 )
694847
848+ if partition .ambiguous_values :
849+ raise HTTPException (
850+ status_code = 400 ,
851+ detail = {
852+ "error" : f"Member ID '{ partition .ambiguous_values [0 ]} ' names more than one LiteLLM user, so the "
853+ "group membership cannot be attributed. Resolve the duplicate, which for an id that also matches a "
854+ "SCIM-provisioned placeholder means deleting that placeholder."
855+ },
856+ )
857+
695858 if partition .unknown_ids and not await _get_scim_upsert_user_setting ():
696859 raise HTTPException (
697860 status_code = 400 ,
@@ -702,6 +865,13 @@ async def _resolve_group_member_ids(
702865 )
703866
704867 unique_unknown_ids : Final = tuple (dict .fromkeys (partition .unknown_ids ))
868+ for user_id in unique_unknown_ids :
869+ verbose_proxy_logger .warning (
870+ "SCIM: creating placeholder user for group member '%s'; matched no user by user_id, sso_user_id or "
871+ "user_email. An SSO-provisioned user's real account stays teamless if this is a mismatch" ,
872+ user_id ,
873+ )
874+
705875 creations : Final = tuple (
706876 [
707877 (
@@ -2428,7 +2598,9 @@ async def _process_group_patch_operations(
24282598 )
24292599
24302600 if op_type == "remove" :
2431- final_members = final_members - {_member_value (member ) for member in patched_members }
2601+ final_members = final_members - await _member_ids_to_drop (
2602+ patched_members , frozenset (final_members ), prisma_client
2603+ )
24322604 else :
24332605 member_result = await _resolve_group_member_ids (
24342606 members = patched_members ,
0 commit comments