diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 6dfec3c42e..3ff2578251 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1843,6 +1843,10 @@ USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = ( os.getenv('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) +USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING = ( + os.getenv('USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING', 'False').lower() == 'true' +) + USER_PERMISSIONS_CHAT_EXPORT = os.getenv('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_IMPORT = os.getenv('USER_PERMISSIONS_CHAT_IMPORT', 'True').lower() == 'true' @@ -1929,6 +1933,7 @@ DEFAULT_USER_PERMISSIONS = { 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, 'folders': USER_PERMISSIONS_FOLDERS_ALLOW_SHARING, 'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING, + 'open_chats': USER_PERMISSIONS_CHAT_ALLOW_OPEN_SHARING, 'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING, }, 'access_grants': { diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index f944ff8655..eb23740273 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -11,6 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +PRINCIPAL_TYPE_ANYONE = 'anyone' +PRINCIPAL_TYPE_GROUP = 'group' +PRINCIPAL_TYPE_USER = 'user' +WILDCARD_PRINCIPAL_ID = '*' + #################### # AccessGrant DB Schema @@ -23,7 +28,7 @@ class AccessGrant(Base): id = Column(Text, primary_key=True) resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file" resource_id = Column(Text, nullable=False) - principal_type = Column(Text, nullable=False) # "user" or "group" + principal_type = Column(Text, nullable=False) # "user", "group", or "anyone" principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public) permission = Column(Text, nullable=False) # "read" or "write" created_at = Column(BigInteger, nullable=False) @@ -163,12 +168,16 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: principal_id = grant.get('principal_id') permission = grant.get('permission') - if principal_type not in ('user', 'group'): + if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE): continue if permission not in ('read', 'write'): continue if not isinstance(principal_id, str) or not principal_id: continue + if principal_type == PRINCIPAL_TYPE_ANYONE and ( + principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read' + ): + continue key = (principal_type, principal_id, permission) deduped[key] = { @@ -186,7 +195,11 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes wildcard public-read. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read': + if ( + grant['principal_type'] == PRINCIPAL_TYPE_USER + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'read' + ): return True return False @@ -196,7 +209,25 @@ def has_public_write_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes wildcard public-write. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write': + if ( + grant['principal_type'] == PRINCIPAL_TYPE_USER + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'write' + ): + return True + return False + + +def has_anyone_read_access_grant(access_grants: Optional[list]) -> bool: + """ + Returns True when a direct grant list includes no-auth anyone-read. + """ + for grant in normalize_access_grants(access_grants): + if ( + grant['principal_type'] == PRINCIPAL_TYPE_ANYONE + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == 'read' + ): return True return False @@ -206,7 +237,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool: Returns True when a direct grant list includes any non-wildcard user grant. """ for grant in normalize_access_grants(access_grants): - if grant['principal_type'] == 'user' and grant['principal_id'] != '*': + if grant['principal_type'] == PRINCIPAL_TYPE_USER and grant['principal_id'] != WILDCARD_PRINCIPAL_ID: return True return False @@ -223,12 +254,27 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list: for grant in access_grants if not ( (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) - == 'user' - and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*' + == PRINCIPAL_TYPE_USER + and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) + != WILDCARD_PRINCIPAL_ID ) ] +def strip_anyone_access_grants(access_grants: Optional[list]) -> list: + """ + Remove no-auth anyone grants from the list. + """ + if not access_grants: + return [] + return [ + grant + for grant in access_grants + if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) + != PRINCIPAL_TYPE_ANYONE + ] + + def grants_to_access_control(grants: list) -> Optional[dict]: """ Convert a list of grant objects (AccessGrantModel or AccessGrantResponse) @@ -493,6 +539,28 @@ class AccessGrantsTable: result_dict[g.resource_id].append(AccessGrantModel.model_validate(g)) return result_dict + async def has_anyone_access( + self, + resource_type: str, + resource_id: str, + permission: str = 'read', + db: Optional[AsyncSession] = None, + ) -> bool: + """Check for a no-auth anyone:* grant. Callers must opt in explicitly.""" + async with get_async_db_context(db) as db: + result = await db.execute( + select(AccessGrant) + .filter( + AccessGrant.resource_type == resource_type, + AccessGrant.resource_id == resource_id, + AccessGrant.principal_type == PRINCIPAL_TYPE_ANYONE, + AccessGrant.principal_id == WILDCARD_PRINCIPAL_ID, + AccessGrant.permission == permission, + ) + .limit(1) + ) + return result.scalars().first() is not None + async def has_access( self, user_id: str, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 1a707607e0..0c657bedba 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -6,8 +6,9 @@ import logging from typing import Optional from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event @@ -37,7 +38,7 @@ from open_webui.socket.main import get_event_emitter from open_webui.tasks import has_active_tasks, 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.auth import bearer_security, get_admin_user, get_current_user, get_verified_user from open_webui.utils.chat_fork import build_fork_history from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage from open_webui.utils.misc import get_message_list @@ -60,6 +61,47 @@ CHAT_CONFIG_KEYS = { } +async def get_optional_verified_user( + request: Request, + response: Response, + background_tasks: BackgroundTasks, + auth_token: HTTPAuthorizationCredentials | None = Depends(bearer_security), +): + try: + user = await get_current_user(request, response, background_tasks, auth_token) + except HTTPException: + return None + + if user.role not in {'user', 'admin'}: + return None + return user + + +async def is_open_shared_chat(shared, db: AsyncSession) -> bool: + return await AccessGrants.has_anyone_access( + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + + +async def can_read_shared_chat(user, shared, db: AsyncSession) -> bool: + if user.role == 'pending': + return False + if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + return True + if shared.user_id == user.id: + return True + return await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, + ) + + async def add_active_state_to_chat_list( request: Request, chat_list: list[ChatTitleIdResponse] ) -> list[ChatTitleIdResponse]: @@ -1082,38 +1124,30 @@ async def get_shared_session_user_chat_list( @router.get('/share/{share_id}', response_model=ChatResponse | None) async def get_shared_chat_by_id( - share_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) + share_id: str, user=Depends(get_optional_verified_user), db: AsyncSession = Depends(get_async_session) ): - if user.role == 'pending': - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + if await is_open_shared_chat(shared, db=db) or ( + user is not None and await can_read_shared_chat(user, shared, db=db) + ): + chat = await Chats.get_chat_by_share_id(share_id, db=db) + if chat: + return ChatResponse(**chat.model_dump()) - chat = await Chats.get_chat_by_share_id(share_id, db=db) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED if user else ERROR_MESSAGES.INVALID_TOKEN, + ) # Fallback: admins can also access any chat directly by chat ID - if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = None + if user is not None and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) + if chat: + return ChatResponse(**chat.model_dump()) - if not chat: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - - # Look up the original chat_id to check access grants (admins bypass) - if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS: - shared = await SharedChats.get_by_id(share_id, db=db) - if shared and shared.user_id != user.id: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=shared.chat_id, - permission='read', - db=db, - ) - if not has_grant: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, - ) - - return ChatResponse(**chat.model_dump()) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1753,7 +1787,7 @@ async def clone_shared_chat_by_id( # Enforce access grants (owner and admins bypass) shared = await SharedChats.get_by_id(id, db=db) if shared and user.role != 'admin' and shared.user_id != user.id: - has_grant = await AccessGrants.has_access( + has_grant = await is_open_shared_chat(shared, db=db) or await AccessGrants.has_access( user_id=user.id, resource_type='shared_chat', resource_id=shared.chat_id, @@ -1948,6 +1982,8 @@ async def update_shared_chat_access_by_id( user.role, form_data.access_grants, 'sharing.public_chats', + 'sharing.open_chats', + db=db, ) await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 975008b4c1..a81aac812a 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -3,9 +3,11 @@ from typing import Any from open_webui.config import DEFAULT_USER_PERMISSIONS from open_webui.models.access_grants import ( + has_anyone_read_access_grant, has_public_read_access_grant, has_public_write_access_grant, has_user_access_grant, + strip_anyone_access_grants, strip_user_access_grants, ) from open_webui.models.groups import Groups @@ -215,13 +217,31 @@ async def filter_allowed_access_grants( user_role: str, access_grants: list, public_permission_key: str, + anyone_permission_key: str | None = None, db: AsyncSession | None = None, ) -> list: """ Checks if the user has the required permissions to grant access to a resource. Returns the filtered list of access grants if permissions are missing. """ - if user_role == 'admin' or not access_grants: + if not access_grants: + return access_grants + + if has_anyone_read_access_grant(access_grants) and ( + not anyone_permission_key + or ( + user_role != 'admin' + and not await has_permission( + user_id, + anyone_permission_key, + default_permissions, + db=db, + ) + ) + ): + access_grants = strip_anyone_access_grants(access_grants) + + if user_role == 'admin': return access_grants # Check if user can share publicly diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 710ca0cb77..2f276ab364 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -494,6 +494,25 @@ {/if} + +