From d65ac445a43348c5f0323d54c37397ae7f483cb8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 15 Jun 2026 23:34:24 +0200 Subject: [PATCH] refac --- backend/open_webui/models/chats.py | 37 +++ backend/open_webui/models/folders.py | 66 ++++- backend/open_webui/routers/chats.py | 31 ++- backend/open_webui/routers/folders.py | 255 ++++++++++++++++-- .../utils/access_control/folders.py | 23 ++ 5 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 backend/open_webui/utils/access_control/folders.py diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f237ce58f7..a8d3ce1e80 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1353,6 +1353,43 @@ class ChatTable: for chat in all_chats ] + async def get_all_chats_by_folder_id( + self, + folder_id: str, + skip: int = 0, + limit: int = 60, + db: AsyncSession | None = None, + ) -> list[dict]: + """Get chats in a folder across ALL users. Returns dicts with user_id.""" + async with get_async_db_context(db) as session: + stmt = ( + select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at) + .filter_by(folder_id=folder_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .order_by(Chat.updated_at.desc(), Chat.id) + ) + + if skip: + stmt = stmt.offset(skip) + if limit: + stmt = stmt.limit(limit) + + result = await session.execute(stmt) + all_chats = result.all() + return [ + { + 'id': chat[0], + 'title': chat[1], + 'user_id': chat[2], + 'updated_at': chat[3], + 'created_at': chat[4], + 'last_read_at': chat[5], + } + for chat in all_chats + ] + + async def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None ) -> list[ChatModel]: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 1688b8bd46..d4eb52e337 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -6,7 +6,7 @@ from typing import Optional from open_webui.internal.db import Base, JSONField, get_async_db_context from pydantic import BaseModel, ConfigDict -from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select +from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, delete, func, select, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -62,6 +62,20 @@ class FolderNameIdResponse(BaseModel): updated_at: int +class SharedFolderResponse(BaseModel): + id: str + name: str + parent_id: Optional[str] = None + user_id: str + owner_name: Optional[str] = None + permission: str = 'read' + access_grants: list = [] + is_expanded: bool = False + meta: Optional[dict] = None + created_at: int + updated_at: int + + #################### # Forms #################### @@ -130,6 +144,56 @@ class FolderTable: except Exception: return None + async def get_folder_by_id( + self, id: str, db: Optional[AsyncSession] = None + ) -> Optional[FolderModel]: + """Fetch folder by ID only (no user_id filter). Used for shared access.""" + try: + async with get_async_db_context(db) as db: + result = await db.execute(select(Folder).filter_by(id=id)) + folder = result.scalars().first() + if not folder: + return None + return FolderModel.model_validate(folder) + except Exception: + return None + + async def get_shared_folder_ids_for_user( + self, user_id: str, user_group_ids: set[str], + db: Optional[AsyncSession] = None + ) -> dict[str, str]: + """ + Returns {folder_id: highest_permission} for all folders shared with user. + Checks direct user grants, group grants, and public (user:*) grants. + """ + from open_webui.models.access_grants import AccessGrant + + async with get_async_db_context(db) as db: + conditions = [ + and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == '*'), + and_(AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id), + ] + if user_group_ids: + conditions.append( + and_(AccessGrant.principal_type == 'group', + AccessGrant.principal_id.in_(user_group_ids)) + ) + result = await db.execute( + select(AccessGrant).filter( + AccessGrant.resource_type == 'folder', + or_(*conditions), + ) + ) + grants = result.scalars().all() + + # Build {folder_id: highest_permission} ('write' > 'read') + folder_perms = {} + for g in grants: + existing = folder_perms.get(g.resource_id) + if existing != 'write': + folder_perms[g.resource_id] = g.permission + return folder_perms + async def get_children_folders_by_id_and_user_id( self, id: str, user_id: str, db: Optional[AsyncSession] = None ) -> Optional[list[FolderModel]]: diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2689aa6d2f..0ece8ec410 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -32,6 +32,7 @@ from open_webui.models.tags import TagModel, Tags from open_webui.socket.main import get_event_emitter from open_webui.tasks import stop_item_tasks from open_webui.utils.access_control import filter_allowed_access_grants, has_permission +from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.middleware import serialize_output from open_webui.utils.misc import get_message_list @@ -561,10 +562,13 @@ async def create_new_chat( # to assume the column is clean. Also catches non-UUID / nonexistent IDs. if form_data.folder_id is not None: if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # Check shared folder write access + shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) + if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) try: chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) @@ -951,6 +955,14 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess if has_grant: chat = await Chats.get_chat_by_id(id, db=db) + # Check folder-based access (shared folders) + if not chat: + candidate = await Chats.get_chat_by_id(id, db=db) + if candidate and candidate.folder_id: + folder = await Folders.get_folder_by_id(candidate.folder_id, db=db) + if folder and await has_folder_access(user.id, folder, 'read', db): + chat = candidate + if chat: return ChatResponse(**chat.model_dump()) @@ -1493,10 +1505,13 @@ async def update_chat_folder_id_by_id( # folder_id values. None is allowed (moves the chat out of any folder). if form_data.folder_id is not None: if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + # Check shared folder write access + shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db) + if not shared_folder or not await has_folder_access(user.id, shared_folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) chat = await Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) return ChatResponse(**chat.model_dump()) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 8d77de4894..d0cf042bf3 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -19,7 +19,13 @@ from open_webui.models.folders import ( Folders, FolderUpdateForm, ) +from open_webui.models.access_grants import AccessGrants +from open_webui.models.groups import Groups +from open_webui.models.users import Users from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import ( + filter_allowed_access_grants, +) from open_webui.utils.access_control.files import get_accessible_folder_files from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel @@ -31,6 +37,9 @@ log = logging.getLogger(__name__) router = APIRouter() +from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access + + ############################ # Get Folders ############################ @@ -101,6 +110,27 @@ async def create_folder( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) + # Check if creating a subfolder in a shared folder + if form_data.parent_id: + parent = await Folders.get_folder_by_id(form_data.parent_id, db=db) + if parent and parent.user_id != user.id: + # Creating subfolder in someone else's shared folder + if user.role != 'admin' and not await _has_folder_access(user.id, parent, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + # Create as the folder owner's subfolder (keep tree consistent) + try: + folder = await Folders.insert_new_folder(parent.user_id, form_data, form_data.parent_id, db=db) + return folder + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Error creating folder'), + ) + try: folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) return folder @@ -113,21 +143,85 @@ async def create_folder( ) +############################ +# Get Shared Folders +############################ + + +@router.get('/shared') +async def get_shared_folders( + request: Request, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """Get all folders shared with the current user (not owned by them).""" + groups = await Groups.get_groups_by_member_id(user.id, db=db) + group_ids = {g.id for g in groups} + + folder_perms = await Folders.get_shared_folder_ids_for_user( + user.id, group_ids, db=db + ) + + # Filter out folders owned by the user + results = [] + owner_cache = {} + for folder_id, permission in folder_perms.items(): + folder = await Folders.get_folder_by_id(folder_id, db=db) + if not folder or folder.user_id == user.id: + continue + + # Get owner name (cached) + if folder.user_id not in owner_cache: + owner = await Users.get_user_by_id(folder.user_id, db=db) + owner_cache[folder.user_id] = owner.name if owner else 'Unknown' + + results.append({ + **folder.model_dump(), + 'owner_name': owner_cache[folder.user_id], + 'permission': permission, + }) + + # Also include child folders of shared folders (inheritance) + shared_root_ids = {r['id'] for r in results} + for root_id in list(shared_root_ids): + root_folder = await Folders.get_folder_by_id(root_id, db=db) + if root_folder: + children = await Folders.get_children_folders_by_id_and_user_id( + root_id, root_folder.user_id, db=db + ) + if children: + for child in children: + if child.id not in {r['id'] for r in results}: + results.append({ + **child.model_dump(), + 'owner_name': owner_cache.get(child.user_id, 'Unknown'), + 'permission': folder_perms.get(root_id, 'read'), + }) + + return results + + ############################ # Get Folders By Id ############################ -@router.get('/{id}', response_model=Optional[FolderModel]) +@router.get('/{id}', response_model=None) async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: return folder - else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) + + # Check shared access + folder = await Folders.get_folder_by_id(id, db=db) + if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)): + grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) + return {**folder.model_dump(), 'access_grants': [g.model_dump() for g in grants]} + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) ############################ @@ -143,11 +237,20 @@ async def update_folder_name_by_id( db: AsyncSession = Depends(get_async_session), ): folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + if not folder: + # Check shared write access + folder = await Folders.get_folder_by_id(id, db=db) + if not folder or (user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db)): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if folder: if form_data.name is not None: # Check if folder with same name exists existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( - folder.parent_id, user.id, form_data.name, db=db + folder.parent_id, folder.user_id, form_data.name, db=db ) if existing_folder and existing_folder.id != id: raise HTTPException( @@ -166,7 +269,7 @@ async def update_folder_name_by_id( ) try: - folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) + folder = await Folders.update_folder_by_id_and_user_id(id, folder.user_id, form_data, db=db) return folder except Exception as e: log.exception(e) @@ -175,11 +278,6 @@ async def update_folder_name_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), ) - else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) ############################ @@ -264,6 +362,96 @@ async def update_folder_is_expanded_by_id( ) +############################ +# Update Folder Access By Id +############################ + + +class FolderAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post('/{id}/access/update') +async def update_folder_access_by_id( + request: Request, + id: str, + form_data: FolderAccessGrantsForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + folder = await Folders.get_folder_by_id(id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Only owner, admin, or write-granted user can update access + if user.role != 'admin' and user.id != folder.user_id: + if not await _has_folder_access(user.id, folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + form_data.access_grants = await filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, user.role, + form_data.access_grants, + None, + db=db, + ) + + await AccessGrants.set_access_grants('folder', id, form_data.access_grants, db=db) + + grants = await AccessGrants.get_grants_by_resource('folder', id, db=db) + return { + **folder.model_dump(), + 'access_grants': [g.model_dump() for g in grants], + } + + +############################ +# Get Shared Folder Chats +############################ + + +@router.get('/{id}/shared/chats') +async def get_shared_folder_chats( + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """Get chats within a shared folder. Returns readonly flag based on permission.""" + folder = await Folders.get_folder_by_id(id, db=db) + if not folder: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + is_owner = user.id == folder.user_id + is_admin = user.role == 'admin' + has_write = is_owner or is_admin or await _has_folder_access(user.id, folder, 'write', db) + has_read = has_write or await _has_folder_access(user.id, folder, 'read', db) + + if not has_read: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chats = await Chats.get_all_chats_by_folder_id(id, db=db) + + return { + 'chats': [ + {**chat, 'readonly': chat['user_id'] != user.id} + for chat in chats + ], + 'folder_permission': 'write' if has_write else 'read', + } + + ############################ # Delete Folder By Id ############################ @@ -277,7 +465,33 @@ async def delete_folder_by_id( user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - if await Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): + folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db) + + if not folder: + # Check if it's a shared subfolder with write access + folder = await Folders.get_folder_by_id(id, db=db) + if folder and folder.parent_id: + if user.role != 'admin' and not await _has_folder_access(user.id, folder, 'write', db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + elif folder and not folder.parent_id: + # Root shared folders can only be deleted by owner/admin + if user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + folder_owner_id = folder.user_id + + if await Chats.count_chats_by_folder_id_and_user_id(id, folder_owner_id, db=db): chat_delete_permission = await has_permission( user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db ) @@ -288,18 +502,21 @@ async def delete_folder_by_id( ) folders = [] - folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) + folders.append(folder) while folders: folder = folders.pop() if folder: try: - folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db) + folder_ids = await Folders.delete_folder_by_id_and_user_id(folder.id, folder_owner_id, db=db) for folder_id in folder_ids: if delete_contents: - await Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db) + await Chats.delete_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, db=db) else: - await Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db) + await Chats.move_chats_by_user_id_and_folder_id(folder_owner_id, folder_id, None, db=db) + + # Clean up access grants for this folder + await AccessGrants.revoke_all_access('folder', folder_id, db=db) return True except Exception as e: @@ -311,7 +528,7 @@ async def delete_folder_by_id( ) finally: # Get all subfolders - subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db) + subfolders = await Folders.get_folders_by_parent_id_and_user_id(folder.id, folder_owner_id, db=db) folders.extend(subfolders) else: diff --git a/backend/open_webui/utils/access_control/folders.py b/backend/open_webui/utils/access_control/folders.py new file mode 100644 index 0000000000..d4dd6e82fc --- /dev/null +++ b/backend/open_webui/utils/access_control/folders.py @@ -0,0 +1,23 @@ +from open_webui.models.access_grants import AccessGrants +from open_webui.models.folders import FolderModel, Folders +from sqlalchemy.ext.asyncio import AsyncSession + + +async def has_folder_access( + user_id: str, folder: FolderModel, permission: str, db: AsyncSession +) -> bool: + """Check if user has access to folder directly or via ancestor inheritance.""" + if await AccessGrants.has_access( + user_id=user_id, + resource_type='folder', + resource_id=folder.id, + permission=permission, + db=db, + ): + return True + # Check ancestor chain for inherited access + if folder.parent_id: + parent = await Folders.get_folder_by_id(folder.parent_id, db=db) + if parent: + return await has_folder_access(user_id, parent, permission, db) + return False