Coverage for open_webui/routers/scim.py: 24%

490 statements  

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

1""" 

2Experimental SCIM 2.0 Implementation for Open WebUI 

3Provides System for Cross-domain Identity Management endpoints for users and groups 

4 

5NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change. 

6""" 

7 

8import hmac 

9import logging 

10import time 

11import uuid 

12from datetime import datetime, timezone 

13from typing import Any, Dict, List, Optional 

14 

15from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status 

16from fastapi.responses import JSONResponse 

17from open_webui.config import OAUTH_PROVIDERS 

18from open_webui.constants import ERROR_MESSAGES 

19from open_webui.events import EVENTS, publish_event 

20from open_webui.env import SCIM_AUTH_PROVIDER 

21from open_webui.internal.db import get_async_session 

22from open_webui.models.groups import GroupModel, Groups 

23from open_webui.models.users import UserModel, Users 

24from open_webui.utils.auth import ( 

25 decode_token, 

26 get_admin_user, 

27 get_current_user, 

28 get_verified_user, 

29) 

30from pydantic import BaseModel, ConfigDict, Field 

31from sqlalchemy.ext.asyncio import AsyncSession 

32 

33log = logging.getLogger(__name__) 

34 

35router = APIRouter() 

36 

37# SCIM 2.0 Schema URIs 

38SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User' 

39SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group' 

40SCIM_LIST_RESPONSE_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse' 

41SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error' 

42 

43# SCIM Resource Types 

44SCIM_RESOURCE_TYPE_USER = 'User' 

45SCIM_RESOURCE_TYPE_GROUP = 'Group' 

46 

47 

48def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None): 

49 """Create a SCIM-compliant error response""" 

50 error_body = { 

51 'schemas': [SCIM_ERROR_SCHEMA], 

52 'status': str(status_code), 

53 'detail': detail, 

54 } 

55 

56 if scim_type: 

57 error_body['scimType'] = scim_type 

58 elif status_code == 404: 

59 error_body['scimType'] = 'invalidValue' 

60 elif status_code == 409: 

61 error_body['scimType'] = 'uniqueness' 

62 elif status_code == 400: 

63 error_body['scimType'] = 'invalidSyntax' 

64 

65 return JSONResponse(status_code=status_code, content=error_body) 

66 

67 

68class SCIMError(BaseModel): 

69 """SCIM Error Response""" 

70 

71 schemas: List[str] = [SCIM_ERROR_SCHEMA] 

72 status: str 

73 scimType: Optional[str] = None 

74 detail: Optional[str] = None 

75 

76 

77class SCIMMeta(BaseModel): 

78 """SCIM Resource Metadata""" 

79 

80 resourceType: str 

81 created: str 

82 lastModified: str 

83 location: Optional[str] = None 

84 version: Optional[str] = None 

85 

86 

87class SCIMName(BaseModel): 

88 """SCIM User Name""" 

89 

90 formatted: Optional[str] = None 

91 familyName: Optional[str] = None 

92 givenName: Optional[str] = None 

93 middleName: Optional[str] = None 

94 honorificPrefix: Optional[str] = None 

95 honorificSuffix: Optional[str] = None 

96 

97 

98class SCIMEmail(BaseModel): 

99 """SCIM Email""" 

100 

101 value: str 

102 type: Optional[str] = 'work' 

103 primary: bool = True 

104 display: Optional[str] = None 

105 

106 

107class SCIMPhoto(BaseModel): 

108 """SCIM Photo""" 

109 

110 value: str 

111 type: Optional[str] = 'photo' 

112 primary: bool = True 

113 display: Optional[str] = None 

114 

115 

116class SCIMGroupMember(BaseModel): 

117 """SCIM Group Member""" 

118 

119 value: str # User ID 

120 ref: Optional[str] = Field(None, alias='$ref') 

121 type: Optional[str] = 'User' 

122 display: Optional[str] = None 

123 

124 

125class SCIMUser(BaseModel): 

126 """SCIM User Resource""" 

127 

128 model_config = ConfigDict(populate_by_name=True) 

129 

130 schemas: List[str] = [SCIM_USER_SCHEMA] 

131 id: str 

132 externalId: Optional[str] = None 

133 userName: str 

134 name: Optional[SCIMName] = None 

135 displayName: str 

136 emails: List[SCIMEmail] 

137 active: bool = True 

138 photos: Optional[List[SCIMPhoto]] = None 

139 groups: Optional[List[Dict[str, str]]] = None 

140 meta: SCIMMeta 

141 

142 

143class SCIMUserCreateRequest(BaseModel): 

144 """SCIM User Create Request""" 

145 

146 model_config = ConfigDict(populate_by_name=True) 

147 

148 schemas: List[str] = [SCIM_USER_SCHEMA] 

149 externalId: Optional[str] = None 

150 userName: str 

151 name: Optional[SCIMName] = None 

152 displayName: str 

153 emails: List[SCIMEmail] 

154 active: bool = True 

155 password: Optional[str] = None 

156 photos: Optional[List[SCIMPhoto]] = None 

157 

158 

159class SCIMUserUpdateRequest(BaseModel): 

160 """SCIM User Update Request""" 

161 

162 model_config = ConfigDict(populate_by_name=True) 

