Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 13 additions & 24 deletions api/apps/restful_apis/chat_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,32 +459,21 @@ async def list_chats():
page_number = int(request.args.get("page", 0))
items_per_page = validate_rest_api_page_size(int(request.args.get("page_size", 0)))

tenants = TenantService.get_joined_tenants_by_user_id(current_user.id)
authorized_owner_ids = {member["tenant_id"] for member in tenants}
authorized_owner_ids.add(current_user.id)

if owner_ids:
requested_owner_ids = set(owner_ids)
unauthorized_owner_ids = requested_owner_ids - authorized_owner_ids
if unauthorized_owner_ids:
logging.warning(
"Rejected list_chats request: user=%s attempted unauthorized owner_ids=%s",
current_user.id,
sorted(unauthorized_owner_ids),
)
return get_json_result(
data=False,
message="Only authorized owner_ids can be queried.",
code=RetCode.OPERATING_ERROR,
)
effective_owner_ids = list(requested_owner_ids)
chats, total = await thread_pool_exec(
DialogService.get_by_tenant_ids,
owner_ids, current_user.id, 0, 0, orderby, desc, keywords, **exact_filters,
)
chats = [chat for chat in chats if chat["tenant_id"] in owner_ids]
total = len(chats)
if page_number and items_per_page:
start = (page_number - 1) * items_per_page
chats = chats[start : start + items_per_page]
else:
effective_owner_ids = list(authorized_owner_ids)

chats, total = await thread_pool_exec(
DialogService.get_by_tenant_ids,
effective_owner_ids, current_user.id, page_number, items_per_page, orderby, desc, keywords, **exact_filters,
)
chats, total = await thread_pool_exec(
DialogService.get_by_tenant_ids,
[], current_user.id, page_number, items_per_page, orderby, desc, keywords, **exact_filters,
)
Comment on lines 462 to +476
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Restore server-side owner authorization before querying.

DialogService.get_by_tenant_ids() treats its first argument as the authorized tenant scope (tenant_id IN joined_tenant_ids OR tenant_id == user_id in api/db/services/dialog_service.py:194-255). Passing request-supplied owner_ids straight through lets any authenticated caller enumerate chats for arbitrary tenants, and the else branch now regresses the default listing to only current_user.id chats even though get_chat still authorizes across UserTenantService.query() at Lines 489-497. Recompute the caller's joined-tenant set first, reject out-of-scope owner_ids, and use that authorized set for both filtered and unfiltered listing.

🔒 Proposed fix
     try:
         page_number = int(request.args.get("page", 0))
         items_per_page = validate_rest_api_page_size(int(request.args.get("page_size", 0)))
+        joined_tenants = await thread_pool_exec(UserTenantService.query, user_id=current_user.id)
+        authorized_owner_ids = {current_user.id}
+        authorized_owner_ids.update(tenant.tenant_id for tenant in joined_tenants)
 
         if owner_ids:
-            chats, total = await thread_pool_exec(
-                DialogService.get_by_tenant_ids,
-                owner_ids, current_user.id, 0, 0, orderby, desc, keywords, **exact_filters,
-            )
-            chats = [chat for chat in chats if chat["tenant_id"] in owner_ids]
-            total = len(chats)
-            if page_number and items_per_page:
-                start = (page_number - 1) * items_per_page
-                chats = chats[start : start + items_per_page]
+            unauthorized_owner_ids = sorted(set(owner_ids) - authorized_owner_ids)
+            if unauthorized_owner_ids:
+                logging.warning(
+                    "Rejecting unauthorized owner_ids on list_chats: user_id=%s owner_ids=%s",
+                    current_user.id,
+                    unauthorized_owner_ids,
+                )
+                return get_json_result(
+                    data=False,
+                    message="Please specify authorized owner_ids only.",
+                    code=RetCode.OPERATING_ERROR,
+                )
+            effective_owner_ids = owner_ids
         else:
-            chats, total = await thread_pool_exec(
-                DialogService.get_by_tenant_ids,
-                [], current_user.id, page_number, items_per_page, orderby, desc, keywords, **exact_filters,
-            )
+            effective_owner_ids = list(authorized_owner_ids)
+
+        chats, total = await thread_pool_exec(
+            DialogService.get_by_tenant_ids,
+            effective_owner_ids,
+            current_user.id,
+            page_number,
+            items_per_page,
+            orderby,
+            desc,
+            keywords,
+            **exact_filters,
+        )

