From 7fffecd16818f616a6113889c907fcb7546e3609 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 6 Aug 2025 14:26:22 +0400 Subject: [PATCH] refac --- backend/open_webui/routers/scim.py | 283 +++++++++++++++-------------- 1 file changed, 146 insertions(+), 137 deletions(-) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index e0e1f26da8..42fe6c716f 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -1,6 +1,8 @@ """ -SCIM 2.0 Implementation for Open WebUI +Experimental SCIM 2.0 Implementation for Open WebUI Provides System for Cross-domain Identity Management endpoints for users and groups + +NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change. """ import logging @@ -15,7 +17,12 @@ from pydantic import BaseModel, Field, ConfigDict from open_webui.models.users import Users, UserModel from open_webui.models.groups import Groups, GroupModel -from open_webui.utils.auth import get_admin_user, get_current_user, decode_token, get_verified_user +from open_webui.utils.auth import ( + get_admin_user, + get_current_user, + decode_token, + get_verified_user, +) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -40,9 +47,9 @@ def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None): error_body = { "schemas": [SCIM_ERROR_SCHEMA], "status": str(status_code), - "detail": detail + "detail": detail, } - + if scim_type: error_body["scimType"] = scim_type elif status_code == 404: @@ -51,15 +58,13 @@ def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None): error_body["scimType"] = "uniqueness" elif status_code == 400: error_body["scimType"] = "invalidSyntax" - - return JSONResponse( - status_code=status_code, - content=error_body - ) + + return JSONResponse(status_code=status_code, content=error_body) class SCIMError(BaseModel): """SCIM Error Response""" + schemas: List[str] = [SCIM_ERROR_SCHEMA] status: str scimType: Optional[str] = None @@ -68,6 +73,7 @@ class SCIMError(BaseModel): class SCIMMeta(BaseModel): """SCIM Resource Metadata""" + resourceType: str created: str lastModified: str @@ -77,6 +83,7 @@ class SCIMMeta(BaseModel): class SCIMName(BaseModel): """SCIM User Name""" + formatted: Optional[str] = None familyName: Optional[str] = None givenName: Optional[str] = None @@ -87,6 +94,7 @@ class SCIMName(BaseModel): class SCIMEmail(BaseModel): """SCIM Email""" + value: str type: Optional[str] = "work" primary: bool = True @@ -95,6 +103,7 @@ class SCIMEmail(BaseModel): class SCIMPhoto(BaseModel): """SCIM Photo""" + value: str type: Optional[str] = "photo" primary: bool = True @@ -103,6 +112,7 @@ class SCIMPhoto(BaseModel): class SCIMGroupMember(BaseModel): """SCIM Group Member""" + value: str # User ID ref: Optional[str] = Field(None, alias="$ref") type: Optional[str] = "User" @@ -111,8 +121,9 @@ class SCIMGroupMember(BaseModel): class SCIMUser(BaseModel): """SCIM User Resource""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_USER_SCHEMA] id: str externalId: Optional[str] = None @@ -128,8 +139,9 @@ class SCIMUser(BaseModel): class SCIMUserCreateRequest(BaseModel): """SCIM User Create Request""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_USER_SCHEMA] externalId: Optional[str] = None userName: str @@ -143,8 +155,9 @@ class SCIMUserCreateRequest(BaseModel): class SCIMUserUpdateRequest(BaseModel): """SCIM User Update Request""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_USER_SCHEMA] id: Optional[str] = None externalId: Optional[str] = None @@ -158,8 +171,9 @@ class SCIMUserUpdateRequest(BaseModel): class SCIMGroup(BaseModel): """SCIM Group Resource""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_GROUP_SCHEMA] id: str displayName: str @@ -169,8 +183,9 @@ class SCIMGroup(BaseModel): class SCIMGroupCreateRequest(BaseModel): """SCIM Group Create Request""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_GROUP_SCHEMA] displayName: str members: Optional[List[SCIMGroupMember]] = [] @@ -178,8 +193,9 @@ class SCIMGroupCreateRequest(BaseModel): class SCIMGroupUpdateRequest(BaseModel): """SCIM Group Update Request""" + model_config = ConfigDict(populate_by_name=True) - + schemas: List[str] = [SCIM_GROUP_SCHEMA] displayName: Optional[str] = None members: Optional[List[SCIMGroupMember]] = None @@ -187,6 +203,7 @@ class SCIMGroupUpdateRequest(BaseModel): class SCIMListResponse(BaseModel): """SCIM List Response""" + schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA] totalResults: int itemsPerPage: int @@ -196,6 +213,7 @@ class SCIMListResponse(BaseModel): class SCIMPatchOperation(BaseModel): """SCIM Patch Operation""" + op: str # "add", "replace", "remove" path: Optional[str] = None value: Optional[Any] = None @@ -203,11 +221,14 @@ class SCIMPatchOperation(BaseModel): class SCIMPatchRequest(BaseModel): """SCIM Patch Request""" + schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] Operations: List[SCIMPatchOperation] -def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) -> bool: +def get_scim_auth( + request: Request, authorization: Optional[str] = Header(None) +) -> bool: """ Verify SCIM authentication Checks for SCIM-specific bearer token configured in the system @@ -218,7 +239,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) detail="Authorization header required", headers={"WWW-Authenticate": "Bearer"}, ) - + try: parts = authorization.split() if len(parts) != 2: @@ -226,19 +247,21 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization format. Expected: Bearer ", ) - + scheme, token = parts if scheme.lower() != "bearer": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication scheme", ) - + # Check if SCIM is enabled scim_enabled = getattr(request.app.state.config, "SCIM_ENABLED", False) - log.info(f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}") + log.info( + f"SCIM auth check - raw SCIM_ENABLED: {scim_enabled}, type: {type(scim_enabled)}" + ) # Handle both PersistentConfig and direct value - if hasattr(scim_enabled, 'value'): + if hasattr(scim_enabled, "value"): scim_enabled = scim_enabled.value log.info(f"SCIM enabled status after conversion: {scim_enabled}") if not scim_enabled: @@ -246,11 +269,11 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) status_code=status.HTTP_403_FORBIDDEN, detail="SCIM is not enabled", ) - + # Verify the SCIM token scim_token = getattr(request.app.state.config, "SCIM_TOKEN", None) # Handle both PersistentConfig and direct value - if hasattr(scim_token, 'value'): + if hasattr(scim_token, "value"): scim_token = scim_token.value log.debug(f"SCIM token configured: {bool(scim_token)}") if not scim_token or token != scim_token: @@ -258,7 +281,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid SCIM token", ) - + return True except HTTPException: # Re-raise HTTP exceptions as-is @@ -266,6 +289,7 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) except Exception as e: log.error(f"SCIM authentication error: {e}") import traceback + log.error(f"Traceback: {traceback.format_exc()}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -273,14 +297,13 @@ def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) ) - def user_to_scim(user: UserModel, request: Request) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components name_parts = user.name.split(" ", 1) if user.name else ["", ""] given_name = name_parts[0] if name_parts else "" family_name = name_parts[1] if len(name_parts) > 1 else "" - + # Get user's groups user_groups = Groups.get_groups_by_member_id(user.id) groups = [ @@ -288,11 +311,11 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser: "value": group.id, "display": group.name, "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", - "type": "direct" + "type": "direct", } for group in user_groups ] - + return SCIMUser( id=user.id, userName=user.email, @@ -304,12 +327,20 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser: displayName=user.name, emails=[SCIMEmail(value=user.email)], active=user.role != "pending", - photos=[SCIMPhoto(value=user.profile_image_url)] if user.profile_image_url else None, + photos=( + [SCIMPhoto(value=user.profile_image_url)] + if user.profile_image_url + else None + ), groups=groups if groups else None, meta=SCIMMeta( resourceType=SCIM_RESOURCE_TYPE_USER, - created=datetime.fromtimestamp(user.created_at, tz=timezone.utc).isoformat(), - lastModified=datetime.fromtimestamp(user.updated_at, tz=timezone.utc).isoformat(), + created=datetime.fromtimestamp( + user.created_at, tz=timezone.utc + ).isoformat(), + lastModified=datetime.fromtimestamp( + user.updated_at, tz=timezone.utc + ).isoformat(), location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}", ), ) @@ -328,55 +359,43 @@ def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup: display=user.name, ) ) - + return SCIMGroup( id=group.id, displayName=group.name, members=members, meta=SCIMMeta( resourceType=SCIM_RESOURCE_TYPE_GROUP, - created=datetime.fromtimestamp(group.created_at, tz=timezone.utc).isoformat(), - lastModified=datetime.fromtimestamp(group.updated_at, tz=timezone.utc).isoformat(), + created=datetime.fromtimestamp( + group.created_at, tz=timezone.utc + ).isoformat(), + lastModified=datetime.fromtimestamp( + group.updated_at, tz=timezone.utc + ).isoformat(), location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", ), ) - # SCIM Service Provider Config @router.get("/ServiceProviderConfig") async def get_service_provider_config(): """Get SCIM Service Provider Configuration""" return { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], - "patch": { - "supported": True - }, - "bulk": { - "supported": False, - "maxOperations": 1000, - "maxPayloadSize": 1048576 - }, - "filter": { - "supported": True, - "maxResults": 200 - }, - "changePassword": { - "supported": False - }, - "sort": { - "supported": False - }, - "etag": { - "supported": False - }, + "patch": {"supported": True}, + "bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576}, + "filter": {"supported": True, "maxResults": 200}, + "changePassword": {"supported": False}, + "sort": {"supported": False}, + "etag": {"supported": False}, "authenticationSchemes": [ { "type": "oauthbearertoken", "name": "OAuth Bearer Token", - "description": "Authentication using OAuth 2.0 Bearer Token" + "description": "Authentication using OAuth 2.0 Bearer Token", } - ] + ], } @@ -393,8 +412,8 @@ async def get_resource_types(request: Request): "schema": SCIM_USER_SCHEMA, "meta": { "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User", - "resourceType": "ResourceType" - } + "resourceType": "ResourceType", + }, }, { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], @@ -404,9 +423,9 @@ async def get_resource_types(request: Request): "schema": SCIM_GROUP_SCHEMA, "meta": { "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group", - "resourceType": "ResourceType" - } - } + "resourceType": "ResourceType", + }, + }, ] @@ -425,25 +444,17 @@ async def get_schemas(): "name": "userName", "type": "string", "required": True, - "uniqueness": "server" - }, - { - "name": "displayName", - "type": "string", - "required": True + "uniqueness": "server", }, + {"name": "displayName", "type": "string", "required": True}, { "name": "emails", "type": "complex", "multiValued": True, - "required": True + "required": True, }, - { - "name": "active", - "type": "boolean", - "required": False - } - ] + {"name": "active", "type": "boolean", "required": False}, + ], }, { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], @@ -451,19 +462,15 @@ async def get_schemas(): "name": "Group", "description": "Group", "attributes": [ - { - "name": "displayName", - "type": "string", - "required": True - }, + {"name": "displayName", "type": "string", "required": True}, { "name": "members", "type": "complex", "multiValued": True, - "required": False - } - ] - } + "required": False, + }, + ], + }, ] @@ -479,7 +486,7 @@ async def get_users( """List SCIM Users""" skip = startIndex - 1 limit = count - + # Get users from database if filter: # Simple filter parsing - supports userName eq "email" @@ -497,10 +504,10 @@ async def get_users( response = Users.get_users(skip=skip, limit=limit) users_list = response["users"] total = response["total"] - + # Convert to SCIM format scim_users = [user_to_scim(user, request) for user in users_list] - + return SCIMListResponse( totalResults=total, itemsPerPage=len(scim_users), @@ -519,10 +526,9 @@ async def get_user( user = Users.get_user_by_id(user_id) if not user: return scim_error( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"User {user_id} not found" + status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found" ) - + return user_to_scim(user, request) @@ -540,11 +546,11 @@ async def create_user( status_code=status.HTTP_409_CONFLICT, detail=f"User with email {user_data.userName} already exists", ) - + # Create user user_id = str(uuid.uuid4()) email = user_data.emails[0].value if user_data.emails else user_data.userName - + # Parse name if provided name = user_data.displayName if user_data.name: @@ -552,12 +558,12 @@ async def create_user( name = user_data.name.formatted elif user_data.name.givenName or user_data.name.familyName: name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() - + # Get profile image if provided profile_image = "/user.png" if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value - + # Create user new_user = Users.insert_new_user( id=user_id, @@ -566,13 +572,13 @@ async def create_user( profile_image_url=profile_image, role="user" if user_data.active else "pending", ) - + if not new_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create user", ) - + return user_to_scim(new_user, request) @@ -590,30 +596,32 @@ async def update_user( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found", ) - + # Build update dict update_data = {} - + if user_data.userName: update_data["email"] = user_data.userName - + if user_data.displayName: update_data["name"] = user_data.displayName elif user_data.name: if user_data.name.formatted: update_data["name"] = user_data.name.formatted elif user_data.name.givenName or user_data.name.familyName: - update_data["name"] = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() - + update_data["name"] = ( + f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() + ) + if user_data.emails and len(user_data.emails) > 0: update_data["email"] = user_data.emails[0].value - + if user_data.active is not None: update_data["role"] = "user" if user_data.active else "pending" - + if user_data.photos and len(user_data.photos) > 0: update_data["profile_image_url"] = user_data.photos[0].value - + # Update user updated_user = Users.update_user_by_id(user_id, update_data) if not updated_user: @@ -621,7 +629,7 @@ async def update_user( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update user", ) - + return user_to_scim(updated_user, request) @@ -639,14 +647,14 @@ async def patch_user( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found", ) - + update_data = {} - + for operation in patch_data.Operations: op = operation.op.lower() path = operation.path value = operation.value - + if op == "replace": if path == "active": update_data["role"] = "user" if value else "pending" @@ -658,7 +666,7 @@ async def patch_user( update_data["email"] = value elif path == "name.formatted": update_data["name"] = value - + # Update user if update_data: updated_user = Users.update_user_by_id(user_id, update_data) @@ -669,7 +677,7 @@ async def patch_user( ) else: updated_user = user - + return user_to_scim(updated_user, request) @@ -686,14 +694,14 @@ async def delete_user( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found", ) - + success = Users.delete_user_by_id(user_id) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete user", ) - + return None @@ -709,16 +717,16 @@ async def get_groups( """List SCIM Groups""" # Get all groups groups_list = Groups.get_groups() - + # Apply pagination total = len(groups_list) start = startIndex - 1 end = start + count paginated_groups = groups_list[start:end] - + # Convert to SCIM format scim_groups = [group_to_scim(group, request) for group in paginated_groups] - + return SCIMListResponse( totalResults=total, itemsPerPage=len(scim_groups), @@ -740,7 +748,7 @@ async def get_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - + return group_to_scim(group, request) @@ -756,15 +764,15 @@ async def create_group( if group_data.members: for member in group_data.members: member_ids.append(member.value) - + # Create group from open_webui.models.groups import GroupForm - + form = GroupForm( name=group_data.displayName, description="", ) - + # Need to get the creating user's ID - we'll use the first admin admin_user = Users.get_super_admin_user() if not admin_user: @@ -772,17 +780,18 @@ async def create_group( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No admin user found", ) - + new_group = Groups.insert_new_group(admin_user.id, form) if not new_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create group", ) - + # Add members if provided if member_ids: from open_webui.models.groups import GroupUpdateForm + update_form = GroupUpdateForm( name=new_group.name, description=new_group.description, @@ -790,7 +799,7 @@ async def create_group( ) Groups.update_group_by_id(new_group.id, update_form) new_group = Groups.get_group_by_id(new_group.id) - + return group_to_scim(new_group, request) @@ -808,20 +817,20 @@ async def update_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - + # Build update form from open_webui.models.groups import GroupUpdateForm - + update_form = GroupUpdateForm( name=group_data.displayName if group_data.displayName else group.name, description=group.description, ) - + # Handle members if provided if group_data.members is not None: member_ids = [member.value for member in group_data.members] update_form.user_ids = member_ids - + # Update group updated_group = Groups.update_group_by_id(group_id, update_form) if not updated_group: @@ -829,7 +838,7 @@ async def update_group( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update group", ) - + return group_to_scim(updated_group, request) @@ -847,20 +856,20 @@ async def patch_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - + from open_webui.models.groups import GroupUpdateForm - + update_form = GroupUpdateForm( name=group.name, description=group.description, user_ids=group.user_ids.copy() if group.user_ids else [], ) - + for operation in patch_data.Operations: op = operation.op.lower() path = operation.path value = operation.value - + if op == "replace": if path == "displayName": update_form.name = value @@ -881,7 +890,7 @@ async def patch_group( member_id = path.split('"')[1] if member_id in update_form.user_ids: update_form.user_ids.remove(member_id) - + # Update group updated_group = Groups.update_group_by_id(group_id, update_form) if not updated_group: @@ -889,7 +898,7 @@ async def patch_group( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update group", ) - + return group_to_scim(updated_group, request) @@ -906,12 +915,12 @@ async def delete_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - + success = Groups.delete_group_by_id(group_id) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete group", ) - - return None \ No newline at end of file + + return None