163 

164 schemas: List[str] = [SCIM_USER_SCHEMA] 

165 id: Optional[str] = None 

166 externalId: Optional[str] = None 

167 userName: Optional[str] = None 

168 name: Optional[SCIMName] = None 

169 displayName: Optional[str] = None 

170 emails: Optional[List[SCIMEmail]] = None 

171 active: Optional[bool] = None 

172 photos: Optional[List[SCIMPhoto]] = None 

173 

174 

175class SCIMGroup(BaseModel): 

176 """SCIM Group Resource""" 

177 

178 model_config = ConfigDict(populate_by_name=True) 

179 

180 schemas: List[str] = [SCIM_GROUP_SCHEMA] 

181 id: str 

182 displayName: str 

183 members: Optional[List[SCIMGroupMember]] = [] 

184 meta: SCIMMeta 

185 

186 

187class SCIMGroupCreateRequest(BaseModel): 

188 """SCIM Group Create Request""" 

189 

190 model_config = ConfigDict(populate_by_name=True) 

191 

192 schemas: List[str] = [SCIM_GROUP_SCHEMA] 

193 displayName: str 

194 members: Optional[List[SCIMGroupMember]] = [] 

195 

196 

197class SCIMGroupUpdateRequest(BaseModel): 

198 """SCIM Group Update Request""" 

199 

200 model_config = ConfigDict(populate_by_name=True) 

201 

202 schemas: List[str] = [SCIM_GROUP_SCHEMA] 

203 displayName: Optional[str] = None 

204 members: Optional[List[SCIMGroupMember]] = None 

205 

206 

207class SCIMListResponse(BaseModel): 

208 """SCIM List Response""" 

209 

210 schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA] 

211 totalResults: int 

212 itemsPerPage: int 

213 startIndex: int 

214 Resources: List[Any] 

215 

216 

217class SCIMPatchOperation(BaseModel): 

218 """SCIM Patch Operation""" 

219 

220 op: str # "add", "replace", "remove" 

221 path: Optional[str] = None 

222 value: Optional[Any] = None 

223 

224 

225class SCIMPatchRequest(BaseModel): 

226 """SCIM Patch Request""" 

227 

228 schemas: List[str] = ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] 

229 Operations: List[SCIMPatchOperation] 

230 

231 

232def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) -> bool: 

233 """ 

234 Verify SCIM authentication 

235 Checks for SCIM-specific bearer token configured in the system 

236 """ 

237 if not authorization: 

238 raise HTTPException( 

239 status_code=status.HTTP_401_UNAUTHORIZED, 

240 detail='Authorization header required', 

241 headers={'WWW-Authenticate': 'Bearer'}, 

242 ) 

243 

244 try: 

245 parts = authorization.split() 

246 if len(parts) != 2: 

247 raise HTTPException( 

248 status_code=status.HTTP_401_UNAUTHORIZED, 

249 detail='Invalid authorization format. Expected: Bearer <token>', 

250 ) 

251 

252 scheme, token = parts 

253 if scheme.lower() != 'bearer': 

254 raise HTTPException( 

255 status_code=status.HTTP_401_UNAUTHORIZED, 

256 detail='Invalid authentication scheme', 

257 ) 

258 

259 # Check if SCIM is enabled 

260 enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False) 

261 log.info('SCIM auth check - raw ENABLE_SCIM: %s, type: %s', enable_scim, type(enable_scim)) 

262 

263 if not enable_scim: 

264 raise HTTPException( 

265 status_code=status.HTTP_403_FORBIDDEN, 

266 detail='SCIM is not enabled', 

267 ) 

268 

269 # Verify the SCIM token 

270 scim_token = getattr(request.app.state, 'SCIM_TOKEN', None) 

271 log.debug('SCIM token configured: %s', bool(scim_token)) 

272 if not scim_token or not hmac.compare_digest(token, scim_token): 

273 raise HTTPException( 

274 status_code=status.HTTP_401_UNAUTHORIZED, 

275 detail='Invalid SCIM token', 

276 ) 

277 

278 return True 

279 except HTTPException: 

280 # Re-raise HTTP exceptions as-is 

281 raise 

282 except Exception as e: 

283 log.error(f'SCIM authentication error: {e}') 

284 import traceback 

285 

286 log.error(f'Traceback: {traceback.format_exc()}') 

287 raise HTTPException( 

288 status_code=status.HTTP_401_UNAUTHORIZED, 

289 detail='Authentication failed', 

290 ) 

291 

292 

293def get_external_id(user: UserModel) -> Optional[str]: 

294 """Extract externalId from a user's scim data. 

295 

296 Checks all stored provider entries and returns the first external_id found. 

297 """ 

298 if not user.scim: 

299 return None 

300 for provider_data in user.scim.values(): 

301 if isinstance(provider_data, dict) and 'external_id' in provider_data: 

302 return provider_data['external_id'] 

303 return None 

304 

305 

306def get_scim_provider() -> str: 

307 """Return the configured SCIM auth provider. 

308 

309 Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc'). 

310 """ 

311 if not SCIM_AUTH_PROVIDER: 

312 raise HTTPException( 

313 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

314 detail='SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled', 

315 ) 

316 return SCIM_AUTH_PROVIDER 

