fix: evict Socket.IO room subscriptions when access is revoked

Channel and note Socket.IO room memberships were only ever established
at user-join / join-channels / join-note / ydoc:document:join time,
using the access grants in effect at that moment. Later mutations that
revoked the underlying access basis — removing a user from a group,
deleting a group that held grants, deactivating or removing a channel
member, updating a channel's access_grants, updating a note's
access_grants, or deleting the resource — did not touch the sessions
holding those subscriptions. The affected users kept receiving channel
broadcasts and ydoc updates until they voluntarily disconnected.

Add three helpers in socket/main.py:

leave_rooms_for_users — direct eviction of every session belonging to
the listed users from a given room. leave_room is a no-op on
non-members, so callers can invoke it optimistically.

resync_channel_rooms_for_users — when the specific lost channels are
cheaper to re-derive than to diff (group mutations), recompute each
user's accessible channels via Channels.get_channels_by_user_id and
evict their sessions from any channel:* room they no longer qualify
for. Enumerates candidate rooms via the socket.io manager's local room
map with defensive fallback.

evict_users_from_note — single helper covering both note rooms
(events-bus note:{id} and ydoc doc_{id}).

Wire these into every revocation path called out in the report:

routers/channels.py: member removal, membership deactivation, channel
update (re-derives membership per current participant), channel
deletion (closes the room entirely).

routers/groups.py: user removal from group and group deletion both
resync channel rooms for the affected users. HTTPException is now
re-raised so the broader except does not mask the authz-adjacent
errors as 400s.

routers/notes.py: access-grant updates walk current participants of
both note rooms and evict anyone who can no longer read; note deletion
closes both rooms.
This commit is contained in:
DrMelone 2026-04-13 01:33:13 +02:00
parent 67023037f8
commit 7adc663b4c
4 changed files with 200 additions and 1 deletions

View file

