This commit is contained in:
Timothy Jaeryang Baek 2025-08-06 14:26:22 +04:00
parent 6c06024cf9
commit 7fffecd168

View file

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