317 

318 

319async def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]: 

320 """Find a user by SCIM externalId, falling back to OAuth sub match.""" 

321 provider = get_scim_provider() 

322 user = await Users.get_user_by_scim_external_id(provider, external_id, db=db) 

323 if user: 

324 return user 

325 

326 # Fallback: check if externalId matches an existing OAuth sub (account linking) 

327 return await Users.get_user_by_oauth_sub(provider, external_id, db=db) 

328 

329 

330async def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: 

331 """Convert internal User model to SCIM User""" 

332 # Parse display name into name components 

333 name_parts = user.name.split(' ', 1) if user.name else ['', ''] 

334 given_name = name_parts[0] if name_parts else '' 

335 family_name = name_parts[1] if len(name_parts) > 1 else '' 

336 

337 # Get user's groups 

338 user_groups = await Groups.get_groups_by_member_id(user.id, db=db) 

339 groups = [ 

340 { 

341 'value': group.id, 

342 'display': group.name, 

343 '$ref': f'{request.base_url}api/v1/scim/v2/Groups/{group.id}', 

344 'type': 'direct', 

345 } 

346 for group in user_groups 

347 ] 

348 

349 return SCIMUser( 

350 id=user.id, 

351 externalId=get_external_id(user), 

352 userName=user.email, 

353 name=SCIMName( 

354 formatted=user.name, 

355 givenName=given_name, 

356 familyName=family_name, 

357 ), 

358 displayName=user.name, 

359 emails=[SCIMEmail(value=user.email)], 

360 active=user.role != 'pending', 

361 photos=([SCIMPhoto(value=user.profile_image_url)] if user.profile_image_url else None), 

362 groups=groups if groups else None, 

363 meta=SCIMMeta( 

364 resourceType=SCIM_RESOURCE_TYPE_USER, 

365 created=datetime.fromtimestamp(user.created_at, tz=timezone.utc).isoformat(), 

366 lastModified=datetime.fromtimestamp(user.updated_at, tz=timezone.utc).isoformat(), 

367 location=f'{request.base_url}api/v1/scim/v2/Users/{user.id}', 

368 ), 

369 ) 

370 

371 

372async def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: 

373 """Convert internal Group model to SCIM Group""" 

374 member_ids = await Groups.get_group_user_ids_by_id(group.id, db) or [] 

375 

376 # Batch-fetch all users to avoid N+1 queries 

377 users = await Users.get_users_by_user_ids(member_ids, db=db) if member_ids else [] 

378 members = [ 

379 SCIMGroupMember( 

380 value=user.id, 

381 ref=f'{request.base_url}api/v1/scim/v2/Users/{user.id}', 

382 display=user.name, 

383 ) 

384 for user in users 

385 ] 

386 

387 return SCIMGroup( 

388 id=group.id, 

389 displayName=group.name, 

390 members=members, 

391 meta=SCIMMeta( 

392 resourceType=SCIM_RESOURCE_TYPE_GROUP, 

393 created=datetime.fromtimestamp(group.created_at, tz=timezone.utc).isoformat(), 

394 lastModified=datetime.fromtimestamp(group.updated_at, tz=timezone.utc).isoformat(), 

395 location=f'{request.base_url}api/v1/scim/v2/Groups/{group.id}', 

396 ), 

397 ) 

398 

399 

400# SCIM Service Provider Config 

401@router.get('/ServiceProviderConfig') 

402async def get_service_provider_config(): 

403 """Get SCIM Service Provider Configuration""" 

404 return { 

405 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], 

406 'patch': {'supported': True}, 

407 'bulk': {'supported': False, 'maxOperations': 1000, 'maxPayloadSize': 1048576}, 

408 'filter': {'supported': True, 'maxResults': 200}, 

409 'changePassword': {'supported': False}, 

410 'sort': {'supported': False}, 

411 'etag': {'supported': False}, 

412 'authenticationSchemes': [ 

413 { 

414 'type': 'oauthbearertoken', 

415 'name': 'OAuth Bearer Token', 

416 'description': 'Authentication using OAuth 2.0 Bearer Token', 

417 } 

418 ], 

419 } 

420 

421 

422# SCIM Resource Types 

423@router.get('/ResourceTypes') 

424async def get_resource_types(request: Request): 

425 """Get SCIM Resource Types""" 

426 return [ 

427 { 

428 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], 

429 'id': 'User', 

430 'name': 'User', 

431 'endpoint': '/Users', 

432 'schema': SCIM_USER_SCHEMA, 

433 'meta': { 

434 'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/User', 

435 'resourceType': 'ResourceType', 

436 }, 

437 }, 

438 { 

439 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], 

440 'id': 'Group', 

441 'name': 'Group', 

442 'endpoint': '/Groups', 

443 'schema': SCIM_GROUP_SCHEMA, 

444 'meta': { 

445 'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/Group', 

446 'resourceType': 'ResourceType', 

447 }, 

448 }, 

449 ] 

450 

451 

452# SCIM Schemas 

453@router.get('/Schemas') 

454async def get_schemas(): 

455 """Get SCIM Schemas""" 

