Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_endpoints/scim/scim_v2.py: 18%

991 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2✨ SCIM v2 Endpoints for LiteLLM Proxy using Internal User/Team Management 

3 

4This is an enterprise feature and requires a premium license. 

5""" 

6 

7import re 

8from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence 

9from copy import deepcopy 

10from dataclasses import dataclass 

11from functools import partial 

12from itertools import chain 

13from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, overload 

14 

15from fastapi import ( 

16 APIRouter, 

17 Body, 

18 Depends, 

19 HTTPException, 

20 Path, 

21 Query, 

22 Request, 

23 Response, 

24) 

25from pydantic import BaseModel, TypeAdapter, ValidationError 

26from typing_extensions import ReadOnly, TypedDict, assert_never 

27 

28import litellm 

29from litellm._logging import verbose_proxy_logger 

30from litellm._uuid import uuid 

31from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

32from litellm.models.user import SCIMPlaceholder 

33from litellm.proxy._types import ( 

34 LiteLLM_TeamTable, 

35 LiteLLM_UserTable, 

36 LitellmUserRoles, 

37 Member, 

38 NewTeamRequest, 

39 NewUserRequest, 

40 NewUserResponse, 

41 ProxyErrorTypes, 

42 ProxyException, 

43 TeamMemberAddRequest, 

44 TeamMemberDeleteRequest, 

45 UserAPIKeyAuth, 

46) 

47from litellm.proxy.auth.auth_checks import _delete_cache_key_object 

48from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 

49from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast 

50from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers 

51from litellm.proxy.management_endpoints.internal_user_endpoints import new_user 

52from litellm.proxy.management_endpoints.scim.scim_transformations import ( 

53 ScimTransformations, 

54) 

55from litellm.proxy.management_endpoints.team_endpoints import ( 

56 new_team, 

57 team_member_add, 

58 team_member_delete, 

59) 

60from litellm.proxy.utils import ( 

61 PrismaClient, 

62 _premium_user_check, 

63 handle_exception_on_proxy, 

64) 

65from litellm.repositories.table_repositories import ( 

66 InvitationLinkRepository, 

67 OrganizationMembershipRepository, 

68 TeamMembershipRepository, 

69) 

70from litellm.repositories.team_repository import TeamRepository 

71from litellm.repositories.user_repository import UserRepository 

72from litellm.repositories.verification_token_repository import ( 

73 VerificationTokenRepository, 

74) 

75from litellm.types.proxy.management_endpoints.scim_v2 import * 

76 

77if TYPE_CHECKING: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 from prisma.models import LiteLLM_VerificationToken as PrismaVerificationToken 

79 

80 

81class _UserTableClient(Protocol): 

82 async def find_first(self, where: Mapping[str, object]) -> LiteLLM_UserTable | None: ... 82 ↛ exitline 82 didn't return from function 'find_first' because

83 

84 async def find_unique(self, where: Mapping[str, object]) -> LiteLLM_UserTable | None: ... 84 ↛ exitline 84 didn't return from function 'find_unique' because

85 

86 async def find_many( 86 ↛ exitline 86 didn't return from function 'find_many' because

87 self, 

88 where: Mapping[str, object] | None = None, 

89 skip: int | None = None, 

90 take: int | None = None, 

91 order: Mapping[str, str] | None = None, 

92 ) -> Sequence[LiteLLM_UserTable]: ... 

93 

94 async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> LiteLLM_UserTable: ... 94 ↛ exitline 94 didn't return from function 'update' because

95 

96 async def delete(self, where: Mapping[str, object]) -> LiteLLM_UserTable | None: ... 96 ↛ exitline 96 didn't return from function 'delete' because

97 

98 async def count(self, where: Mapping[str, object] | None = None) -> int: ... 98 ↛ exitline 98 didn't return from function 'count' because

99 

100 

101class _TeamTableClient(Protocol): 

102 async def find_unique(self, where: Mapping[str, object]) -> LiteLLM_TeamTable | None: ... 102 ↛ exitline 102 didn't return from function 'find_unique' because

103 

104 async def find_many( 104 ↛ exitline 104 didn't return from function 'find_many' because

105 self, 

106 where: Mapping[str, object] | None = None, 

107 skip: int | None = None, 

108 take: int | None = None, 

109 order: Mapping[str, str] | None = None, 

110 ) -> Sequence[LiteLLM_TeamTable]: ... 

111 

112 async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> LiteLLM_TeamTable: ... 112 ↛ exitline 112 didn't return from function 'update' because

113 

114 async def delete(self, where: Mapping[str, object]) -> LiteLLM_TeamTable | None: ... 114 ↛ exitline 114 didn't return from function 'delete' because

115 

116 async def count(self, where: Mapping[str, object] | None = None) -> int: ... 116 ↛ exitline 116 didn't return from function 'count' because

117 

118 

119class _VerificationTokenTableClient(Protocol): 

120 async def find_many(self, where: Mapping[str, object] | None = None) -> "Sequence[PrismaVerificationToken]": ... 120 ↛ exitline 120 didn't return from function 'find_many' because

121 

122 async def update( 122 ↛ exitline 122 didn't return from function 'update' because

123 self, where: Mapping[str, object], data: Mapping[str, object] 

124 ) -> "PrismaVerificationToken | None": ... 

125 

126 

127class _UserReferencingTableClient(Protocol): 

128 async def delete_many(self, where: Mapping[str, object]) -> int: ... 128 ↛ exitline 128 didn't return from function 'delete_many' because

129 

130 

131@overload 

132def _table(repository: UserRepository) -> _UserTableClient: ... 132 ↛ exitline 132 didn't return from function '_table' because

133 

134 

135@overload 

136def _table(repository: TeamRepository) -> _TeamTableClient: ... 136 ↛ exitline 136 didn't return from function '_table' because

137 

138 

139@overload 

140def _table(repository: VerificationTokenRepository) -> _VerificationTokenTableClient: ... 140 ↛ exitline 140 didn't return from function '_table' because

141 

142 

143@overload 

144def _table( 144 ↛ exitline 144 didn't return from function '_table' because

145 repository: InvitationLinkRepository | OrganizationMembershipRepository | TeamMembershipRepository, 

146) -> _UserReferencingTableClient: ... 

147 

148 

149def _table( 

150 repository: UserRepository 

151 | TeamRepository 

152 | VerificationTokenRepository 

153 | InvitationLinkRepository 

154 | OrganizationMembershipRepository 

155 | TeamMembershipRepository, 

156) -> object: 

157 return repository.table 

158 

159 

160class UserProvisionerHelpers: 

161 """Helper methods for user provisioning operations.""" 

162 

163 @staticmethod 

164 async def handle_existing_user_by_email( 

165 prisma_client: PrismaClient, 

166 new_user_request: NewUserRequest, 

167 admin_group: str | None = None, 

168 ) -> SCIMUser | None: 

169 """ 

170 Check if a user with the given email already exists and update them if found. 

171 

172 The matched row keeps its existing user_id even when the SCIM userName differs. 

173 Virtual keys, team rosters, team/organization memberships and spend logs all 

174 reference that id, so re-keying the user row would strand every one of them and 

175 make removals against rosters holding the old id no-op. SCIM ids are opaque to 

176 the client, which reads the stable id back from the response. 

177 

178 When admin_group is configured the resolved global role on new_user_request 

179 is persisted too, so re-upserting an existing email demotes a user who is no 

180 longer in the admin group instead of leaving the stale role. 

181 

182 IdPs like Entra manage membership exclusively through /Groups and never send 

183 ``groups`` on POST /Users, so a request without teams means "unspecified", 

184 not "remove from every team": existing memberships are preserved then. 

185 

186 Args: 

187 prisma_client: Database client 

188 new_user_request: New user request data 

189 admin_group: Configured SCIM admin group, or None to leave role untouched 

190 

191 Returns: 

192 SCIMUser if user was updated, None if no existing user found 

193 """ 

194 if not new_user_request.user_email: 

195 return None 

196 

197 existing_user: Final = await _table(UserRepository(prisma_client)).find_first( 

198 where={"user_email": new_user_request.user_email} 

199 ) 

200 

201 if not existing_user: 

202 return None 

203 

204 requested_teams: Final = list(dict.fromkeys(new_user_request.teams or [])) 

205 new_teams: Final = requested_teams if requested_teams else list(existing_user.teams or []) 

206 

207 if new_user_request.user_id != existing_user.user_id: 

208 verbose_proxy_logger.info( 

209 "SCIM: email %s already provisioned as user_id=%s, keeping that id instead of re-keying to %s", 

210 new_user_request.user_email, 

211 existing_user.user_id, 

212 new_user_request.user_id, 

213 ) 

214 

215 await _handle_team_membership_changes( 

216 user_id=existing_user.user_id, 

217 existing_teams=existing_user.teams or [], 

218 new_teams=new_teams, 

219 ) 

220 

221 updated_user: Final = await _table(UserRepository(prisma_client)).update( 

222 where={"user_id": existing_user.user_id}, 

223 data={ 

224 "user_email": new_user_request.user_email, 

225 "user_alias": new_user_request.user_alias, 

226 "teams": new_teams, 

227 "metadata": safe_dumps(new_user_request.metadata), 

228 **({"user_role": new_user_request.user_role} if admin_group is not None else {}), 

229 }, 

230 ) 

231 

232 return await ScimTransformations.transform_litellm_user_to_scim_user(updated_user) 

233 

234 

235class ScimUserData(TypedDict): 

236 """Typed structure for extracted SCIM user data.""" 

237 

238 user_email: str | None 

239 user_alias: str | None 

240 sso_user_id: str | None 

241 teams: list[str] 

242 given_name: str | None 

243 family_name: str | None 

244 active: bool | None 

245 enterprise: SCIMEnterpriseUser | None 

246 entitlements: list[SCIMMultiValuedAttribute] | None 

247 roles: list[SCIMMultiValuedAttribute] | None 

248 

249 

250class GroupMemberExtractionResult(BaseModel): 

251 """Result of extracting and processing group members. 

252 

253 ``all_member_ids`` is deduped order-preserving; ``existing_member_ids`` is not, 

254 so a repeated resolved id appears once in the former and twice in the latter. 

255 """ 

256 

257 existing_member_ids: list[str] 

258 created_users: list[NewUserResponse] 

259 all_member_ids: list[str] # existing + newly created 

260 

261 

262scim_router: Final = APIRouter( 

263 prefix="/scim/v2", 

264 tags=["✨ SCIM v2 (Enterprise Only)"], 

265 dependencies=[Depends(_premium_user_check)], 

266) 

267 

268SCIM_MAX_PAGE_SIZE: Final = 100 

269 

270 

271# Helper functions for common operations 

272async def _get_prisma_client_or_raise_exception(): 

273 """Check if database is connected and raise HTTPException if not.""" 

274 from litellm.proxy.proxy_server import prisma_client 

275 

276 if prisma_client is None: 

277 raise HTTPException(status_code=500, detail={"error": "No database connected"}) 

278 return prisma_client 

279 

280 

281async def _check_user_exists(user_id: str) -> LiteLLM_UserTable: 

282 """Check if user exists and return user, raise 404 if not found.""" 

283 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

284 

285 user: Final = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": user_id}) 

286 

287 if not user: 

288 raise HTTPException(status_code=404, detail={"error": f"User not found with ID: {user_id}"}) 

289 

290 return user 

291 

292 

293async def _check_team_exists(team_id: str) -> LiteLLM_TeamTable: 

294 """Check if team exists and return team, raise 404 if not found.""" 

295 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

296 

297 team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id}) 

298 

299 if not team: 

300 raise HTTPException(status_code=404, detail={"error": f"Group not found with ID: {team_id}"}) 

301 

302 return team 

303 

304 

305def _extract_scim_user_data(user: SCIMUser) -> ScimUserData: 

306 """Extract common data from SCIMUser object.""" 

307 user_email = None 

308 if user.emails and len(user.emails) > 0: 

309 user_email = user.emails[0].value 

310 

311 user_alias = None 

312 if user.name and user.name.givenName: 

313 user_alias = user.name.givenName 

314 

315 teams = [] 

316 if user.groups: 

317 teams = [group.value for group in user.groups] 

318 

319 return { 

320 "user_email": user_email, 

321 "user_alias": user_alias, 

322 "sso_user_id": user.externalId, 

323 "teams": teams, 

324 "given_name": user.name.givenName if user.name else None, 

325 "family_name": user.name.familyName if user.name else None, 

326 "active": user.active, 

327 "enterprise": user.enterprise_user, 

328 "entitlements": user.entitlements, 

329 "roles": user.roles, 

330 } 

331 

332 

333def _build_scim_metadata( 

334 given_name: str | None, 

335 family_name: str | None, 

336 active: bool | None = None, 

337 enterprise: SCIMEnterpriseUser | None = None, 

338 entitlements: list[SCIMMultiValuedAttribute] | None = None, 

339 roles: list[SCIMMultiValuedAttribute] | None = None, 

340) -> dict[str, object]: 

341 """Build metadata dictionary with SCIM data.""" 

342 metadata: Final[dict[str, object]] = { 

343 "scim_metadata": LiteLLM_UserScimMetadata( 

344 givenName=given_name, 

345 familyName=family_name, 

346 ).model_dump() 

347 } 

348 

349 if active is not None: 

350 metadata["scim_active"] = active 

351 

352 if enterprise is not None: 

353 metadata[SCIM_ENTERPRISE_METADATA_KEY] = enterprise.model_dump(by_alias=True, exclude_none=True) 

354 

355 if entitlements is not None: 

356 metadata[SCIM_ENTITLEMENTS_METADATA_KEY] = [e.model_dump(exclude_none=True) for e in entitlements] 

357 

358 if roles is not None: 

359 metadata[SCIM_ROLES_METADATA_KEY] = [r.model_dump(exclude_none=True) for r in roles] 

360 

361 return metadata 

362 

363 

364async def _get_scim_upsert_user_setting() -> bool: 

365 """ 

366 Get the scim_upsert_user setting from litellm_settings. 

367 

368 Returns: 

369 True if scim_upsert_user is not set or is True (default behavior), 

370 False if scim_upsert_user is explicitly set to False (SCIM 2.0 strict mode) 

