18
18
Response ,
19
19
)
20
20
from typing_extensions import TypedDict
21
+ from pydantic import BaseModel
21
22
22
23
import litellm
23
24
from litellm ._logging import verbose_proxy_logger
29
30
Member ,
30
31
NewTeamRequest ,
31
32
NewUserRequest ,
33
+ NewUserResponse ,
32
34
TeamMemberAddRequest ,
33
35
TeamMemberDeleteRequest ,
34
36
UserAPIKeyAuth ,
@@ -101,6 +103,13 @@ class ScimUserData(TypedDict):
101
103
active : Optional [bool ]
102
104
103
105
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
+
104
113
scim_router = APIRouter (
105
114
prefix = "/scim/v2" ,
106
115
tags = ["✨ SCIM v2 (Enterprise Only)" ],
@@ -190,21 +199,47 @@ def _build_scim_metadata(given_name: Optional[str], family_name: Optional[str],
190
199
return metadata
191
200
192
201
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
+ """
195
209
prisma_client = await _get_prisma_client_or_raise_exception ()
196
- member_ids = []
210
+ existing_member_ids = []
211
+ created_users = []
212
+ all_member_ids = []
197
213
198
214
if group .members :
199
215
for member in group .members :
216
+ user_id = member .value
217
+
200
218
# Check if user exists
201
219
user = await prisma_client .db .litellm_usertable .find_unique (
202
- where = {"user_id" : member . value }
220
+ where = {"user_id" : user_id }
203
221
)
222
+
204
223
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)
206
237
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
+ )
208
243
209
244
210
245
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
239
274
)
240
275
241
276
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
+
242
322
async def _get_team_member_user_ids_from_team (team : LiteLLM_TeamTable ) -> List [str ]:
243
323
"""
244
324
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
256
336
member_user_ids .append (user_id )
257
337
return member_user_ids
258
338
339
+
340
+
259
341
# Dependency to set the correct SCIM Content-Type
260
342
async def set_scim_content_type (response : Response ):
261
343
"""Sets the Content-Type header to application/scim+json"""
@@ -914,9 +996,9 @@ async def create_group(
914
996
detail = {"error" : f"Group already exists with ID: { team_id } " },
915
997
)
916
998
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 ]
920
1002
921
1003
# Create team in database
922
1004
created_team = await new_team (
@@ -959,9 +1041,10 @@ async def update_group(
959
1041
prisma_client = await _get_prisma_client_or_raise_exception ()
960
1042
existing_team = await _check_team_exists (group_id )
961
1043
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 )} " )
965
1048
966
1049
# Prepare update data
967
1050
existing_metadata = existing_team .metadata if existing_team .metadata else {}
@@ -978,10 +1061,10 @@ async def update_group(
978
1061
data = update_data ,
979
1062
)
980
1063
981
- # Handle user-team relationship changes using the same approach as patch_group
1064
+ # Handle user-team relationship changes
982
1065
current_members = set (await _get_team_member_user_ids_from_team (existing_team ))
983
1066
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 )
985
1068
verbose_proxy_logger .debug (f"SCIM PUT GROUP final_members: { final_members } " )
986
1069
987
1070
await _handle_group_membership_changes (
@@ -1075,14 +1158,24 @@ async def _process_group_patch_operations(
1075
1158
elif path .startswith ("members" ):
1076
1159
# Handle member operations
1077
1160
member_values = _extract_group_values (value )
1078
- # Validate that users exist
1161
+ # Create users that don't exist and get all valid member IDs
1079
1162
valid_members = []
1080
1163
for member_id in member_values :
1081
1164
user = await prisma_client .db .litellm_usertable .find_unique (
1082
1165
where = {"user_id" : member_id }
1083
1166
)
1084
1167
if user :
1085
1168
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)
1086
1179
1087
1180
if op_type == "replace" :
1088
1181
final_members = set (valid_members )
0 commit comments