456 return [ 

457 { 

458 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'], 

459 'id': SCIM_USER_SCHEMA, 

460 'name': 'User', 

461 'description': 'User Account', 

462 'attributes': [ 

463 { 

464 'name': 'userName', 

465 'type': 'string', 

466 'required': True, 

467 'uniqueness': 'server', 

468 }, 

469 {'name': 'displayName', 'type': 'string', 'required': True}, 

470 { 

471 'name': 'emails', 

472 'type': 'complex', 

473 'multiValued': True, 

474 'required': True, 

475 }, 

476 {'name': 'active', 'type': 'boolean', 'required': False}, 

477 ], 

478 }, 

479 { 

480 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'], 

481 'id': SCIM_GROUP_SCHEMA, 

482 'name': 'Group', 

483 'description': 'Group', 

484 'attributes': [ 

485 {'name': 'displayName', 'type': 'string', 'required': True}, 

486 { 

487 'name': 'members', 

488 'type': 'complex', 

489 'multiValued': True, 

490 'required': False, 

491 }, 

492 ], 

493 }, 

494 ] 

495 

496 

497# Users endpoints 

498@router.get('/Users', response_model=SCIMListResponse) 

499async def get_users( 

500 request: Request, 

501 startIndex: int = Query(1), 

502 count: int = Query(20), 

503 filter: Optional[str] = None, 

504 _: bool = Depends(get_scim_auth), 

505 db: AsyncSession = Depends(get_async_session), 

506): 

507 """List SCIM Users""" 

508 # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): 

509 # startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0. 

510 startIndex = max(1, startIndex) 

511 count = max(0, min(100, count)) 

512 skip = startIndex - 1 

513 limit = count 

514 

515 # Get users from database 

516 if filter: 

517 # Simple filter parsing - supports userName eq, externalId eq 

518 if 'userName eq' in filter: 

519 email = filter.split('"')[1] 

520 response = await Users.get_scim_users(filter={'email': email}, limit=1, db=db) 

521 users_list = response['users'] 

522 total = response['total'] 

523 elif 'externalId eq' in filter: 

524 external_id = filter.split('"')[1] 

525 user = await find_user_by_external_id(external_id, db=db) 

526 users_list = [user] if user else [] 

527 total = 1 if user else 0 

528 else: 

529 response = await Users.get_scim_users( 

530 sort={'order_by': 'created_at'}, 

531 skip=skip, 

532 limit=limit, 

533 db=db, 

534 ) 

535 users_list = response['users'] 

536 total = response['total'] 

537 else: 

538 response = await Users.get_scim_users( 

539 sort={'order_by': 'created_at'}, 

540 skip=skip, 

541 limit=limit, 

542 db=db, 

543 ) 

544 users_list = response['users'] 

545 total = response['total'] 

546 

547 # Convert to SCIM format 

548 scim_users = [await user_to_scim(user, request, db=db) for user in users_list] 

549 

550 return SCIMListResponse( 

551 totalResults=total, 

552 itemsPerPage=len(scim_users), 

553 startIndex=startIndex, 

554 Resources=scim_users, 

555 ) 

556 

557 

558@router.get('/Users/{user_id}', response_model=SCIMUser) 

559async def get_user( 

560 user_id: str, 

561 request: Request, 

562 _: bool = Depends(get_scim_auth), 

563 db: AsyncSession = Depends(get_async_session), 

564): 

565 """Get SCIM User by ID""" 

566 user = await Users.get_scim_user_by_id(user_id, db=db) 

567 if not user: 

