diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 03a7ef7202..74f8afe62a 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -188,6 +188,10 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: return list(deduped.values()) +def _grant_principals(grants: list[AccessGrantModel]) -> set[tuple[str, str, str]]: + return {(grant.principal_type, grant.principal_id, grant.permission) for grant in grants} + + def has_public_read_access_grant(access_grants: Optional[list]) -> bool: """ Returns True when a direct grant list includes wildcard public-read. @@ -720,6 +724,54 @@ class AccessGrantsTable: return await Users.get_users_by_user_ids(list(user_ids_with_access), db=db) + async def _get_read_user_ids_from_grants( + self, + grants: list[AccessGrantModel], + db: Optional[AsyncSession] = None, + ) -> set[str]: + """Resolve a grant list to the user ids it gives read access to, group and public principals expanded.""" + from open_webui.models.groups import Groups + from open_webui.models.users import Users + + async with get_async_db_context(db) as db: + user_ids = set() + group_ids = [] + for grant in grants: + if grant.permission != 'read': + continue + if grant.principal_type == PRINCIPAL_TYPE_GROUP: + group_ids.append(grant.principal_id) + elif grant.principal_type == PRINCIPAL_TYPE_USER and grant.principal_id == WILDCARD_PRINCIPAL_ID: + result = await Users.get_users(filter={'roles': ['!pending']}, db=db) + user_ids.update(user.id for user in result.get('users', [])) + else: + user_ids.add(grant.principal_id) + + if group_ids: + members_by_group = await Groups.get_group_user_ids_by_ids(group_ids, db=db) + for members in members_by_group.values(): + user_ids.update(members) + return user_ids + + async def get_revoked_read_user_ids( + self, + resource_type: str, + resource_id: str, + previous_grants: list[AccessGrantModel], + db: Optional[AsyncSession] = None, + ) -> set[str]: + """Get the user ids that had read access under previous_grants and no longer have it.""" + async with get_async_db_context(db) as db: + current_grants = await self.get_grants_by_resource(resource_type, resource_id, db=db) + if _grant_principals(previous_grants) == _grant_principals(current_grants): + return set() + if has_public_read_access_grant(previous_grants) and has_public_read_access_grant(current_grants): + return set() + + previous_user_ids = await self._get_read_user_ids_from_grants(previous_grants, db=db) + current_user_ids = await self._get_read_user_ids_from_grants(current_grants, db=db) + return previous_user_ids - current_user_ids + def has_permission_filter( self, db, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 3c165f16ff..001535f881 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -40,6 +40,7 @@ from open_webui.socket.main import ( emit_to_users, enter_room_for_users, get_user_ids_from_room, + leave_room_for_users, sio, ) from open_webui.utils.access_control import filter_allowed_access_grants, has_permission @@ -695,6 +696,7 @@ async def remove_members_by_id( subject_id=channel.id, data={'user_ids': form_data.user_ids}, ) + return deleted except Exception as e: log.exception(e) @@ -731,8 +733,19 @@ async def update_channel_by_id( 'sharing.public_channels', ) + previous_grants = [] + if form_data.access_grants is not None: + previous_grants = await AccessGrants.get_grants_by_resource('channel', id, db=db) + try: channel = await Channels.update_channel_by_id(id, form_data, db=db) + + # Standard channels take read access from grants; group and dm channels take it from membership. + if form_data.access_grants is not None and channel.type not in ['group', 'dm']: + revoked_user_ids = await AccessGrants.get_revoked_read_user_ids('channel', id, previous_grants, db=db) + revoked_user_ids.discard(channel.user_id) + await leave_room_for_users(f'channel:{id}', list(revoked_user_ids)) + await publish_event( request, EVENTS.CHANNEL_UPDATED, @@ -776,6 +789,7 @@ async def delete_channel_by_id( subject_id=id, data={'name': channel.name, 'type': channel.type}, ) + await sio.close_room(f'channel:{id}') return True except Exception as e: log.exception(e) diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index c1951d2097..60de161d79 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -11,7 +11,7 @@ from open_webui.config import ( from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.chats import ChatForm, ChatResponse, Chats from open_webui.models.config import Config from open_webui.models.groups import Groups @@ -23,7 +23,7 @@ from open_webui.models.notes import ( NoteUserResponse, ) from open_webui.models.users import UserResponse, Users -from open_webui.socket.main import sio +from open_webui.socket.main import get_note_rooms, leave_room_for_users, sio from open_webui.utils.access_control import ( filter_allowed_access_grants, has_permission, @@ -46,6 +46,22 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optiona return {'content': {'md': md[:max_length]}} +async def _leave_note_rooms_for_revoked_users( + note: NoteModel, previous_grants: list[AccessGrantModel], db: AsyncSession +) -> None: + """Drop every user that lost read access on this update out of the note's rooms.""" + revoked_user_ids = await AccessGrants.get_revoked_read_user_ids('note', note.id, previous_grants, db=db) + revoked_user_ids.discard(note.user_id) + if not revoked_user_ids: + return + + revoked_users = await Users.get_users_by_user_ids(list(revoked_user_ids), db=db) + # Admins keep access to every note regardless of grants, so they must stay in the rooms. + user_ids = [revoked_user.id for revoked_user in revoked_users if revoked_user.role != 'admin'] + for room in get_note_rooms(note.id): + await leave_room_for_users(room, user_ids) + + ############################ # GetNotes ############################ @@ -563,11 +579,15 @@ async def update_note_by_id( db=db, ) + previous_grants = await AccessGrants.get_grants_by_resource('note', id, db=db) + try: note = await Notes.update_note_by_id(id, form_data, db=db) pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) note.is_pinned = note.id in pinned_note_ids + await _leave_note_rooms_for_revoked_users(note, previous_grants, db=db) + event_data = note.model_dump() if form_data.data is not None: event_data['data'] = { @@ -642,8 +662,11 @@ async def update_note_access_by_id( 'sharing.public_notes', ) + previous_grants = await AccessGrants.get_grants_by_resource('note', id, db=db) await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) + await _leave_note_rooms_for_revoked_users(note, previous_grants, db=db) + note = await Notes.get_note_by_id(id, db=db) pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db) note.is_pinned = note.id in pinned_note_ids @@ -749,6 +772,8 @@ async def delete_note_by_id( actor=user, subject_id=id, ) + for room in get_note_rooms(id): + await sio.close_room(room) return True except Exception as e: log.exception(e) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index c3678f592d..f8463c2d8b 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -369,6 +369,30 @@ async def enter_room_for_users(room: str, user_ids: list[str]): log.debug('Failed to make users %s join room %s: %s', user_ids, room, e) +async def leave_room_for_users(room: str, user_ids: list[str]): + """Make all sessions of each user leave a specific room, on whichever worker holds them.""" + if not user_ids: + return + + target_user_ids = set(user_ids) + session_ids = set() + for user_id in target_user_ids: + session_ids.update(get_session_ids_from_room(f'user:{user_id}')) + for batch in get_session_pool_batches(): + session_ids.update(sid for sid, entry in batch if entry and entry.get('id') in target_user_ids) + + for sid in session_ids: + try: + await sio.leave_room(sid, room) + except Exception as e: + log.debug('Failed to make session %s leave room %s: %s', sid, room, e) + + +def get_note_rooms(note_id: str) -> list[str]: + """The rooms a note is broadcast to: its event room and its ydoc collaboration room.""" + return [f'note:{note_id}', f'doc_note:{note_id}'] + + async def disconnect_user_sessions(user_id: str): """Disconnect all Socket.IO sessions belonging to a user.