As per coding guidelines, "Add logging for new flows".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/apps/restful_apis/chat_api.py` around lines 462 - 476, The code is
passing request-supplied owner_ids directly into DialogService.get_by_tenant_ids
which bypasses server-side tenant authorization; compute the caller's authorized
tenant set by calling UserTenantService.query(current_user.id) (or the existing
helper that returns joined_tenant_ids), validate and reject any owner_ids not in
that set, then call DialogService.get_by_tenant_ids with the authorized set (use
the computed joined_tenant_ids for both the branch that had owner_ids and the
else branch) so listing respects server-side scope; also add a debug/info log
entry (processLogger or request logger) noting the resolved tenant scope and any
rejected owner_ids for the new flow.


return get_json_result(
data={"chats": [_build_chat_response(chat) for chat in chats], "total": total}
Expand Down
32 changes: 8 additions & 24 deletions api/apps/restful_apis/search_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,31 +84,15 @@ def list_searches():
owner_ids = request.args.getlist("owner_ids")

try:
tenants = TenantService.get_joined_tenants_by_user_id(current_user.id)
authorized_owner_ids = {member["tenant_id"] for member in tenants}
authorized_owner_ids.add(current_user.id)

if owner_ids:
requested_owner_ids = set(owner_ids)
unauthorized_owner_ids = requested_owner_ids - authorized_owner_ids
if unauthorized_owner_ids:
logging.warning(
"Rejected list_searches request: user=%s attempted unauthorized owner_ids=%s",
current_user.id,
sorted(unauthorized_owner_ids),
)
return get_json_result(
data=False,
message="Only authorized owner_ids can be queried.",
code=RetCode.OPERATING_ERROR,
)
effective_owner_ids = list(requested_owner_ids)
if not owner_ids:
tenants = []
search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, page_number, items_per_page, orderby, desc, keywords)
else:
effective_owner_ids = list(authorized_owner_ids)

search_apps, total = SearchService.get_by_tenant_ids(
effective_owner_ids, current_user.id, page_number, items_per_page, orderby, desc, keywords
)
search_apps, total = SearchService.get_by_tenant_ids(owner_ids, current_user.id, 0, 0, orderby, desc, keywords)
search_apps = [s for s in search_apps if s["tenant_id"] in owner_ids]
total = len(search_apps)
if page_number and items_per_page:
search_apps = search_apps[(page_number - 1) * items_per_page: page_number * items_per_page]
Comment on lines +87 to +95
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Do not treat request owner_ids as the authorized tenant set.

SearchService.get_by_tenant_ids() uses its first argument as joined_tenant_ids (api/db/services/search_service.py:83-116), so this rewrite lets any authenticated user list search apps for arbitrary tenants by supplying their ids here. It also changes the default path to [], which hides joined-tenant search apps even though detail() still authorizes through UserTenantService.query() at Lines 105-110. Resolve the caller's authorized tenant ids server-side, reject any out-of-scope owner_ids, and query once with that validated set.

🔒 Proposed fix
     try:
-        if not owner_ids:
-            tenants = []
-            search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, page_number, items_per_page, orderby, desc, keywords)
-        else:
-            search_apps, total = SearchService.get_by_tenant_ids(owner_ids, current_user.id, 0, 0, orderby, desc, keywords)
-            search_apps = [s for s in search_apps if s["tenant_id"] in owner_ids]
-            total = len(search_apps)
-            if page_number and items_per_page:
-                search_apps = search_apps[(page_number - 1) * items_per_page: page_number * items_per_page]
+        joined_tenants = UserTenantService.query(user_id=current_user.id)
+        authorized_owner_ids = {current_user.id}
+        authorized_owner_ids.update(tenant.tenant_id for tenant in joined_tenants)
+        if owner_ids:
+            unauthorized_owner_ids = sorted(set(owner_ids) - authorized_owner_ids)
+            if unauthorized_owner_ids:
+                logging.warning(
+                    "Rejecting unauthorized owner_ids on list_searches: user_id=%s owner_ids=%s",
+                    current_user.id,
+                    unauthorized_owner_ids,
+                )
+                return get_json_result(
+                    data=False,
+                    message="Please specify authorized owner_ids only.",
+                    code=RetCode.OPERATING_ERROR,
+                )
+            effective_owner_ids = owner_ids
+        else:
+            effective_owner_ids = list(authorized_owner_ids)
+
+        search_apps, total = SearchService.get_by_tenant_ids(
+            effective_owner_ids,
+            current_user.id,
+            page_number,
+            items_per_page,
+            orderby,
+            desc,
+            keywords,
+        )
         return get_json_result(data={"search_apps": search_apps, "total": total})

As per coding guidelines, "Add logging for new flows".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not owner_ids:
tenants = []
search_apps, total = SearchService.get_by_tenant_ids(tenants, current_user.id, page_number, items_per_page, orderby, desc, keywords)
else:
effective_owner_ids = list(authorized_owner_ids)
search_apps, total = SearchService.get_by_tenant_ids(
effective_owner_ids, current_user.id, page_number, items_per_page, orderby, desc, keywords
)
search_apps, total = SearchService.get_by_tenant_ids(owner_ids, current_user.id, 0, 0, orderby, desc, keywords)
search_apps = [s for s in search_apps if s["tenant_id"] in owner_ids]
total = len(search_apps)
if page_number and items_per_page:
search_apps = search_apps[(page_number - 1) * items_per_page: page_number * items_per_page]
try:
joined_tenants = UserTenantService.query(user_id=current_user.id)
authorized_owner_ids = {current_user.id}
authorized_owner_ids.update(tenant.tenant_id for tenant in joined_tenants)
if owner_ids:
unauthorized_owner_ids = sorted(set(owner_ids) - authorized_owner_ids)
if unauthorized_owner_ids:
logging.warning(
"Rejecting unauthorized owner_ids on list_searches: user_id=%s owner_ids=%s",
current_user.id,
unauthorized_owner_ids,
)
return get_json_result(
data=False,
message="Please specify authorized owner_ids only.",
code=RetCode.OPERATING_ERROR,
)
effective_owner_ids = owner_ids
else:
effective_owner_ids = list(authorized_owner_ids)
search_apps, total = SearchService.get_by_tenant_ids(
effective_owner_ids,
current_user.id,
page_number,
items_per_page,
orderby,
desc,
keywords,
)
return get_json_result(data={"search_apps": search_apps, "total": total})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/apps/restful_apis/search_api.py` around lines 87 - 95, The handler
currently trusts request owner_ids and calls SearchService.get_by_tenant_ids
with unvalidated tenant IDs; instead validate and compute the caller's
authorized tenant IDs server-side (use UserTenantService.query/current_user.id
to fetch joined/authorized tenant IDs), intersect or reject any requested
owner_ids that are out-of-scope, then call SearchService.get_by_tenant_ids
exactly once with the validated tenant set and apply paging only after that;
also add logging for the authorization and rejection flow (e.g., log requested
owner_ids, authorized_tenants, and when a request is rejected or adjusted).