568 return scim_error(status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found') 

569 

570 return await user_to_scim(user, request, db=db) 

571 

572 

573@router.post('/Users', response_model=SCIMUser, status_code=status.HTTP_201_CREATED) 

574async def create_user( 

575 request: Request, 

576 user_data: SCIMUserCreateRequest, 

577 _: bool = Depends(get_scim_auth), 

578 db: AsyncSession = Depends(get_async_session), 

579): 

580 """Create SCIM User""" 

581 # Check for duplicate by externalId 

582 if user_data.externalId: 

583 existing_user = await find_user_by_external_id(user_data.externalId, db=db) 

584 if existing_user: 

585 raise HTTPException( 

586 status_code=status.HTTP_409_CONFLICT, 

587 detail=f'User with externalId {user_data.externalId} already exists', 

588 ) 

589 

590 # Determine primary email (lowercased per RFC 5321) 

591 email = user_data.userName 

592 for entry in user_data.emails: 

593 if entry.primary: 

594 email = entry.value 

595 break 

596 email = email.lower() 

597 

598 # Check for duplicate by email 

599 existing_user = await Users.get_user_by_email(email, db=db) 

600 if existing_user: 

601 raise HTTPException( 

602 status_code=status.HTTP_409_CONFLICT, 

603 detail=f'User with email {email} already exists', 

604 ) 

605 

606 # Create user 

607 user_id = str(uuid.uuid4()) 

608 

609 # Parse name if provided 

610 name = user_data.displayName 

611 if user_data.name: 

612 if user_data.name.formatted: 

613 name = user_data.name.formatted 

614 elif user_data.name.givenName or user_data.name.familyName: 

615 name = f'{user_data.name.givenName or ""} {user_data.name.familyName or ""}'.strip() 

616 

617 # Get profile image if provided 

618 profile_image = '/user.png' 

619 if user_data.photos and len(user_data.photos) > 0: 

620 profile_image = user_data.photos[0].value 

621 

622 new_user = await Users.insert_new_user( 

623 id=user_id, 

624 name=name, 

625 email=email, 

626 profile_image_url=profile_image, 

627 role='user' if user_data.active else 'pending', 

628 db=db, 

629 ) 

630 

631 if not new_user: 

632 raise HTTPException( 

633 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

634 detail='Failed to create user', 

635 ) 

636 

637 new_user = await Users.update_user_scim_by_id(user_id, get_scim_provider(), user_data.externalId, db=db) 

638 if not new_user: 

639 raise HTTPException( 

640 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

641 detail='Failed to stamp SCIM user', 

642 ) 

643 

644 await publish_event( 

645 request, 

646 EVENTS.USER_CREATED, 

647 subject_id=new_user.id, 

648 source='scim', 

649 data={ 

650 'email': new_user.email, 

651 'role': new_user.role, 

652 'external_id': user_data.externalId, 

653 }, 

654 ) 

655 

656 return await user_to_scim(new_user, request, db=db) 

657 

658 

659@router.put('/Users/{user_id}', response_model=SCIMUser) 

660async def update_user( 

661 user_id: str, 

662 request: Request, 

663 user_data: SCIMUserUpdateRequest, 

664 _: bool = Depends(get_scim_auth), 

665 db: AsyncSession = Depends(get_async_session), 

666): 

667 """Update SCIM User (full update)""" 

668 user = await Users.get_scim_user_by_id(user_id, db=db) 

669 if not user: 

670 raise HTTPException( 

671 status_code=status.HTTP_404_NOT_FOUND, 

672 detail=f'User {user_id} not found', 

673 ) 

674 

675 # Build update dict 

676 update_data = {} 

677 

678 if user_data.userName: 

679 update_data['email'] = user_data.userName 

680 

681 if user_data.displayName: 

682 update_data['name'] = user_data.displayName 

683 elif user_data.name: 

684 if user_data.name.formatted: 

685 update_data['name'] = user_data.name.formatted 

686 elif user_data.name.givenName or user_data.name.familyName: 

687 update_data['name'] = f'{user_data.name.givenName or ""} {user_data.name.familyName or ""}'.strip() 

688 

689 if user_data.emails and len(user_data.emails) > 0: 

690 update_data['email'] = user_data.emails[0].value 

691 

692 # Do not let SCIM's active flag demote an existing admin: a routine IdP sync or misconfiguration 

693 # must not silently strip a locally-provisioned admin's role and lock the instance out. Admin 

694 # role changes go through the dedicated admin endpoints, not SCIM provisioning. 

695 if user_data.active is not None and user.role != 'admin': 

696 update_data['role'] = 'user' if user_data.active else 'pending' 

697 

698 if user_data.photos and len(user_data.photos) > 0: 

699 update_data['profile_image_url'] = user_data.photos[0].value 

700 

701 updated_user = await Users.update_user_by_id(user_id, update_data, db=db) 

702 if not updated_user: 

703 raise HTTPException( 

704 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

705 detail='Failed to update user', 

706 ) 

707 

708 # Update externalId in the scim field 

709 if user_data.externalId: 

710 provider = get_scim_provider() 

711 await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) 

712 updated_user = await Users.get_user_by_id(user_id, db=db) 

713 

714 updated_fields = list(update_data.keys()) + (['externalId'] if user_data.externalId else []) 

715 role_changed = updated_user.role != user.role 

716 user_updated_fields = [field for field in updated_fields if field != 'role'] 

717 

718 if user_updated_fields: 

719 await publish_event( 

720 request, 

721 EVENTS.USER_UPDATED, 

722 subject_id=user_id, 

723 source='scim', 

724 data={'updated_fields': user_updated_fields}, 

725 ) 

726 

727 if role_changed: 

728 await publish_event( 

729 request, 

730 EVENTS.USER_ROLE_UPDATED, 

731 subject_id=user_id, 

732 source='scim', 

733 data={'role': updated_user.role}, 

734 ) 

735 

736 return await user_to_scim(updated_user, request, db=db) 

737 

738 

739@router.patch('/Users/{user_id}', response_model=SCIMUser) 

740async def patch_user( 

741 user_id: str, 

742 request: Request, 

743 patch_data: SCIMPatchRequest, 

744 _: bool = Depends(get_scim_auth), 

745 db: AsyncSession = Depends(get_async_session), 

746): 

747 """Update SCIM User (partial update)""" 

748 user = await Users.get_scim_user_by_id(user_id, db=db) 

749 if not user: 

750 raise HTTPException( 

751 status_code=status.HTTP_404_NOT_FOUND, 

752 detail=f'User {user_id} not found', 

753 ) 

754 

755 update_data = {} 

756 fields = { 

757 'userName': 'email', 

758 'displayName': 'name', 

759 'emails[primary eq true].value': 'email', 

760 'name.formatted': 'name', 

761 } 

762 

763 for operation in patch_data.Operations: 

764 op = operation.op.lower() 

765 path = operation.path 

766 

767 if op not in ('add', 'replace', 'remove'): 

768 return scim_error(400, f'Unsupported PATCH operation: {operation.op}') 