371 """ 

372 try: 

373 from litellm.proxy.proxy_server import proxy_config 

374 

375 config: Final = await proxy_config.get_config() 

376 litellm_settings: Final = config.get("litellm_settings", {}) or {} 

377 scim_upsert_user: Final = litellm_settings.get("scim_upsert_user", True) 

378 

379 # Default to True if not set (backward compatibility) 

380 return bool(scim_upsert_user) 

381 except Exception as e: 

382 verbose_proxy_logger.warning("Error reading scim_upsert_user setting, defaulting to True: %s", e) 

383 # Default to True for backward compatibility 

384 return True 

385 

386 

387ScimUserRole = Literal[ 

388 LitellmUserRoles.PROXY_ADMIN, 

389 LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, 

390 LitellmUserRoles.INTERNAL_USER, 

391 LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, 

392] 

393 

394 

395def _default_scim_user_role() -> ScimUserRole: 

396 """Non-admin default role for SCIM-provisioned users.""" 

397 if litellm.default_internal_user_params: 

398 configured_role: Final = litellm.default_internal_user_params.get("user_role") 

399 if configured_role is not None: 

400 return configured_role 

401 return LitellmUserRoles.INTERNAL_USER_VIEW_ONLY 

402 

403 

404async def _get_scim_admin_group() -> str | None: 

405 """ 

406 Get the scim_admin_group setting from litellm_settings. 

407 

408 Returns the configured admin group identifier, or None when unset so callers 

409 leave a user's global role untouched (default-safe). 

410 """ 

411 try: 

412 from litellm.proxy.proxy_server import proxy_config 

413 

414 config: Final = await proxy_config.get_config() 

415 litellm_settings: Final = config.get("litellm_settings", {}) or {} 

416 return litellm_settings.get("scim_admin_group") or None 

417 except Exception as e: 

418 verbose_proxy_logger.warning("Error reading scim_admin_group setting, defaulting to None: %s", e) 

419 return None 

420 

421 

422def _resolve_scim_user_role( 

423 groups: list[SCIMUserGroup], 

424 admin_group: str | None, 

425 default_role: ScimUserRole, 

426) -> LitellmUserRoles | None: 

427 """ 

428 Resolve a user's global proxy role from their SCIM groups. 

429 

430 Returns None when no admin group is configured, signalling callers to leave 

431 the role unchanged. Otherwise grants PROXY_ADMIN when any group matches the 

432 admin group by value or display, and falls back to the non-admin default. 

433 """ 

434 if admin_group is None: 

435 return None 

436 for group in groups: 

437 if group.value == admin_group or group.display == admin_group: 

438 return LitellmUserRoles.PROXY_ADMIN 

439 return default_role 

440 

441 

442async def _scim_groups_from_team_ids(prisma_client: PrismaClient, team_ids: list[str]) -> list[SCIMUserGroup]: 

443 """ 

444 Build SCIMUserGroup objects from team ids, populating display from each 

445 team's alias so admin-group matching by display name works the same way it 

446 does on PUT (where SCIM groups carry display names natively). 

447 """ 

448 teams: Final = [ 

449 await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id}) for team_id in team_ids 

450 ] 

451 return [ 

452 SCIMUserGroup( 

453 value=team_id, 

454 display=team.team_alias if team is not None else None, 

455 ) 

456 for team_id, team in zip(team_ids, teams) 

457 ] 

458 

459 

460async def _recompute_scim_member_roles(prisma_client: PrismaClient, user_ids: Iterable[str]) -> None: 

461 """ 

462 Recompute and persist each user's global proxy role from their resulting team 

463 membership. No-op unless scim_admin_group is configured, so a SCIM group write 

464 that drops a member from the admin group demotes them just like the user 

465 endpoints do, and the role is left untouched when the feature is off. 

466 """ 

467 admin_group: Final = await _get_scim_admin_group() 

468 if admin_group is None: 

469 return 

470 

471 default_role: Final = _default_scim_user_role() 

472 for user_id in user_ids: 

473 user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": user_id}) 

474 if user is None: 

475 continue 

476 resolved_role = _resolve_scim_user_role( 

477 await _scim_groups_from_team_ids(prisma_client, user.teams or []), 

478 admin_group, 

479 default_role, 

480 ) 

481 await _table(UserRepository(prisma_client)).update( 

482 where={"user_id": user_id}, 

483 data={"user_role": resolved_role}, 

484 ) 

485 

486 

487class _ResolvedUserMember(NamedTuple): 

488 user_id: str 

489 

490 

491class _SkippedGroupMember(NamedTuple): 

492 value: str 

493 reason: Literal["nested_group", "non_user_type", "existing_team"] 

494 

495 

496class _UnknownMember(NamedTuple): 

497 value: str 

498 

499 

500class _AmbiguousMember(NamedTuple): 

501 value: str 

502 

503 

504_ClassifiedGroupMember = Union[_ResolvedUserMember, _SkippedGroupMember, _UnknownMember, _AmbiguousMember] 

505 

506 

507class _PartitionedMembers(NamedTuple): 

508 resolved_ids: tuple[str, ...] 

509 skipped: tuple[_SkippedGroupMember, ...] 

510 unknown_ids: tuple[str, ...] 

511 ambiguous_values: tuple[str, ...] 

512 

513 

514def _member_value(member: SCIMMember) -> str: 

515 """A member id is opaque to us but has to be there; an empty one is a client error.""" 

516 if not member.value or not member.value.strip(): 

517 raise HTTPException( 

518 status_code=400, 

519 detail={"error": "Invalid member: user ID cannot be empty."}, 

520 ) 

521 return member.value 

522 

523 

524def _normalized_member_type(member: SCIMMember) -> str | None: 

525 """The canonical ``type`` a member declares, lowercased; blank or absent means none.""" 

526 normalized: Final = (member.type or "").strip().lower() 

527 return normalized or None 

528 

529 

530_JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object]) 

531 

532 

533def _json_object_fields(raw: object) -> Mapping[str, object] | None: 

534 """A typed, read-only view of a JSON object, or None when it is not one.""" 

535 try: 

536 return _JSON_OBJECT_ADAPTER.validate_python(raw) 

537 except ValidationError: 

538 return None 

539 

540 

541def _team_metadata_has_scim_provenance(team_metadata: object) -> bool: 

542 """Whether a group write from the identity provider left its mark on this team. 

543 

544 ``SCIM_TEAM_DATA_METADATA_KEY`` counts because PUT has been writing it since 

545 long before the explicit marker, so a team the identity provider already 

546 syncs is recognized without waiting to be written again. 

547 """ 

548 fields: Final = _json_object_fields(team_metadata) 

549 if fields is None: 

550 return False 

551 return bool(fields.get(SCIM_MANAGED_TEAM_METADATA_KEY)) or fields.get(SCIM_TEAM_DATA_METADATA_KEY) is not None 

552 

553 

554class _CaseInsensitiveMatch(TypedDict): 

555 equals: ReadOnly[str] 

556 mode: ReadOnly[str] 

557 

558 

559async def _users_named_by_member_value( 

560 value: str, prisma_client: PrismaClient, *, take: int | None = 2 

561) -> tuple[str, ...]: 

562 """Every user id this member value names, by SSO identity or by email. 

563 

564 Both fields are searched in one pass, because searching either first would hide a 

565 value that names one account by its SSO identity and another by its email, and 

566 hand the group to whichever field was searched first. 

567 

568 They are not compared alike. An email is matched the way ``new_user`` matches one 

569 before it accepts a new account, case-insensitively: matching more strictly than 

570 the layer that would reject the placeholder is what turned a member id whose 

571 casing differed from the stored email into a 500 on the whole push. An SSO 

572 identity is matched exactly, because OIDC defines ``sub`` as case-sensitive and 

573 nothing folds its case on the way in, so treating two subjects that differ in case 

574 as one would hand the group to an account the provider never named. 

575 

576 ``take`` bounds the read for a caller that only needs to know whether the value 

577 names one account or several; ``user_email`` carries no index, so letting the scan 

578 stop early is worth the two rows. A caller that has to know *which* accounts, as a 

579 removal does, passes None. That set is the accounts sharing one identity, which is 

580 a handful at worst. 

581 """ 

582 subject: Final = value.strip() 

583 email: Final[_CaseInsensitiveMatch] = {"equals": subject, "mode": "insensitive"} 

584 rows: Final = await _table(UserRepository(prisma_client)).find_many( 

585 where={"OR": [{"sso_user_id": subject}, {"user_email": email}]}, 

586 take=take, 

587 ) 

588 return tuple(dict.fromkeys(row.user_id for row in rows)) 

589 

590 

591async def _accounts_named_by_member_value(value: str, prisma_client: PrismaClient) -> tuple[str, ...]: 

592 """Every user id this member value names, by user id, SSO identity or email. 

593 

594 Classification needs to know whether the value is one account's ``user_id`` and 

595 whether it names any other account, so all three fields are read in one pass. The 

596 id is compared exactly and unstripped, as a primary key lookup would; the 

597 identities compare as ``_users_named_by_member_value`` describes. Two rows are 

598 enough to tell one account from several, so the read stops there. Only a full 

599 read that lacks the row keyed by the value leaves that row's existence open, and 

600 only then is the id read on its own. 

601 """ 

602 subject: Final = value.strip() 

603 email: Final[_CaseInsensitiveMatch] = {"equals": subject, "mode": "insensitive"} 

604 users: Final = _table(UserRepository(prisma_client)) 

605 rows: Final = await users.find_many( 

606 where={ # mutable-ok: Prisma filter 

607 "OR": [ # mutable-ok: Prisma filter 

608 {"user_id": value}, # mutable-ok: Prisma filter 

609 {"sso_user_id": subject}, # mutable-ok: Prisma filter 

610 {"user_email": email}, # mutable-ok: Prisma filter 

611 ], 

612 }, 

613 take=2, 

614 ) 

615 named: Final = tuple(dict.fromkeys(row.user_id for row in rows)) 

616 if len(named) < 2 or value in named: 

617 return named 

618 keyed: Final = await users.find_unique(where={"user_id": value}) 

619 return named if keyed is None else (value, *named) 

620 

621 

622async def _classify_group_member(member: SCIMMember, prisma_client: PrismaClient) -> _ClassifiedGroupMember: 

623 """ 

624 Decide what a single SCIM group member refers to. 

625 

626 A LiteLLM team only holds users, so a member is dropped when it declares a type 

627 other than ``User`` or when its id names an existing team. Both of those checks 

628 are placed around the user lookup rather than before it, because the id of a 

629 real user is the one thing that outranks them: 

630 

631 - ``"type": "Group"`` (what Entra sends for a nested group) is dropped without 

632 a lookup. This bug provisioned nested group GUIDs as users, so those rows 

633 exist in the wild and would otherwise resolve as members all over again. 

634 - any other unrecognized type is dropped only after the user lookup misses. 

635 Clients do send non-canonical types on real members (RFC 7643 defines 

636 ``direct`` for ``User.groups``), and dropping a live user over one would 

637 revoke that user's team access on the next full sync. 

638 - an id that names an existing team is dropped only when the member arrives 

639 untyped, which is how Okta sends nested groups, and only when that team is 

640 one the identity provider writes. An id the IdP called a User is a user 

641 even if some team happens to share the id, and a team created here rather 

642 than through SCIM is not evidence of anything about the member. 

643 

644 When those checks miss on an otherwise user-shaped member, its value is looked 

645 up as an SSO identity or an email, and a match resolves to that user's 

646 ``user_id``. A value that names more than one account is ambiguous rather than 

647 unknown: it names a real person we cannot identify, so it is neither guessed at 

648 nor provisioned. 

649 

650 An exact ``user_id`` hit is checked the same way rather than trusted outright. A 

651 value can be one account's id and another's SSO identity or email, and taking the 

652 id on sight would hand the group to whichever account happened to be keyed by it. 

653 The placeholders this bug provisioned are that shape exactly, since they are keyed 

654 by the very id the provider keeps pushing, so on a tenant that already has them 

655 the membership is refused and named rather than silently landing on the 

656 placeholder again. 

657 """ 

658 value: Final = _member_value(member) 

659 member_type: Final = _normalized_member_type(member) 

660 

661 if member_type == "group": 

662 return _SkippedGroupMember(value=value, reason="nested_group") 

663 

664 named: Final = await _accounts_named_by_member_value(value, prisma_client) 

665 if value in named: 

666 shared_with: Final = tuple(other for other in named if other != value) 

667 if shared_with: 

668 verbose_proxy_logger.warning( 

669 "SCIM: group member '%s' is one account's user id and is also account '%s' by SSO identity or email, " 

670 "so the membership cannot be attributed. A placeholder an earlier release provisioned under this id " 

671 "looks exactly like this and should be deleted so the real account can be matched", 

672 value, 

673 shared_with[0], 

674 ) 

675 return _AmbiguousMember(value=value) 

676 return _ResolvedUserMember(user_id=value) 

677 

678 if member_type is not None and member_type != "user": 

679 return _SkippedGroupMember(value=value, reason="non_user_type") 

680 

681 if member_type is None: 

682 team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": value}) 

683 if team is not None and _team_metadata_has_scim_provenance(team.metadata): 

684 return _SkippedGroupMember(value=value, reason="existing_team") 

685 

686 if len(named) == 1: 

687 verbose_proxy_logger.info( 

688 "SCIM: group member '%s' matched user_id '%s' by SSO identity or email", 

689 value, 

690 named[0], 

691 ) 

692 return _ResolvedUserMember(user_id=named[0]) 

693 if len(named) > 1: 

694 verbose_proxy_logger.warning( 

695 "SCIM: group member '%s' names more than one account by SSO identity or email and cannot be resolved " 

696 "unambiguously", 

697 value, 

698 ) 

699 return _AmbiguousMember(value=value) 

700 

701 return _UnknownMember(value=value) 

702 

703 

704def _bucketed_member(entry: _ClassifiedGroupMember) -> _PartitionedMembers: 

705 """The single-member partition one classified entry contributes.""" 

706 match entry: 

707 case _ResolvedUserMember(user_id=user_id): 

708 return _PartitionedMembers(resolved_ids=(user_id,), skipped=(), unknown_ids=(), ambiguous_values=()) 

709 case _SkippedGroupMember(): 

710 return _PartitionedMembers(resolved_ids=(), skipped=(entry,), unknown_ids=(), ambiguous_values=()) 

711 case _UnknownMember(value=value): 

712 return _PartitionedMembers(resolved_ids=(), skipped=(), unknown_ids=(value,), ambiguous_values=()) 

713 case _AmbiguousMember(value=value): 

714 return _PartitionedMembers(resolved_ids=(), skipped=(), unknown_ids=(), ambiguous_values=(value,)) 

715 case _: 

716 assert_never(entry) 

717 

718 

719def _partition_classified_members(classified: Iterable[_ClassifiedGroupMember]) -> _PartitionedMembers: 

720 """Split classified members into the buckets the resolver acts on, keeping request order.""" 

721 bucketed: Final = tuple(_bucketed_member(entry) for entry in classified) 

722 return _PartitionedMembers( 

723 resolved_ids=tuple(chain.from_iterable(bucket.resolved_ids for bucket in bucketed)), 

724 skipped=tuple(chain.from_iterable(bucket.skipped for bucket in bucketed)), 

725 unknown_ids=tuple(chain.from_iterable(bucket.unknown_ids for bucket in bucketed)), 

726 ambiguous_values=tuple(chain.from_iterable(bucket.ambiguous_values for bucket in bucketed)), 

727 ) 

728 

729 

730def _admitted_member_id(entry: _ClassifiedGroupMember, created_ids: frozenset[str]) -> str | None: 

731 match entry: 

732 case _ResolvedUserMember(user_id=user_id): 

733 return user_id 

734 case _UnknownMember(value=value): 

735 return value if value in created_ids else None 

736 case _SkippedGroupMember() | _AmbiguousMember(): 

737 return None 

738 case _: 

739 assert_never(entry) 

740 

741 

742def _admitted_member_ids(classified: Iterable[_ClassifiedGroupMember], created_ids: frozenset[str]) -> tuple[str, ...]: 

743 """Member ids that survive resolution, in the order the request listed them. 