return get_json_result(data={"search_apps": search_apps, "total": total})
except Exception as e:
return server_error_response(e)
Expand Down
27 changes: 19 additions & 8 deletions test/testcases/restful_api/test_chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -1251,7 +1251,7 @@ def _save(**kwargs):


@pytest.mark.p2
def test_list_chats_defaults_to_authorized_owner_ids_when_omitted_unit(monkeypatch):
def test_list_chats_passes_empty_owner_ids_when_omitted_unit(monkeypatch):
module = _load_chat_routes_unit_module(monkeypatch)
captured = {}
monkeypatch.setattr(
Expand Down Expand Up @@ -1280,33 +1280,44 @@ def _get_by_tenant_ids(owner_ids, *_args, **_kwargs):
monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids)
res = _run(module.list_chats.__wrapped__())
assert res["code"] == 0
assert set(captured["owner_ids"]) == {"tenant-1", "team-tenant-2"}
assert captured["owner_ids"] == []


@pytest.mark.p2
def test_list_chats_rejects_unauthorized_owner_ids_unit(monkeypatch):
def test_list_chats_filters_by_requested_owner_ids_unit(monkeypatch):
module = _load_chat_routes_unit_module(monkeypatch)
captured = {}
monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"keywords": "",
"page": "0",
"page_size": "0",
"page": "1",
"page_size": "10",
"orderby": "create_time",
"desc": "true",
"id": None,
"name": None,
}.get(key, default),
getlist=lambda key: ["foreign-tenant-id"] if key == "owner_ids" else [],
getlist=lambda key: ["team-tenant-2"] if key == "owner_ids" else [],
)
),
)