769 if op == 'remove': 

770 if not path: 

771 return scim_error(400, 'Remove requires a path', 'noTarget') 

772 if path != 'externalId': 

773 return scim_error(400, f'Removing {path} is not supported', 'mutability') 

774 values = {path: None} 

775 elif path is None: 

776 if not isinstance(operation.value, dict) or not operation.value: 

777 return scim_error(400, 'A pathless operation requires an attribute object', 'invalidValue') 

778 values = operation.value 

779 else: 

780 values = {path: operation.value} 

781 

782 for path, value in values.items(): 

783 if path == 'active': 

784 if not isinstance(value, bool): 

785 return scim_error(400, 'active must be a boolean', 'invalidValue') 

786 # Same guard as update_user: never demote an existing admin via SCIM. 

787 if user.role != 'admin': 

788 update_data['role'] = 'user' if value else 'pending' 

789 elif path in fields: 

790 if not isinstance(value, str): 

791 return scim_error(400, f'{path} must be a string', 'invalidValue') 

792 update_data[fields[path]] = value 

793 elif path == 'externalId': 

794 if value is not None and not isinstance(value, str): 

795 return scim_error(400, 'externalId must be a string or null', 'invalidValue') 

796 provider = get_scim_provider() 

797 scim = dict(update_data.get('scim', user.scim) or {}) 

798 scim[provider] = {'external_id': value} 

799 update_data['scim'] = scim 

800 else: 

801 return scim_error(400, f'Unsupported PATCH path: {path}', 'invalidPath') 

802 

803 # Validate all operations before persisting once, and leave identical writes unchanged. 

804 update_data = {key: value for key, value in update_data.items() if value != getattr(user, key)} 

805 user_updated_fields = ['externalId' if field == 'scim' else field for field in update_data if field != 'role'] 

806 

807 # Update user 

808 if update_data: 

809 update_data['updated_at'] = int(time.time()) 

810 updated_user = await Users.update_user_by_id(user_id, update_data, db=db) 

811 if not updated_user: 

812 raise HTTPException( 

813 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

814 detail='Failed to update user', 

815 ) 

816 else: 

817 updated_user = user 

818 

819 role_changed = updated_user.role != user.role 

820 

821 if user_updated_fields: 

822 await publish_event( 

823 request, 

824 EVENTS.USER_UPDATED, 

825 subject_id=user_id, 

826 source='scim', 

827 data={'updated_fields': user_updated_fields}, 

828 ) 

829 

830 if role_changed: 

831 await publish_event( 

832 request, 

833 EVENTS.USER_ROLE_UPDATED, 

834 subject_id=user_id, 

835 source='scim', 

836 data={'role': updated_user.role}, 

837 ) 

838 

839 return await user_to_scim(updated_user, request, db=db) 

840 

841 

842@router.delete('/Users/{user_id}', status_code=status.HTTP_204_NO_CONTENT) 

843async def delete_user( 

844 user_id: str, 

845 request: Request, 

846 _: bool = Depends(get_scim_auth), 

847 db: AsyncSession = Depends(get_async_session), 

848): 

849 """Delete SCIM User""" 

850 user = await Users.get_scim_user_by_id(user_id, db=db) 

851 if not user: 

852 raise HTTPException( 

853 status_code=status.HTTP_404_NOT_FOUND, 

854 detail=f'User {user_id} not found', 

855 ) 

856 

857 success = await Users.delete_user_by_id(user_id, db=db) 

858 if not success: 

859 raise HTTPException( 

860 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

861 detail='Failed to delete user', 

862 ) 

863 

864 await publish_event( 

865 request, 

866 EVENTS.USER_DELETED, 

867 subject_id=user_id, 

868 source='scim', 

869 data={'email': user.email}, 

870 ) 

871 

872 return None 

873 

874 

875# Groups endpoints 

876@router.get('/Groups', response_model=SCIMListResponse) 

877async def get_groups( 

878 request: Request, 

879 startIndex: int = Query(1), 

880 count: int = Query(20), 

881 filter: Optional[str] = None, 

882 _: bool = Depends(get_scim_auth), 

883 db: AsyncSession = Depends(get_async_session), 

884): 

885 """List SCIM Groups""" 

886 # Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4): 

887 # startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0. 

888 startIndex = max(1, startIndex) 

889 count = max(0, min(100, count)) 

890 

891 # Get groups, applying filter if provided 

892 if filter: 

893 if 'displayName eq' in filter: 

894 display_name = filter.split('"')[1] 

895 group = await Groups.get_group_by_name(display_name, db=db) 

896 groups_list = [group] if group else [] 

897 else: 

898 # Unrecognized filter — fall back to all groups 

899 groups_list = await Groups.get_all_groups(db=db) 

900 else: 

901 groups_list = await Groups.get_all_groups(db=db) 

902 

903 # Apply pagination 

904 total = len(groups_list) 

905 start = startIndex - 1 

906 end = start + count 

907 paginated_groups = groups_list[start:end] 

908 

909 # Convert to SCIM format 

910 scim_groups = [await group_to_scim(group, request, db=db) for group in paginated_groups] 

911 