744 

745 An id the request repeats is one member: the roster these ids are written to 

746 holds one row per member, and a second creation attempt for the same id fails 

747 against the real unique constraint even though the first one succeeded. 

748 """ 

749 return tuple( 

750 dict.fromkeys( 

751 member_id for entry in classified if (member_id := _admitted_member_id(entry, created_ids)) is not None 

752 ) 

753 ) 

754 

755 

756class _UserIdWhere(TypedDict): 

757 user_id: ReadOnly[str] 

758 

759 

760class _ScimErrorDetail(TypedDict): 

761 error: ReadOnly[str] 

762 

763 

764async def _ensure_group_member_user( 

765 user_id: str, 

766 created_via: str, 

767 prisma_client: PrismaClient, 

768) -> NewUserResponse | None: 

769 """The created user, or None when the id already resolves to a user row (a 

770 concurrent provisioning request won the creation race after our lookup missed). 

771 

772 Raises: 

773 HTTPException: 500 when the user can neither be created nor found. The 

774 request has to fail so the identity provider retries, instead of recording 

775 success for a member the roster silently dropped. 

776 """ 

777 created: Final = await _create_user_if_not_exists(user_id=user_id, created_via=created_via) 

778 if created is not None: 

779 return created 

780 where: Final[_UserIdWhere] = {"user_id": user_id} 

781 existing: Final = await _table(UserRepository(prisma_client)).find_unique(where=where) 

782 if existing is not None: 

783 return None 

784 detail: Final[_ScimErrorDetail] = { 

785 "error": f"Failed to create user '{user_id}' while provisioning group membership." 

786 } 

787 raise HTTPException(status_code=500, detail=detail) 

788 

789 

790def _roster_entries_named_by(value: str, roster: frozenset[str], resolved: tuple[str, ...]) -> tuple[str, ...]: 

791 """The members of this group a removal value names. 

792 

793 Both ways of naming one count together. The id as written counts when the roster 

794 holds it verbatim, which is how an earlier release recorded a member it could not 

795 match, and the accounts it resolves to count when they are on the roster. Counting 

796 only the resolved ones would let a value that is one member's canonical id and 

797 another member's email revoke both, since each looks singular on its own. 

798 """ 

799 return tuple( 

800 dict.fromkeys( 

801 chain( 

802 (value,) if value in roster else (), 

803 (user_id for user_id in resolved if user_id in roster), 

804 ) 

805 ) 

806 ) 

807 

808 

809async def _member_ids_to_drop( 

810 members: Sequence[SCIMMember], roster: frozenset[str], prisma_client: PrismaClient 

811) -> frozenset[str]: 

812 """The members a ``remove`` clears, one per id the request names. 

813 

814 The roster holds canonical user ids, so a directory that added someone by their 

815 email or SSO identity has to be able to remove them by that same value, and a 

816 member an earlier release recorded under the raw id has to stay removable by it. 

817 

818 Ambiguity is a property of the table as it stands, not of the value, so a value 

819 that named one person when they were admitted can name two later. Resolving a 

820 removal against the whole table would then drop nobody while answering 200, and 

821 the person the directory just took out of the group would keep the team. So a 

822 removal keeps only the accounts already on the roster: one is unambiguous however 

823 many strangers share the address, none means there is nothing to revoke, and only 

824 a value naming two of this group's own members is genuinely undecidable. That last 

825 case fails rather than reporting a removal it did not perform, or revoking both. 

826 

827 Raises: 

828 HTTPException: 400 when a member id names more than one current member. 

829 """ 

830 written: Final = frozenset(_member_value(member) for member in members) 

831 matched: Final = tuple( 

832 [ 

833 ( 

834 value, 

835 _roster_entries_named_by( 

836 value, roster, await _users_named_by_member_value(value, prisma_client, take=None) 

837 ), 

838 ) 

839 for value in sorted(written) 

840 ] 

841 ) 

842 undecidable: Final = tuple(value for value, entries in matched if len(entries) > 1) 

843 if undecidable: 

844 raise HTTPException( 

845 status_code=400, 

846 detail={ 

847 "error": f"Member ID '{undecidable[0]}' names more than one member of this group, so the removal " 

848 "cannot be attributed. Send the LiteLLM user ID as the member value, or resolve the duplicate." 

849 }, 

850 ) 

851 return frozenset(chain.from_iterable(entries for _, entries in matched)) 

852 

853 

854async def _resolve_group_member_ids( 

855 members: Sequence[SCIMMember], 

856 created_via: str, 

857 prisma_client: PrismaClient, 

858) -> GroupMemberExtractionResult: 

859 """ 

860 Resolve SCIM group members to LiteLLM user ids, dropping members that are not users. 

861 

862 Member ids are matched by ``user_id`` first, then by SSO identity or email. An 

863 id that resolves to nothing is created when litellm_settings.scim_upsert_user is 

864 True (default) and rejected per SCIM 2.0 otherwise. Removals do not come through 

865 here: they resolve through ``_member_ids_to_drop`` instead, which neither creates 

866 a user nor fails on an id it cannot place. 

867 

868 Raises: 

869 HTTPException: 400 when a member id is empty, when a member id names more 

870 than one user, or when scim_upsert_user is False and a member id is neither 

871 an existing user, an existing team, nor a member declared to be something 

872 other than a user. 500 when a member's user row can neither be created nor 

873 found. 

874 """ 

875 classified: Final = tuple([await _classify_group_member(member, prisma_client) for member in members]) 

876 partition: Final = _partition_classified_members(classified) 

877 

878 for skipped in partition.skipped: 

879 verbose_proxy_logger.info( 

880 "SCIM: ignoring non-user group member '%s' (%s); LiteLLM teams contain users only", 

881 skipped.value, 

882 skipped.reason, 

883 ) 

884 

885 if partition.ambiguous_values: 

886 raise HTTPException( 

887 status_code=400, 

888 detail={ 

889 "error": f"Member ID '{partition.ambiguous_values[0]}' names more than one LiteLLM user, so the " 

890 "group membership cannot be attributed. Resolve the duplicate, which for an id that also matches a " 

891 "SCIM-provisioned placeholder means deleting that placeholder." 

892 }, 

893 ) 

894 

895 if partition.unknown_ids and not await _get_scim_upsert_user_setting(): 

896 raise HTTPException( 

897 status_code=400, 

898 detail={ 

899 "error": f"User with ID '{partition.unknown_ids[0]}' does not exist. " 

900 "Please create the user first via POST /Users before adding to group." 

901 }, 

902 ) 

903 

904 unique_unknown_ids: Final = tuple(dict.fromkeys(partition.unknown_ids)) 

905 for user_id in unique_unknown_ids: 

906 verbose_proxy_logger.warning( 

907 "SCIM: creating placeholder user for group member '%s'; matched no user by user_id, sso_user_id or " 

908 "user_email. An SSO-provisioned user's real account stays teamless if this is a mismatch", 

909 user_id, 

910 ) 

911 

912 creations: Final = tuple( 

913 [ 

914 ( 

915 user_id, 

916 await _ensure_group_member_user(user_id=user_id, created_via=created_via, prisma_client=prisma_client), 

917 ) 

918 for user_id in unique_unknown_ids 

919 ] 

920 ) 

921 created_users: Final = tuple(created for _, created in creations if created is not None) 

922 

923 return GroupMemberExtractionResult( 

924 existing_member_ids=partition.resolved_ids, 

925 created_users=created_users, 

926 all_member_ids=_admitted_member_ids(classified, frozenset(unique_unknown_ids)), 

927 ) 

928 

929 

930async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionResult: 

931 """ 

932 Extract member IDs from SCIMGroup, validating that all users exist. 

933 

934 Behavior depends on litellm_settings.scim_upsert_user: 

935 - If True (default): Creates users that don't exist (backward compatible) 

936 - If False: Rejects non-existent users per SCIM 2.0 protocol 

937 

938 Returns: 

939 GroupMemberExtractionResult with existing members, created users, and all member IDs 

940 

941 Raises: 

942 HTTPException: If scim_upsert_user is False and any member user does not exist (400 Bad Request) 

943 """ 

944 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

945 return await _resolve_group_member_ids( 

946 members=group.members or [], 

947 created_via="scim_group_membership", 

948 prisma_client=prisma_client, 

949 ) 

950 

951 

952async def _get_team_members_display(member_ids: list[str]) -> list[SCIMMember]: 

953 """Get SCIMMember objects with display names for a list of member IDs.""" 

954 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

955 members: Final[list[SCIMMember]] = [] 

956 

957 for member_id in member_ids: 

958 user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": member_id}) 

959 if user: 

960 display_name = user.user_email or user.user_id 

961 members.append(SCIMMember(value=user.user_id, display=display_name, type="User")) 

962 

963 return members 

964 

965 

966async def _handle_team_membership_changes( 

967 user_id: str, 

968 existing_teams: list[str], 

969 new_teams: list[str], 

970) -> None: 

971 """Handle adding/removing user from teams based on changes. 

972 

973 Roster write failures propagate so the SCIM endpoint returns an error the IdP 

974 retries, instead of persisting a ``teams`` array the roster never received. 

975 """ 

976 existing_teams_set: Final = set(existing_teams) 

977 new_teams_set: Final = set(new_teams) 

978 

979 teams_to_add: Final = new_teams_set - existing_teams_set 

980 teams_to_remove: Final = existing_teams_set - new_teams_set 

981 

982 if teams_to_add or teams_to_remove: 

983 await patch_team_membership( 

984 user_id=user_id, 

985 teams_ids_to_add_user_to=list(teams_to_add), 

986 teams_ids_to_remove_user_from=list(teams_to_remove), 

987 raise_on_error=True, 

988 ) 

989 

990 

991SCIM_BLOCKED_METADATA_KEY: Final = "scim_blocked" 

992 

993 

994def _key_was_scim_blocked(metadata: object) -> bool: 

995 """True if a verification token carries the SCIM-block marker in metadata.""" 

996 return isinstance(metadata, dict) and metadata.get(SCIM_BLOCKED_METADATA_KEY) is True 

997 

998 

999async def _set_user_keys_blocked(user_id: str, blocked: bool) -> int: 

1000 """ 

1001 Block or unblock virtual keys owned by a user and invalidate them in the 

1002 in-memory/redis caches so the change takes effect immediately. 

1003 

1004 Each key SCIM blocks is tagged with ``metadata.scim_blocked = True``. On 

1005 reactivation we only unblock keys carrying that marker, so a key an admin 

1006 blocked manually for unrelated reasons is left alone. 

1007 

1008 Returns the number of keys whose state was flipped. 

1009 """ 

1010 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache 

1011 

1012 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1013 

1014 if blocked: 

1015 # `blocked` is a nullable column with no default, so existing rows 

1016 # typically hold NULL; treat NULL as "not blocked" since SQL equality 

1017 # on NULL would otherwise silently skip them. 

1018 candidates = await _table(VerificationTokenRepository(prisma_client)).find_many( 

1019 where={ 

1020 "user_id": user_id, 

1021 "OR": [{"blocked": False}, {"blocked": None}], 

1022 }, 

1023 ) 

1024 affected_keys = candidates 

1025 else: 

1026 candidates = await _table(VerificationTokenRepository(prisma_client)).find_many( 

1027 where={"user_id": user_id, "blocked": True}, 

1028 ) 

1029 affected_keys = [k for k in candidates if _key_was_scim_blocked(k.metadata)] 

1030 

1031 if not affected_keys: 

1032 return 0 

1033 

1034 for key_row in affected_keys: 

1035 current_metadata: dict[str, object] = dict(key_row.metadata) if isinstance(key_row.metadata, dict) else {} 

1036 if blocked: 

1037 new_metadata = {**current_metadata, SCIM_BLOCKED_METADATA_KEY: True} 

1038 else: 

1039 new_metadata = {k: v for k, v in current_metadata.items() if k != SCIM_BLOCKED_METADATA_KEY} 

1040 await _table(VerificationTokenRepository(prisma_client)).update( 

1041 where={"token": key_row.token}, 

1042 data={"blocked": blocked, "metadata": safe_dumps(new_metadata)}, 

1043 ) 

1044 

1045 for key_row in affected_keys: 

1046 await _delete_cache_key_object( 

1047 hashed_token=key_row.token, 

1048 user_api_key_cache=user_api_key_cache, 

1049 proxy_logging_obj=proxy_logging_obj, 

1050 ) 

1051 

1052 verbose_proxy_logger.info( 

1053 "SCIM: %s %d virtual key(s) for user_id=%s", 

1054 "blocked" if blocked else "unblocked", 

1055 len(affected_keys), 

1056 user_id, 

1057 ) 

1058 return len(affected_keys) 

1059 

1060 

1061async def _delete_rows_referencing_user(prisma_client: PrismaClient, *, user_id: str) -> None: 

1062 """Drop rows whose foreign keys reference ``LiteLLM_UserTable.user_id``. 

1063 

1064 Required before deleting the user row itself, otherwise Postgres rejects 

1065 the user delete with an FK constraint violation (e.g. 

1066 ``LiteLLM_InvitationLink_user_id_fkey``). 

