From e3e37ce5263c9ffb82d13010f3c0c71ba75d9638 Mon Sep 17 00:00:00 2001 From: Syed Ali Jaseem Date: Sun, 7 Jun 2026 14:32:21 -0500 Subject: [PATCH] fix(sessions): scope enrichment queries by owner, add LIMIT to auto_sort (#3350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/sessions fired full-table scans against sessions, documents, and gallery_images on every call. Added DbSession.owner == user (line 265), Document.owner == user (line 283), GalleryImage.owner == user (line 289), and .limit(2000) to auto_sort_sessions (line 1013). All follow the existing owner-scoping pattern at lines 700 and 1230. No behaviour change — the response was already correct; this eliminates the over-fetch. --- routes/session_routes.py | 10 ++-- tests/test_session_list_owner_scope.py | 74 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/test_session_list_owner_scope.py diff --git a/routes/session_routes.py b/routes/session_routes.py index 0f2fa01c8..fd2721e3f 100644 --- a/routes/session_routes.py +++ b/routes/session_routes.py @@ -262,7 +262,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ last_msg_map = {} mode_map = {} msg_count_map = {} - rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False).all() + rows = db.query(DbSession.id, DbSession.folder, DbSession.total_input_tokens, DbSession.total_output_tokens, DbSession.is_important, DbSession.created_at, DbSession.updated_at, DbSession.last_message_at, DbSession.mode, DbSession.message_count).filter(DbSession.archived == False, DbSession.owner == user).all() for row in rows: folder_map[row.id] = row.folder token_map[row.id] = (row.total_input_tokens or 0) + (row.total_output_tokens or 0) @@ -284,12 +284,14 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ r[0] for r in db.query(Document.session_id) .filter(Document.is_active == True, Document.current_content != None, - func.trim(Document.current_content) != "") + func.trim(Document.current_content) != "", + Document.owner == user) .distinct().all() ) img_session_ids = set( r[0] for r in db.query(GalleryImage.session_id) - .filter(GalleryImage.session_id != None) + .filter(GalleryImage.session_id != None, + GalleryImage.owner == user) .distinct().all() ) finally: @@ -1010,7 +1012,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_ } _THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages try: - rows = db.query(DbSession).filter(DbSession.archived == False, DbSession.owner == user).all() + rows = db.query(DbSession).filter(DbSession.archived == False, DbSession.owner == user).limit(2000).all() folder_map = {r.id: r.folder for r in rows} # Precompute per-session message counts in TWO aggregate queries # instead of 1–3 queries PER session — with many chats the per-row diff --git a/tests/test_session_list_owner_scope.py b/tests/test_session_list_owner_scope.py new file mode 100644 index 000000000..8bd9f3123 --- /dev/null +++ b/tests/test_session_list_owner_scope.py @@ -0,0 +1,74 @@ +"""list_sessions must return only the authenticated user's sessions. + +Regression for the enrichment query at routes/session_routes.py:265 which +previously fetched rows for all owners on every GET /api/sessions call. +""" +import sys +import tempfile +import types +import uuid + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import Session as DbSession + +_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_ENGINE = create_engine( + f"sqlite:///{_TMPDB.name}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, +) +cdb.Base.metadata.create_all(_ENGINE) +_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False) + + +def _stub_multipart_if_missing(monkeypatch): + try: + import python_multipart # noqa: F401 + return + except ImportError: + pass + stub = types.ModuleType("python_multipart") + stub.__version__ = "0.0.20" + monkeypatch.setitem(sys.modules, "python_multipart", stub) + + +def test_list_sessions_excludes_other_users_sessions(monkeypatch): + import routes.session_routes as sr + from unittest.mock import MagicMock + + _stub_multipart_if_missing(monkeypatch) + monkeypatch.setattr(sr, "SessionLocal", _TS) + monkeypatch.setattr(sr, "effective_user", lambda request: "alice") + + alice_id = str(uuid.uuid4()) + bob_id = str(uuid.uuid4()) + db = _TS() + try: + db.query(DbSession).delete() + db.add(DbSession(id=alice_id, owner="alice", name="alice session", + endpoint_url="http://localhost", model="gpt-4", archived=False)) + db.add(DbSession(id=bob_id, owner="bob", name="bob session", + endpoint_url="http://localhost", model="gpt-4", archived=False)) + db.commit() + finally: + db.close() + + alice_session = MagicMock(id=alice_id, name="alice session", + model="gpt-4", endpoint_url="http://localhost", + rag=False, archived=False) + sm = MagicMock() + sm.get_sessions_for_user.return_value = {alice_id: alice_session} + router = sr.setup_session_routes(sm, {}) + endpoint = next(r.endpoint for r in router.routes + if getattr(r, "path", "") == "/api/sessions" + and "GET" in getattr(r, "methods", set())) + + result = endpoint(request=MagicMock()) + returned_ids = {s["id"] for s in result} + assert alice_id in returned_ids + assert bob_id not in returned_ids