def _get_by_tenant_ids(owner_ids, *_args, **_kwargs):
captured["owner_ids"] = owner_ids
team_chat = _DummyDialogRecord({"id": "team-chat", "tenant_id": "team-tenant-2", "name": "team"}).to_dict()
own_chat = _DummyDialogRecord({"id": "own-chat", "tenant_id": "tenant-1", "name": "own"}).to_dict()
return ([team_chat, own_chat], 2)

monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids)
res = _run(module.list_chats.__wrapped__())
assert res["code"] == module.RetCode.OPERATING_ERROR
assert "authorized owner_ids" in res["message"]
assert res["code"] == 0
assert captured["owner_ids"] == ["team-tenant-2"]
assert [chat["id"] for chat in res["data"]["chats"]] == ["team-chat"]
assert res["data"]["total"] == 1
Comment on lines +1287 to +1320
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Cover the new owner_ids manual-pagination contract, not just filtering.

When owner_ids is present, list_chats now intentionally calls DialogService.get_by_tenant_ids(..., 0, 0, ...), filters in memory, then slices the filtered list. With page=1, page_size=10, and only one matching row, this test still passes if the route falls back to normal DB pagination or skips the 0,0 call entirely.

Suggested test tightening
                 get=lambda key, default=None: {
                     "keywords": "",
-                    "page": "1",
-                    "page_size": "10",
+                    "page": "2",
+                    "page_size": "1",
                     "orderby": "create_time",
                     "desc": "true",
                     "id": None,
                     "name": None,
                 }.get(key, default),
                 getlist=lambda key: ["team-tenant-2"] if key == "owner_ids" else [],
             )
         ),
     )

-    def _get_by_tenant_ids(owner_ids, *_args, **_kwargs):
+    def _get_by_tenant_ids(owner_ids, _user_id, page_number, items_per_page, *_args, **_kwargs):
         captured["owner_ids"] = owner_ids
-        team_chat = _DummyDialogRecord({"id": "team-chat", "tenant_id": "team-tenant-2", "name": "team"}).to_dict()
+        captured["page_number"] = page_number
+        captured["items_per_page"] = items_per_page
+        team_chat_1 = _DummyDialogRecord({"id": "team-chat-1", "tenant_id": "team-tenant-2", "name": "team-1"}).to_dict()
         own_chat = _DummyDialogRecord({"id": "own-chat", "tenant_id": "tenant-1", "name": "own"}).to_dict()
-        return ([team_chat, own_chat], 2)
+        team_chat_2 = _DummyDialogRecord({"id": "team-chat-2", "tenant_id": "team-tenant-2", "name": "team-2"}).to_dict()
+        return ([team_chat_1, own_chat, team_chat_2], 3)

     monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids)
     res = _run(module.list_chats.__wrapped__())
     assert res["code"] == 0
     assert captured["owner_ids"] == ["team-tenant-2"]
-    assert [chat["id"] for chat in res["data"]["chats"]] == ["team-chat"]
-    assert res["data"]["total"] == 1
+    assert captured["page_number"] == 0
+    assert captured["items_per_page"] == 0
+    assert [chat["id"] for chat in res["data"]["chats"]] == ["team-chat-2"]
+    assert res["data"]["total"] == 2

As per coding guidelines, "Add/adjust tests for behavior changes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/testcases/restful_api/test_chats.py` around lines 1287 - 1320, The test
must verify the new manual-pagination contract: have the mocked
DialogService.get_by_tenant_ids (used by list_chats) be called with offset=0 and
limit=0 and return the full unpaginated list so list_chats does in-memory
filter+slice; modify the _get_by_tenant_ids stub to capture owner_ids, offset
and limit (e.g., captured["owner_ids"], captured["offset"], captured["limit"]),
ignore any incoming offset/limit args and return both team_chat and own_chat
plus total 2, then assert captured["owner_ids"] == ["team-tenant-2"],
captured["offset"] == 0 and captured["limit"] == 0 and that the final response
contains only ["team-chat"] and total 1 to prove filtering+manual pagination
occurred.