1067 """ 

1068 await _table(InvitationLinkRepository(prisma_client)).delete_many( 

1069 where={ 

1070 "OR": [ 

1071 {"user_id": user_id}, 

1072 {"created_by": user_id}, 

1073 {"updated_by": user_id}, 

1074 ] 

1075 } 

1076 ) 

1077 await _table(OrganizationMembershipRepository(prisma_client)).delete_many(where={"user_id": user_id}) 

1078 await _table(TeamMembershipRepository(prisma_client)).delete_many(where={"user_id": user_id}) 

1079 

1080 

1081def _scim_active_value(metadata: Mapping[str, object] | None) -> bool | None: 

1082 """Read the SCIM active flag from a user's metadata dict, if present.""" 

1083 if not metadata: 

1084 return None 

1085 value: Final = metadata.get("scim_active") 

1086 if value is None: 

1087 return None 

1088 return bool(value) 

1089 

1090 

1091def _user_scim_active(user: LiteLLM_UserTable) -> bool | None: 

1092 """Read the SCIM active flag off a user row's metadata, if present.""" 

1093 metadata: Final[dict[str, object] | None] = user.metadata 

1094 return _scim_active_value(metadata) 

1095 

1096 

1097async def _create_user_if_not_exists(user_id: str, created_via: str = "scim_group") -> NewUserResponse | None: 

1098 """ 

1099 Helper function to create a user if they don't exist. 

1100 

1101 Args: 

1102 user_id: The user ID to create 

1103 created_via: Context for where the user was created from 

1104 

1105 Returns: 

1106 LiteLLM_UserTable if user was created, None if creation failed 

1107 """ 

1108 from litellm.proxy.management_endpoints.internal_user_endpoints import new_user 

1109 

1110 try: 

1111 # Get default role for new internal users 

1112 default_role: ( 

1113 Literal[ 

1114 LitellmUserRoles.PROXY_ADMIN, 

1115 LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, 

1116 LitellmUserRoles.INTERNAL_USER, 

1117 LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, 

1118 ] 

1119 | None 

1120 ) = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY 

1121 if litellm.default_internal_user_params: 

1122 default_role = litellm.default_internal_user_params.get("user_role") 

1123 

1124 new_user_request: Final = NewUserRequest( 

1125 user_id=user_id, 

1126 user_email=user_id, # We don't have email from group membership 

1127 user_alias=None, 

1128 metadata={"created_via": created_via}, 

1129 auto_create_key=False, 

1130 user_role=default_role, 

1131 ) 

1132 

1133 created_user: Final = await new_user( 

1134 data=new_user_request, 

1135 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

1136 ) 

1137 verbose_proxy_logger.info("Created user %s via %s", user_id, created_via) 

1138 return created_user 

1139 

1140 except Exception as e: 

1141 verbose_proxy_logger.exception("Failed to create user %s: %s", user_id, e) 

1142 return None 

1143 

1144 

1145async def _get_team_member_user_ids_from_team(team: LiteLLM_TeamTable) -> list[str]: 

1146 """ 

1147 Get the IDs of the members from a team. 

1148 

1149 Use one source of truth for the member IDs: team.members_with_roles 

1150 

1151 """ 

1152 member_user_ids: Final[list[str]] = [] 

1153 for member in team.members_with_roles or []: 

1154 if hasattr(member, "user_id") and member.user_id is not None: 

1155 member_user_ids.append(member.user_id) 

1156 elif isinstance(member, dict) and "user_id" in member: 

1157 user_id = member.get("user_id") 

1158 if user_id is not None: 

1159 member_user_ids.append(user_id) 

1160 return member_user_ids 

1161 

1162 

1163# Dependency to set the correct SCIM Content-Type 

1164async def set_scim_content_type(response: Response): 

1165 """Sets the Content-Type header to application/scim+json""" 

1166 # Check if content type is already application/json, only override in that case 

1167 # Avoids overriding for non-JSON responses or already correct types if they were set manually 

1168 response.headers["Content-Type"] = "application/scim+json" 

1169 

1170 

1171def _get_resource_types(base_url: str = "/scim/v2") -> Sequence[SCIMResourceType]: 

1172 """Return the list of SCIM ResourceType definitions per RFC 7643 Section 6.""" 

1173 return [ 

1174 SCIMResourceType( 

1175 id="User", 

1176 name="User", 

1177 description="User Account", 

1178 endpoint="/Users", 

1179 schema_="urn:ietf:params:scim:schemas:core:2.0:User", 

1180 meta={ 

1181 "location": f"{base_url}/ResourceTypes/User", 

1182 "resourceType": "ResourceType", 

1183 }, 

1184 ), 

1185 SCIMResourceType( 

1186 id="Group", 

1187 name="Group", 

1188 description="Group", 

1189 endpoint="/Groups", 

1190 schema_="urn:ietf:params:scim:schemas:core:2.0:Group", 

1191 meta={ 

1192 "location": f"{base_url}/ResourceTypes/Group", 

1193 "resourceType": "ResourceType", 

1194 }, 

1195 ), 

1196 ] 

1197 

1198 

1199def _get_schemas() -> Sequence[SCIMSchema]: 

1200 """Return the list of SCIM Schema definitions per RFC 7643 Section 7.""" 

1201 return [ 

1202 SCIMSchema( 

1203 id="urn:ietf:params:scim:schemas:core:2.0:User", 

1204 name="User", 

1205 description="User Account", 

1206 attributes=[ 

1207 SCIMSchemaAttribute( 

1208 name="userName", 

1209 type="string", 

1210 multiValued=False, 

1211 description="Unique identifier for the User.", 

1212 required=True, 

1213 mutability="readWrite", 

1214 returned="default", 

1215 uniqueness="server", 

1216 ), 

1217 SCIMSchemaAttribute( 

1218 name="name", 

1219 type="complex", 

1220 multiValued=False, 

1221 description="The components of the user's real name.", 

1222 required=False, 

1223 subAttributes=[ 

1224 SCIMSchemaAttribute( 

1225 name="givenName", 

1226 type="string", 

1227 description="The given name of the User.", 

1228 ), 

1229 SCIMSchemaAttribute( 

1230 name="familyName", 

1231 type="string", 

1232 description="The family name of the User.", 

1233 ), 

1234 SCIMSchemaAttribute( 

1235 name="formatted", 

1236 type="string", 

1237 description="The full name.", 

1238 ), 

1239 ], 

1240 ), 

1241 SCIMSchemaAttribute( 

1242 name="displayName", 

1243 type="string", 

1244 multiValued=False, 

1245 description="The name of the User, suitable for display.", 

1246 ), 

1247 SCIMSchemaAttribute( 

1248 name="emails", 

1249 type="complex", 

1250 multiValued=True, 

1251 description="Email addresses for the user.", 

1252 subAttributes=[ 

1253 SCIMSchemaAttribute( 

1254 name="value", 

1255 type="string", 

1256 description="Email address value.", 

1257 ), 

1258 SCIMSchemaAttribute( 

1259 name="type", 

1260 type="string", 

1261 description="Type of email (work, home, etc.).", 

1262 ), 

1263 SCIMSchemaAttribute( 

1264 name="primary", 

1265 type="boolean", 

1266 description="Whether this is the primary email.", 

1267 ), 

1268 ], 

1269 ), 

1270 SCIMSchemaAttribute( 

1271 name="active", 

1272 type="boolean", 

1273 multiValued=False, 

1274 description="Whether the user account is active.", 

1275 ), 

1276 SCIMSchemaAttribute( 

1277 name="groups", 

1278 type="complex", 

1279 multiValued=True, 

1280 description="Groups to which the user belongs.", 

1281 mutability="readOnly", 

1282 subAttributes=[ 

1283 SCIMSchemaAttribute( 

1284 name="value", 

1285 type="string", 

1286 description="Group identifier.", 

1287 ), 

1288 SCIMSchemaAttribute( 

1289 name="display", 

1290 type="string", 

1291 description="Group display name.", 

1292 ), 

1293 ], 

1294 ), 

1295 SCIMSchemaAttribute( 

1296 name="entitlements", 

1297 type="complex", 

1298 multiValued=True, 

1299 description="A list of entitlements for the user.", 

1300 subAttributes=[ 

1301 SCIMSchemaAttribute( 

1302 name="value", 

1303 type="string", 

1304 description="The value of an entitlement.", 

1305 ), 

1306 SCIMSchemaAttribute( 

1307 name="display", 

1308 type="string", 

1309 description="A human-readable name for the entitlement.", 

1310 ), 

1311 SCIMSchemaAttribute( 

1312 name="type", 

1313 type="string", 

1314 description="A label indicating the entitlement's function.", 

1315 ), 

1316 SCIMSchemaAttribute( 

1317 name="primary", 

1318 type="boolean", 

1319 description="Whether this is the primary entitlement.", 

1320 ), 

1321 ], 

1322 ), 

1323 SCIMSchemaAttribute( 

1324 name="roles", 

1325 type="complex", 

1326 multiValued=True, 

1327 description="A list of roles for the user.", 

1328 subAttributes=[ 

1329 SCIMSchemaAttribute( 

1330 name="value", 

1331 type="string", 

1332 description="The value of a role.", 

1333 ), 

1334 SCIMSchemaAttribute( 

1335 name="display", 

1336 type="string", 

1337 description="A human-readable name for the role.", 

1338 ), 

1339 SCIMSchemaAttribute( 

1340 name="type", 

1341 type="string", 

1342 description="A label indicating the role's function.", 

1343 ), 

1344 SCIMSchemaAttribute( 

1345 name="primary", 

1346 type="boolean", 

1347 description="Whether this is the primary role.", 

1348 ), 

1349 ], 

1350 ), 

1351 ], 

1352 meta={ 

1353 "location": "/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", 

1354 "resourceType": "Schema", 

1355 }, 

1356 ), 

1357 SCIMSchema( 

1358 id="urn:ietf:params:scim:schemas:core:2.0:Group", 

1359 name="Group", 

1360 description="Group", 

1361 attributes=[ 

1362 SCIMSchemaAttribute( 

1363 name="displayName", 

1364 type="string", 

1365 multiValued=False, 

1366 description="A human-readable name for the Group.", 

1367 required=True, 

1368 mutability="readWrite", 

1369 returned="default", 

1370 uniqueness="none", 

1371 ), 

1372 SCIMSchemaAttribute( 

1373 name="members", 

1374 type="complex", 

1375 multiValued=True, 

1376 description="A list of members of the Group.", 

1377 subAttributes=[ 

1378 SCIMSchemaAttribute( 

1379 name="value", 

1380 type="string", 

1381 description="Member identifier.", 

1382 ), 

1383 SCIMSchemaAttribute( 

1384 name="display", 

1385 type="string", 

1386 description="Member display name.", 

1387 ), 

1388 SCIMSchemaAttribute( 

1389 name="type", 

1390 type="string", 

1391 description=( 

1392 'The type of member; canonical values are "User" and "Group". ' 

1393 "Only members of type User are honored, LiteLLM teams contain users only." 

1394 ), 

1395 ), 

1396 ], 

1397 ), 

1398 ], 

1399 meta={ 

1400 "location": "/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group", 

1401 "resourceType": "Schema", 

1402 }, 

1403 ), 

1404 ] 

1405 

1406 

1407@scim_router.get( 

1408 "", 

1409 status_code=200, 

1410 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1411) 

1412@scim_router.get( 

1413 "/", 

1414 status_code=200, 

1415 include_in_schema=False, 

1416 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1417) 

1418async def get_scim_base(request: Request): 

1419 """ 

1420 Base SCIM v2 endpoint for resource discovery per RFC 7644 Section 4. 

1421 

1422 Returns a ListResponse of ResourceTypes supported by this SCIM service provider. 

1423 Identity providers (Okta, Azure AD, etc.) use this endpoint for resource discovery. 

1424 """ 

1425 verbose_proxy_logger.debug( 

1426 "SCIM base resource discovery request: method=%s url=%s", 

1427 request.method, 

1428 request.url, 

1429 ) 

1430 base_url: Final = str(request.base_url).rstrip("/") + "/scim/v2" 

1431 resource_types: Final = _get_resource_types(base_url) 

1432 return { 

1433 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 

1434 "totalResults": len(resource_types), 

1435 "Resources": [rt.model_dump() for rt in resource_types], 

1436 } 

1437 

1438 

1439@scim_router.get( 

1440 "/ResourceTypes", 

1441 status_code=200, 

1442 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1443) 

1444async def get_resource_types(request: Request): 

1445 """ 

1446 SCIM ResourceTypes endpoint per RFC 7644 Section 4. 

1447 

1448 Returns a ListResponse of all resource types supported by this service provider. 

1449 """ 

1450 verbose_proxy_logger.debug( 

1451 "SCIM ResourceTypes request: method=%s url=%s", 

1452 request.method, 

1453 request.url, 

1454 ) 

1455 base_url: Final = str(request.base_url).rstrip("/") + "/scim/v2" 

1456 resource_types: Final = _get_resource_types(base_url) 

1457 return { 

1458 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 

1459 "totalResults": len(resource_types), 

1460 "Resources": [rt.model_dump() for rt in resource_types], 

1461 } 

1462 

1463 

1464@scim_router.get( 

1465 "/ResourceTypes/{resource_type_id}", 

1466 status_code=200, 

1467 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1468) 

1469async def get_resource_type( 

1470 request: Request, 

1471 resource_type_id: str = Path(..., title="ResourceType ID"), 

1472): 

1473 """ 

1474 Get a single ResourceType by ID per RFC 7644. 

1475 """ 

1476 verbose_proxy_logger.debug("SCIM ResourceType request for id=%s", resource_type_id) 

1477 base_url: Final = str(request.base_url).rstrip("/") + "/scim/v2" 

1478 resource_types: Final = _get_resource_types(base_url) 

1479 for rt in resource_types: 

1480 if rt.id == resource_type_id: 

1481 return rt.model_dump() 

1482 raise HTTPException( 

1483 status_code=404, 

1484 detail={"error": f"ResourceType not found: {resource_type_id}"}, 

1485 ) 

1486 

1487 

1488@scim_router.get( 

1489 "/Schemas", 

1490 status_code=200, 

1491 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1492) 

1493async def get_schemas(request: Request): 

1494 """ 

1495 SCIM Schemas endpoint per RFC 7643 Section 7. 

1496 

1497 Returns a ListResponse of all schemas supported by this service provider. 

1498 """ 

1499 verbose_proxy_logger.debug( 

1500 "SCIM Schemas request: method=%s url=%s", 

1501 request.method, 

1502 request.url, 

1503 ) 

1504 schemas: Final = _get_schemas() 

1505 return { 

1506 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], 

1507 "totalResults": len(schemas), 

1508 "Resources": [s.model_dump() for s in schemas], 

1509 } 

