Skip to content

Commit cebacd6

Browse files
authored
[Bug Fix] SCIM v2 - ensure group PUSH and PUT ops allow creating non-existent members (#14581)
* fix: scim handle non existent members * test - scim v2 * test fix * fix: NewUserResponse
1 parent 30c3e7b commit cebacd6

File tree

2 files changed

+386
-16
lines changed

2 files changed

+386
-16
lines changed

litellm/proxy/management_endpoints/scim/scim_v2.py

Lines changed: 108 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Response,
1919
)
2020
from typing_extensions import TypedDict
21+
from pydantic import BaseModel
2122

2223
import litellm
2324
from litellm._logging import verbose_proxy_logger
@@ -29,6 +30,7 @@
2930
Member,
3031
NewTeamRequest,
3132
NewUserRequest,
33+
NewUserResponse,
3234
TeamMemberAddRequest,
3335
TeamMemberDeleteRequest,
3436
UserAPIKeyAuth,
@@ -101,6 +103,13 @@ class ScimUserData(TypedDict):
101103
active: Optional[bool]
102104

103105

106+
class GroupMemberExtractionResult(BaseModel):
107+
"""Result of extracting and processing group members."""
108+
existing_member_ids: List[str]
109+
created_users: List[NewUserResponse]
110+
all_member_ids: List[str] # existing + newly created
111+
112+
104113
scim_router = APIRouter(
105114
prefix="/scim/v2",
106115
tags=["✨ SCIM v2 (Enterprise Only)"],
@@ -190,21 +199,47 @@ def _build_scim_metadata(given_name: Optional[str], family_name: Optional[str],
190199
return metadata
191200

192201

193-
async def _extract_group_member_ids(group: SCIMGroup) -> List[str]:
194-
"""Extract valid member IDs from SCIMGroup, verifying users exist."""
202+
async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionResult:
203+
"""
204+
Extract member IDs from SCIMGroup, creating users that don't exist.
205+
206+
Returns:
207+
GroupMemberExtractionResult with existing members, created users, and all member IDs
208+
"""
195209
prisma_client = await _get_prisma_client_or_raise_exception()
196-
member_ids = []
210+
existing_member_ids = []
211+
created_users = []
212+
all_member_ids = []
197213

198214
if group.members:
199215
for member in group.members:
216+
user_id = member.value
217+
200218
# Check if user exists
201219
user = await prisma_client.db.litellm_usertable.find_unique(
202-
where={"user_id": member.value}
220+
where={"user_id": user_id}
203221
)
222+
204223
if user:
205-
member_ids.append(member.value)
224+
existing_member_ids.append(user_id)
225+
all_member_ids.append(user_id)
226+
else:
227+
# Create the user if they don't exist using our helper
228+
created_user = await _create_user_if_not_exists(
229+
user_id=user_id,
230+
created_via="scim_group_membership"
231+
)
232+
233+
if created_user:
234+
created_users.append(created_user)
235+
all_member_ids.append(user_id)
236+
# If creation failed, user is skipped (logged in helper)
206237

207-
return member_ids
238+
return GroupMemberExtractionResult(
239+
existing_member_ids=existing_member_ids,
240+
created_users=created_users,
241+
all_member_ids=all_member_ids
242+
)
208243

209244

210245
async def _get_team_members_display(member_ids: List[str]) -> List[SCIMMember]:
@@ -239,6 +274,51 @@ async def _handle_team_membership_changes(user_id: str, existing_teams: List[str
239274
)
240275

241276

277+
async def _create_user_if_not_exists(user_id: str, created_via: str = "scim_group") -> Optional[NewUserResponse]:
278+
"""
279+
Helper function to create a user if they don't exist.
280+
281+
Args:
282+
user_id: The user ID to create
283+
created_via: Context for where the user was created from
284+
285+
Returns:
286+
LiteLLM_UserTable if user was created, None if creation failed
287+
"""
288+
from litellm.proxy.management_endpoints.internal_user_endpoints import new_user
289+
290+
try:
291+
# Get default role for new internal users
292+
default_role: Optional[
293+
Literal[
294+
LitellmUserRoles.PROXY_ADMIN,
295+
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
296+
LitellmUserRoles.INTERNAL_USER,
297+
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
298+
]
299+
] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
300+
if litellm.default_internal_user_params:
301+
default_role = litellm.default_internal_user_params.get("user_role")
302+
303+
new_user_request = NewUserRequest(
304+
user_id=user_id,
305+
user_email=user_id, # We don't have email from group membership
306+
user_alias=None,
307+
teams=[], # Teams will be added separately
308+
metadata={"created_via": created_via},
309+
auto_create_key=False,
310+
user_role=default_role,
311+
)
312+
313+
created_user = await new_user(data=new_user_request)
314+
verbose_proxy_logger.info(f"Created user {user_id} via {created_via}")
315+
return created_user
316+
317+
except Exception as e:
318+
verbose_proxy_logger.exception(f"Failed to create user {user_id}: {e}")
319+
return None
320+
321+
242322
async def _get_team_member_user_ids_from_team(team: LiteLLM_TeamTable) -> List[str]:
243323
"""
244324
Get the IDs of the members from a team.
@@ -256,6 +336,8 @@ async def _get_team_member_user_ids_from_team(team: LiteLLM_TeamTable) -> List[s
256336
member_user_ids.append(user_id)
257337
return member_user_ids
258338

339+
340+
259341
# Dependency to set the correct SCIM Content-Type
260342
async def set_scim_content_type(response: Response):
261343
"""Sets the Content-Type header to application/scim+json"""
@@ -914,9 +996,9 @@ async def create_group(
914996
detail={"error": f"Group already exists with ID: {team_id}"},
915997
)
916998

917-
# Extract valid member IDs
918-
member_ids = await _extract_group_member_ids(group)
919-
members_with_roles = [Member(user_id=member_id, role="user") for member_id in member_ids]
999+
# Extract and process group members (creating users that don't exist)
1000+
member_result = await _extract_group_member_ids(group)
1001+
members_with_roles = [Member(user_id=member_id, role="user") for member_id in member_result.all_member_ids]
9201002

9211003
# Create team in database
9221004
created_team = await new_team(
@@ -959,9 +1041,10 @@ async def update_group(
9591041
prisma_client = await _get_prisma_client_or_raise_exception()
9601042
existing_team = await _check_team_exists(group_id)
9611043

962-
# Extract valid member IDs
963-
member_ids = await _extract_group_member_ids(group)
964-
verbose_proxy_logger.debug(f"SCIM PUT GROUP member_ids: {member_ids}")
1044+
# Extract and process group members (creating users that don't exist)
1045+
member_result = await _extract_group_member_ids(group)
1046+
verbose_proxy_logger.debug(f"SCIM PUT GROUP all_member_ids: {member_result.all_member_ids}")
1047+
verbose_proxy_logger.debug(f"SCIM PUT GROUP created_users: {len(member_result.created_users)}")
9651048

9661049
# Prepare update data
9671050
existing_metadata = existing_team.metadata if existing_team.metadata else {}
@@ -978,10 +1061,10 @@ async def update_group(
9781061
data=update_data,
9791062
)
9801063

981-
# Handle user-team relationship changes using the same approach as patch_group
1064+
# Handle user-team relationship changes
9821065
current_members = set(await _get_team_member_user_ids_from_team(existing_team))
9831066
verbose_proxy_logger.debug(f"SCIM PUT GROUP current_members: {current_members}")
984-
final_members = set(member_ids)
1067+
final_members = set(member_result.all_member_ids)
9851068
verbose_proxy_logger.debug(f"SCIM PUT GROUP final_members: {final_members}")
9861069

9871070
await _handle_group_membership_changes(
@@ -1075,14 +1158,24 @@ async def _process_group_patch_operations(
10751158
elif path.startswith("members"):
10761159
# Handle member operations
10771160
member_values = _extract_group_values(value)
1078-
# Validate that users exist
1161+
# Create users that don't exist and get all valid member IDs
10791162
valid_members = []
10801163
for member_id in member_values:
10811164
user = await prisma_client.db.litellm_usertable.find_unique(
10821165
where={"user_id": member_id}
10831166
)
10841167
if user:
10851168
valid_members.append(member_id)
1169+
else:
1170+
# Create the user if they don't exist using our helper
1171+
created_user = await _create_user_if_not_exists(
1172+
user_id=member_id,
1173+
created_via="scim_group_patch"
1174+
)
1175+
1176+
if created_user:
1177+
valid_members.append(member_id)
1178+
# If creation failed, user is skipped (logged in helper)
10861179

10871180
if op_type == "replace":
10881181
final_members = set(valid_members)

0 commit comments

Comments
 (0)