From 412ecbac7c18d6180bbce710ae2fb45fb8ce318c Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:45:56 +0200 Subject: [PATCH] refac: invalidate socket sessions from the user model Role changes and user deletion now tear down the user's Socket.IO sessions in the model layer, where the row is written, rather than in the admin user routes. Sessions are looked up in the shared session pool as well as in this process's room map, and the client reconnects when the server closes its socket. --- backend/open_webui/models/users.py | 23 ++++++++++++++--------- backend/open_webui/routers/users.py | 5 ----- backend/open_webui/socket/main.py | 21 +++++++++++++++------ src/routes/+layout.svelte | 6 ++++++ 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 1eff4932d5..3c53ff3715 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -582,13 +582,7 @@ class UsersTable: return result.scalar() async def update_user_role_by_id(self, id: str, role: str, db: AsyncSession | None = None) -> UserModel | None: - async with get_async_db_context(db) as session: - user = await session.get(User, id) - if not user: - return None - user.role = role - await session.commit() - return UserModel.model_validate(user) + return await self.update_user_by_id(id, {'role': role}, db=db) async def update_user_status_by_id( self, id: str, form_data: UserStatus, db: AsyncSession | None = None @@ -664,10 +658,18 @@ class UsersTable: user = await session.get(User, id) if not user: return None + role_changed = 'role' in updated and updated['role'] != user.role for key, value in updated.items(): setattr(user, key, value) await session.commit() - return UserModel.model_validate(user) + updated_user = UserModel.model_validate(user) + + if role_changed: + # Deferred import: socket.main imports this module. + from open_webui.socket.main import disconnect_user_sessions + + await disconnect_user_sessions(id) + return updated_user # settings update helper async def update_user_settings_by_id( @@ -686,6 +688,7 @@ class UsersTable: async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool: from open_webui.models.chats import Chats from open_webui.models.groups import Groups + from open_webui.socket.main import disconnect_user_sessions # Remove User from Groups await Groups.remove_user_from_all_groups(id) @@ -697,7 +700,9 @@ class UsersTable: return False # chats deletion failed await session.execute(delete(User).where(User.id == id)) await session.commit() - return True + + await disconnect_user_sessions(id) + return True async def get_user_api_key_by_id(self, id: str, db: AsyncSession | None = None) -> str | None: async with get_async_db_context(db) as session: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 622882e75b..dd98b19493 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -36,7 +36,6 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models from open_webui.models.tools import Tools -from open_webui.socket.main import disconnect_user_sessions from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.auth import ( get_admin_user, @@ -940,10 +939,7 @@ async def update_user_by_id( updated_user = user if updated_user: - # If the role changed, disconnect all socket sessions so stale - # privileges cached in SESSION_POOL are invalidated. if updated_user.role != user.role: - await disconnect_user_sessions(user_id) await publish_event( request, EVENTS.USER_ROLE_UPDATED, @@ -1011,7 +1007,6 @@ async def delete_user_by_id( result = await Auths.delete_auth_by_id(user_id, db=db) if result: - await disconnect_user_sessions(user_id) await publish_event( request, EVENTS.USER_DELETED, diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 5dbd071dee..317e2945b6 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -327,13 +327,22 @@ async def disconnect_user_sessions(user_id: str): fresh data from the database. """ try: - session_ids = get_session_ids_from_room(f'user:{user_id}') - for sid in session_ids: + # SESSION_POOL reaches other processes; the room map also holds sockets whose pool entry + # was dropped without disconnecting them. + session_ids = {sid for sid, entry in SESSION_POOL.items() if entry.get('id') == user_id} + session_ids.update(get_session_ids_from_room(f'user:{user_id}')) + except Exception: + # Non-fatal: whatever changed the user is already committed. + log.exception('Failed to look up sessions for user %s', user_id) + return + + for sid in session_ids: + try: await sio.disconnect(sid) - if session_ids: - log.info('Disconnected %s session(s) for user %s', len(session_ids), user_id) - except Exception as e: - log.warning(f'Failed to disconnect sessions for user {user_id}: {e}') + except Exception as e: + log.warning('Failed to disconnect session %s for user %s: %s', sid, user_id, e) + if session_ids: + log.info('Requested disconnect of %s session(s) for user %s', len(session_ids), user_id) @sio.on('usage') diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 00a3725a3d..4dc74c3ed0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -266,6 +266,12 @@ heartbeatInterval = null; } + // socket.io never retries a server-initiated disconnect, and the server uses one to drop + // sessions holding stale user data. Reconnecting re-reads that data from the database. + if (reason === 'io server disconnect') { + _socket.connect(); + } + if (details) { console.log('Additional details:', details); }