1510 

1511 

1512@scim_router.get( 

1513 "/Schemas/{schema_id:path}", 

1514 status_code=200, 

1515 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1516) 

1517async def get_schema( 

1518 request: Request, 

1519 schema_id: str = Path(..., title="Schema URI"), 

1520): 

1521 """ 

1522 Get a single Schema by its URI per RFC 7643 Section 7. 

1523 """ 

1524 verbose_proxy_logger.debug("SCIM Schema request for id=%s", schema_id) 

1525 schemas: Final = _get_schemas() 

1526 for s in schemas: 

1527 if s.id == schema_id: 

1528 return s.model_dump() 

1529 raise HTTPException( 

1530 status_code=404, 

1531 detail={"error": f"Schema not found: {schema_id}"}, 

1532 ) 

1533 

1534 

1535@scim_router.get( 

1536 "/ServiceProviderConfig", 

1537 response_model=SCIMServiceProviderConfig, 

1538 status_code=200, 

1539 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1540) 

1541async def get_service_provider_config(request: Request): 

1542 """Return SCIM Service Provider Configuration.""" 

1543 verbose_proxy_logger.debug( 

1544 "SCIM ServiceProviderConfig request: method=%s url=%s headers=%s", 

1545 request.method, 

1546 request.url, 

1547 _safe_get_request_headers(request), 

1548 ) 

1549 meta: Final = { 

1550 "resourceType": "ServiceProviderConfig", 

1551 "location": str(request.url), 

1552 } 

1553 return SCIMServiceProviderConfig(meta=meta) 

1554 

1555 

1556def _parse_scim_eq_filter(scim_filter: str) -> tuple[str, str] | None: 

1557 """Parse the SCIM equality filters Okta uses before user lifecycle changes.""" 

1558 match: Final = re.match( 

1559 r"""\s*([\w.]+)\s+eq\s+(['"]?)(.*?)\2\s*$""", 

1560 scim_filter, 

1561 flags=re.IGNORECASE, 

1562 ) 

1563 if not match: 

1564 return None 

1565 return match.group(1).lower(), match.group(3) 

1566 

1567 

1568# User Endpoints 

1569@scim_router.get( 

1570 "/Users", 

1571 response_model=SCIMListResponse, 

1572 status_code=200, 

1573 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1574) 

1575async def get_users( 

1576 startIndex: int = Query(1, ge=1), 

1577 count: int = Query(10, ge=0), 

1578 filter: str | None = Query(None), 

1579): 

1580 """ 

1581 Get a list of users according to SCIM v2 protocol 

1582 """ 

1583 page_size: Final = min(count, SCIM_MAX_PAGE_SIZE) 

1584 verbose_proxy_logger.debug( 

1585 "SCIM GET USERS request: startIndex=%s count=%s filter=%s", 

1586 startIndex, 

1587 count, 

1588 filter, 

1589 ) 

1590 try: 

1591 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1592 # Parse filter if provided (basic support) 

1593 where_conditions: Final[dict[str, object]] = {} 

1594 if filter: 

1595 # Okta locates users by userName before deprovisioning. LiteLLM 

1596 # exposes SCIM userName from user_email, while older SCIM-created 

1597 # users may still have user_id == userName, so support both. 

1598 parsed_filter: Final = _parse_scim_eq_filter(filter) 

1599 if parsed_filter: 

1600 filter_attribute, filter_value = parsed_filter 

1601 if filter_attribute == "username": 

1602 where_conditions["OR"] = [ 

1603 {"user_email": filter_value}, 

1604 {"user_id": filter_value}, 

1605 ] 

1606 elif filter_attribute == "emails.value": 

1607 where_conditions["user_email"] = filter_value 

1608 

1609 # Get users from database 

1610 users: Final[Sequence[LiteLLM_UserTable]] = await _table(UserRepository(prisma_client)).find_many( 

1611 where=where_conditions, 

1612 skip=(startIndex - 1), 

1613 take=page_size, 

1614 order={"created_at": "desc"}, 

1615 ) 

1616 

1617 # Get total count for pagination 

1618 total_count: Final = await _table(UserRepository(prisma_client)).count(where=where_conditions) 

1619 

1620 # Convert to SCIM format 

1621 scim_users: Final[list[SCIMUser]] = [] 

1622 for user in users: 

1623 scim_user = await ScimTransformations.transform_litellm_user_to_scim_user(user=user) 

1624 scim_users.append(scim_user) 

1625 

1626 return SCIMListResponse( 

1627 totalResults=total_count, 

1628 startIndex=startIndex, 

1629 itemsPerPage=len(scim_users), 

1630 Resources=scim_users, 

1631 ) 

1632 

1633 except Exception as e: 

1634 raise handle_exception_on_proxy(e) 

1635 

1636 

1637@scim_router.get( 

1638 "/Users/{user_id}", 

1639 response_model=SCIMUser, 

1640 status_code=200, 

1641 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1642) 

1643async def get_user( 

1644 user_id: str = Path(..., title="User ID"), 

1645): 

1646 """ 

1647 Get a single user by ID according to SCIM v2 protocol 

1648 """ 

1649 verbose_proxy_logger.debug("SCIM GET USER request for user_id=%s", user_id) 

1650 try: 

1651 user: Final = await _check_user_exists(user_id) 

1652 

1653 # Convert to SCIM format 

1654 scim_user: Final = await ScimTransformations.transform_litellm_user_to_scim_user(user) 

1655 return scim_user 

1656 

1657 except Exception as e: 

1658 raise handle_exception_on_proxy(e) 

1659 

1660 

1661@scim_router.post( 

1662 "/Users", 

1663 response_model=SCIMUser, 

1664 status_code=201, 

1665 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1666) 

1667async def create_user( 

1668 user: SCIMUser = Body(...), 

1669): 

1670 """ 

1671 Create a user according to SCIM v2 protocol 

1672 """ 

1673 try: 

1674 verbose_proxy_logger.debug("SCIM CREATE USER request: %s", user.model_dump()) 

1675 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1676 

1677 # Extract data from SCIM user 

1678 user_data: Final = _extract_scim_user_data(user) 

1679 

1680 # Check if user already exists 

1681 if user.userName: 

1682 existing_user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": user.userName}) 

1683 if existing_user: 

1684 raise HTTPException( 

1685 status_code=409, 

1686 detail={"error": f"User already exists with username: {user.userName}"}, 

1687 ) 

1688 

1689 # Create user in database 

1690 user_id: Final = user.userName or str(uuid.uuid4()) 

1691 metadata: Final = _build_scim_metadata( 

1692 user_data["given_name"], 

1693 user_data["family_name"], 

1694 enterprise=user_data["enterprise"], 

1695 entitlements=user_data["entitlements"], 

1696 roles=user_data["roles"], 

1697 ) 

1698 

1699 default_role: Final = _default_scim_user_role() 

1700 admin_group: Final = await _get_scim_admin_group() 

1701 resolved_role: Final = _resolve_scim_user_role(user.groups or [], admin_group, default_role) 

1702 

1703 new_user_request: Final = NewUserRequest( 

1704 user_id=user_id, 

1705 user_email=user_data["user_email"], 

1706 user_alias=user_data["user_alias"], 

1707 teams=user_data["teams"] or None, 

1708 metadata=metadata, 

1709 auto_create_key=False, 

1710 user_role=resolved_role if admin_group is not None else default_role, 

1711 ) 

1712 

1713 # Check if user with email already exists and update if found 

1714 existing_user_scim: Final = await UserProvisionerHelpers.handle_existing_user_by_email( 

1715 prisma_client=prisma_client, 

1716 new_user_request=new_user_request, 

1717 admin_group=admin_group, 

1718 ) 

1719 

1720 if existing_user_scim: 

1721 return existing_user_scim 

1722 

1723 created_user: Final = await new_user( 

1724 data=new_user_request, 

1725 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

1726 ) 

1727 

1728 scim_user: Final = await ScimTransformations.transform_litellm_user_to_scim_user(user=created_user) 

1729 return scim_user 

1730 except HTTPException as e: # allow exceptions like SCIMUserAlreadyExists to be raised 

1731 raise e 

1732 except Exception as e: 

1733 raise handle_exception_on_proxy(e) 

1734 

1735 

1736@scim_router.put( 

1737 "/Users/{user_id}", 

1738 response_model=SCIMUser, 

1739 status_code=200, 

1740 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

1741) 

1742async def update_user( 

1743 user_id: str = Path(..., title="User ID"), 

1744 user: SCIMUser = Body(...), 

1745): 

1746 """ 

1747 Update a user according to SCIM v2 protocol (full replacement) 

1748 """ 

1749 verbose_proxy_logger.debug( 

1750 "SCIM PUT USER request for user_id=%s: %s", 

1751 user_id, 

1752 user.model_dump(), 

1753 ) 

1754 

1755 try: 

1756 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1757 existing_user: Final = await _check_user_exists(user_id) 

1758 

1759 prev_active: Final = _user_scim_active(existing_user) 

1760 

1761 user_data: Final = _extract_scim_user_data(user) 

1762 

1763 # SCIM PUT may legally omit `active` (full-replace with the field absent). 

1764 # Pydantic fills the model default, so distinguish "client sent active" 

1765 # from "client omitted it" via model_fields_set, and preserve the prior 

1766 # SCIM active state when omitted — otherwise a vanilla PUT to a 

1767 # deactivated user would silently re-enable them and unblock their keys. 

1768 client_set_active: Final = "active" in user.model_fields_set 

1769 scim_active_for_metadata: Final = user_data["active"] if client_set_active else prev_active 

1770 

1771 metadata: Final = _build_scim_metadata( 

1772 user_data["given_name"], 

1773 user_data["family_name"], 

1774 scim_active_for_metadata, 

1775 enterprise=user_data["enterprise"], 

1776 entitlements=user_data["entitlements"], 

1777 roles=user_data["roles"], 

1778 ) 

1779 

1780 # SCIM User.groups is readOnly (RFC 7643 4.1.2): IdPs sync membership via /Groups and send 

1781 # no groups or `groups: []` on profile PUTs, so empty means unspecified, not "remove from every team" 

1782 target_teams: Final = user_data["teams"] or existing_user.teams 

1783 await _handle_team_membership_changes( 

1784 user_id=user_id, 

1785 existing_teams=existing_user.teams, 

1786 new_teams=target_teams, 

1787 ) 

1788 

1789 update_data: Final = { 

1790 "user_email": user_data["user_email"], 

1791 "user_alias": user_data["user_alias"], 

1792 "sso_user_id": user_data["sso_user_id"], 

1793 "teams": target_teams, 

1794 "metadata": safe_dumps(metadata), 

1795 } 

1796 

1797 admin_group: Final = await _get_scim_admin_group() 

1798 if admin_group is not None and user_data["teams"]: 

1799 update_data["user_role"] = _resolve_scim_user_role( 

1800 user.groups or [], admin_group, _default_scim_user_role() 

1801 ) 

1802 

1803 updated_user: Final = await _table(UserRepository(prisma_client)).update( 

1804 where={"user_id": user_id}, 

1805 data=update_data, 

1806 ) 

1807 from litellm.proxy.proxy_server import user_api_key_cache 

1808 

1809 await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache) 

1810 

1811 if client_set_active: 

1812 new_active: Final = _scim_active_value(metadata) 

1813 if new_active is not None and new_active != (True if prev_active is None else prev_active): 

1814 await _set_user_keys_blocked(user_id=user_id, blocked=not new_active) 

1815 

1816 # Convert back to SCIM format 

1817 scim_user: Final = await ScimTransformations.transform_litellm_user_to_scim_user(updated_user) 

1818 

1819 return scim_user 

1820 

1821 except Exception as e: 

1822 raise handle_exception_on_proxy(e) 

1823 

1824 

1825@scim_router.delete( 

1826 "/Users/{user_id}", 

1827 status_code=204, 

1828 dependencies=[Depends(user_api_key_auth)], 

1829) 

1830async def delete_user( 

1831 user_id: str = Path(..., title="User ID"), 

1832): 

1833 """ 

1834 Delete a user according to SCIM v2 protocol 

1835 """ 

1836 verbose_proxy_logger.debug("SCIM DELETE USER request for user_id=%s", user_id) 

1837 try: 

1838 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1839 existing_user: Final = await _check_user_exists(user_id) 

1840 

1841 # Get teams user belongs to 

1842 found_teams: Final = tuple( 

1843 [ 

1844 await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id}) 

1845 for team_id in existing_user.teams or [] 

1846 ] 

1847 ) 

1848 teams: Final = tuple(team for team in found_teams if team) 

1849 

1850 # Remove user from all teams 

1851 for team in teams: 

1852 current_members: Sequence[str] = team.members or [] 

1853 if user_id in current_members: 

1854 new_members = [m for m in current_members if m != user_id] 

1855 await _table(TeamRepository(prisma_client)).update( 

1856 where={"team_id": team.team_id}, data={"members": new_members} 

1857 ) 

1858 

1859 team_row = LiteLLM_TeamTable.model_validate(team.model_dump()) 

1860 if any(member.user_id == user_id for member in team_row.members_with_roles or []): 

1861 await team_member_delete( 

1862 data=TeamMemberDeleteRequest(team_id=team_row.team_id, user_id=user_id), 

1863 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

1864 ) 

1865 

1866 await _set_user_keys_blocked(user_id=user_id, blocked=True) 

1867 

1868 await _delete_rows_referencing_user(prisma_client, user_id=user_id) 

1869 

1870 # Delete user 

1871 await _table(UserRepository(prisma_client)).delete(where={"user_id": user_id}) 

1872 

1873 from litellm.proxy.proxy_server import user_api_key_cache 

1874 

1875 await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache) 

1876 

1877 return Response(status_code=204) 

1878 except Exception as e: 

1879 raise handle_exception_on_proxy(e) 

1880 

1881 

1882@scim_router.get( 

1883 "/placeholders", 

1884 response_model=tuple[SCIMPlaceholder, ...], 

1885 dependencies=(Depends(user_api_key_auth),), 

1886) 

1887async def list_placeholders() -> tuple[SCIMPlaceholder, ...]: 

1888 """ 

1889 List user rows whose id is another account's SSO identity or email. 

1890 

1891 An earlier release provisioned a group member it could not match as a user keyed 

1892 by the raw member value, and that row now shadows the account the value really 

1893 names, so every push of that member is refused. This lists those rows so an 

1894 operator can fold each one into the account it shadows with 

1895 ``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of 

1896 its own or owns virtual keys is left out: someone uses that account. 

1897 """ 

1898 try: 

