From 841af697e0aac5dbb4eee3e31110072bf534db8d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:48:36 +0200 Subject: [PATCH] fix: drop room subscriptions when a note or channel grant is revoked Socket.IO room membership for notes and channels is decided once, when the client joins, from the access grants in effect at that moment, and nothing revisits it. When a note's or a channel's grants are narrowed, everyone who just lost read access keeps the subscription and keeps receiving that resource's events and its live collaboration updates until they close the tab. On the current code a revoked user still received four ydoc updates while an authorised collaborator typed into the note. Updating a note's or a channel's grants now diffs the read principals before and after the write and drops the sessions of anyone who lost access, on whichever worker holds them. Deleting a note or a channel closes its rooms outright. Owners stay, and so do admins on notes, since both keep access whatever the grants say. Re-checking access on every emit would cover more cases, but it puts a permission query on the hot broadcast path to catch something that happens perhaps once a day. User group membership is the other way to lose access and is not covered here: removing someone from a group, or deleting a group that holds a grant, still leaves them subscribed. Covering it means re-deriving every resource a user reaches through that group, which is separate work with its own risk, so it is left for a follow-up. --- backend/open_webui/models/access_grants.py | 52 ++++++++++++++++++++++ backend/open_webui/routers/channels.py | 14 ++++++ backend/open_webui/routers/notes.py | 29 +++++++++++- backend/open_webui/socket/main.py | 24 ++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) 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.