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
« 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
5NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change.
6"""
8import hmac
9import logging
10import time
11import uuid
12from datetime import datetime, timezone
13from typing import Any, Dict, List, Optional
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
33log = logging.getLogger(__name__)
35router = APIRouter()
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'
43# SCIM Resource Types
44SCIM_RESOURCE_TYPE_USER = 'User'
45SCIM_RESOURCE_TYPE_GROUP = 'Group'
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 }
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'
65 return JSONResponse(status_code=status_code, content=error_body)
68class SCIMError(BaseModel):
69 """SCIM Error Response"""
71 schemas: List[str] = [SCIM_ERROR_SCHEMA]
72 status: str
73 scimType: Optional[str] = None
74 detail: Optional[str] = None
77class SCIMMeta(BaseModel):
78 """SCIM Resource Metadata"""
80 resourceType: str
81 created: str
82 lastModified: str
83 location: Optional[str] = None
84 version: Optional[str] = None
87class SCIMName(BaseModel):
88 """SCIM User Name"""
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
98class SCIMEmail(BaseModel):
99 """SCIM Email"""
101 value: str
102 type: Optional[str] = 'work'
103 primary: bool = True
104 display: Optional[str] = None
107class SCIMPhoto(BaseModel):
108 """SCIM Photo"""
110 value: str
111 type: Optional[str] = 'photo'
112 primary: bool = True
113 display: Optional[str] = None
116class SCIMGroupMember(BaseModel):
117 """SCIM Group Member"""
119 value: str # User ID
120 ref: Optional[str] = Field(None, alias='$ref')
121 type: Optional[str] = 'User'
122 display: Optional[str] = None
125class SCIMUser(BaseModel):
126 """SCIM User Resource"""
128 model_config = ConfigDict(populate_by_name=True)
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
143class SCIMUserCreateRequest(BaseModel):
144 """SCIM User Create Request"""
146 model_config = ConfigDict(populate_by_name=True)
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
159class SCIMUserUpdateRequest(BaseModel):
160 """SCIM User Update Request"""
162 model_config = ConfigDict(populate_by_name=True)
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
175class SCIMGroup(BaseModel):
176 """SCIM Group Resource"""
178 model_config = ConfigDict(populate_by_name=True)
180 schemas: List[str] = [SCIM_GROUP_SCHEMA]
181 id: str
182 displayName: str
183 members: Optional[List[SCIMGroupMember]] = []
184 meta: SCIMMeta
187class SCIMGroupCreateRequest(BaseModel):
188 """SCIM Group Create Request"""
190 model_config = ConfigDict(populate_by_name=True)
192 schemas: List[str] = [SCIM_GROUP_SCHEMA]
193 displayName: str
194 members: Optional[List[SCIMGroupMember]] = []
197class SCIMGroupUpdateRequest(BaseModel):
198 """SCIM Group Update Request"""
200 model_config = ConfigDict(populate_by_name=True)
202 schemas: List[str] = [SCIM_GROUP_SCHEMA]
203 displayName: Optional[str] = None
204 members: Optional[List[SCIMGroupMember]] = None
207class SCIMListResponse(BaseModel):
208 """SCIM List Response"""
210 schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
211 totalResults: int
212 itemsPerPage: int
213 startIndex: int
214 Resources: List[Any]
217class SCIMPatchOperation(BaseModel):
218 """SCIM Patch Operation"""
220 op: str # "add", "replace", "remove"
221 path: Optional[str] = None
222 value: Optional[Any] = None
225class SCIMPatchRequest(BaseModel):
226 """SCIM Patch Request"""
228 schemas: List[str] = ['urn:ietf:params:scim:api:messages:2.0:PatchOp']
229 Operations: List[SCIMPatchOperation]
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 )
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 )
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 )
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))
263 if not enable_scim:
264 raise HTTPException(
265 status_code=status.HTTP_403_FORBIDDEN,
266 detail='SCIM is not enabled',
267 )
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 )
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
286 log.error(f'Traceback: {traceback.format_exc()}')
287 raise HTTPException(
288 status_code=status.HTTP_401_UNAUTHORIZED,
289 detail='Authentication failed',
290 )
293def get_external_id(user: UserModel) -> Optional[str]:
294 """Extract externalId from a user's scim data.
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
306def get_scim_provider() -> str:
307 """Return the configured SCIM auth provider.
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
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
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)
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 ''
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 ]
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 )
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 []
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 ]
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 )
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 }
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 ]
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 ]
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
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']
547 # Convert to SCIM format
548 scim_users = [await user_to_scim(user, request, db=db) for user in users_list]
550 return SCIMListResponse(
551 totalResults=total,
552 itemsPerPage=len(scim_users),
553 startIndex=startIndex,
554 Resources=scim_users,
555 )
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')
570 return await user_to_scim(user, request, db=db)
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 )
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()
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 )
606 # Create user
607 user_id = str(uuid.uuid4())
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()
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
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 )
631 if not new_user:
632 raise HTTPException(
633 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
634 detail='Failed to create user',
635 )
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 )
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 )
656 return await user_to_scim(new_user, request, db=db)
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 )
675 # Build update dict
676 update_data = {}
678 if user_data.userName:
679 update_data['email'] = user_data.userName
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()
689 if user_data.emails and len(user_data.emails) > 0:
690 update_data['email'] = user_data.emails[0].value
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'
698 if user_data.photos and len(user_data.photos) > 0:
699 update_data['profile_image_url'] = user_data.photos[0].value
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 )
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)
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']
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 )
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 )
736 return await user_to_scim(updated_user, request, db=db)
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 )
755 update_data = {}
756 fields = {
757 'userName': 'email',
758 'displayName': 'name',
759 'emails[primary eq true].value': 'email',
760 'name.formatted': 'name',
761 }
763 for operation in patch_data.Operations:
764 op = operation.op.lower()
765 path = operation.path
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}
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')
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']
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
819 role_changed = updated_user.role != user.role
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 )
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 )
839 return await user_to_scim(updated_user, request, db=db)
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 )
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 )
864 await publish_event(
865 request,
866 EVENTS.USER_DELETED,
867 subject_id=user_id,
868 source='scim',
869 data={'email': user.email},
870 )
872 return None
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))
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)
903 # Apply pagination
904 total = len(groups_list)
905 start = startIndex - 1
906 end = start + count
907 paginated_groups = groups_list[start:end]
909 # Convert to SCIM format
910 scim_groups = [await group_to_scim(group, request, db=db) for group in paginated_groups]
912 return SCIMListResponse(
913 totalResults=total,
914 itemsPerPage=len(scim_groups),
915 startIndex=startIndex,
916 Resources=scim_groups,
917 )
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 )
935 return await group_to_scim(group, request, db=db)
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)
952 # Create group
953 from open_webui.models.groups import GroupForm
955 form = GroupForm(
956 name=group_data.displayName,
957 description='',
958 )
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 )
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 )
975 # Add members if provided
976 if member_ids:
977 from open_webui.models.groups import GroupUpdateForm
979 update_form = GroupUpdateForm(
980 name=new_group.name,
981 description=new_group.description,
982 )
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)
987 new_group = await Groups.get_group_by_id(new_group.id, db=db)
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 )
1005 return await group_to_scim(new_group, request, db=db)
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 )
1024 # Build update form
1025 from open_webui.models.groups import GroupUpdateForm
1027 update_form = GroupUpdateForm(
1028 name=group_data.displayName if group_data.displayName else group.name,
1029 description=group.description,
1030 )
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)
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 )
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 )
1075 return await group_to_scim(updated_group, request, db=db)
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 )
1094 from open_webui.models.groups import GroupUpdateForm
1096 update_form = GroupUpdateForm(
1097 name=group.name,
1098 description=group.description,
1099 )
1100 added_member_ids = []
1101 removed_member_ids = []
1103 for operation in patch_data.Operations:
1104 op = operation.op.lower()
1105 path = operation.path
1106 value = operation.value
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))
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)
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 )
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 )
1167 return await group_to_scim(updated_group, request, db=db)
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 )
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 )
1192 await publish_event(
1193 request,
1194 EVENTS.GROUP_DELETED,
1195 subject_id=group_id,
1196 source='scim',
1197 data={'name': group.name},
1198 )
1200 return None