912 return SCIMListResponse( 

913 totalResults=total, 

914 itemsPerPage=len(scim_groups), 

915 startIndex=startIndex, 

916 Resources=scim_groups, 

917 ) 

918 

919 

920@router.get('/Groups/{group_id}', response_model=SCIMGroup) 

921async def get_group( 

922 group_id: str, 

923 request: Request, 

924 _: bool = Depends(get_scim_auth), 

925 db: AsyncSession = Depends(get_async_session), 

926): 

927 """Get SCIM Group by ID""" 

928 group = await Groups.get_group_by_id(group_id, db=db) 

929 if not group: 

930 raise HTTPException( 

931 status_code=status.HTTP_404_NOT_FOUND, 

932 detail=f'Group {group_id} not found', 

933 ) 

934 

935 return await group_to_scim(group, request, db=db) 

936 

937 

938@router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) 

939async def create_group( 

940 request: Request, 

941 group_data: SCIMGroupCreateRequest, 

942 _: bool = Depends(get_scim_auth), 

943 db: AsyncSession = Depends(get_async_session), 

944): 

945 """Create SCIM Group""" 

946 # Extract member IDs 

947 member_ids = [] 

948 if group_data.members: 

949 for member in group_data.members: 

950 member_ids.append(member.value) 

951 

952 # Create group 

953 from open_webui.models.groups import GroupForm 

954 

955 form = GroupForm( 

956 name=group_data.displayName, 

957 description='', 

958 ) 

959 

960 # Need to get the creating user's ID - we'll use the first admin 

961 admin_user = await Users.get_super_admin_user(db=db) 

962 if not admin_user: 

963 raise HTTPException( 

964 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

965 detail='No admin user found', 

966 ) 

967 

968 new_group = await Groups.insert_new_group(admin_user.id, form, db=db) 

969 if not new_group: 

970 raise HTTPException( 

971 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

972 detail='Failed to create group', 

973 ) 

974 

975 # Add members if provided 

976 if member_ids: 

977 from open_webui.models.groups import GroupUpdateForm 

978 

979 update_form = GroupUpdateForm( 

980 name=new_group.name, 

981 description=new_group.description, 

982 ) 

983 

984 await Groups.update_group_by_id(new_group.id, update_form, db=db) 

985 await Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db) 

986 

987 new_group = await Groups.get_group_by_id(new_group.id, db=db) 

988 

989 await publish_event( 

990 request, 

991 EVENTS.GROUP_CREATED, 

992 subject_id=new_group.id, 

993 source='scim', 

994 data={'name': new_group.name, 'member_ids': member_ids, 'member_count': len(member_ids)}, 

995 ) 

996 if member_ids: 

997 await publish_event( 

998 request, 

999 EVENTS.GROUP_MEMBER_ADDED, 

1000 subject_id=new_group.id, 

1001 source='scim', 

1002 data={'member_ids': member_ids, 'count': len(member_ids)}, 

1003 ) 

1004 

1005 return await group_to_scim(new_group, request, db=db) 

1006 

1007 

1008@router.put('/Groups/{group_id}', response_model=SCIMGroup) 

1009async def update_group( 

1010 group_id: str, 

1011 request: Request, 

1012 group_data: SCIMGroupUpdateRequest, 

1013 _: bool = Depends(get_scim_auth), 

1014 db: AsyncSession = Depends(get_async_session), 

1015): 

1016 """Update SCIM Group (full update)""" 

1017 group = await Groups.get_group_by_id(group_id, db=db) 

1018 if not group: 

1019 raise HTTPException( 

1020 status_code=status.HTTP_404_NOT_FOUND, 

1021 detail=f'Group {group_id} not found', 

1022 ) 

1023 

1024 # Build update form 

1025 from open_webui.models.groups import GroupUpdateForm 

1026 

1027 update_form = GroupUpdateForm( 

1028 name=group_data.displayName if group_data.displayName else group.name, 

1029 description=group.description, 

1030 ) 

1031 

1032 # Handle members if provided 

1033 added_member_ids = [] 

1034 removed_member_ids = [] 

1035 if group_data.members is not None: 

1036 old_member_ids = set(await Groups.get_group_user_ids_by_id(group_id, db) or []) 

1037 member_ids = [member.value for member in group_data.members] 

1038 await Groups.set_group_user_ids_by_id(group_id, member_ids, db=db) 

1039 new_member_ids = set(member_ids) 

1040 added_member_ids = sorted(new_member_ids - old_member_ids) 

1041 removed_member_ids = sorted(old_member_ids - new_member_ids) 

1042 

1043 # Update group 

1044 updated_group = await Groups.update_group_by_id(group_id, update_form, db=db) 

1045 if not updated_group: 

1046 raise HTTPException( 

1047 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1048 detail='Failed to update group', 

1049 ) 

1050 

1051 await publish_event( 

1052 request, 

1053 EVENTS.GROUP_UPDATED, 

1054 subject_id=group_id, 

1055 source='scim', 

1056 data={'updated_fields': ['name', 'members'] if group_data.members is not None else ['name']}, 

1057 ) 

1058 if added_member_ids: 

1059 await publish_event( 

1060 request, 

1061 EVENTS.GROUP_MEMBER_ADDED, 

1062 subject_id=group_id, 

1063 source='scim', 

1064 data={'member_ids': added_member_ids, 'count': len(added_member_ids)}, 

1065 ) 

