This commit is contained in:
Timothy Jaeryang Baek committed 2026-06-15 23:34:24 +02:00
1 parent 38920c0ed1
commit d65ac445a4
5 files changed
+384 -28

No files matched your search

+37
View File
@@ -1353,6 +1353,43 @@ class ChatTable:
for chat in all_chats 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( async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]: ) -> list[ChatModel]:
+65 -1
View File
@@ -6,7 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict 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 from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -62,6 +62,20 @@ class FolderNameIdResponse(BaseModel):
updated_at: int 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 # Forms
#################### ####################
@@ -130,6 +144,56 @@ class FolderTable:
except Exception: except Exception:
return None 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( async def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[list[FolderModel]]: ) -> Optional[list[FolderModel]]:
+23 -8
View File
@@ -32,6 +32,7 @@ from open_webui.models.tags import TagModel, Tags
from open_webui.socket.main import get_event_emitter from open_webui.socket.main import get_event_emitter
from open_webui.tasks import stop_item_tasks 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 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.auth import get_admin_user, get_verified_user
from open_webui.utils.middleware import serialize_output from open_webui.utils.middleware import serialize_output
from open_webui.utils.misc import get_message_list 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. # to assume the column is clean. Also catches non-UUID / nonexistent IDs.
if form_data.folder_id is not None: 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): if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
raise HTTPException( # Check shared folder write access
status_code=status.HTTP_404_NOT_FOUND, shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
detail=ERROR_MESSAGES.NOT_FOUND, 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: try:
chat = await Chats.insert_new_chat(str(uuid4()), user.id, form_data, db=db) 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: if has_grant:
chat = await Chats.get_chat_by_id(id, db=db) 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: if chat:
return ChatResponse(**chat.model_dump()) 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). # folder_id values. None is allowed (moves the chat out of any folder).
if form_data.folder_id is not None: 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): if not await Folders.get_folder_by_id_and_user_id(form_data.folder_id, user.id, db=db):
raise HTTPException( # Check shared folder write access
status_code=status.HTTP_404_NOT_FOUND, shared_folder = await Folders.get_folder_by_id(form_data.folder_id, db=db)
detail=ERROR_MESSAGES.NOT_FOUND, 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) 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()) return ChatResponse(**chat.model_dump())
+236 -19
View File
@@ -19,7 +19,13 @@ from open_webui.models.folders import (
Folders, Folders,
FolderUpdateForm, 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 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 get_accessible_folder_files
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel from pydantic import BaseModel
@@ -31,6 +37,9 @@ log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
from open_webui.utils.access_control.folders import has_folder_access as _has_folder_access
############################ ############################
# Get Folders # Get Folders
############################ ############################
@@ -101,6 +110,27 @@ async def create_folder(
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), 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: try:
folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) folder = await Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db)
return folder 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 # 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)): 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) folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder: if folder:
return folder return folder
else:
raise HTTPException( # Check shared access
status_code=status.HTTP_404_NOT_FOUND, folder = await Folders.get_folder_by_id(id, db=db)
detail=ERROR_MESSAGES.NOT_FOUND, 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), db: AsyncSession = Depends(get_async_session),
): ):
folder = await Folders.get_folder_by_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 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 folder:
if form_data.name is not None: if form_data.name is not None:
# Check if folder with same name exists # Check if folder with same name exists
existing_folder = await Folders.get_folder_by_parent_id_and_user_id_and_name( 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: if existing_folder and existing_folder.id != id:
raise HTTPException( raise HTTPException(
@@ -166,7 +269,7 @@ async def update_folder_name_by_id(
) )
try: 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 return folder
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@@ -175,11 +278,6 @@ async def update_folder_name_by_id(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), 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 # Delete Folder By Id
############################ ############################
@@ -277,7 +465,33 @@ async def delete_folder_by_id(
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session), 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( chat_delete_permission = await has_permission(
user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db
) )
@@ -288,18 +502,21 @@ async def delete_folder_by_id(
) )
folders = [] folders = []
folders.append(await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) folders.append(folder)
while folders: while folders:
folder = folders.pop() folder = folders.pop()
if folder: if folder:
try: 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: for folder_id in folder_ids:
if delete_contents: 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: 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 return True
except Exception as e: except Exception as e:
@@ -311,7 +528,7 @@ async def delete_folder_by_id(
) )
finally: finally:
# Get all subfolders # 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) folders.extend(subfolders)
else: else:
@@ -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