Skip to content

Commit f1c4145

Browse files
fix(scim): resolve group members by SSO identity or email before creating a placeholder (#37686)
SCIM group members were matched against litellm user ids only. An identity provider that lists people by email or by the OIDC subject therefore matched nothing, and the member fell through to placeholder creation. Since #37688 made a failed member creation fail the group sync rather than drop the member, that fallthrough is no longer quiet: the placeholder is created with user_email set to the member value, the duplicate-email check rejects it, and the whole group push answers 500. So on current staging a group listing anyone by their email fails outright, every other member in the payload included. An unmatched member id is now looked up across sso_user_id and user_email in one query. Searching either field first would hide a value that names one account by its SSO identity and another by its email, and hand the group to whichever was searched first. The two are not compared alike: an email is matched the way new_user matches one before accepting a new account, case-insensitively, because matching more strictly than the layer that would reject the placeholder is what turned an id whose casing differed from the stored email into that same 500. An SSO identity is matched exactly, since OIDC defines sub as case-sensitive and nothing folds its case on the way in. An exact user_id hit is checked the same way rather than trusted outright, since a value can be one account's id and another's SSO identity or email. That is not a corner case: the placeholders this bug provisioned are keyed by the very id the provider keeps pushing, so on a tenant that already has them the placeholder wins the id lookup and the real account can never be matched. Refusing names the problem instead of silently landing on the placeholder again. Those rows still have to be deleted before the real account resolves; making the sync heal itself needs a trustworthy way to tell a placeholder from an account someone created, and created_via lives in caller-writable metadata, so it is left to a follow-up. A value that names more than one account is refused with a 400 naming the id rather than attributed to one of them. Removals resolve too, since the roster holds canonical user ids and a directory removes people by the id it added them with. A removal counts the members one value names: the id as written when the roster holds it verbatim, which is how an earlier release recorded a member it could not match, together with the members it resolves to. Counting only the accounts on the roster keeps someone removable after a second account takes their email, which resolving table-wide would not, and counting both ways of naming a member together stops one value revoking two people when it is one member's canonical id and another's email. A value naming two of the group's own members is undecidable and fails rather than guessing or reporting a removal it did not perform. Resolves LIT-5383 Co-authored-by: Yassin Kortam <yassin@berri.ai>
1 parent 65b4ac0 commit f1c4145

2 files changed

Lines changed: 889 additions & 21 deletions

File tree

litellm/proxy/management_endpoints/scim/scim_v2.py

Lines changed: 187 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

493497
class _PartitionedMembers(NamedTuple):
494498
resolved_ids: tuple[str, ...]
495499
skipped: tuple[_SkippedGroupMember, ...]
496500
unknown_ids: tuple[str, ...]
501+
ambiguous_values: tuple[str, ...]
497502

498503

499504
def _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+
539582
async 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

582667
def _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+
665817
async 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

Comments
 (0)