1899 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1900 async with prisma_client.tx() as tx: 

1901 return await UserRepository(prisma_client).find_shadowing_placeholders(tx) 

1902 except Exception as e: 

1903 raise handle_exception_on_proxy(e) 

1904 

1905 

1906def _placeholder_rejection(placeholder: LiteLLM_UserTable, resolved: tuple[str, ...], key_count: int) -> str | None: 

1907 if placeholder.sso_user_id is not None: 

1908 return f"User '{placeholder.user_id}' has an SSO identity of its own, so it is an account someone signs in to" 

1909 if key_count: 

1910 return f"User '{placeholder.user_id}' owns {key_count} virtual keys. Move or delete them before merging it" 

1911 if not resolved: 

1912 return f"User '{placeholder.user_id}' shadows no account: no other user has that id as SSO identity or email" 

1913 if len(resolved) > 1: 

1914 return ( 

1915 f"User '{placeholder.user_id}' names {len(resolved)} accounts ({', '.join(resolved)}). Resolve that first" 

1916 ) 

1917 return None 

1918 

1919 

1920@scim_router.post( 

1921 "/placeholders/{user_id}/merge", 

1922 response_model=SCIMPlaceholderMergeResult, 

1923 dependencies=(Depends(user_api_key_auth),), 

1924) 

1925async def merge_placeholder( 

1926 user_id: str = Path(..., title="User ID"), 

1927) -> SCIMPlaceholderMergeResult: 

1928 """ 

1929 Fold a placeholder user into the one account its id names by SSO identity or email. 

1930 

1931 The account is added to every team the placeholder is on, then the placeholder is 

1932 deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group 

1933 push resolves the member value to the real account. Refused with 409 when the row 

1934 has an SSO identity of its own, owns virtual keys, or names no account or several. 

1935 """ 

1936 try: 

1937 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

1938 placeholder: Final = await _check_user_exists(user_id) 

1939 resolved: Final = tuple( 

1940 other for other in await _users_named_by_member_value(user_id, prisma_client, take=None) if other != user_id 

1941 ) 

1942 owned_keys: Final[_UserIdWhere] = {"user_id": user_id} 

1943 keys: Final = await _table(VerificationTokenRepository(prisma_client)).find_many(where=owned_keys) 

1944 rejection: Final = _placeholder_rejection(placeholder, resolved, len(keys)) 

1945 if rejection is not None: 

1946 detail: Final[_ScimErrorDetail] = {"error": rejection} 

1947 raise HTTPException(status_code=409, detail=detail) 

1948 

1949 target_user_id: Final = resolved[0] 

1950 team_ids: Final = tuple(placeholder.teams) 

1951 for team_id in team_ids: 

1952 await _add_user_to_team(user_id=target_user_id, team_id=team_id) 

1953 await delete_user(user_id=user_id) 

1954 await _recompute_scim_member_roles(prisma_client, (target_user_id,)) 

1955 verbose_proxy_logger.info( 

1956 "SCIM: merged placeholder user '%s' into '%s', moving teams %s", user_id, target_user_id, team_ids 

1957 ) 

1958 return SCIMPlaceholderMergeResult( 

1959 placeholder_user_id=user_id, merged_into_user_id=target_user_id, team_ids=team_ids 

1960 ) 

1961 except Exception as e: 

1962 raise handle_exception_on_proxy(e) 

1963 

1964 

1965def _parse_member_entry(entry: object) -> SCIMMember | None: 

1966 """Parse one entry of a SCIM patch value, or None when it carries no id.""" 

1967 if isinstance(entry, str): 

1968 return SCIMMember(value=entry) 

1969 

1970 fields: Final = _json_object_fields(entry) 

1971 if fields is None: 

1972 return None 

1973 

1974 entry_value: Final = fields.get("value") 

1975 if not entry_value: 

1976 return None 

1977 

1978 entry_display: Final = fields.get("display") 

1979 entry_type: Final = fields.get("type") 

1980 return SCIMMember( 

1981 value=str(entry_value), 

1982 display=str(entry_display) if entry_display is not None else None, 

1983 type=entry_type if isinstance(entry_type, str) else None, 

1984 ) 

1985 

1986 

1987def _parse_member_entries(value: object) -> tuple[SCIMMember, ...]: 

1988 """Parse a SCIM patch value into members, keeping each entry's ``type``. 

1989 

1990 PATCH bodies bypass SCIMGroup parsing (SCIMPatchOperation.value is untyped), 

1991 so member objects arrive as raw dicts and the ``type`` that marks a nested 

1992 group would otherwise be lost. 

1993 """ 

1994 entries: Final[tuple[object, ...]] = tuple(value) if isinstance(value, list) else (value,) 

1995 return tuple(member for member in (_parse_member_entry(entry) for entry in entries) if member is not None) 

1996 

1997 

1998def _extract_group_values(value: object) -> list[str]: 

1999 """Return group ids from a SCIM patch value.""" 

2000 return [member.value for member in _parse_member_entries(value)] 

2001 

2002 

2003def _extract_ids_from_path_filter(path: str | None, attribute: str) -> list[str]: 

2004 """Return ids from a SCIM filtered path like ``members[value eq "id"]``. 

2005 

2006 Okta commonly sends membership removals as a filtered path and omits the 

2007 request body ``value``, so the id lives only inside the ``[value eq "..."]`` 

2008 filter. The ``eq`` operator is matched case-insensitively per the SCIM 

2009 spec; the id keeps its original case. Per the SCIM filter grammar the 

2010 compared value must be quoted (single or double), so malformed unquoted 

2011 filters yield no id. A quoted id may contain escaped quotes and 

2012 backslashes (``\\"`` and ``\\\\``), which are unescaped before use. 

2013 ``path`` must be the raw, case-preserving path from the patch op. 

2014 """ 

2015 if not path: 

2016 return [] 

2017 match: Final = re.match( 

2018 rf"""\s*{re.escape(attribute)}\s*\[\s*value\s+eq\s+(['"])((?:\\.|[^\\])*?)\1\s*\]\s*$""", 

2019 path, 

2020 flags=re.IGNORECASE, 

2021 ) 

2022 if not match: 

2023 return [] 

2024 extracted: Final = re.sub(r"\\(.)", r"\1", match.group(2)) 

2025 return [extracted] if extracted else [] 

2026 

2027 

2028def _handle_displayname_update(op_type: str, value: object, update_data: dict[str, object]) -> None: 

2029 """Handle displayname updates.""" 

2030 if op_type == "remove": 

2031 update_data["user_alias"] = None 

2032 else: 

2033 update_data["user_alias"] = str(value) 

2034 

2035 

2036def _handle_externalid_update(op_type: str, value: object, update_data: dict[str, object]) -> None: 

2037 """Handle externalid updates.""" 

2038 if op_type == "remove": 

2039 update_data["sso_user_id"] = None 

2040 else: 

2041 update_data["sso_user_id"] = str(value) 

2042 

2043 

2044def _handle_active_update(op_type: str, value: object, metadata: dict[str, object]) -> None: 

2045 """Handle active status updates.""" 

2046 if op_type == "remove": 

2047 metadata.pop("scim_active", None) 

2048 else: 

2049 bool_val = value 

2050 if isinstance(value, str): 

2051 bool_val = value.lower() == "true" 

2052 else: 

2053 bool_val = bool(value) 

2054 metadata["scim_active"] = bool_val 

2055 

2056 

2057def _handle_name_update(path: str, op_type: str, value: object, scim_metadata: dict[str, object]) -> None: 

2058 """Handle name field updates (givenName, familyName).""" 

2059 if path == "name.givenname": 

2060 if op_type == "remove": 

2061 scim_metadata.pop("givenName", None) 

2062 else: 

2063 scim_metadata["givenName"] = str(value) 

2064 elif path == "name.familyname": 

2065 if op_type == "remove": 

2066 scim_metadata.pop("familyName", None) 

2067 else: 

2068 scim_metadata["familyName"] = str(value) 

2069 

2070 

2071def _handle_group_operations(op_type: str, value: object, teams_set: set[str], path: str | None) -> set[str] | None: 

2072 """Handle group/team membership operations.""" 

2073 group_values = _extract_group_values(value) 

2074 if not group_values and value is None: 

2075 group_values = _extract_ids_from_path_filter(path, "groups") 

2076 if op_type == "replace": 

2077 return set(group_values) 

2078 elif op_type == "add": 

2079 teams_set.update(group_values) 

2080 elif op_type == "remove": 

2081 for gid in group_values: 

2082 teams_set.discard(gid) 

2083 return None 

2084 

2085 

2086def _multi_valued_attribute_base(path: str) -> str: 

2087 """The attribute name a SCIM path targets, stripped of any value filter or sub-attribute.""" 

2088 return path.split("[", 1)[0].split(".", 1)[0] 

2089 

2090 

2091def _handle_multi_valued_attribute_update(path: str, op_type: str, value: object, metadata: dict[str, object]) -> None: 

2092 """Handle add/replace/remove for the entitlements and roles multi-valued attributes.""" 

2093 base: Final = _multi_valued_attribute_base(path) 

2094 metadata_key: Final = SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS[base] 

2095 if path != base: 

2096 raise HTTPException( 

2097 status_code=400, 

2098 detail={"error": f"Filtered or sub-attribute paths are not supported for {base}; PATCH the full attribute"}, 

2099 ) 

2100 

2101 if op_type == "remove": 

2102 metadata.pop(metadata_key, None) 

2103 return 

2104 

2105 if value is None: 

2106 raise HTTPException( 

2107 status_code=400, 

2108 detail={"error": f"The {op_type} operation on {base} requires a 'value' member (RFC 7644 Section 3.5.2)"}, 

2109 ) 

2110 

2111 normalized: Final = value if isinstance(value, list) else [value] 

2112 try: 

2113 attrs: Final = SCIM_MULTI_VALUED_LIST_ADAPTER.validate_python(normalized) 

2114 except ValidationError: 

2115 raise HTTPException( 

2116 status_code=400, 

2117 detail={"error": f"Invalid value for {base}: expected a list of objects or strings"}, 

2118 ) 

2119 

2120 dumped: Final = [attr.model_dump(exclude_none=True) for attr in attrs] 

2121 existing: Final = metadata.get(metadata_key) 

2122 if op_type == "add" and isinstance(existing, list): 

2123 metadata[metadata_key] = existing + dumped 

2124 return 

2125 metadata[metadata_key] = dumped 

2126 

2127 

2128def _handle_generic_metadata(path: str, op_type: str, value: object, metadata: dict[str, object]) -> None: 

2129 """Handle generic metadata operations for unknown paths.""" 

2130 if op_type == "remove": 

2131 metadata.pop(path, None) 

2132 else: 

2133 metadata[path] = value 

2134 

2135 

2136def _apply_patch_ops( 

2137 existing_user: LiteLLM_UserTable, 

2138 patch_ops: SCIMPatchOp, 

2139) -> tuple[dict[str, object], set[str]]: 

2140 """Apply patch operations and return update data and final team set.""" 

2141 update_data: Final[dict[str, object]] = {} 

2142 metadata: Final = existing_user.metadata or {} 

2143 scim_metadata: Final = metadata.get("scim_metadata", {}) 

2144 

2145 teams_set: Final[set[str]] = set(existing_user.teams or []) 

2146 replace_team_set: set[str] | None = None 

2147 

2148 for op in patch_ops.Operations: 

2149 path = (op.path or "").lower() 

2150 value = op.value 

2151 op_type = op.op 

2152 

2153 # Handle SCIM operations without path where value contains the fields 

2154 if not path and isinstance(value, dict): 

2155 for key, val in value.items(): 

2156 key_lower = key.lower() 

2157 if key_lower == "active": 

2158 _handle_active_update(op_type, val, metadata) 

2159 elif key_lower == "displayname": 

2160 _handle_displayname_update(op_type, val, update_data) 

2161 elif key_lower == "externalid": 

2162 _handle_externalid_update(op_type, val, update_data) 

2163 elif key_lower in SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS: 

2164 _handle_multi_valued_attribute_update(key_lower, op_type, val, metadata) 

2165 elif key_lower == "name" and isinstance(val, dict): 

2166 for name_key, name_val in val.items(): 

2167 name_key_lower = name_key.lower() 

2168 if name_key_lower in ("givenname", "familyname"): 

2169 _handle_name_update( 

2170 f"name.{name_key_lower}", 

2171 op_type, 

2172 name_val, 

2173 scim_metadata, 

2174 ) 

2175 continue 

2176 

2177 if path == "displayname": 

2178 _handle_displayname_update(op_type, value, update_data) 

2179 elif path == "externalid": 

2180 _handle_externalid_update(op_type, value, update_data) 

2181 elif path == "active": 

2182 _handle_active_update(op_type, value, metadata) 

2183 elif path in ("name.givenname", "name.familyname"): 

2184 _handle_name_update(path, op_type, value, scim_metadata) 

2185 elif _multi_valued_attribute_base(path) in SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS: 

2186 _handle_multi_valued_attribute_update(path, op_type, value, metadata) 

2187 elif path.startswith("groups"): 

2188 new_replace_set = _handle_group_operations(op_type, value, teams_set, op.path) 

2189 if new_replace_set is not None: 

2190 replace_team_set = new_replace_set 

2191 else: 

2192 _handle_generic_metadata(path, op_type, value, metadata) 

2193 

2194 final_team_set: Final = replace_team_set if replace_team_set is not None else teams_set 

2195 metadata["scim_metadata"] = scim_metadata 

2196 update_data["metadata"] = metadata 

2197 return update_data, final_team_set 

2198 

2199 

2200def _is_user_not_in_team_error(exc: HTTPException) -> bool: 

2201 """True when team_member_delete reports the user was already absent from the 

2202 team, which is the idempotent no-op case for a removal.""" 

2203 detail: Final = exc.detail 

2204 return isinstance(detail, dict) and detail.get("error") == "User not found in team" 

2205 

2206 

2207@dataclass(frozen=True, slots=True) 

2208class RosterWriteFailure: 

2209 description: str 

2210 status_code: int 

2211 

2212 

2213def _roster_write_status(exc: Exception) -> int: 

2214 if isinstance(exc, HTTPException): 

2215 return exc.status_code 

2216 if isinstance(exc, ProxyException): 

2217 return int(exc.code) if exc.code.isdigit() else 500 

2218 return 500 

2219 

2220 

2221class SCIMRosterSyncError(Exception): 

