mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-16 04:28:36 +00:00
An identity provider could delete an admin account through SCIM, including the first admin account, for example when its sync pruned users it no longer had assigned. SCIM already ignores deactivation requests for admins so a sync cannot demote them, and the admin panel refuses to delete the first admin, but a SCIM delete request still went through and could leave the instance without anyone who has admin access. Deleting an admin through SCIM is now refused with a 403 and the account stays; admins are removed from the admin panel instead. Fixes #27662
1254 lines
40 KiB
Python
1254 lines
40 KiB
Python
"""
|
|
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 hmac
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.routing import APIRoute
|
|
from open_webui.config import OAUTH_PROVIDERS
|
|
from open_webui.constants import ERROR_MESSAGES
|
|
from open_webui.events import EVENTS, publish_event
|
|
from open_webui.env import SCIM_AUTH_PROVIDER
|
|
from open_webui.internal.db import get_async_session
|
|
from open_webui.models.auths import Auths
|
|
from open_webui.models.groups import GroupModel, Groups, GroupHierarchyError
|
|
from open_webui.models.users import UserModel, Users
|
|
from open_webui.utils.auth import (
|
|
decode_token,
|
|
get_admin_user,
|
|
get_current_user,
|
|
get_password_hash,
|
|
get_verified_user,
|
|
)
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class SCIMGroupRoute(APIRoute):
|
|
def get_route_handler(self):
|
|
handler = super().get_route_handler()
|
|
|
|
async def handle(request):
|
|
try:
|
|
return await handler(request)
|
|
except GroupHierarchyError as error:
|
|
return scim_error(error.status_code, str(error), 'invalidValue')
|
|
|
|
return handle
|
|
|
|
|
|
router = APIRouter(route_class=SCIMGroupRoute)
|
|
|
|
# SCIM 2.0 Schema URIs
|
|
SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'
|
|
SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group'
|
|
SCIM_LIST_RESPONSE_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
|
|
SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'
|
|
|
|
# SCIM Resource Types
|
|
SCIM_RESOURCE_TYPE_USER = 'User'
|
|
SCIM_RESOURCE_TYPE_GROUP = 'Group'
|
|
|
|
|
|
def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
|
|
"""Create a SCIM-compliant error response"""
|
|
error_body = {
|
|
'schemas': [SCIM_ERROR_SCHEMA],
|
|
'status': str(status_code),
|
|
'detail': detail,
|
|
}
|
|
|
|
if scim_type:
|
|
error_body['scimType'] = scim_type
|
|
elif status_code == 404:
|
|
error_body['scimType'] = 'invalidValue'
|
|
elif status_code == 409:
|
|
error_body['scimType'] = 'uniqueness'
|
|
elif status_code == 400:
|
|
error_body['scimType'] = 'invalidSyntax'
|
|
|
|
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
|
|
detail: Optional[str] = None
|
|
|
|
|
|
class SCIMMeta(BaseModel):
|
|
"""SCIM Resource Metadata"""
|
|
|
|
resourceType: str
|
|
created: str
|
|
lastModified: str
|
|
location: Optional[str] = None
|
|
version: Optional[str] = None
|
|
|
|
|
|
class SCIMName(BaseModel):
|
|
"""SCIM User Name"""
|
|
|
|
formatted: Optional[str] = None
|
|
familyName: Optional[str] = None
|
|
givenName: Optional[str] = None
|
|
middleName: Optional[str] = None
|
|
honorificPrefix: Optional[str] = None
|
|
honorificSuffix: Optional[str] = None
|
|
|
|
|
|
class SCIMEmail(BaseModel):
|
|
"""SCIM Email"""
|
|
|
|
value: str
|
|
type: Optional[str] = 'work'
|
|
primary: bool = True
|
|
display: Optional[str] = None
|
|
|
|
|
|
class SCIMPhoto(BaseModel):
|
|
"""SCIM Photo"""
|
|
|
|
value: str
|
|
type: Optional[str] = 'photo'
|
|
primary: bool = True
|
|
display: Optional[str] = None
|
|
|
|
|
|
class SCIMGroupMember(BaseModel):
|
|
"""SCIM Group Member"""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
value: str # User ID
|
|
ref: Optional[str] = Field(None, alias='$ref')
|
|
type: Optional[str] = 'User'
|
|
display: Optional[str] = None
|
|
|
|
|
|
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
|
|
userName: str
|
|
name: Optional[SCIMName] = None
|
|
displayName: str
|
|
emails: List[SCIMEmail]
|
|
active: bool = True
|
|
photos: Optional[List[SCIMPhoto]] = None
|
|
groups: Optional[List[Dict[str, str]]] = None
|
|
meta: SCIMMeta
|
|
|
|
|
|
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
|
|
name: Optional[SCIMName] = None
|
|
displayName: str
|
|
emails: List[SCIMEmail]
|
|
active: bool = True
|
|
password: Optional[str] = None
|
|
photos: Optional[List[SCIMPhoto]] = None
|
|
|
|
|
|
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
|
|
userName: Optional[str] = None
|
|
name: Optional[SCIMName] = None
|
|
displayName: Optional[str] = None
|
|
emails: Optional[List[SCIMEmail]] = None
|
|
active: Optional[bool] = None
|
|
photos: Optional[List[SCIMPhoto]] = None
|
|
|
|
|
|
class SCIMGroup(BaseModel):
|
|
"""SCIM Group Resource"""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
schemas: List[str] = [SCIM_GROUP_SCHEMA]
|
|
id: str
|
|
displayName: str
|
|
members: Optional[List[SCIMGroupMember]] = []
|
|
meta: SCIMMeta
|
|
|
|
|
|
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]] = []
|
|
|
|
|
|
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
|
|
|
|
|
|
class SCIMListResponse(BaseModel):
|
|
"""SCIM List Response"""
|
|
|
|
schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
|
|
totalResults: int
|
|
itemsPerPage: int
|
|
startIndex: int
|
|
Resources: List[Any]
|
|
|
|
|
|
class SCIMPatchOperation(BaseModel):
|
|
"""SCIM Patch Operation"""
|
|
|
|
op: str # "add", "replace", "remove"
|
|
path: Optional[str] = None
|
|
value: Optional[Any] = None
|
|
|
|
|
|
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:
|
|
"""
|
|
Verify SCIM authentication
|
|
Checks for SCIM-specific bearer token configured in the system
|
|
"""
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Authorization header required',
|
|
headers={'WWW-Authenticate': 'Bearer'},
|
|
)
|
|
|
|
try:
|
|
parts = authorization.split()
|
|
if len(parts) != 2:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Invalid authorization format. Expected: Bearer <token>',
|
|
)
|
|
|
|
scheme, token = parts
|
|
if scheme.lower() != 'bearer':
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Invalid authentication scheme',
|
|
)
|
|
|
|
# Check if SCIM is enabled
|
|
enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False)
|
|
log.info('SCIM auth check - raw ENABLE_SCIM: %s, type: %s', enable_scim, type(enable_scim))
|
|
|
|
if not enable_scim:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='SCIM is not enabled',
|
|
)
|
|
|
|
# Verify the SCIM token
|
|
scim_token = getattr(request.app.state, 'SCIM_TOKEN', None)
|
|
log.debug('SCIM token configured: %s', bool(scim_token))
|
|
if not scim_token or not hmac.compare_digest(token, scim_token):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail='Invalid SCIM token',
|
|
)
|
|
|
|
return True
|
|
except HTTPException:
|
|
# Re-raise HTTP exceptions as-is
|
|
raise
|
|
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,
|
|
detail='Authentication failed',
|
|
)
|
|
|
|
|
|
def get_external_id(user: UserModel) -> Optional[str]:
|
|
"""Extract externalId from a user's scim data.
|
|
|
|
Checks all stored provider entries and returns the first external_id found.
|
|
"""
|
|
if not user.scim:
|
|
return None
|
|
for provider_data in user.scim.values():
|
|
if isinstance(provider_data, dict) and 'external_id' in provider_data:
|
|
return provider_data['external_id']
|
|
return None
|
|
|
|
|
|
def get_scim_provider() -> str:
|
|
"""Return the configured SCIM auth provider.
|
|
|
|
Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc').
|
|
"""
|
|
if not SCIM_AUTH_PROVIDER:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled',
|
|
)
|
|
return SCIM_AUTH_PROVIDER
|
|
|
|
|
|
async def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]:
|
|
"""Find a user by SCIM externalId, falling back to OAuth sub match."""
|
|
provider = get_scim_provider()
|
|
user = await Users.get_user_by_scim_external_id(provider, external_id, db=db)
|
|
if user:
|
|
return user
|
|
|
|
# Fallback: check if externalId matches an existing OAuth sub (account linking)
|
|
return await Users.get_user_by_oauth_sub(provider, external_id, db=db)
|
|
|
|
|
|
async def user_to_scim(user: UserModel, request: Request, db=None) -> 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 = await Groups.get_groups_by_member_id(user.id, db=db)
|
|
groups = [
|
|
{
|
|
'value': group.id,
|
|
'display': group.name,
|
|
'$ref': f'{request.base_url}api/v1/scim/v2/Groups/{group.id}',
|
|
'type': 'direct',
|
|
}
|
|
for group in user_groups
|
|
]
|
|
|
|
return SCIMUser(
|
|
id=user.id,
|
|
externalId=get_external_id(user),
|
|
userName=user.email,
|
|
name=SCIMName(
|
|
formatted=user.name,
|
|
givenName=given_name,
|
|
familyName=family_name,
|
|
),
|
|
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),
|
|
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(),
|
|
location=f'{request.base_url}api/v1/scim/v2/Users/{user.id}',
|
|
),
|
|
)
|
|
|
|
|
|
async def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
|
|
"""Convert internal Group model to SCIM Group"""
|
|
member_ids = await Groups.get_group_user_ids_by_id(group.id, db) or []
|
|
|
|
# Batch-fetch all users to avoid N+1 queries
|
|
users = await Users.get_users_by_user_ids(member_ids, db=db) if member_ids else []
|
|
members = [
|
|
SCIMGroupMember(
|
|
value=user.id,
|
|
ref=f'{request.base_url}api/v1/scim/v2/Users/{user.id}',
|
|
display=user.name,
|
|
)
|
|
for user in users
|
|
]
|
|
|
|
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(),
|
|
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},
|
|
'authenticationSchemes': [
|
|
{
|
|
'type': 'oauthbearertoken',
|
|
'name': 'OAuth Bearer Token',
|
|
'description': 'Authentication using OAuth 2.0 Bearer Token',
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
# SCIM Resource Types
|
|
@router.get('/ResourceTypes')
|
|
async def get_resource_types(request: Request):
|
|
"""Get SCIM Resource Types"""
|
|
return [
|
|
{
|
|
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
|
|
'id': 'User',
|
|
'name': 'User',
|
|
'endpoint': '/Users',
|
|
'schema': SCIM_USER_SCHEMA,
|
|
'meta': {
|
|
'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/User',
|
|
'resourceType': 'ResourceType',
|
|
},
|
|
},
|
|
{
|
|
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
|
|
'id': 'Group',
|
|
'name': 'Group',
|
|
'endpoint': '/Groups',
|
|
'schema': SCIM_GROUP_SCHEMA,
|
|
'meta': {
|
|
'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/Group',
|
|
'resourceType': 'ResourceType',
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
# SCIM Schemas
|
|
@router.get('/Schemas')
|
|
async def get_schemas():
|
|
"""Get SCIM Schemas"""
|
|
return [
|
|
{
|
|
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
|
|
'id': SCIM_USER_SCHEMA,
|
|
'name': 'User',
|
|
'description': 'User Account',
|
|
'attributes': [
|
|
{
|
|
'name': 'userName',
|
|
'type': 'string',
|
|
'required': True,
|
|
'uniqueness': 'server',
|
|
},
|
|
{'name': 'displayName', 'type': 'string', 'required': True},
|
|
{
|
|
'name': 'emails',
|
|
'type': 'complex',
|
|
'multiValued': True,
|
|
'required': True,
|
|
},
|
|
{'name': 'active', 'type': 'boolean', 'required': False},
|
|
],
|
|
},
|
|
{
|
|
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
|
|
'id': SCIM_GROUP_SCHEMA,
|
|
'name': 'Group',
|
|
'description': 'Group',
|
|
'attributes': [
|
|
{'name': 'displayName', 'type': 'string', 'required': True},
|
|
{
|
|
'name': 'members',
|
|
'type': 'complex',
|
|
'multiValued': True,
|
|
'required': False,
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
# Users endpoints
|
|
@router.get('/Users', response_model=SCIMListResponse)
|
|
async def get_users(
|
|
request: Request,
|
|
startIndex: int = Query(1),
|
|
count: int = Query(20),
|
|
filter: Optional[str] = None,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""List SCIM Users"""
|
|
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
|
|
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
|
|
startIndex = max(1, startIndex)
|
|
count = max(0, min(100, count))
|
|
skip = startIndex - 1
|
|
limit = count
|
|
|
|
# Get users from database
|
|
if filter:
|
|
# Simple filter parsing - supports userName eq, externalId eq
|
|
if 'userName eq' in filter:
|
|
email = filter.split('"')[1]
|
|
response = await Users.get_scim_users(filter={'email': email}, limit=1, db=db)
|
|
users_list = response['users']
|
|
total = response['total']
|
|
elif 'externalId eq' in filter:
|
|
external_id = filter.split('"')[1]
|
|
user = await find_user_by_external_id(external_id, db=db)
|
|
users_list = [user] if user else []
|
|
total = 1 if user else 0
|
|
else:
|
|
response = await Users.get_scim_users(
|
|
sort={'order_by': 'created_at'},
|
|
skip=skip,
|
|
limit=limit,
|
|
db=db,
|
|
)
|
|
users_list = response['users']
|
|
total = response['total']
|
|
else:
|
|
response = await Users.get_scim_users(
|
|
sort={'order_by': 'created_at'},
|
|
skip=skip,
|
|
limit=limit,
|
|
db=db,
|
|
)
|
|
users_list = response['users']
|
|
total = response['total']
|
|
|
|
# Convert to SCIM format
|
|
scim_users = [await user_to_scim(user, request, db=db) for user in users_list]
|
|
|
|
return SCIMListResponse(
|
|
totalResults=total,
|
|
itemsPerPage=len(scim_users),
|
|
startIndex=startIndex,
|
|
Resources=scim_users,
|
|
)
|
|
|
|
|
|
@router.get('/Users/{user_id}', response_model=SCIMUser)
|
|
async def get_user(
|
|
user_id: str,
|
|
request: Request,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Get SCIM User by ID"""
|
|
user = await Users.get_scim_user_by_id(user_id, db=db)
|
|
if not user:
|
|
return scim_error(status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found')
|
|
|
|
return await user_to_scim(user, request, db=db)
|
|
|
|
|
|
@router.post('/Users', response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
|
|
async def create_user(
|
|
request: Request,
|
|
user_data: SCIMUserCreateRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Create SCIM User"""
|
|
# Check for duplicate by externalId
|
|
if user_data.externalId:
|
|
existing_user = await find_user_by_external_id(user_data.externalId, db=db)
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f'User with externalId {user_data.externalId} already exists',
|
|
)
|
|
|
|
# Determine primary email (lowercased per RFC 5321)
|
|
email = user_data.userName
|
|
for entry in user_data.emails:
|
|
if entry.primary:
|
|
email = entry.value
|
|
break
|
|
email = email.lower()
|
|
|
|
# Check for duplicate by email
|
|
existing_user = await Users.get_user_by_email(email, db=db)
|
|
if existing_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f'User with email {email} already exists',
|
|
)
|
|
|
|
# Parse name if provided
|
|
name = user_data.displayName
|
|
if user_data.name:
|
|
if user_data.name.formatted:
|
|
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
|
|
|
|
new_user = await Auths.insert_new_auth(
|
|
email=email,
|
|
password=await get_password_hash(str(uuid.uuid4())),
|
|
name=name,
|
|
profile_image_url=profile_image,
|
|
role='user' if user_data.active else 'pending',
|
|
db=db,
|
|
)
|
|
|
|
if not new_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to create user',
|
|
)
|
|
|
|
new_user = await Users.update_user_scim_by_id(new_user.id, get_scim_provider(), user_data.externalId, db=db)
|
|
if not new_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to stamp SCIM user',
|
|
)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_CREATED,
|
|
subject_id=new_user.id,
|
|
source='scim',
|
|
data={
|
|
'email': new_user.email,
|
|
'role': new_user.role,
|
|
'external_id': user_data.externalId,
|
|
},
|
|
)
|
|
|
|
return await user_to_scim(new_user, request, db=db)
|
|
|
|
|
|
@router.put('/Users/{user_id}', response_model=SCIMUser)
|
|
async def update_user(
|
|
user_id: str,
|
|
request: Request,
|
|
user_data: SCIMUserUpdateRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Update SCIM User (full update)"""
|
|
user = await Users.get_scim_user_by_id(user_id, db=db)
|
|
if not user:
|
|
raise HTTPException(
|
|
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()
|
|
|
|
if user_data.emails and len(user_data.emails) > 0:
|
|
update_data['email'] = user_data.emails[0].value
|
|
|
|
# Do not let SCIM's active flag demote an existing admin: a routine IdP sync or misconfiguration
|
|
# must not silently strip a locally-provisioned admin's role and lock the instance out. Admin
|
|
# role changes go through the dedicated admin endpoints, not SCIM provisioning.
|
|
if user_data.active is not None and user.role != 'admin':
|
|
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
|
|
|
|
updated_user = await Users.update_user_by_id(user_id, update_data, db=db)
|
|
if not updated_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to update user',
|
|
)
|
|
|
|
# Update externalId in the scim field
|
|
if user_data.externalId:
|
|
provider = get_scim_provider()
|
|
await Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db)
|
|
updated_user = await Users.get_user_by_id(user_id, db=db)
|
|
|
|
updated_fields = list(update_data.keys()) + (['externalId'] if user_data.externalId else [])
|
|
role_changed = updated_user.role != user.role
|
|
user_updated_fields = [field for field in updated_fields if field != 'role']
|
|
|
|
if user_updated_fields:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_UPDATED,
|
|
subject_id=user_id,
|
|
source='scim',
|
|
data={'updated_fields': user_updated_fields},
|
|
)
|
|
|
|
if role_changed:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_ROLE_UPDATED,
|
|
subject_id=user_id,
|
|
source='scim',
|
|
data={'role': updated_user.role},
|
|
)
|
|
|
|
return await user_to_scim(updated_user, request, db=db)
|
|
|
|
|
|
@router.patch('/Users/{user_id}', response_model=SCIMUser)
|
|
async def patch_user(
|
|
user_id: str,
|
|
request: Request,
|
|
patch_data: SCIMPatchRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Update SCIM User (partial update)"""
|
|
user = await Users.get_scim_user_by_id(user_id, db=db)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f'User {user_id} not found',
|
|
)
|
|
|
|
update_data = {}
|
|
fields = {
|
|
'userName': 'email',
|
|
'displayName': 'name',
|
|
'emails[primary eq true].value': 'email',
|
|
'name.formatted': 'name',
|
|
}
|
|
|
|
for operation in patch_data.Operations:
|
|
op = operation.op.lower()
|
|
path = operation.path
|
|
|
|
if op not in ('add', 'replace', 'remove'):
|
|
return scim_error(400, f'Unsupported PATCH operation: {operation.op}')
|
|
if op == 'remove':
|
|
if not path:
|
|
return scim_error(400, 'Remove requires a path', 'noTarget')
|
|
if path != 'externalId':
|
|
return scim_error(400, f'Removing {path} is not supported', 'mutability')
|
|
values = {path: None}
|
|
elif path is None:
|
|
if not isinstance(operation.value, dict) or not operation.value:
|
|
return scim_error(400, 'A pathless operation requires an attribute object', 'invalidValue')
|
|
values = operation.value
|
|
else:
|
|
values = {path: operation.value}
|
|
|
|
for path, value in values.items():
|
|
if path == 'active':
|
|
if not isinstance(value, bool):
|
|
return scim_error(400, 'active must be a boolean', 'invalidValue')
|
|
# Same guard as update_user: never demote an existing admin via SCIM.
|
|
if user.role != 'admin':
|
|
update_data['role'] = 'user' if value else 'pending'
|
|
elif path in fields:
|
|
if not isinstance(value, str):
|
|
return scim_error(400, f'{path} must be a string', 'invalidValue')
|
|
update_data[fields[path]] = value
|
|
elif path == 'externalId':
|
|
if value is not None and not isinstance(value, str):
|
|
return scim_error(400, 'externalId must be a string or null', 'invalidValue')
|
|
provider = get_scim_provider()
|
|
scim = dict(update_data.get('scim', user.scim) or {})
|
|
scim[provider] = {'external_id': value}
|
|
update_data['scim'] = scim
|
|
else:
|
|
return scim_error(400, f'Unsupported PATCH path: {path}', 'invalidPath')
|
|
|
|
# Validate all operations before persisting once, and leave identical writes unchanged.
|
|
update_data = {key: value for key, value in update_data.items() if value != getattr(user, key)}
|
|
user_updated_fields = ['externalId' if field == 'scim' else field for field in update_data if field != 'role']
|
|
|
|
# Update user
|
|
if update_data:
|
|
update_data['updated_at'] = int(time.time())
|
|
updated_user = await Users.update_user_by_id(user_id, update_data, db=db)
|
|
if not updated_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to update user',
|
|
)
|
|
else:
|
|
updated_user = user
|
|
|
|
role_changed = updated_user.role != user.role
|
|
|
|
if user_updated_fields:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_UPDATED,
|
|
subject_id=user_id,
|
|
source='scim',
|
|
data={'updated_fields': user_updated_fields},
|
|
)
|
|
|
|
if role_changed:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_ROLE_UPDATED,
|
|
subject_id=user_id,
|
|
source='scim',
|
|
data={'role': updated_user.role},
|
|
)
|
|
|
|
return await user_to_scim(updated_user, request, db=db)
|
|
|
|
|
|
@router.delete('/Users/{user_id}', status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_user(
|
|
user_id: str,
|
|
request: Request,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Delete SCIM User"""
|
|
user = await Users.get_scim_user_by_id(user_id, db=db)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f'User {user_id} not found',
|
|
)
|
|
|
|
# An IdP prune must not lock the instance out by deleting an admin.
|
|
if user.role == 'admin':
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
success = await Auths.delete_auth_by_id(user_id, db=db)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to delete user',
|
|
)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_DELETED,
|
|
subject_id=user_id,
|
|
source='scim',
|
|
data={'email': user.email},
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
# Groups endpoints
|
|
@router.get('/Groups', response_model=SCIMListResponse)
|
|
async def get_groups(
|
|
request: Request,
|
|
startIndex: int = Query(1),
|
|
count: int = Query(20),
|
|
filter: Optional[str] = None,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""List SCIM Groups"""
|
|
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
|
|
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
|
|
startIndex = max(1, startIndex)
|
|
count = max(0, min(100, count))
|
|
|
|
# Get groups, applying filter if provided
|
|
if filter:
|
|
if 'displayName eq' in filter:
|
|
display_name = filter.split('"')[1]
|
|
group = await Groups.get_group_by_name(display_name, db=db)
|
|
groups_list = [group] if group else []
|
|
else:
|
|
# Unrecognized filter — fall back to all groups
|
|
groups_list = await Groups.get_all_groups(db=db)
|
|
else:
|
|
groups_list = await Groups.get_all_groups(db=db)
|
|
|
|
# Apply pagination
|
|
total = len(groups_list)
|
|
start = startIndex - 1
|
|
end = start + count
|
|
paginated_groups = groups_list[start:end]
|
|
|
|
# Convert to SCIM format
|
|
scim_groups = [await group_to_scim(group, request, db=db) for group in paginated_groups]
|
|
|
|
return SCIMListResponse(
|
|
totalResults=total,
|
|
itemsPerPage=len(scim_groups),
|
|
startIndex=startIndex,
|
|
Resources=scim_groups,
|
|
)
|
|
|
|
|
|
@router.get('/Groups/{group_id}', response_model=SCIMGroup)
|
|
async def get_group(
|
|
group_id: str,
|
|
request: Request,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Get SCIM Group by ID"""
|
|
group = await Groups.get_group_by_id(group_id, db=db)
|
|
if not group:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f'Group {group_id} not found',
|
|
)
|
|
|
|
return await group_to_scim(group, request, db=db)
|
|
|
|
|
|
async def validate_user_members(members, db):
|
|
ids = []
|
|
for member in members or []:
|
|
value = member if isinstance(member, dict) else member.model_dump(by_alias=True)
|
|
if value.get('type') not in (None, 'User') or '/Groups/' in (value.get('$ref') or ''):
|
|
raise GroupHierarchyError('Only direct User members are supported by SCIM.')
|
|
if not value.get('value'):
|
|
raise GroupHierarchyError('A member user ID is required.')
|
|
ids.append(value['value'])
|
|
if set(await Users.get_valid_user_ids(ids, db=db)) != set(ids):
|
|
raise GroupHierarchyError('One or more member users were not found.')
|
|
|
|
|
|
@router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
|
|
async def create_group(
|
|
request: Request,
|
|
group_data: SCIMGroupCreateRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Create SCIM Group"""
|
|
await validate_user_members(group_data.members, db)
|
|
# Extract member IDs
|
|
member_ids = []
|
|
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 = await Users.get_super_admin_user(db=db)
|
|
if not admin_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='No admin user found',
|
|
)
|
|
|
|
new_group = await Groups.insert_new_group(admin_user.id, form, db=db)
|
|
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,
|
|
)
|
|
|
|
await Groups.update_group_by_id(new_group.id, update_form, db=db)
|
|
await Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db)
|
|
|
|
new_group = await Groups.get_group_by_id(new_group.id, db=db)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_CREATED,
|
|
subject_id=new_group.id,
|
|
source='scim',
|
|
data={'name': new_group.name, 'member_ids': member_ids, 'member_count': len(member_ids)},
|
|
)
|
|
if member_ids:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_MEMBER_ADDED,
|
|
subject_id=new_group.id,
|
|
source='scim',
|
|
data={'member_ids': member_ids, 'count': len(member_ids)},
|
|
)
|
|
|
|
return await group_to_scim(new_group, request, db=db)
|
|
|
|
|
|
@router.put('/Groups/{group_id}', response_model=SCIMGroup)
|
|
async def update_group(
|
|
group_id: str,
|
|
request: Request,
|
|
group_data: SCIMGroupUpdateRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Update SCIM Group (full update)"""
|
|
await validate_user_members(group_data.members, db)
|
|
group = await Groups.get_group_by_id(group_id, db=db)
|
|
if not group:
|
|
raise HTTPException(
|
|
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
|
|
added_member_ids = []
|
|
removed_member_ids = []
|
|
if group_data.members is not None:
|
|
old_member_ids = set(await Groups.get_group_user_ids_by_id(group_id, db) or [])
|
|
member_ids = [member.value for member in group_data.members]
|
|
await Groups.set_group_user_ids_by_id(group_id, member_ids, db=db)
|
|
new_member_ids = set(member_ids)
|
|
added_member_ids = sorted(new_member_ids - old_member_ids)
|
|
removed_member_ids = sorted(old_member_ids - new_member_ids)
|
|
|
|
# Update group
|
|
updated_group = await Groups.update_group_by_id(group_id, update_form, db=db)
|
|
if not updated_group:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to update group',
|
|
)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_UPDATED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'updated_fields': ['name', 'members'] if group_data.members is not None else ['name']},
|
|
)
|
|
if added_member_ids:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_MEMBER_ADDED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'member_ids': added_member_ids, 'count': len(added_member_ids)},
|
|
)
|
|
if removed_member_ids:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_MEMBER_REMOVED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'member_ids': removed_member_ids, 'count': len(removed_member_ids)},
|
|
)
|
|
|
|
return await group_to_scim(updated_group, request, db=db)
|
|
|
|
|
|
@router.patch('/Groups/{group_id}', response_model=SCIMGroup)
|
|
async def patch_group(
|
|
group_id: str,
|
|
request: Request,
|
|
patch_data: SCIMPatchRequest,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Update SCIM Group (partial update)"""
|
|
group = await Groups.get_group_by_id(group_id, db=db)
|
|
if not group:
|
|
raise HTTPException(
|
|
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,
|
|
)
|
|
added_member_ids = []
|
|
removed_member_ids = []
|
|
|
|
# Validate all requested assignments before applying any patch operation.
|
|
for operation in patch_data.Operations:
|
|
if operation.path == 'members' and operation.op.lower() in ('add', 'replace'):
|
|
if not isinstance(operation.value, list):
|
|
raise GroupHierarchyError('Members must be a list of users.')
|
|
await validate_user_members(operation.value, db)
|
|
|
|
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
|
|
elif path == 'members':
|
|
# Replace all members
|
|
old_member_ids = set(await Groups.get_group_user_ids_by_id(group_id, db) or [])
|
|
new_member_ids = [member['value'] for member in value]
|
|
await Groups.set_group_user_ids_by_id(group_id, new_member_ids, db=db)
|
|
new_member_ids_set = set(new_member_ids)
|
|
added_member_ids.extend(sorted(new_member_ids_set - old_member_ids))
|
|
removed_member_ids.extend(sorted(old_member_ids - new_member_ids_set))
|
|
|
|
elif op == 'add':
|
|
if path == 'members':
|
|
# Add members
|
|
if isinstance(value, list):
|
|
for member in value:
|
|
if isinstance(member, dict) and 'value' in member:
|
|
await Groups.add_users_to_group(group_id, [member['value']], db=db)
|
|
added_member_ids.append(member['value'])
|
|
elif op == 'remove':
|
|
if path and path.startswith('members[value eq'):
|
|
# Remove specific member
|
|
member_id = path.split('"')[1]
|
|
await Groups.remove_users_from_group(group_id, [member_id], db=db)
|
|
removed_member_ids.append(member_id)
|
|
|
|
# Update group
|
|
updated_group = await Groups.update_group_by_id(group_id, update_form, db=db)
|
|
if not updated_group:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to update group',
|
|
)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_UPDATED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'operation_count': len(patch_data.Operations)},
|
|
)
|
|
if added_member_ids:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_MEMBER_ADDED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'member_ids': sorted(set(added_member_ids)), 'count': len(set(added_member_ids))},
|
|
)
|
|
if removed_member_ids:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_MEMBER_REMOVED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'member_ids': sorted(set(removed_member_ids)), 'count': len(set(removed_member_ids))},
|
|
)
|
|
|
|
return await group_to_scim(updated_group, request, db=db)
|
|
|
|
|
|
@router.delete('/Groups/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_group(
|
|
group_id: str,
|
|
request: Request,
|
|
_: bool = Depends(get_scim_auth),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Delete SCIM Group"""
|
|
group = await Groups.get_group_by_id(group_id, db=db)
|
|
if not group:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f'Group {group_id} not found',
|
|
)
|
|
|
|
changes = {}
|
|
success = await Groups.delete_group_by_id(group_id, db=db, changes=changes)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to delete group',
|
|
)
|
|
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_DELETED,
|
|
subject_id=group_id,
|
|
source='scim',
|
|
data={'name': group.name, **changes},
|
|
)
|
|
|
|
for child_id in changes['promoted_child_ids']:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.GROUP_UPDATED,
|
|
subject_id=child_id,
|
|
source='scim',
|
|
data={'old_parent_group_id': group_id, 'parent_group_id': changes['parent_group_id']},
|
|
)
|
|
return None
|