@ -13,6 +13,7 @@ from pydantic import field_validator
from open_webui.socket.main import (
emit_to_users,
enter_room_for_users,
leave_rooms_for_users,
sio,
get_user_ids_from_room,
)
@ -544,6 +545,10 @@ async def update_is_active_member_by_id_and_user_id(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
await Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db)
if not form_data.is_active:
# Deactivating membership revokes live subscription; evict the user's
# open sessions from the channel room so they stop receiving events.
await leave_rooms_for_users(f'channel:{channel.id}', [user.id])
return True
@ -613,6 +618,9 @@ async def remove_members_by_id(
try:
deleted = await Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db)
if form_data.user_ids:
await leave_rooms_for_users(f'channel:{channel.id}', form_data.user_ids)
return deleted
except Exception as e:
log.exception(e)
@ -651,6 +659,18 @@ async def update_channel_by_id(
try:
channel = await Channels.update_channel_by_id(id, form_data, db=db)
# Access grants may have changed. Evict live subscribers who no longer
# belong — checked against the current membership/grant resolution.
room_name = f'channel:{id}'
for participant_user_id in get_user_ids_from_room(room_name):
try:
still_member = await Channels.is_user_channel_member(id, participant_user_id, db=db)
except Exception:
still_member = True # Fail-open on transient errors; next mutation retries.
if not still_member:
await leave_rooms_for_users(room_name, [participant_user_id])
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
@ -680,6 +700,12 @@ async def delete_channel_by_id(
try:
await Channels.delete_channel_by_id(id, db=db)
# Tear down every open subscription to this channel room — no one has
# access after deletion.
try:
await sio.close_room(f'channel:{id}')
except Exception as e:
log.debug(f'Failed to close channel room {id}: {e}')
return True
except Exception as e:
log.exception(e)

View file

@ -21,6 +21,7 @@ from open_webui.internal.db import get_async_session
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.socket.main import resync_channel_rooms_for_users
log = logging.getLogger(__name__)
@ -235,8 +236,13 @@ async def remove_users_from_group(
db: AsyncSession = Depends(get_async_session),
):
try:
removed_user_ids = list(form_data.user_ids or [])
group = await Groups.remove_users_from_group(id, form_data.user_ids, db=db)
if group:
# Group membership backs channel access grants; evict the removed
# users from any channel:* rooms they no longer qualify for.
if removed_user_ids:
await resync_channel_rooms_for_users(removed_user_ids)
return GroupResponse(
**group.model_dump(),
member_count=await Groups.get_group_member_count_by_id(group.id, db=db),
@ -246,6 +252,8 @@ async def remove_users_from_group(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error removing users from group'),
)
except HTTPException:
raise
except Exception as e:
log.exception(f'Error removing users from group {id}: {e}')
raise HTTPException(
@ -262,14 +270,26 @@ async def remove_users_from_group(
@router.delete('/id/{id}/delete', response_model=bool)
async def delete_group_by_id(id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
try:
# Capture members before deletion so we can resync their live rooms
# afterwards — the group is the access basis, not the members.
try:
members_before = await Users.get_users_by_group_id(id, db=db)
affected_user_ids = [m.id for m in members_before]
except Exception:
affected_user_ids = []
result = await Groups.delete_group_by_id(id, db=db)
if result:
if affected_user_ids:
await resync_channel_rooms_for_users(affected_user_ids)
return result
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Error deleting group'),
)
except HTTPException:
raise
except Exception as e:
log.exception(f'Error deleting group {id}: {e}')
raise HTTPException(

View file

@ -6,7 +6,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
from open_webui.socket.main import sio
from open_webui.socket.main import sio, evict_users_from_note, get_user_ids_from_room
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@ -361,6 +361,27 @@ async def update_note_access_by_id(
await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db)
# Access grants just changed. Sweep every user currently subscribed to
# either note room and drop the ones whose access no longer stands.
# The note owner and admins are never evicted by has_access — the check
# below only returns False for users who truly lost the grant.
participant_user_ids = set(get_user_ids_from_room(f'note:{id}')) | set(get_user_ids_from_room(f'doc_{id}'))
for participant_user_id in participant_user_ids:
if participant_user_id == note.user_id:
continue
participant = await Users.get_user_by_id(participant_user_id, db=db)
if participant is None or participant.role == 'admin':
continue
still_has_access = await AccessGrants.has_access(
user_id=participant_user_id,
resource_type='note',
resource_id=id,
permission='read',
db=db,
)
if not still_has_access:
await evict_users_from_note(id, [participant_user_id])
return await Notes.get_note_by_id(id, db=db)
@ -402,6 +423,12 @@ async def delete_note_by_id(
try:
note = await Notes.delete_note_by_id(id, db=db)
# Close both note rooms — with the note gone, no one retains access.
for room_name in (f'note:{id}', f'doc_{id}'):
try:
await sio.close_room(room_name)
except Exception as e:
log.debug(f'Failed to close room {room_name}: {e}')
return True
except Exception as e:
log.exception(e)

View file

@ -312,6 +312,132 @@ async def enter_room_for_users(room: str, user_ids: list[str]):
log.debug(f'Failed to make users {user_ids} join room {room}: {e}')
async def leave_rooms_for_users(room: str, user_ids: list[str]):
"""
Make all active sessions of each user leave a specific Socket.IO room.
Call this when an access basis (group membership, channel membership,
access grant) is revoked the user's open sessions still hold the
room subscriptions established at join time and would otherwise keep
receiving real-time events from a resource they no longer access.
leave_room is a no-op when the sid is not actually a member, so it is
safe to call optimistically.
"""
try:
for user_id in user_ids:
for sid in get_session_ids_from_room(f'user:{user_id}'):
await sio.leave_room(sid, room)
except Exception as e:
log.debug(f'Failed to leave room {room} for users {user_ids}: {e}')
async def resync_channel_rooms_for_users(user_ids: list[str]):
"""
Recompute each user's channel access and evict their active sessions
from channel:* rooms they no longer have access to.
Used by access-control mutations that do not name a specific channel
but can still revoke channel access indirectly removing a user from
a group, deleting a group that held grants, or channel access-grant
updates where the exact delta is cheaper to re-derive than to diff.
Uses the manager's local room map (available in both the in-memory
and Redis-backed managers) to enumerate candidate rooms. Degrades
gracefully if the attribute is not present in a future library
version in that case no eviction happens and callers should rely
on targeted leave_rooms_for_users calls instead.
"""
if not user_ids:
return
try:
ns_rooms = getattr(sio.manager, 'rooms', {}).get('/', {}) or {}
except Exception as e:
log.debug(f'resync_channel_rooms_for_users: could not read manager rooms: {e}')
return
channel_rooms = [r for r in list(ns_rooms.keys()) if isinstance(r, str) and r.startswith('channel:')]
if not channel_rooms:
return
for user_id in user_ids:
session_ids = get_session_ids_from_room(f'user:{user_id}')
if not session_ids:
continue
try:
user = await Users.get_user_by_id(user_id)
except Exception:
user = None
allowed_ids: set[str] = set()
if user is not None and (user.role == 'admin' or await has_permission(user.id, 'features.channels')):
try:
allowed_ids = {c.id for c in await Channels.get_channels_by_user_id(user.id)}
except Exception as e:
log.debug(f'resync_channel_rooms_for_users: channel lookup failed for {user_id}: {e}')
continue
for room in channel_rooms:
channel_id = room[len('channel:'):]
if channel_id in allowed_ids:
continue
for sid in session_ids:
try:
await sio.leave_room(sid, room)
except Exception as e:
log.debug(f'Failed to leave {room} for sid {sid}: {e}')
async def evict_users_from_note(note_id: str, user_ids: list[str]):
"""
Evict users from both note rooms for a given note: the events-bus
room (note:{id}) and the ydoc collaboration room (doc_{id}). Call
this when a note's access grants change or the note is deleted.
"""
await leave_rooms_for_users(f'note:{note_id}', user_ids)
await leave_rooms_for_users(f'doc_{note_id}', user_ids)
async def resync_note_rooms_for_users(user_ids: list[str], note_ids: list[str]):
"""
For each (user, note) pair, leave the note's rooms if the user no
longer has access. Use this after access-grant changes that affect
specific notes.
"""
if not user_ids or not note_ids:
return
for user_id in user_ids:
session_ids = get_session_ids_from_room(f'user:{user_id}')
if not session_ids:
continue
try:
user = await Users.get_user_by_id(user_id)
except Exception:
user = None
if user is None:
continue
for note_id in note_ids:
try:
note = await Notes.get_note_by_id(note_id)
except Exception:
note = None
has_access = False
if note is not None:
if user.role == 'admin' or user.id == note.user_id:
has_access = True
else:
try:
has_access = await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
)
except Exception:
has_access = False
if not has_access:
await evict_users_from_note(note_id, [user_id])
async def disconnect_user_sessions(user_id: str):
"""Disconnect all Socket.IO sessions belonging to a user.