2222 """Every roster write in the batch was attempted; these are the ones that did not land. 

2223 

2224 Rolling the successful ones back is not safe, since the compensating write can fail 

2225 too and can strip a membership that pre-dated the push. Naming the exact failures 

2226 instead lets the IdP's next push, which is idempotent, close the gap. handle_exception_on_proxy 

2227 reads ``status_code`` off this, so a unanimous failure keeps its own status and a mixed 

2228 batch reports 500. 

2229 """ 

2230 

2231 def __init__(self, failures: tuple[RosterWriteFailure, ...], attempted: int) -> None: 

2232 statuses: Final = frozenset(failure.status_code for failure in failures) 

2233 self.failures: Final[tuple[RosterWriteFailure, ...]] = failures 

2234 self.status_code: Final[int] = next(iter(statuses)) if len(statuses) == 1 else 500 

2235 super().__init__( 

2236 f"SCIM roster sync failed on {len(failures)} of {attempted} team membership writes, " 

2237 f"leaving the roster partially updated. Retry the push to reconcile it. " 

2238 f"Failed writes: {'; '.join(failure.description for failure in failures)}" 

2239 ) 

2240 

2241 

2242async def _attempt_roster_write(label: str, write: Callable[[], Awaitable[object]]) -> tuple[RosterWriteFailure, ...]: 

2243 """Run one roster write and return what failed, so the caller can keep going.""" 

2244 try: 

2245 await write() 

2246 except SCIMRosterSyncError as e: 

2247 return e.failures 

2248 except Exception as e: # noqa: BLE001 # this boundary turns any write failure into a value so the batch continues 

2249 verbose_proxy_logger.exception("SCIM roster write failed (%s): %s", label, e) 

2250 return (RosterWriteFailure(description=f"{label}: {e}", status_code=_roster_write_status(e)),) 

2251 return () 

2252 

2253 

2254async def _collect_roster_write_failures( 

2255 writes: Sequence[tuple[str, Callable[[], Awaitable[object]]]], 

2256) -> tuple[RosterWriteFailure, ...]: 

2257 per_write: Final = tuple([await _attempt_roster_write(label, write) for label, write in writes]) 

2258 return tuple(chain.from_iterable(per_write)) 

2259 

2260 

2261async def _add_user_to_team(user_id: str, team_id: str) -> None: 

2262 try: 

2263 await team_member_add( 

2264 data=TeamMemberAddRequest( 

2265 team_id=team_id, 

2266 member=Member(user_id=user_id, role="user"), 

2267 ), 

2268 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

2269 ) 

2270 except ProxyException as e: 

2271 if e.type != ProxyErrorTypes.team_member_already_in_team: 

2272 raise 

2273 verbose_proxy_logger.debug("User %s is already in team %s, skipping add", user_id, team_id) 

2274 

2275 

2276async def _remove_user_from_team(user_id: str, team_id: str) -> None: 

2277 try: 

2278 await team_member_delete( 

2279 data=TeamMemberDeleteRequest(team_id=team_id, user_id=user_id), 

2280 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

2281 ) 

2282 except HTTPException as e: 

2283 if not _is_user_not_in_team_error(e): 

2284 raise 

2285 verbose_proxy_logger.debug("User %s is not in team %s, skipping remove", user_id, team_id) 

2286 

2287 

2288async def patch_team_membership( 

2289 user_id: str, 

2290 teams_ids_to_add_user_to: list[str], 

2291 teams_ids_to_remove_user_from: list[str], 

2292 raise_on_error: bool = False, 

2293) -> bool: 

2294 """ 

2295 Add or remove user from teams 

2296 

2297 Handles duplicate membership gracefully (idempotent operation). 

2298 A user already being in a team (on add) or already absent from it (on 

2299 remove) is treated as a no-op, not an error. 

2300 

2301 Every team is attempted before anything is reported, so one failing team cannot 

2302 strand the others unattempted. When ``raise_on_error`` is True the writes that did 

2303 not land are reported together, instead of a teams array the roster never received 

2304 being persisted as a success. 

2305 """ 

2306 writes: Final = tuple( 

2307 chain( 

2308 ( 

2309 (f"add {user_id} to {team_id}", partial(_add_user_to_team, user_id, team_id)) 

2310 for team_id in teams_ids_to_add_user_to 

2311 ), 

2312 ( 

2313 (f"remove {user_id} from {team_id}", partial(_remove_user_from_team, user_id, team_id)) 

2314 for team_id in teams_ids_to_remove_user_from 

2315 ), 

2316 ) 

2317 ) 

2318 failures: Final = await _collect_roster_write_failures(writes) 

2319 if failures and raise_on_error: 

2320 raise SCIMRosterSyncError(failures, attempted=len(writes)) 

2321 

2322 return True 

2323 

2324 

2325@scim_router.patch( 

2326 "/Users/{user_id}", 

2327 response_model=SCIMUser, 

2328 status_code=200, 

2329 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2330) 

2331async def patch_user( 

2332 user_id: str = Path(..., title="User ID"), 

2333 patch_ops: SCIMPatchOp = Body(...), 

2334): 

2335 """ 

2336 Patch a user according to SCIM v2 protocol 

2337 """ 

2338 verbose_proxy_logger.debug( 

2339 "SCIM PATCH USER request for user_id=%s: %s", 

2340 user_id, 

2341 patch_ops.model_dump(), 

2342 ) 

2343 

2344 try: 

2345 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2346 existing_user: Final = await _check_user_exists(user_id) 

2347 

2348 prev_active: Final = _user_scim_active(existing_user) 

2349 

2350 update_data, final_team_set = _apply_patch_ops( 

2351 existing_user=existing_user, 

2352 patch_ops=patch_ops, 

2353 ) 

2354 

2355 patched_metadata: Final = update_data.get("metadata") 

2356 new_active: Final = _scim_active_value(patched_metadata if isinstance(patched_metadata, Mapping) else None) 

2357 

2358 # Handle team membership changes 

2359 await _handle_team_membership_changes( 

2360 user_id=user_id, 

2361 existing_teams=existing_user.teams or [], 

2362 new_teams=list(final_team_set), 

2363 ) 

2364 

2365 update_data["teams"] = list(final_team_set) 

2366 

2367 admin_group: Final = await _get_scim_admin_group() 

2368 if admin_group is not None: 

2369 update_data["user_role"] = _resolve_scim_user_role( 

2370 await _scim_groups_from_team_ids(prisma_client, list(final_team_set)), 

2371 admin_group, 

2372 _default_scim_user_role(), 

2373 ) 

2374 

2375 # Serialize metadata to JSON string for Prisma to avoid GraphQL parsing issues 

2376 if "metadata" in update_data and isinstance(update_data["metadata"], dict): 

2377 from litellm.litellm_core_utils.safe_json_dumps import safe_dumps 

2378 

2379 update_data["metadata"] = safe_dumps(update_data["metadata"]) 

2380 

2381 updated_user: Final = await _table(UserRepository(prisma_client)).update( 

2382 where={"user_id": user_id}, 

2383 data=update_data, 

2384 ) 

2385 from litellm.proxy.proxy_server import user_api_key_cache 

2386 

2387 await evict_and_broadcast(cache_keys=(user_id,), user_api_key_cache=user_api_key_cache) 

2388 

2389 if new_active is not None and new_active != (True if prev_active is None else prev_active): 

2390 await _set_user_keys_blocked(user_id=user_id, blocked=not new_active) 

2391 

2392 scim_user: Final = await ScimTransformations.transform_litellm_user_to_scim_user(updated_user) 

2393 

2394 return scim_user 

2395 

2396 except Exception as e: 

2397 raise handle_exception_on_proxy(e) 

2398 

2399 

2400class _TeamWhereConditions(TypedDict, total=False): 

2401 """The team columns SCIM GET /Groups can filter on, as Prisma where-conditions.""" 

2402 

2403 team_alias: str 

2404 

2405 

2406# Group Endpoints 

2407@scim_router.get( 

2408 "/Groups", 

2409 response_model=SCIMListResponse, 

2410 status_code=200, 

2411 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2412) 

2413async def get_groups( 

2414 startIndex: int = Query(1, ge=1), 

2415 count: int = Query(10, ge=0), 

2416 filter: str | None = Query(None), 

2417): 

2418 """ 

2419 Get a list of groups according to SCIM v2 protocol 

2420 """ 

2421 page_size: Final = min(count, SCIM_MAX_PAGE_SIZE) 

2422 verbose_proxy_logger.debug( 

2423 "SCIM GET GROUPS request: startIndex=%s count=%s filter=%s", 

2424 startIndex, 

2425 count, 

2426 filter, 

2427 ) 

2428 try: 

2429 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2430 # Parse filter if provided (basic support) 

2431 where_conditions: Final[_TeamWhereConditions] = {} 

2432 if filter: 

2433 # Very basic filter support - only handling displayName eq 

2434 if "displayName eq" in filter: 

2435 team_alias = filter.split("displayName eq ")[1].strip("\"'") 

2436 where_conditions["team_alias"] = team_alias 

2437 

2438 # Get teams from database 

2439 teams: Final = await _table(TeamRepository(prisma_client)).find_many( 

2440 where=where_conditions, 

2441 skip=(startIndex - 1), 

2442 take=page_size, 

2443 order={"created_at": "desc"}, 

2444 ) 

2445 

2446 # Get total count for pagination 

2447 total_count: Final = await _table(TeamRepository(prisma_client)).count(where=where_conditions) 

2448 

2449 # Convert to SCIM format 

2450 scim_groups: Final[list[SCIMGroup]] = [] 

2451 for team in teams: 

2452 # Get team members with display names. members_with_roles is the 

2453 # source of truth; the legacy `members` column is not populated by 

2454 # team creation, so reading it here would report an empty member 

2455 # list to the IdP and trigger repeated re-provisioning. 

2456 members = await _get_team_members_display(await _get_team_member_user_ids_from_team(team)) 

2457 verbose_proxy_logger.debug("SCIM GET GROUPS members: %s", members) 

2458 team_alias = getattr(team, "team_alias", team.team_id) 

2459 team_created_at = team.created_at.isoformat() if team.created_at else None 

2460 team_updated_at = team.updated_at.isoformat() if team.updated_at else None 

2461 

2462 scim_group = SCIMGroup( 

2463 schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], 

2464 id=team.team_id, 

2465 displayName=team_alias, 

2466 members=members, 

2467 meta={ 

2468 "resourceType": "Group", 

2469 "created": team_created_at, 

2470 "lastModified": team_updated_at, 

2471 }, 

2472 ) 

2473 scim_groups.append(scim_group) 

2474 

2475 verbose_proxy_logger.debug("SCIM GET GROUPS response: %s", scim_groups) 

2476 return SCIMListResponse( 

2477 totalResults=total_count, 

2478 startIndex=startIndex, 

2479 itemsPerPage=len(scim_groups), 

2480 Resources=scim_groups, 

2481 ) 

2482 

2483 except Exception as e: 

2484 raise handle_exception_on_proxy(e) 

2485 

2486 

2487@scim_router.get( 

2488 "/Groups/{group_id}", 

2489 response_model=SCIMGroup, 

2490 status_code=200, 

2491 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2492) 

2493async def get_group( 

2494 group_id: str = Path(..., title="Group ID"), 

2495): 

2496 """ 

2497 Get a single group by ID according to SCIM v2 protocol 

2498 """ 

2499 verbose_proxy_logger.debug("SCIM GET GROUP request for group_id=%s", group_id) 

2500 try: 

2501 team: Final = await _check_team_exists(group_id) 

2502 

2503 scim_group: Final = await ScimTransformations.transform_litellm_team_to_scim_group(team) 

2504 verbose_proxy_logger.debug("SCIM GET GROUP response: %s", scim_group) 

2505 return scim_group 

2506 

2507 except Exception as e: 

2508 raise handle_exception_on_proxy(e) 

2509 

2510 

2511def _new_team_request_with_defaults( 

2512 team_id: str, 

2513 team_alias: str | None, 

2514 members_with_roles: Sequence[Member], 

2515) -> NewTeamRequest: 

2516 """Build the SCIM group's team request, applying litellm.default_team_params 

2517 (including models) the same way SSO auto-created teams do.""" 

2518 default_params: Final = litellm.default_team_params 

2519 defaults: Final[Mapping[str, object]] = ( 

2520 deepcopy(default_params) 

2521 if isinstance(default_params, dict) 

2522 else default_params.model_dump(exclude_none=True) 

2523 if default_params is not None 

2524 else {} 

2525 ) 

2526 default_metadata: Final = defaults.get("metadata") 

2527 metadata: Final = { 

2528 **(default_metadata if isinstance(default_metadata, dict) else {}), 

2529 SCIM_MANAGED_TEAM_METADATA_KEY: True, 

2530 } 

2531 return NewTeamRequest.model_validate( 

2532 { 

2533 **defaults, 

2534 "team_id": team_id, 

2535 "team_alias": team_alias, 

2536 "members_with_roles": members_with_roles, 

2537 "metadata": metadata, 

2538 } 

2539 ) 

2540 

2541 

2542@scim_router.post( 

2543 "/Groups", 

2544 response_model=SCIMGroup, 

2545 status_code=201, 

2546 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2547) 

2548async def create_group( 

2549 group: SCIMGroup = Body(...), 

2550): 

2551 """ 

2552 Create a group according to SCIM v2 protocol 

2553 """ 

2554 verbose_proxy_logger.debug( 

2555 "SCIM CREATE GROUP request: %s", 

2556 group.model_dump(), 

2557 ) 

2558 try: 

2559 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2560 

2561 # Generate ID if not provided 

2562 team_id: Final = group.id or group.externalId or str(uuid.uuid4()) 

2563 

2564 # Check if team already exists 

2565 existing_team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id}) 

2566 

2567 if existing_team: 

2568 raise HTTPException( 

2569 status_code=409, 

2570 detail={"error": f"Group already exists with ID: {team_id}"}, 

2571 ) 

2572 

2573 # Extract and validate group members (all users must exist) 

2574 member_result: Final = await _extract_group_member_ids(group) 

2575 members_with_roles = [Member(user_id=member_id, role="user") for member_id in member_result.all_member_ids] 

2576 

2577 # Create team in database 

2578 created_team: Final = await new_team( 

2579 data=_new_team_request_with_defaults( 

2580 team_id=team_id, 

2581 team_alias=group.displayName, 

2582 members_with_roles=members_with_roles, 

2583 ), 

2584 http_request=Request(scope={"type": "http", "path": "/scim/v2/Groups"}), 

2585 user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), 

2586 ) 

2587 

2588 await _recompute_scim_member_roles(prisma_client, member_result.all_member_ids) 

2589 

2590 scim_group: Final = await ScimTransformations.transform_litellm_team_to_scim_group(created_team) 

