mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 11:06:18 +00:00
* fix(gateway): preserve owner isolation when thread metadata is missing Follow-up to the #5448 review P1 (post-merge finding): owner_check=True also authorizes threads whose meta row is missing (legacy compatibility) or NULL-owner (shared/pre-auth data). _run_scope_user_id returned None for every trusted internal caller, which dropped the only remaining per-user filter on those threads and let an internal caller acting for owner A read owner B's persisted runs. _run_scope_user_id now takes the thread_id and consults the thread meta store: when an existing meta row establishes ownership, the authorized thread's runs are still read unfiltered (merged #5448 semantics, including owner-header-less internal callers); when the meta row is missing or NULL-owner, the filter falls back to the acting owner's raw stamp (the exact value start_run writes) — or the synthetic "default" identity without an owner header — so cross-user runs stay hidden. Isolation coverage uses the real MemoryThreadMetaStore with no metadata row (and a NULL-owner row) plus another user's persisted run: /runs and /runs/page must be empty and /runs/{run_id} must 404 for internal callers, while an established-ownership thread keeps the unfiltered read. * fix(gateway): gate run-scoped sub-resource reads for internal callers Review follow-up on #5484: the P1 owner-isolation class remained reachable through run-scoped sibling reads that apply no per-user filter at all — /runs/{run_id}/messages, /events, /join, /stream and /workspace-changes query by (thread_id, run_id) directly, so on missing/NULL-owner threads an internal caller acting for owner A could still read owner B's run content by id (verified 200 at the previous head). - Extract _thread_ownership_established (shared meta-row check) and add _require_run_visible_to_scope: for internal callers on threads without established ownership, the run's own user_id stamp must match the acting owner's raw value (or the legacy "default" stamp) or the read 404s. Established-ownership threads and every non-internal caller keep their existing thread-scoped semantics. - Wire the gate into join, stream, messages, events and workspace-changes; reword the now-stale messages comment to track the new scoping semantics. Regression tests: sub-resource reads 404 for a mismatched internal owner while the matching owner reads them normally, and the owner-less fallback branch (synthetic "default" filter on missing-meta threads) is pinned. Red confirmed against the pre-gate head. * fix(gateway): gate cancel and artifact archive for internal callers Review follow-up on #5484 round 2: POST /cancel resolved runs unscoped (require_existing=True only closes the missing-meta case — NULL-owner meta rows still pass), so an internal caller acting for a different owner could interrupt another owner's active run on a shared thread while /join and /stream were already gated. The archive manifest and download pair likewise leaked the other owner's delivered-file count and a 200-vs-409 delivery oracle on NULL-owner threads (missing-meta threads were already denied by require_existing=True). All three routes now call _require_run_visible_to_scope; its docstring records the extended coverage. NULL-owner-thread regression tests pin: a mismatched internal owner gets 404 from cancel, manifest and archive download, while the acting owner reaches the real conflict path (409 on a terminal run) and reads the manifest (file_count 2). * fix(gateway): tolerate state-less request stand-ins in the scope helpers The new owner-isolation gate and _run_scope_user_id read request.state directly, which crashed the FakeRequest-based unit suites for the run events, workspace-changes and scope endpoints (backend-unit-tests shards 1/2/4 on #5484). Read the state object defensively first: a request without state is simply not an internal caller, so those paths keep their pre-gate semantics. * fix(gateway): scope the thread token-usage aggregate by owner Review follow-up on #5484 round 4: GET /{thread_id}/token-usage called aggregate_tokens_by_thread(thread_id) with no user filter at all, so on missing/NULL-owner threads an internal caller acting for owner A read owner B's spend, model names, run count and (with include_active=true) live activity; the NULL-owner variant reached browser sessions too. build_context_usage's latest-model lookup was unfiltered as well. aggregate_tokens_by_thread gains an optional user_id (mirroring list_by_thread: explicit None = unfiltered, AUTO resolves the contextvar) in the memory store, the SQL repository and the store base; build_context_usage/_resolve_thread_model_name thread the scope through the latest-run lookup; the token-usage endpoint passes _run_scope_user_id's value. Established-ownership threads aggregate unfiltered as before; shared/missing-meta threads narrow to the acting identity. Stale helper-test comment reworded after the #5482 merge adaptation. * test(gateway): pin the unfiltered aggregate on established-ownership threads Review follow-up on #5484 round 5: the established-ownership branch of the token-usage scoping (store receives user_id=None) was the only unpinned half of the contract — the round-4 call-assertions never set app.state.thread_store, so their None came from the user-less stand-in path. test_token_usage_unfiltered_on_established_ownership_for_ internal_callers seeds an established meta row plus runs stamped by two different identities and asserts the totals fold (166 = 111 + 55); together with the isolation tests it now catches both failure modes (always-stamp narrowing and always-None leak).
781 lines
30 KiB
Python
781 lines
30 KiB
Python
"""Regression coverage for the runs/messages read endpoints' identity scoping (#5437).
|
|
|
|
Trusted internal callers are *authorized* as a synthetic internal user
|
|
(``system_role="internal"``) whose id is ``"default"`` or the
|
|
``make_safe_user_id``-normalized owner, while ``start_run`` stamps run rows
|
|
with the raw trusted-owner value. Filtering the reads by the authorization
|
|
identity therefore never matches the persisted rows. The read endpoints must
|
|
skip the per-user filter for internal callers — thread visibility is already
|
|
authorized by ``@require_permission(..., owner_check=True)`` — and keep it for
|
|
browser/API sessions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException, Request, Response
|
|
from fastapi.testclient import TestClient
|
|
from langgraph.store.memory import InMemoryStore
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION
|
|
from app.gateway.authz import AuthContext, Permissions
|
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, get_internal_user
|
|
from app.gateway.routers import thread_runs
|
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.runs.manager import RunManager
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
THREAD_ID = "thread-scope"
|
|
BROWSER_USER_ID = UUID("00000000-0000-0000-0000-00000000000a")
|
|
# A lossy trusted-owner value: make_safe_user_id normalizes it to
|
|
# "feishu-owner-777-<digest>", which can never equal the raw value stamped on
|
|
# the run row — the exact mismatch class reported in #5437.
|
|
OWNER_RAW = "feishu:owner-777"
|
|
RUN_BROWSER = "run-browser-row"
|
|
RUN_OWNER = "run-owner-row"
|
|
|
|
_STUB_PERMISSIONS: list[str] = [
|
|
Permissions.THREADS_READ,
|
|
Permissions.RUNS_READ,
|
|
Permissions.RUNS_CANCEL,
|
|
]
|
|
|
|
|
|
class _ScopeAuthMiddleware(BaseHTTPMiddleware):
|
|
"""Stamp the same state trio production ``AuthMiddleware`` stamps."""
|
|
|
|
def __init__(self, app, *, user, auth_source: str) -> None:
|
|
super().__init__(app)
|
|
self._user = user
|
|
self._auth_source = auth_source
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
request.state.user = self._user
|
|
request.state.auth_source = self._auth_source
|
|
request.state.auth = AuthContext(user=self._user, permissions=list(_STUB_PERMISSIONS))
|
|
return await call_next(request)
|
|
|
|
|
|
class _PermissiveThreadStore:
|
|
"""Stands in for the thread store behind ``owner_check=True``.
|
|
|
|
The existing scope tests exercise the established-ownership path, so
|
|
``get`` reports an existing, owner-established meta row.
|
|
"""
|
|
|
|
async def check_access(self, _thread_id: str, _user_id: str, *, require_existing: bool = False) -> bool:
|
|
return True
|
|
|
|
async def get(self, _thread_id: str, *, user_id: str | None | object = None) -> dict | None:
|
|
return {"thread_id": THREAD_ID, "user_id": "established-owner"}
|
|
|
|
|
|
class _RecordingRunStore(MemoryRunStore):
|
|
"""Records the per-user filter identity each read resolves to.
|
|
|
|
``MemoryRunEventStore.list_messages`` ignores ``user_id`` (only the SQL
|
|
backends honor it), so the runs store is where the resolved filter id is
|
|
observable in-memory: hidden-run lookups and turn-duration injection both
|
|
flow through ``list_by_thread``/``get`` with the endpoint's filter id.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.list_by_thread_user_ids: list[str | None] = []
|
|
self.get_user_ids: list[str | None] = []
|
|
|
|
async def list_by_thread(self, thread_id, *, user_id=None, **kwargs):
|
|
self.list_by_thread_user_ids.append(user_id)
|
|
return await super().list_by_thread(thread_id, user_id=user_id, **kwargs)
|
|
|
|
async def get(self, run_id, *, user_id=None, **kwargs):
|
|
self.get_user_ids.append(user_id)
|
|
return await super().get(run_id, user_id=user_id, **kwargs)
|
|
|
|
|
|
class _RecordingFeedbackRepo:
|
|
"""Records the per-user identity the feedback queries are scoped with."""
|
|
|
|
def __init__(self) -> None:
|
|
self.list_by_thread_user_ids: list[str | None] = []
|
|
self.list_by_run_ids_user_ids: list[str | None] = []
|
|
|
|
async def list_by_thread_grouped(self, thread_id, *, user_id=None):
|
|
self.list_by_thread_user_ids.append(user_id)
|
|
return {}
|
|
|
|
async def list_by_run_ids(self, thread_id, run_ids, *, user_id=None):
|
|
self.list_by_run_ids_user_ids.append(user_id)
|
|
return {}
|
|
|
|
|
|
def _browser_user() -> User:
|
|
return User(id=BROWSER_USER_ID, email="scope-test@example.com", password_hash="x", system_role="user")
|
|
|
|
|
|
def _internal_user(owner_raw: str | None):
|
|
# Mirrors AuthMiddleware + get_internal_user: the synthetic internal user
|
|
# carries the safe-spelled owner id, or "default" without an owner header.
|
|
return get_internal_user(owner_user_id=owner_raw)
|
|
|
|
|
|
def _seed_run(store: MemoryRunStore, run_id: str, *, user_id: str | None, status: str = "success") -> None:
|
|
asyncio.run(store.put(run_id, thread_id=THREAD_ID, user_id=user_id, status=status))
|
|
|
|
|
|
def _seed_message(event_store: MemoryRunEventStore, run_id: str, message_id: str) -> None:
|
|
asyncio.run(
|
|
event_store.put(
|
|
thread_id=THREAD_ID,
|
|
run_id=run_id,
|
|
event_type="llm.ai.response",
|
|
category="message",
|
|
content={"type": "ai", "id": message_id, "content": message_id, "additional_kwargs": {}},
|
|
metadata={},
|
|
)
|
|
)
|
|
|
|
|
|
def _make_app(
|
|
*,
|
|
user,
|
|
auth_source: str,
|
|
run_store: MemoryRunStore,
|
|
event_store: MemoryRunEventStore | None = None,
|
|
feedback_repo: _RecordingFeedbackRepo | None = None,
|
|
thread_store=None,
|
|
) -> TestClient:
|
|
app = FastAPI()
|
|
app.add_middleware(_ScopeAuthMiddleware, user=user, auth_source=auth_source)
|
|
app.state.thread_store = thread_store if thread_store is not None else _PermissiveThreadStore()
|
|
app.state.run_store = run_store
|
|
app.state.run_manager = RunManager(store=run_store)
|
|
if event_store is not None:
|
|
app.state.run_event_store = event_store
|
|
if feedback_repo is not None:
|
|
app.state.feedback_repo = feedback_repo
|
|
app.include_router(thread_runs.router)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture()
|
|
def mixed_owner_store() -> MemoryRunStore:
|
|
store = MemoryRunStore()
|
|
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID))
|
|
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW)
|
|
return store
|
|
|
|
|
|
def test_internal_caller_lists_owner_stamped_runs(mixed_owner_store: MemoryRunStore) -> None:
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=mixed_owner_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert {row["run_id"] for row in response.json()} == {RUN_BROWSER, RUN_OWNER}
|
|
|
|
|
|
def test_internal_caller_get_run_owner_stamped(mixed_owner_store: MemoryRunStore) -> None:
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=mixed_owner_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["run_id"] == RUN_OWNER
|
|
|
|
|
|
def test_internal_caller_runs_page_owner_stamped(mixed_owner_store: MemoryRunStore) -> None:
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=mixed_owner_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/page",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert {row["run_id"] for row in response.json()["data"]} == {RUN_BROWSER, RUN_OWNER}
|
|
assert response.json()["has_more"] is False
|
|
|
|
|
|
def test_internal_caller_without_owner_header_sees_authorized_thread_runs(mixed_owner_store: MemoryRunStore) -> None:
|
|
"""No owner header ⇒ synthetic id "default", which matches nothing either.
|
|
|
|
The thread is authorized via owner_check, so its runs stay listable.
|
|
"""
|
|
client = _make_app(
|
|
user=_internal_user(None),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=mixed_owner_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(f"/api/threads/{THREAD_ID}/runs")
|
|
|
|
assert response.status_code == 200
|
|
assert {row["run_id"] for row in response.json()} == {RUN_BROWSER, RUN_OWNER}
|
|
|
|
|
|
def test_browser_session_keeps_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
|
|
"""Browser sessions keep filtering by their own data identity."""
|
|
client = _make_app(
|
|
user=_browser_user(),
|
|
auth_source=AUTH_SOURCE_SESSION,
|
|
run_store=mixed_owner_store,
|
|
)
|
|
|
|
with client:
|
|
listed = client.get(f"/api/threads/{THREAD_ID}/runs")
|
|
cross_user = client.get(f"/api/threads/{THREAD_ID}/runs/{RUN_OWNER}")
|
|
|
|
assert listed.status_code == 200
|
|
assert [row["run_id"] for row in listed.json()] == [RUN_BROWSER]
|
|
assert cross_user.status_code == 404
|
|
|
|
|
|
def test_internal_caller_messages_skip_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
|
|
"""Internal callers read the authorized thread's messages unfiltered.
|
|
|
|
The observable filter identity is what reaches the scoped queries — the
|
|
runs store (hidden-run lookups, turn durations) and the feedback repo —
|
|
``None`` for internal callers.
|
|
"""
|
|
event_store = MemoryRunEventStore()
|
|
_seed_message(event_store, RUN_OWNER, "msg-owner")
|
|
_seed_message(event_store, RUN_BROWSER, "msg-browser")
|
|
run_store = _RecordingRunStore()
|
|
for run_id, user_id in ((RUN_BROWSER, str(BROWSER_USER_ID)), (RUN_OWNER, OWNER_RAW)):
|
|
_seed_run(run_store, run_id, user_id=user_id)
|
|
feedback_repo = _RecordingFeedbackRepo()
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
feedback_repo=feedback_repo,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/messages",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
page = client.get(
|
|
f"/api/threads/{THREAD_ID}/messages/page",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert {row["content"]["id"] for row in response.json()} == {"msg-owner", "msg-browser"}
|
|
assert page.status_code == 200
|
|
assert {row["content"]["id"] for row in page.json()["data"]} == {"msg-owner", "msg-browser"}
|
|
assert run_store.list_by_thread_user_ids and all(uid is None for uid in run_store.list_by_thread_user_ids)
|
|
assert feedback_repo.list_by_thread_user_ids == [None]
|
|
assert feedback_repo.list_by_run_ids_user_ids == [None]
|
|
|
|
|
|
def test_browser_session_messages_keep_per_user_filter(mixed_owner_store: MemoryRunStore) -> None:
|
|
"""Browser sessions keep passing their own id to the messages pipeline."""
|
|
event_store = MemoryRunEventStore()
|
|
_seed_message(event_store, RUN_OWNER, "msg-owner")
|
|
_seed_message(event_store, RUN_BROWSER, "msg-browser")
|
|
run_store = _RecordingRunStore()
|
|
for run_id, user_id in ((RUN_BROWSER, str(BROWSER_USER_ID)), (RUN_OWNER, OWNER_RAW)):
|
|
_seed_run(run_store, run_id, user_id=user_id)
|
|
feedback_repo = _RecordingFeedbackRepo()
|
|
|
|
client = _make_app(
|
|
user=_browser_user(),
|
|
auth_source=AUTH_SOURCE_SESSION,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
feedback_repo=feedback_repo,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(f"/api/threads/{THREAD_ID}/messages")
|
|
|
|
assert response.status_code == 200
|
|
assert {row["content"]["id"] for row in response.json()} == {"msg-owner", "msg-browser"}
|
|
assert run_store.list_by_thread_user_ids and all(uid == str(BROWSER_USER_ID) for uid in run_store.list_by_thread_user_ids)
|
|
assert feedback_repo.list_by_thread_user_ids == [str(BROWSER_USER_ID)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# owner isolation on threads without established metadata (#5448 review P1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_missing_thread_meta_keeps_owner_isolation_for_internal_callers() -> None:
|
|
"""owner_check also authorizes missing-meta (legacy shared) threads.
|
|
|
|
There, unfiltered reads would expose other users' persisted runs to the
|
|
acting owner's internal caller, so the raw trusted owner stays the filter
|
|
— the exact value ``start_run`` stamps on run rows (#5448 review P1).
|
|
"""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no metadata row at all
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
listed = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
page = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/page",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
single = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/run-other-user",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert listed.status_code == 200
|
|
assert listed.json() == []
|
|
assert page.status_code == 200
|
|
assert page.json()["data"] == []
|
|
assert single.status_code == 404
|
|
# The acting owner's own raw-stamped runs remain visible: seed one and
|
|
# confirm it comes back through the same endpoints.
|
|
owned_store = _RecordingRunStore()
|
|
_seed_run(owned_store, "run-own-owner-stamp", user_id=OWNER_RAW, status="success")
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=owned_store,
|
|
thread_store=thread_store,
|
|
)
|
|
with client:
|
|
own = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/run-own-owner-stamp",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
assert own.status_code == 200
|
|
assert own.json()["run_id"] == "run-own-owner-stamp"
|
|
|
|
|
|
def test_null_owner_thread_meta_keeps_owner_isolation_for_internal_callers() -> None:
|
|
"""NULL-owner meta rows (shared/pre-auth data) isolate by raw owner too."""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
|
asyncio.run(
|
|
thread_store.create(
|
|
THREAD_ID,
|
|
assistant_id=None,
|
|
user_id=None, # shared / pre-auth: meta row exists with NULL owner
|
|
)
|
|
)
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
listed = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
single = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/run-other-user",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert listed.status_code == 200
|
|
assert listed.json() == []
|
|
assert single.status_code == 404
|
|
|
|
|
|
def test_established_ownership_still_reads_thread_runs_unfiltered(mixed_owner_store: MemoryRunStore) -> None:
|
|
"""Established meta ownership keeps the #5437 unfiltered-read behavior."""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
|
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=OWNER_RAW))
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, RUN_OWNER, user_id=OWNER_RAW, status="success")
|
|
_seed_run(run_store, "run-legacy-default", user_id="default", status="success")
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/runs",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert {row["run_id"] for row in response.json()} == {RUN_OWNER, "run-legacy-default"}
|
|
|
|
|
|
def test_ownerless_internal_caller_default_filter_on_missing_meta() -> None:
|
|
"""Without an owner header the synthetic "default" identity is the filter.
|
|
|
|
Pins the owner-less fallback branch of ``_run_scope_user_id``: a run
|
|
stamped with another owner's raw id stays hidden on missing-meta threads.
|
|
"""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
|
|
client = _make_app(
|
|
user=_internal_user(None),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(f"/api/threads/{THREAD_ID}/runs")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
def test_subresource_reads_stay_owner_isolated_without_meta() -> None:
|
|
"""Run-scoped sub-resources must respect the acting owner's stamp.
|
|
|
|
These reads query by ``(thread_id, run_id)`` with no per-user filter of
|
|
their own; on missing-meta threads an internal caller acting for owner A
|
|
could otherwise read owner B's run content by id (#5448 review P1
|
|
follow-up).
|
|
"""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
event_store = MemoryRunEventStore()
|
|
_seed_message(event_store, "run-owner-777", "msg-owner-run")
|
|
|
|
stranger_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: "feishu:owner-999"}
|
|
owner_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW}
|
|
base = f"/api/threads/{THREAD_ID}/runs/run-owner-777"
|
|
|
|
stranger = _make_app(
|
|
user=_internal_user("feishu:owner-999"),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
thread_store=thread_store,
|
|
)
|
|
owner_client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with stranger:
|
|
assert stranger.get(base + "/messages", headers=stranger_headers).status_code == 404
|
|
assert stranger.get(base + "/events", headers=stranger_headers).status_code == 404
|
|
assert stranger.get(base + "/workspace-changes", headers=stranger_headers).status_code == 404
|
|
assert stranger.get(base + "/join", headers=stranger_headers).status_code == 404
|
|
|
|
with owner_client:
|
|
messages = owner_client.get(base + "/messages", headers=owner_headers)
|
|
events = owner_client.get(base + "/events", headers=owner_headers)
|
|
|
|
assert messages.status_code == 200
|
|
assert [row["content"]["id"] for row in messages.json()["data"]] == ["msg-owner-run"]
|
|
assert events.status_code == 200
|
|
assert any(event.get("run_id") == "run-owner-777" for event in events.json())
|
|
|
|
|
|
def test_null_owner_thread_gates_cancel_and_archive_for_internal_callers() -> None:
|
|
"""NULL-owner meta rows gate POST /cancel and the archive pair too.
|
|
|
|
The round-2 findings: cancel resolved runs unscoped (an interrupt-vs-join
|
|
inconsistency) and the archive manifest leaked the other owner's
|
|
delivered-file count plus a 200-vs-409 delivery oracle on shared threads.
|
|
"""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
|
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
event_store = MemoryRunEventStore()
|
|
|
|
stranger_headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: "feishu:owner-999"}
|
|
stranger = _make_app(
|
|
user=_internal_user("feishu:owner-999"),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with stranger:
|
|
cancel = stranger.post(
|
|
f"/api/threads/{THREAD_ID}/runs/run-owner-777/cancel?action=interrupt",
|
|
headers=stranger_headers,
|
|
)
|
|
manifest = stranger.get(
|
|
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
|
|
headers=stranger_headers,
|
|
)
|
|
archive = stranger.post(
|
|
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
|
|
headers=stranger_headers,
|
|
)
|
|
|
|
assert cancel.status_code == 404
|
|
assert manifest.status_code == 404
|
|
assert archive.status_code == 404
|
|
|
|
|
|
def test_null_owner_thread_matching_owner_cancels_and_reads_manifest() -> None:
|
|
"""The acting owner keeps cancel and archive access on shared threads."""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
|
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=None))
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
event_store = MemoryRunEventStore()
|
|
asyncio.run(
|
|
event_store.put(
|
|
thread_id=THREAD_ID,
|
|
run_id="run-owner-777",
|
|
event_type="run.delivery",
|
|
category="outputs",
|
|
content={"presented": 2, "by_tool": {"present_files": ["/mnt/user-data/outputs/a.txt", "/mnt/user-data/outputs/b.txt"]}},
|
|
)
|
|
)
|
|
|
|
owner_client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
event_store=event_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with owner_client:
|
|
manifest = owner_client.get(
|
|
f"/api/threads/{THREAD_ID}/runs/run-owner-777/artifacts/archive",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
cancel = owner_client.post(
|
|
f"/api/threads/{THREAD_ID}/runs/run-owner-777/cancel?action=interrupt",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert manifest.status_code == 200
|
|
assert manifest.json() == {"file_count": 2}
|
|
# A terminal run cannot be cancelled again: the acting owner reaches the
|
|
# real conflict path instead of a 404 anti-enumeration answer.
|
|
assert cancel.status_code == 409
|
|
|
|
|
|
# edit/regenerate helper fallback paths (#5482)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _helper_request(*, user, auth_source: str, run_store, event_store, owner_header: str | None = None):
|
|
"""Minimal Request stand-in: the helpers touch state, app.state and headers."""
|
|
app_state = SimpleNamespace(run_manager=RunManager(store=run_store), run_event_store=event_store)
|
|
headers = {INTERNAL_OWNER_USER_ID_HEADER_NAME: owner_header} if owner_header else {}
|
|
return SimpleNamespace(
|
|
state=SimpleNamespace(user=user, auth_source=auth_source),
|
|
app=SimpleNamespace(state=app_state),
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
def test_helper_fallback_paths_resolve_internal_caller_runs() -> None:
|
|
"""The edit/regenerate helper fallbacks must use the data identity (#5482)."""
|
|
store = _RecordingRunStore()
|
|
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW, status="interrupted")
|
|
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID), status="success")
|
|
request = _helper_request(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=store,
|
|
event_store=MemoryRunEventStore(),
|
|
owner_header=OWNER_RAW,
|
|
)
|
|
|
|
# The owner-stamped interrupted run resolves through the raw owner stamp.
|
|
interrupted = asyncio.run(thread_runs._find_interrupted_target_run_id(THREAD_ID, {"additional_kwargs": {"run_id": RUN_OWNER}}, request))
|
|
assert interrupted == RUN_OWNER
|
|
assert store.get_user_ids[-1] == OWNER_RAW
|
|
|
|
# An interrupted run is not an editable source run, but the lookup itself
|
|
# must have reached it (409 for status, not for a missing record).
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_OWNER, request))
|
|
assert exc.value.status_code == 409
|
|
assert "successful" in exc.value.detail
|
|
assert store.get_user_ids[-1] == OWNER_RAW
|
|
|
|
# Fallback scan without any event-store or kwargs anchors still scans the
|
|
# authorized thread through the acting owner's raw-stamp scope (and 409s
|
|
# on the miss).
|
|
with pytest.raises(HTTPException) as exc2:
|
|
asyncio.run(
|
|
thread_runs._find_target_run_id(
|
|
THREAD_ID,
|
|
"missing-message",
|
|
{"content": "unmatched"},
|
|
{"additional_kwargs": {}},
|
|
request,
|
|
)
|
|
)
|
|
assert exc2.value.status_code == 409
|
|
assert store.list_by_thread_user_ids and store.list_by_thread_user_ids[-1] == OWNER_RAW
|
|
|
|
|
|
def test_helper_fallback_paths_keep_per_user_filter_for_browser_sessions() -> None:
|
|
"""Browser sessions keep the per-user filter in the helper fallbacks."""
|
|
store = _RecordingRunStore()
|
|
_seed_run(store, RUN_OWNER, user_id=OWNER_RAW, status="interrupted")
|
|
_seed_run(store, RUN_BROWSER, user_id=str(BROWSER_USER_ID), status="success")
|
|
request = _helper_request(
|
|
user=_browser_user(),
|
|
auth_source=AUTH_SOURCE_SESSION,
|
|
run_store=store,
|
|
event_store=MemoryRunEventStore(),
|
|
)
|
|
|
|
# The owner-stamped run is invisible under the browser user's filter.
|
|
assert asyncio.run(thread_runs._find_interrupted_target_run_id(THREAD_ID, {"additional_kwargs": {"run_id": RUN_OWNER}}, request)) is None
|
|
assert store.get_user_ids[-1] == str(BROWSER_USER_ID)
|
|
|
|
# Their own successful run still resolves, and a cross-user one 409s.
|
|
record = asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_BROWSER, request))
|
|
assert record.run_id == RUN_BROWSER
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(thread_runs._require_successful_source_run(THREAD_ID, RUN_OWNER, request))
|
|
assert exc.value.status_code == 409
|
|
assert store.get_user_ids[-1] == str(BROWSER_USER_ID)
|
|
|
|
|
|
def test_token_usage_isolated_without_meta_for_internal_callers() -> None:
|
|
"""Token-usage aggregate honors the acting owner's raw stamp (#5484 r4)."""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
_seed_run(run_store, "run-other-user", user_id=str(BROWSER_USER_ID), status="success")
|
|
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
|
|
run_store._runs["run-owner-777"]["total_tokens"] = 111
|
|
run_store._runs["run-other-user"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 999}}
|
|
run_store._runs["run-other-user"]["total_tokens"] = 999
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/token-usage",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["total_tokens"] == 111
|
|
assert body["total_runs"] == 1
|
|
|
|
|
|
def test_token_usage_narrowed_for_browser_sessions_without_meta() -> None:
|
|
"""Browser sessions on shared threads see only their own spend too."""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore()) # no meta row
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
_seed_run(run_store, "run-browser", user_id=str(BROWSER_USER_ID), status="success")
|
|
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
|
|
run_store._runs["run-owner-777"]["total_tokens"] = 111
|
|
run_store._runs["run-browser"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 222}}
|
|
run_store._runs["run-browser"]["total_tokens"] = 222
|
|
|
|
client = _make_app(
|
|
user=_browser_user(),
|
|
auth_source=AUTH_SOURCE_SESSION,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(f"/api/threads/{THREAD_ID}/token-usage")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["total_tokens"] == 222
|
|
|
|
|
|
def test_token_usage_unfiltered_on_established_ownership_for_internal_callers() -> None:
|
|
"""Established meta ownership keeps the unfiltered aggregate.
|
|
|
|
Pins the other half of the scoping contract: on an established thread the
|
|
internal caller's aggregate folds runs stamped by different identities
|
|
(the store must receive ``user_id=None``), mirroring
|
|
``test_established_ownership_still_reads_thread_runs_unfiltered``.
|
|
"""
|
|
thread_store = MemoryThreadMetaStore(InMemoryStore())
|
|
asyncio.run(thread_store.create(THREAD_ID, assistant_id=None, user_id=OWNER_RAW))
|
|
run_store = _RecordingRunStore()
|
|
_seed_run(run_store, "run-owner-777", user_id=OWNER_RAW, status="success")
|
|
_seed_run(run_store, "run-legacy-default", user_id="default", status="success")
|
|
run_store._runs["run-owner-777"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 111}}
|
|
run_store._runs["run-owner-777"]["total_tokens"] = 111
|
|
run_store._runs["run-legacy-default"]["token_usage_by_model"] = {"gpt-x": {"total_tokens": 55}}
|
|
run_store._runs["run-legacy-default"]["total_tokens"] = 55
|
|
|
|
client = _make_app(
|
|
user=_internal_user(OWNER_RAW),
|
|
auth_source=AUTH_SOURCE_INTERNAL,
|
|
run_store=run_store,
|
|
thread_store=thread_store,
|
|
)
|
|
|
|
with client:
|
|
response = client.get(
|
|
f"/api/threads/{THREAD_ID}/token-usage",
|
|
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: OWNER_RAW},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["total_tokens"] == 166 # both stamps fold when ownership is established
|