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.
This commit is contained in:
Classic298 2026-09-05 17:48:36 +02:00
parent 85b11a4f35
commit 841af697e0
4 changed files with 117 additions and 2 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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.