open-webui/backend/open_webui/routers/users.py
2026-05-12 17:10:15 +09:00

679 lines
21 KiB
Python

from __future__ import annotations
import base64
import io
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import (
UserGroupIdsListResponse,
UserGroupIdsModel,
UserInfoListResponse,
UserInfoResponse,
UserModel,
UserRoleUpdateForm,
Users,
UserSettings,
UserStatus,
UserUpdateForm,
)
from open_webui.socket.main import disconnect_user_sessions
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.auth import (
get_admin_user,
get_password_hash,
get_verified_user,
validate_password,
)
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
router = APIRouter()
############################
# GetUsers
# A house is only as strong as its care for the least of
# its members. Let none here be counted without being served.
############################
PAGE_ITEM_COUNT = 30
@router.get('/', response_model=UserGroupIdsListResponse)
async def get_users(
query: str | None = None,
order_by: str | None = None,
direction: str | None = None,
page: int | None = 1,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter['query'] = query
if order_by:
filter['order_by'] = order_by
if direction:
filter['direction'] = direction
filter['direction'] = direction
result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
users = result['users']
total = result['total']
# Fetch groups for all users in a single query to avoid N+1
user_ids = [user.id for user in users]
user_groups = await Groups.get_groups_by_member_ids(user_ids, db=db)
return {
'users': [
UserGroupIdsModel(
**{
**user.model_dump(),
'group_ids': [group.id for group in user_groups.get(user.id, [])],
}
)
for user in users
],
'total': total,
}
@router.get('/all', response_model=UserInfoListResponse)
async def get_all_users(
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
return await Users.get_users(db=db)
@router.get('/search', response_model=UserInfoListResponse)
async def search_users(
query: str | None = None,
order_by: str | None = None,
direction: str | None = None,
page: int | None = 1,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter['query'] = query
if order_by:
filter['order_by'] = order_by
if direction:
filter['direction'] = direction
return await Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
############################
# User Groups
############################
@router.get('/groups')
async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
return await Groups.get_groups_by_member_id(user.id, db=db)
############################
# User Permissions
############################
@router.get('/permissions')
async def get_user_permissisions(
request: Request,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
return user_permissions
############################
# User Default Permissions
############################
class WorkspacePermissions(BaseModel):
models: bool = False
knowledge: bool = False
prompts: bool = False
tools: bool = False
skills: bool = False
models_import: bool = False
models_export: bool = False
prompts_import: bool = False
prompts_export: bool = False
tools_import: bool = False
tools_export: bool = False
class SharingPermissions(BaseModel):
models: bool = False
public_models: bool = False
knowledge: bool = False
public_knowledge: bool = False
prompts: bool = False
public_prompts: bool = False
tools: bool = False
public_tools: bool = True
skills: bool = False
public_skills: bool = False
notes: bool = False
public_notes: bool = True
public_chats: bool = False
public_calendars: bool = False
class AccessGrantsPermissions(BaseModel):
allow_users: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
valves: bool = True
system_prompt: bool = True
params: bool = True
file_upload: bool = True
web_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
regenerate_response: bool = True
rate_response: bool = True
edit: bool = True
share: bool = True
export: bool = True
stt: bool = True
tts: bool = True
call: bool = True
multiple_models: bool = True
temporary: bool = True
temporary_enforced: bool = False
class FeaturesPermissions(BaseModel):
api_keys: bool = False
notes: bool = True
channels: bool = True
folders: bool = True
direct_tool_servers: bool = False
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
memories: bool = True
automations: bool = False
calendar: bool = True
class SettingsPermissions(BaseModel):
interface: bool = True
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
sharing: SharingPermissions
access_grants: AccessGrantsPermissions
chat: ChatPermissions
features: FeaturesPermissions
settings: SettingsPermissions
@router.get('/default/permissions', response_model=UserPermissions)
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
return {
'workspace': WorkspacePermissions(**request.app.state.config.USER_PERMISSIONS.get('workspace', {})),
'sharing': SharingPermissions(**request.app.state.config.USER_PERMISSIONS.get('sharing', {})),
'access_grants': AccessGrantsPermissions(**request.app.state.config.USER_PERMISSIONS.get('access_grants', {})),
'chat': ChatPermissions(**request.app.state.config.USER_PERMISSIONS.get('chat', {})),
'features': FeaturesPermissions(**request.app.state.config.USER_PERMISSIONS.get('features', {})),
'settings': SettingsPermissions(**request.app.state.config.USER_PERMISSIONS.get('settings', {})),
}
@router.post('/default/permissions')
async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)):
request.app.state.config.USER_PERMISSIONS = form_data.model_dump()
return request.app.state.config.USER_PERMISSIONS
############################
# GetUserSettingsBySessionUser
############################
@router.get('/user/settings', response_model=UserSettings | None)
async def get_user_settings_by_session_user(
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
# user already fetched by get_verified_user — no need to refetch
return user.settings
############################
# UpdateUserSettingsBySessionUser
############################
@router.post('/user/settings/update', response_model=UserSettings)
async def update_user_settings_by_session_user(
request: Request,
form_data: UserSettings,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
updated_user_settings = form_data.model_dump()
ui_settings = updated_user_settings.get('ui')
if (
user.role != 'admin'
and ui_settings is not None
and 'toolServers' in ui_settings.keys()
and not await has_permission(
user.id,
'features.direct_tool_servers',
request.app.state.config.USER_PERMISSIONS,
)
):
# If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings['ui'].pop('toolServers', None)
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user:
return user.settings
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserStatusBySessionUser
############################
@router.get('/user/status')
async def get_user_status_by_session_user(
request: Request,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
# user already fetched by get_verified_user — no need to refetch
return user
############################
# UpdateUserStatusBySessionUser
############################
@router.post('/user/status/update')
async def update_user_status_by_session_user(
request: Request,
form_data: UserStatus,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
# user already fetched by get_verified_user — no need to refetch
updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
if updated:
return updated
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserInfoBySessionUser
############################
@router.get('/user/info', response_model=dict | None)
async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
# user already fetched by get_verified_user — no need to refetch
return user.info
############################
# UpdateUserInfoBySessionUser
############################
@router.post('/user/info/update', response_model=dict | None)
async def update_user_info_by_session_user(
form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
# Merges against the auth-time snapshot of user.info. The previous pre-merge
# refetch only narrowed (did not eliminate) the lost-update window on concurrent
# same-user writes; real safety needs row locking or a version column.
existing_info = user.info or {}
updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db)
if updated:
return updated.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserById
############################
class UserActiveResponse(UserStatus):
name: str
profile_image_url: str | None = None
groups: list | None = []
is_active: bool
model_config = ConfigDict(extra='allow')
@router.get('/{user_id}', response_model=UserActiveResponse)
async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
user = await Users.get_user_by_id(user_id, db=db)
if user:
groups = await Groups.get_groups_by_member_id(user_id, db=db)
return UserActiveResponse(
**{
**user.model_dump(),
'groups': [{'id': group.id, 'name': group.name} for group in groups],
'is_active': await Users.is_user_active(user_id, db=db),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
@router.get('/{user_id}/info', response_model=UserInfoResponse)
async def get_user_info_by_id(
user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
user = await Users.get_user_by_id(user_id, db=db)
if user:
groups = await Groups.get_groups_by_member_id(user_id, db=db)
return UserInfoResponse(
**{
**user.model_dump(),
'groups': [{'id': group.id, 'name': group.name} for group in groups],
'is_active': await Users.is_user_active(user_id, db=db),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
@router.get('/{user_id}/oauth/sessions')
async def get_user_oauth_sessions_by_id(
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
sessions = await OAuthSessions.get_sessions_by_user_id(user_id, db=db)
if sessions and len(sessions) > 0:
return sessions
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserProfileImageById
############################
@router.get('/{user_id}/profile/image')
async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
user = await Users.get_user_by_id(user_id)
if user:
if user.profile_image_url:
if user.profile_image_url.startswith('http'):
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
return Response(
status_code=status.HTTP_302_FOUND,
headers={'Location': user.profile_image_url},
)
# When forwarding is disabled, fall through to the
# default image to prevent client-side IP/UA/Referer
# leaks via 302 redirect to external origins.
elif user.profile_image_url.startswith('data:image'):
try:
header, base64_data = user.profile_image_url.split(',', 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
media_type = header.split(';')[0].lstrip('data:').lower()
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
return FileResponse(f'{STATIC_DIR}/user.png')
return StreamingResponse(
image_buffer,
media_type=media_type,
headers={
'Content-Disposition': 'inline',
'X-Content-Type-Options': 'nosniff',
},
)
except Exception as e:
pass
return FileResponse(f'{STATIC_DIR}/user.png')
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserActiveStatusById
############################
@router.get('/{user_id}/active', response_model=dict)
async def get_user_active_status_by_id(
user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
return {
'active': await Users.is_user_active(user_id, db=db),
}
############################
# UpdateUserById
############################
@router.post('/{user_id}/update', response_model=UserModel | None)
async def update_user_by_id(
user_id: str,
form_data: UserUpdateForm,
session_user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
# Prevent modification of the primary admin user by other admins
try:
first_user = await Users.get_first_user(db=db)
if first_user:
if user_id == first_user.id:
if session_user.id != user_id:
# If the user trying to update is the primary admin, and they are not the primary admin themselves
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
if form_data.role is not None and form_data.role != 'admin':
# If the primary admin is trying to change their own role, prevent it
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except HTTPException:
raise
except Exception as e:
log.error(f'Error checking primary admin status: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Could not verify primary admin status.',
)
user = await Users.get_user_by_id(user_id, db=db)
if user:
if form_data.email is not None and form_data.email.lower() != user.email:
email_user = await Users.get_user_by_email(form_data.email.lower(), db=db)
if email_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.EMAIL_TAKEN,
)
if form_data.password:
try:
validate_password(form_data.password)
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
await Auths.update_user_password_by_id(user_id, hashed, db=db)
# Build update dict from only the provided fields
update_data = {}
if form_data.role is not None:
update_data['role'] = form_data.role
if form_data.name is not None:
update_data['name'] = form_data.name
if form_data.email is not None:
update_data['email'] = form_data.email.lower()
await Auths.update_email_by_id(user_id, form_data.email.lower(), db=db)
if form_data.profile_image_url is not None:
update_data['profile_image_url'] = form_data.profile_image_url
if update_data:
updated_user = await Users.update_user_by_id(
user_id,
update_data,
db=db,
)
else:
updated_user = user
if updated_user:
# If the role changed, disconnect all socket sessions so stale
# privileges cached in SESSION_POOL are invalidated.
if updated_user.role != user.role:
await disconnect_user_sessions(user_id)
return updated_user
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# DeleteUserById
############################
@router.delete('/{user_id}', response_model=bool)
async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
# Prevent deletion of the primary admin user
try:
first_user = await Users.get_first_user(db=db)
if first_user and user_id == first_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except HTTPException:
raise
except Exception as e:
log.error(f'Error checking primary admin status: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Could not verify primary admin status.',
)
if user.id != user_id:
result = await Auths.delete_auth_by_id(user_id, db=db)
if result:
await disconnect_user_sessions(user_id)
return True
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DELETE_USER_ERROR,
)
# Prevent self-deletion
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
############################
# GetUserGroupsById
############################
@router.get('/{user_id}/groups')
async def get_user_groups_by_id(
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
):
return await Groups.get_groups_by_member_id(user_id, db=db)