1066 if removed_member_ids: 

1067 await publish_event( 

1068 request, 

1069 EVENTS.GROUP_MEMBER_REMOVED, 

1070 subject_id=group_id, 

1071 source='scim', 

1072 data={'member_ids': removed_member_ids, 'count': len(removed_member_ids)}, 

1073 ) 

1074 

1075 return await group_to_scim(updated_group, request, db=db) 

1076 

1077 

1078@router.patch('/Groups/{group_id}', response_model=SCIMGroup) 

1079async def patch_group( 

1080 group_id: str, 

1081 request: Request, 

1082 patch_data: SCIMPatchRequest, 

1083 _: bool = Depends(get_scim_auth), 

1084 db: AsyncSession = Depends(get_async_session), 

1085): 

1086 """Update SCIM Group (partial update)""" 

1087 group = await Groups.get_group_by_id(group_id, db=db) 

1088 if not group: 

1089 raise HTTPException( 

1090 status_code=status.HTTP_404_NOT_FOUND, 

1091 detail=f'Group {group_id} not found', 

1092 ) 

1093 

1094 from open_webui.models.groups import GroupUpdateForm 

1095 

1096 update_form = GroupUpdateForm( 

1097 name=group.name, 

1098 description=group.description, 

1099 ) 

1100 added_member_ids = [] 

1101 removed_member_ids = [] 

1102 

1103 for operation in patch_data.Operations: 

1104 op = operation.op.lower() 

1105 path = operation.path 

1106 value = operation.value 

1107 

1108 if op == 'replace': 

1109 if path == 'displayName': 

1110 update_form.name = value 

1111 elif path == 'members': 

1112 # Replace all members 

1113 old_member_ids = set(await Groups.get_group_user_ids_by_id(group_id, db) or []) 

1114 new_member_ids = [member['value'] for member in value] 

1115 await Groups.set_group_user_ids_by_id(group_id, new_member_ids, db=db) 

1116 new_member_ids_set = set(new_member_ids) 

1117 added_member_ids.extend(sorted(new_member_ids_set - old_member_ids)) 

1118 removed_member_ids.extend(sorted(old_member_ids - new_member_ids_set)) 

1119 

1120 elif op == 'add': 

1121 if path == 'members': 

1122 # Add members 

1123 if isinstance(value, list): 

1124 for member in value: 

1125 if isinstance(member, dict) and 'value' in member: 

1126 await Groups.add_users_to_group(group_id, [member['value']], db=db) 

1127 added_member_ids.append(member['value']) 

1128 elif op == 'remove': 

1129 if path and path.startswith('members[value eq'): 

1130 # Remove specific member 

1131 member_id = path.split('"')[1] 

1132 await Groups.remove_users_from_group(group_id, [member_id], db=db) 

1133 removed_member_ids.append(member_id) 

1134 

1135 # Update group 

1136 updated_group = await Groups.update_group_by_id(group_id, update_form, db=db) 

1137 if not updated_group: 

1138 raise HTTPException( 

1139 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1140 detail='Failed to update group', 

1141 ) 

1142 

1143 await publish_event( 

1144 request, 

1145 EVENTS.GROUP_UPDATED, 

1146 subject_id=group_id, 

1147 source='scim', 

1148 data={'operation_count': len(patch_data.Operations)}, 

1149 ) 

1150 if added_member_ids: 

1151 await publish_event( 

1152 request, 

1153 EVENTS.GROUP_MEMBER_ADDED, 

1154 subject_id=group_id, 

1155 source='scim', 

1156 data={'member_ids': sorted(set(added_member_ids)), 'count': len(set(added_member_ids))}, 

1157 ) 

1158 if removed_member_ids: 

1159 await publish_event( 

1160 request, 

1161 EVENTS.GROUP_MEMBER_REMOVED, 

1162 subject_id=group_id, 

1163 source='scim', 

1164 data={'member_ids': sorted(set(removed_member_ids)), 'count': len(set(removed_member_ids))}, 

1165 ) 

1166 

1167 return await group_to_scim(updated_group, request, db=db) 

1168 

1169 

1170@router.delete('/Groups/{group_id}', status_code=status.HTTP_204_NO_CONTENT) 

1171async def delete_group( 

1172 group_id: str, 

1173 request: Request, 

1174 _: bool = Depends(get_scim_auth), 

1175 db: AsyncSession = Depends(get_async_session), 

1176): 

1177 """Delete SCIM Group""" 

1178 group = await Groups.get_group_by_id(group_id, db=db) 

1179 if not group: 

1180 raise HTTPException( 

1181 status_code=status.HTTP_404_NOT_FOUND, 

1182 detail=f'Group {group_id} not found', 

1183 ) 

1184 

1185 success = await Groups.delete_group_by_id(group_id, db=db) 

1186 if not success: 

1187 raise HTTPException( 

1188 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1189 detail='Failed to delete group', 

1190 ) 

1191 

1192 await publish_event( 

1193 request, 

1194 EVENTS.GROUP_DELETED, 

1195 subject_id=group_id, 

1196 source='scim', 

1197 data={'name': group.name}, 

1198 ) 

1199 

1200 return None