@pytest.mark.p2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from utils import wait_for


@wait_for(200, 1, "Document parsing timeout")
@wait_for(30, 1, "Document parsing timeout")
def condition(_auth, _dataset_id):
res = list_documents(_auth, _dataset_id)
for doc in res["data"]["docs"]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,6 @@ class _StubLLMType(str, Enum):
class _StubRetCode(int, Enum):
SUCCESS = 0
DATA_ERROR = 102
OPERATING_ERROR = 103
AUTHENTICATION_ERROR = 109

class _StubStatusEnum(str, Enum):
Expand Down Expand Up @@ -377,10 +376,6 @@ class _StubTenantService:
def get_by_id(_tenant_id):
return True, SimpleNamespace(llm_id="glm-4")

@staticmethod
def get_joined_tenants_by_user_id(_user_id):
return [{"tenant_id": "tenant-1"}, {"tenant_id": "team-tenant-2"}]

class _StubUserTenantService:
@staticmethod
def query(**_kwargs):
Expand Down Expand Up @@ -887,112 +882,6 @@ def _get_by_tenant_ids(_owner_ids, _user_id, page_number, items_per_page, *_args
assert len(res["data"]["chats"]) == 1


@pytest.mark.p2
def test_list_chats_rejects_unauthorized_owner_ids(monkeypatch):
module = _load_chat_module(monkeypatch)
monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"keywords": "",
"page": "0",
"page_size": "0",
"orderby": "create_time",
"desc": "true",
"id": None,
"name": None,
}.get(key, default),
getlist=lambda key: ["foreign-tenant-id"] if key == "owner_ids" else [],
)
),
)
res = _run(module.list_chats.__wrapped__())
assert res["code"] == module.RetCode.OPERATING_ERROR
assert "authorized owner_ids" in res["message"]


@pytest.mark.p2
def test_list_chats_authorized_multi_tenant(monkeypatch):
module = _load_chat_module(monkeypatch)
captured = {}
monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"keywords": "",
"page": "1",
"page_size": "10",
"orderby": "create_time",
"desc": "true",
"id": None,
"name": None,
}.get(key, default),
getlist=lambda key: ["tenant-1", "team-tenant-2"] if key == "owner_ids" else [],
)
),
)

def _get_by_tenant_ids(owner_ids, user_id, *args, **kwargs):
captured["owner_ids"] = owner_ids
captured["user_id"] = user_id
return (
[
{**_DummyDialogRecord().to_dict(), "tenant_id": "tenant-1", "id": "c1"},
{**_DummyDialogRecord().to_dict(), "tenant_id": "team-tenant-2", "id": "c2"},
],
2,
)

monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids)
monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, _DummyKB()))

res = _run(module.list_chats.__wrapped__())
assert res["code"] == 0
assert res["data"]["total"] == 2
assert {c["id"] for c in res["data"]["chats"]} == {"c1", "c2"}
assert set(captured["owner_ids"]) == {"tenant-1", "team-tenant-2"}
assert captured["user_id"] == "tenant-1"


@pytest.mark.p2
def test_list_chats_defaults_to_authorized_owner_ids_when_omitted(monkeypatch):
module = _load_chat_module(monkeypatch)
captured = {}

monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"keywords": "",
"page": "1",
"page_size": "10",
"orderby": "create_time",
"desc": "true",
"id": None,
"name": None,
}.get(key, default),
getlist=lambda _key: [],
)
),
)

def _get_by_tenant_ids(owner_ids, *_args, **_kwargs):
captured["owner_ids"] = owner_ids
return ([], 0)

monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids)
res = _run(module.list_chats.__wrapped__())

assert res["code"] == 0
assert set(captured["owner_ids"]) == {"tenant-1", "team-tenant-2"}


@pytest.mark.p2
def test_chat_session_create_and_update_guard_matrix_unit(monkeypatch):
module = _load_chat_module(monkeypatch)
Expand Down
Loading
Loading