diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 1119fdbce9..952b1e6b26 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -48,7 +48,7 @@ from open_webui.retrieval.external import retrieve_external_knowledge from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.main import GetResult, SearchResult from open_webui.retrieval.web.utils import get_web_loader -from open_webui.utils.access_control.files import has_access_to_file +from open_webui.utils.access_control.files import get_owner_accessible_folder_files, has_access_to_file from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.headers import include_user_info_headers from open_webui.utils.misc import get_content_from_message, get_message_list @@ -1354,7 +1354,7 @@ async def get_sources_from_items( folder = await Folders.get_folder_by_id(folder_id) if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)): - files = (folder.data or {}).get('files', []) + files = await get_owner_accessible_folder_files(folder) folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict)) items.extend(files) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index 50782ea0e4..3221e984a0 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -30,7 +30,7 @@ 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.access_control.files import can_read_all_folder_files, get_accessible_folder_files from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.tasks import has_active_tasks from pydantic import BaseModel @@ -156,6 +156,18 @@ async def create_folder( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + if form_data.data and 'files' in form_data.data: + owner = await Users.get_user_by_id(parent.user_id, db=db) + if not owner: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if not await can_read_all_folder_files(form_data.data['files'], owner, db=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) @@ -174,6 +186,14 @@ async def create_folder( detail=ERROR_MESSAGES.DEFAULT('Error creating folder'), ) + if form_data.data and 'files' in form_data.data and not await can_read_all_folder_files( + form_data.data['files'], user, db=db + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + try: folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) await publish_event( @@ -315,11 +335,14 @@ async def update_folder_name_by_id( detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) - # Validate read access to every file/collection being attached. - # Folder files are consumed by chat middleware as RAG context. - if form_data.data and isinstance(form_data.data.get('files'), list): - accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db) - if len(accessible_files) != len(form_data.data['files']): + if form_data.data and 'files' in form_data.data: + owner = user if folder.user_id == user.id else await Users.get_user_by_id(folder.user_id, db=db) + if not owner: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + if not await can_read_all_folder_files(form_data.data['files'], owner, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index f015e71c70..f1e4569cbb 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -4,14 +4,17 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels from open_webui.models.chats import Chats from open_webui.models.files import Files +from open_webui.models.folders import FolderModel from open_webui.models.groups import Groups from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models -from open_webui.models.users import UserModel +from open_webui.models.users import UserModel, Users from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +FOLDER_FILE_TYPES = {'file', 'collection', 'note'} + async def has_access_to_file( file_id: str | None, @@ -122,27 +125,26 @@ async def get_accessible_folder_files( ) -> list[dict]: """Filter folder.data['files'] entries to those the caller can read. - Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and - note ids are each access-checked against the caller; admins bypass all checks and - genuinely unknown types are kept as-is. + Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. Entries of any other + shape are dropped because they cannot be access-checked. """ - if not entries: + if not isinstance(entries, list): return [] + entries = [ + entry + for entry in entries + if isinstance(entry, dict) and entry.get('type') in FOLDER_FILE_TYPES and entry.get('id') + ] if user.role == 'admin': - return list(entries) + return entries # One group-membership fetch for the whole folder listing user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)} accessible: list[dict] = [] for entry in entries: - if not isinstance(entry, dict): - continue entry_type = entry.get('type') entry_id = entry.get('id') - if not entry_id: - accessible.append(entry) - continue if entry_type == 'file': if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids): accessible.append(entry) @@ -165,6 +167,32 @@ async def get_accessible_folder_files( ) ): accessible.append(entry) - else: - accessible.append(entry) return accessible + + +async def can_read_all_folder_files( + entries: list[dict] | None, + user: UserModel, + db: AsyncSession | None = None, +) -> bool: + if entries is None: + return True + if not isinstance(entries, list): + return False + if not entries: + return True + + return len(await get_accessible_folder_files(entries, user, db=db)) == len(entries) + + +async def get_owner_accessible_folder_files(folder: FolderModel, db: AsyncSession | None = None) -> list[dict]: + """Return the folder entries its owner can still delegate.""" + files = (folder.data or {}).get('files') or [] + if not files: + return [] + + owner = await Users.get_user_by_id(folder.user_id, db=db) + if not owner: + return [] + + return await get_accessible_folder_files(files, owner, db=db) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 4f8ecf4a7c..9e76cca824 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -77,6 +77,7 @@ from open_webui.socket.main import ( ) from open_webui.utils.access_control import has_connection_access, has_permission from open_webui.models.access_grants import AccessGrants +from open_webui.utils.access_control.files import get_owner_accessible_folder_files from open_webui.utils.access_control.folders import has_folder_access from open_webui.utils.chat import generate_chat_completion from open_webui.utils.chat_id import is_saved_chat_id @@ -2456,7 +2457,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): else: # Native FC: skip RAG injection, builtin tools # will read folder knowledge from metadata. - metadata['folder_knowledge'] = folder.data['files'] + metadata['folder_knowledge'] = await get_owner_accessible_folder_files(folder) # Model "Knowledge" handling user_message = get_last_user_message(form_data['messages'])