2591 return scim_group 

2592 except Exception as e: 

2593 raise handle_exception_on_proxy(e) 

2594 

2595 

2596@scim_router.put( 

2597 "/Groups/{group_id}", 

2598 response_model=SCIMGroup, 

2599 status_code=200, 

2600 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2601) 

2602async def update_group( 

2603 group_id: str = Path(..., title="Group ID"), 

2604 group: SCIMGroup = Body(...), 

2605): 

2606 """ 

2607 Update a group according to SCIM v2 protocol 

2608 """ 

2609 verbose_proxy_logger.debug( 

2610 "SCIM PUT GROUP request for group_id=%s: %s", 

2611 group_id, 

2612 group.model_dump(), 

2613 ) 

2614 try: 

2615 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2616 existing_team: Final = await _check_team_exists(group_id) 

2617 

2618 # Extract and validate group members (all users must exist) 

2619 member_result: Final = await _extract_group_member_ids(group) 

2620 verbose_proxy_logger.debug("SCIM PUT GROUP all_member_ids: %s", member_result.all_member_ids) 

2621 verbose_proxy_logger.debug("SCIM PUT GROUP created_users: %s", len(member_result.created_users)) 

2622 

2623 # Prepare update data 

2624 existing_metadata: Final = existing_team.metadata if existing_team.metadata else {} 

2625 updated_metadata: Final = { 

2626 **existing_metadata, 

2627 SCIM_TEAM_DATA_METADATA_KEY: group.model_dump(), 

2628 SCIM_MANAGED_TEAM_METADATA_KEY: True, 

2629 } 

2630 

2631 update_data: Final = { 

2632 "team_alias": group.displayName, 

2633 "metadata": safe_dumps(updated_metadata), 

2634 } 

2635 

2636 # Update team in database 

2637 updated_team: Final = await _table(TeamRepository(prisma_client)).update( 

2638 where={"team_id": group_id}, 

2639 data=update_data, 

2640 ) 

2641 

2642 # Handle user-team relationship changes 

2643 current_members: Final = set(await _get_team_member_user_ids_from_team(existing_team)) 

2644 verbose_proxy_logger.debug("SCIM PUT GROUP current_members: %s", current_members) 

2645 final_members: Final = set(member_result.all_member_ids) 

2646 verbose_proxy_logger.debug("SCIM PUT GROUP final_members: %s", final_members) 

2647 

2648 await _handle_group_membership_changes( 

2649 group_id=group_id, 

2650 current_members=current_members, 

2651 final_members=final_members, 

2652 ) 

2653 

2654 # A rename can flip whether this group matches scim_admin_group by display 

2655 # name, so retained members must be re-resolved too, not just the ones whose 

2656 # membership changed. 

2657 alias_changed: Final = existing_team.team_alias != group.displayName 

2658 await _recompute_scim_member_roles( 

2659 prisma_client, 

2660 (current_members | final_members if alias_changed else current_members ^ final_members), 

2661 ) 

2662 

2663 # Convert to SCIM format and return 

2664 scim_group: Final = await ScimTransformations.transform_litellm_team_to_scim_group(updated_team) 

2665 return scim_group 

2666 

2667 except Exception as e: 

2668 raise handle_exception_on_proxy(e) 

2669 

2670 

2671@scim_router.delete( 

2672 "/Groups/{group_id}", 

2673 status_code=204, 

2674 dependencies=[Depends(user_api_key_auth)], 

2675) 

2676async def delete_group( 

2677 group_id: str = Path(..., title="Group ID"), 

2678): 

2679 """ 

2680 Delete a group according to SCIM v2 protocol 

2681 """ 

2682 verbose_proxy_logger.debug("SCIM DELETE GROUP request for group_id=%s", group_id) 

2683 try: 

2684 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2685 existing_team: Final = await _check_team_exists(group_id) 

2686 

2687 member_ids: Final = await _get_team_member_user_ids_from_team(existing_team) 

2688 

2689 # For each member, remove this team from their teams list 

2690 for member_id in member_ids: 

2691 user = await _table(UserRepository(prisma_client)).find_unique(where={"user_id": member_id}) 

2692 if user: 

2693 current_teams = user.teams or [] 

2694 if group_id in current_teams: 

2695 new_teams = [t for t in current_teams if t != group_id] 

2696 await _table(UserRepository(prisma_client)).update( 

2697 where={"user_id": member_id}, data={"teams": new_teams} 

2698 ) 

2699 

2700 await _recompute_scim_member_roles(prisma_client, member_ids) 

2701 

2702 # Delete team 

2703 await _table(TeamRepository(prisma_client)).delete(where={"team_id": group_id}) 

2704 

2705 return Response(status_code=204) 

2706 

2707 except Exception as e: 

2708 raise handle_exception_on_proxy(e) 

2709 

2710 

2711async def _process_group_patch_operations( 

2712 patch_ops: SCIMPatchOp, existing_team: LiteLLM_TeamTable, prisma_client: PrismaClient 

2713) -> tuple[dict[str, object], set[str], set[str] | None]: 

2714 """Process patch operations for a group and return update data, final members 

2715 and, when the request contained a member ``replace`` op, the absolute target 

2716 roster it declared (``None`` otherwise). 

2717 

2718 ``add``/``remove`` are deltas relative to the current roster, but ``replace`` 

2719 is absolute: it declares the roster is exactly this set, so the caller must 

2720 reconcile against it as a set-to-target rather than rebasing it onto a 

2721 concurrently-mutated roster. 

2722 

2723 A ``remove`` drops the ids it names without resolving them first. Removal is 

2724 idempotent and cannot put anything on a roster, while resolving would make it 

2725 conditional on what the id turns out to be and leave members we should never 

2726 have admitted - the phantom users this endpoint used to create for nested 

2727 groups - impossible to clean up. 

2728 """ 

2729 update_data: Final[dict[str, object]] = {} 

2730 

2731 # Create a fresh copy of existing metadata to avoid Prisma issues 

2732 metadata: Final = {**(existing_team.metadata or {}), SCIM_MANAGED_TEAM_METADATA_KEY: True} 

2733 

2734 # Track member changes. members_with_roles is the source of truth for team 

2735 # membership; the legacy `members` column is not populated by team creation 

2736 # or the real team endpoints, so seeding from it would make an `add`/`remove` 

2737 # operation recompute the member set from an empty base and silently drop 

2738 # everyone already in the team. 

2739 current_members: Final = set(await _get_team_member_user_ids_from_team(existing_team)) 

2740 final_members = current_members.copy() 

2741 

2742 # Process each patch operation 

2743 for op in patch_ops.Operations: 

2744 path = (op.path or "").lower() 

2745 value = op.value 

2746 op_type = op.op 

2747 

2748 if path == "displayname": 

2749 if op_type == "remove": 

2750 update_data["team_alias"] = None 

2751 else: 

2752 update_data["team_alias"] = str(value) 

2753 elif path == "externalid": 

2754 if op_type == "remove": 

2755 metadata.pop("externalId", None) 

2756 else: 

2757 metadata["externalId"] = str(value) 

2758 elif path.startswith("members"): 

2759 # Handle member operations 

2760 patched_members = ( 

2761 _parse_member_entries(value) 

2762 if value is not None 

2763 else tuple( 

2764 SCIMMember(value=member_id) for member_id in _extract_ids_from_path_filter(op.path, "members") 

2765 ) 

2766 ) 

2767 

2768 if op_type == "remove": 

2769 final_members = final_members - await _member_ids_to_drop( 

2770 patched_members, frozenset(final_members), prisma_client 

2771 ) 

2772 else: 

2773 member_result = await _resolve_group_member_ids( 

2774 members=patched_members, 

2775 created_via="scim_group_patch", 

2776 prisma_client=prisma_client, 

2777 ) 

2778 if op_type == "replace": 

2779 final_members = set(member_result.all_member_ids) 

2780 elif op_type == "add": 

2781 final_members = final_members | set(member_result.all_member_ids) 

2782 else: 

2783 # Handle other generic metadata 

2784 if op_type == "remove": 

2785 metadata.pop(path, None) 

2786 else: 

2787 metadata[path] = value 

2788 

2789 update_data["metadata"] = metadata 

2790 

2791 member_replace_present: Final = any( 

2792 op.op == "replace" and (op.path or "").lower().startswith("members") for op in patch_ops.Operations 

2793 ) 

2794 replace_target: Final = set(final_members) if member_replace_present else None 

2795 

2796 return update_data, final_members, replace_target 

2797 

2798 

2799async def _apply_group_patch_updates(group_id: str, update_data: dict[str, object], prisma_client: PrismaClient): 

2800 """Apply the group's metadata/displayName patch updates to the database. 

2801 

2802 Membership itself is not written here; it is reconciled onto the source of 

2803 truth (members_with_roles and each member's user.teams) by 

2804 _handle_group_membership_changes via team_member_add/team_member_delete. 

2805 Writing the legacy `members` column here too would create a second, unread 

2806 copy of membership that could drift from the source of truth. 

2807 """ 

2808 if "metadata" in update_data and isinstance(update_data["metadata"], dict): 

2809 update_data["metadata"] = safe_dumps(update_data["metadata"]) 

2810 

2811 if update_data: 

2812 return await TeamRepository(prisma_client).table.update( 

2813 where={"team_id": group_id}, 

2814 data=update_data, 

2815 ) 

2816 return await TeamRepository(prisma_client).table.find_unique(where={"team_id": group_id}) 

2817 

2818 

2819async def _handle_group_membership_changes(group_id: str, current_members: set[str], final_members: set[str]) -> None: 

2820 """Reconcile the group roster, attempting every member before reporting failures. 

2821 

2822 Aborting on the first failure would leave the remaining members unattempted on top 

2823 of unrolled-back, so every member is written and the ones that failed are named for 

2824 the IdP's next push to reconcile. 

2825 """ 

2826 members_to_add: Final = sorted(final_members - current_members) 

2827 members_to_remove: Final = sorted(current_members - final_members) 

2828 

2829 verbose_proxy_logger.debug("members_to_add: %s", members_to_add) 

2830 verbose_proxy_logger.debug("members_to_remove: %s", members_to_remove) 

2831 

2832 writes: Final = tuple( 

2833 chain( 

2834 ( 

2835 ( 

2836 f"add {member_id} to {group_id}", 

2837 partial( 

2838 patch_team_membership, 

2839 user_id=member_id, 

2840 teams_ids_to_add_user_to=[group_id], 

2841 teams_ids_to_remove_user_from=[], 

2842 raise_on_error=True, 

2843 ), 

2844 ) 

2845 for member_id in members_to_add 

2846 ), 

2847 ( 

2848 ( 

2849 f"remove {member_id} from {group_id}", 

2850 partial( 

2851 patch_team_membership, 

2852 user_id=member_id, 

2853 teams_ids_to_add_user_to=[], 

2854 teams_ids_to_remove_user_from=[group_id], 

2855 raise_on_error=True, 

2856 ), 

2857 ) 

2858 for member_id in members_to_remove 

2859 ), 

2860 ) 

2861 ) 

2862 failures: Final = await _collect_roster_write_failures(writes) 

2863 if failures: 

2864 raise SCIMRosterSyncError(failures, attempted=len(writes)) 

2865 

2866 

2867@scim_router.patch( 

2868 "/Groups/{group_id}", 

2869 response_model=SCIMGroup, 

2870 status_code=200, 

2871 dependencies=[Depends(user_api_key_auth), Depends(set_scim_content_type)], 

2872) 

2873async def patch_group( 

2874 group_id: str = Path(..., title="Group ID"), 

2875 patch_ops: SCIMPatchOp = Body(...), 

2876): 

2877 """ 

2878 Patch a group according to SCIM v2 protocol 

2879 """ 

2880 verbose_proxy_logger.debug( 

2881 "SCIM PATCH GROUP request for group_id=%s: %s", 

2882 group_id, 

2883 patch_ops.model_dump(), 

2884 ) 

2885 

2886 try: 

2887 prisma_client: Final = await _get_prisma_client_or_raise_exception() 

2888 existing_team: Final = await _check_team_exists(group_id) 

2889 

2890 # Process patch operations 

2891 update_data, final_members, replace_target = await _process_group_patch_operations( 

2892 patch_ops, existing_team, prisma_client 

2893 ) 

2894 

2895 snapshot_members: Final = set(await _get_team_member_user_ids_from_team(existing_team)) 

2896 intended_add: Final = final_members - snapshot_members 

2897 intended_remove: Final = snapshot_members - final_members 

2898 

2899 # Apply the metadata/displayName updates to the database 

2900 updated_team = await _apply_group_patch_updates(group_id, update_data, prisma_client) 

2901 

2902 refreshed_team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": group_id}) 

2903 refreshed_current: Final = ( 

2904 set( 

2905 await _get_team_member_user_ids_from_team(LiteLLM_TeamTable.model_validate(refreshed_team.model_dump())) 

2906 ) 

2907 if refreshed_team 

2908 else snapshot_members 

2909 ) 

2910 

2911 effective_final: Final = ( 

2912 replace_target if replace_target is not None else (refreshed_current | intended_add) - intended_remove 

2913 ) 

2914 

2915 await _handle_group_membership_changes(group_id, refreshed_current, effective_final) 

2916 

2917 # A rename can flip whether this group matches scim_admin_group by display 

2918 # name, so retained members must be re-resolved too, not just the ones whose 

2919 # membership changed. 

2920 new_alias: Final = update_data.get("team_alias", existing_team.team_alias) 

2921 alias_changed: Final = new_alias != existing_team.team_alias 

2922 await _recompute_scim_member_roles( 

2923 prisma_client, 

2924 (refreshed_current | effective_final if alias_changed else refreshed_current ^ effective_final), 

2925 ) 

2926 

2927 # Refresh team one more time to get final state after membership changes 

2928 final_team: Final = await _table(TeamRepository(prisma_client)).find_unique(where={"team_id": group_id}) 

2929 if final_team: 

2930 updated_team = final_team 

2931 

2932 if updated_team is None: 

2933 raise HTTPException( 

2934 status_code=404, 

2935 detail={"error": f"Group not found with ID: {group_id}"}, # mutable-ok: FastAPI detail contract 

2936 ) 

2937 

2938 # Convert to SCIM format and return 

2939 scim_group: Final = await ScimTransformations.transform_litellm_team_to_scim_group( 

2940 LiteLLM_TeamTable.model_validate(updated_team.model_dump()) 

2941 ) 

2942 return scim_group 

2943 

2944 except Exception as e: 

2945 raise handle_exception_on_proxy(e)