mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(projects): project workspaces with scoped chats and thread membership
Backend:
- projects table model and migration; fail-closed ProjectRepository with
ownership checks, CRUD/archive/restore/delete router, and atomic thread
move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
metadata; project-aware thread create/search with pagination bounds and
membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
at write time and dropped when invalid; serialized against project
deletion and thread assignment
- branch creation inherits the source thread's project membership (an
archived/deleted project degrades the branch to unassigned instead of
failing the request)
Frontend:
- projects data layer, thread move API, and sidebar projects section with
flat/grouped modes, archived-project threads, and stable virtual-list
offsets
- project detail page with project-scoped new chat
(/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
continuation is dropped instead of saving the goal or launching the
abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
an open project page never keeps stale titles, recency, or pagination
* fix(chats): keep archive undo toast when the sidebar row unmounts
The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.
Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.
* fix(projects): pin project thread listing contract and exclude archived chats
GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.
The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.
Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.
* docs(migrations): record the 0019/0020 chain against the bootstrap reservation
The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.
Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.
* fix(chats): invalidate project thread lists on archive/restore
useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.
An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.
Regression test asserts the project-list prefix is invalidated on success.
* fix(projects): fetch project discovery only in grouped sidebar mode
RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.
Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.
* fix(threads): fail closed on project-scoped create in memory mode
MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).
Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.
Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.
* fix(projects): window the project page thread list
ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.
Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.
* fix(projects): restore row dividers and pin them with a render test
The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.
The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.
* fix(projects): validate forward schemas and fence membership reads
776 lines
32 KiB
Python
776 lines
32 KiB
Python
"""Integration tests for PAT authentication (#4849).
|
|
|
|
Covers credential precedence in AuthMiddleware, the CSRF boundary for
|
|
Bearer-authenticated requests and for the safe-method stream join (#5092),
|
|
scope intersection, PAT management routes, and the self-protection rules
|
|
(a PAT may not manage PATs or auth state).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import Depends, FastAPI, Request
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
from starlette.testclient import TestClient
|
|
|
|
import deerflow.persistence.models # noqa: F401 (register every table)
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_PAT, AUTH_SOURCE_SESSION
|
|
from app.gateway.auth_middleware import AuthMiddleware
|
|
from app.gateway.authz import require_cancel_permission_if
|
|
from app.gateway.csrf_middleware import CSRFMiddleware
|
|
from app.gateway.routers.auth import router as auth_router
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from deerflow.config.authorization_config import AuthorizationConfig
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.personal_access_tokens import PersonalAccessTokenRepository
|
|
|
|
TEST_JWT_SECRET = "test-pat-jwt-secret-0123456789abcdef"
|
|
|
|
|
|
class _FakeProvider:
|
|
"""Minimal LocalAuthProvider stand-in: resolves users by id."""
|
|
|
|
def __init__(self, *users) -> None:
|
|
self._users = {str(user.id): user for user in users}
|
|
|
|
async def get_user(self, user_id: str):
|
|
return self._users.get(str(user_id))
|
|
|
|
|
|
def _fake_user(user_id: str = "user-1", *, system_role: str = "user"):
|
|
return SimpleNamespace(
|
|
id=user_id,
|
|
email=f"{user_id}@example.com",
|
|
system_role=system_role,
|
|
needs_setup=False,
|
|
token_version=0,
|
|
oauth_provider=None,
|
|
password_hash=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _default_route_authorization_config(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"app.gateway.authz._get_route_authorization_config",
|
|
lambda: AuthorizationConfig(),
|
|
)
|
|
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
|
|
set_auth_config(AuthConfig(jwt_secret=TEST_JWT_SECRET, token_expiry_days=7))
|
|
|
|
|
|
def _make_pat_app(with_pat_repo: bool = True):
|
|
app = FastAPI()
|
|
# Production order: AuthMiddleware added first (inner), CSRF last (outer).
|
|
app.add_middleware(AuthMiddleware)
|
|
app.add_middleware(CSRFMiddleware)
|
|
app.include_router(auth_router)
|
|
|
|
@app.get("/api/threads/whoami")
|
|
async def whoami(request: Request):
|
|
return {"user_id": str(request.state.user.id), "auth_source": request.state.auth_source}
|
|
|
|
@app.get("/api/admin-check")
|
|
async def admin_check(request: Request):
|
|
from app.gateway.deps import is_admin_user
|
|
|
|
return {"is_admin": await is_admin_user(request)}
|
|
|
|
@app.post("/api/threads/{thread_id}/runs/stream")
|
|
async def run_stream(request: Request):
|
|
return {"ok": True, "permissions": list(request.state.auth.permissions)}
|
|
|
|
@app.delete("/api/memory")
|
|
async def memory_delete(request: Request):
|
|
return {"deleted": True}
|
|
|
|
@app.delete("/api/threads/{thread_id}")
|
|
async def thread_delete(request: Request):
|
|
return {"deleted": True}
|
|
|
|
# Mirrors the real stateless run entrypoint (routers/runs.py), including
|
|
# the @require_permission decorator, so scope enforcement is exercised
|
|
# end-to-end through the middleware's permission intersection.
|
|
from app.gateway.authz import require_permission
|
|
|
|
@app.post("/api/runs/stream")
|
|
@require_permission("runs", "create")
|
|
async def stateless_run_stream(request: Request):
|
|
return {"ok": True}
|
|
|
|
# Mirrors the real cancel-then-stream entrypoint (thread_runs.py
|
|
# stream_existing_run): runs:read at the decorator, plus the real
|
|
# conditional runs:cancel check the handler applies when `action` is set.
|
|
from app.gateway.routers.thread_runs import require_cancel_permission_when_action
|
|
|
|
@app.post("/api/threads/{thread_id}/runs/{run_id}/stream")
|
|
@require_permission("runs", "read")
|
|
async def cancel_then_stream(thread_id: str, run_id: str, request: Request, action: str | None = None):
|
|
require_cancel_permission_when_action(request, action)
|
|
return {"ok": True}
|
|
|
|
# Mirrors the real GET-only join surface (thread_runs.py
|
|
# join_existing_run_stream): registers the production route dependency
|
|
# that rejects cancel actions before thread ownership or run lookup, so
|
|
# the guard is exercised through the production middleware order above.
|
|
from app.gateway.routers.thread_runs import _reject_get_stream_action
|
|
|
|
@app.get(
|
|
"/api/threads/{thread_id}/runs/{run_id}/stream",
|
|
dependencies=[Depends(_reject_get_stream_action)],
|
|
)
|
|
@require_permission("runs", "read")
|
|
async def join_stream(thread_id: str, run_id: str, request: Request):
|
|
return {"ok": True}
|
|
|
|
# Mirrors the real run-creation entrypoints (thread_runs.py / runs.py):
|
|
# runs:create at the decorator, plus the cancel-capability gate that
|
|
# start_run applies to mutating multitask strategies. RunCreateRequest is
|
|
# imported at module level — FastAPI resolves body annotations against
|
|
# module globals under postponed annotation evaluation.
|
|
@app.post("/api/threads/{thread_id}/runs")
|
|
@require_permission("runs", "create")
|
|
async def create_run(thread_id: str, body: RunCreateRequest, request: Request):
|
|
require_cancel_permission_if(request, body.multitask_strategy != "reject")
|
|
return {"ok": True}
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def pat_env(tmp_path, monkeypatch):
|
|
"""Engine + PAT repo + patched user provider; returns (client, repo)."""
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/pats.db", poolclass=NullPool)
|
|
asyncio.run(_create_tables(engine))
|
|
repo = PersonalAccessTokenRepository(async_sessionmaker(engine, expire_on_commit=False))
|
|
|
|
fake_provider = _FakeProvider(_fake_user("user-1"), _fake_user("user-2"), _fake_user("admin-1", system_role="admin"))
|
|
monkeypatch.setattr("app.gateway.deps.get_local_provider", lambda: fake_provider)
|
|
monkeypatch.setattr("app.gateway.routers.auth.get_local_provider", lambda: fake_provider)
|
|
|
|
app = _make_pat_app()
|
|
app.state.pat_repo = repo
|
|
return app, repo, engine
|
|
|
|
|
|
async def _create_tables(engine) -> None:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(pat_env):
|
|
app, repo, engine = pat_env
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|
|
asyncio.run(engine.dispose())
|
|
|
|
|
|
def _session_cookie(client: TestClient, user_id: str = "user-1", token_version: int = 0) -> str:
|
|
from app.gateway.auth import create_access_token
|
|
|
|
token = create_access_token(user_id, token_version=token_version)
|
|
client.cookies.set("access_token", token)
|
|
return token
|
|
|
|
|
|
def _create_pat(client: TestClient, *, name: str = "test-token", scopes: list[str] | None = None, user_id: str = "user-1", expires_in_days: int | None = None) -> dict:
|
|
"""Create a PAT via the management API with session auth + CSRF pair."""
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client, user_id=user_id)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
payload = {"name": name, "scopes": scopes or ["runs:read", "threads:read"]}
|
|
if expires_in_days is not None:
|
|
payload["expires_in_days"] = expires_in_days
|
|
response = client.post(
|
|
"/api/v1/auth/pats",
|
|
json=payload,
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
payload = response.json()
|
|
assert payload["token"].startswith("dfp_")
|
|
return payload
|
|
|
|
|
|
# ── Middleware precedence (#4849 point 3) ─────────────────────────────────
|
|
|
|
|
|
def test_valid_pat_authenticates_without_cookie(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"user_id": "user-1", "auth_source": AUTH_SOURCE_PAT}
|
|
|
|
|
|
def test_invalid_bearer_never_falls_back_to_session_cookie(client):
|
|
_session_cookie(client) # victim session is present and valid
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_not-a-real-token"})
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "Invalid token"
|
|
|
|
|
|
def test_non_bearer_authorization_scheme_is_rejected(client):
|
|
_session_cookie(client)
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": "Basic dXNlcjpwYXNz"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_valid_pat_takes_precedence_over_session_cookie(client):
|
|
created = _create_pat(client) # sets a session cookie too
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == AUTH_SOURCE_PAT
|
|
|
|
|
|
def test_no_bearer_header_keeps_session_behavior(client):
|
|
_session_cookie(client)
|
|
response = client.get("/api/threads/whoami")
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == AUTH_SOURCE_SESSION
|
|
|
|
|
|
def test_revoked_pat_is_rejected_immediately(client):
|
|
created = _create_pat(client)
|
|
delete = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={"X-CSRF-Token": client.cookies.get("csrf_token")})
|
|
assert delete.status_code == 200, delete.text
|
|
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_pat_with_unresolvable_user_is_rejected(client, pat_env):
|
|
app, repo, _engine = pat_env
|
|
# Row owned by a user the provider cannot resolve (deleted user).
|
|
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
|
|
|
|
token = generate_pat_token()
|
|
asyncio.run(repo.create(user_id="user-deleted", name="orphan", scopes=["runs:read"], token_digest=pat_token_digest(token)))
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_pat_without_durable_store_is_rejected():
|
|
from fastapi import FastAPI
|
|
|
|
app = FastAPI()
|
|
app.add_middleware(AuthMiddleware)
|
|
|
|
@app.get("/api/threads/whoami")
|
|
async def whoami(request): # pragma: no cover - never reached
|
|
return {}
|
|
|
|
with TestClient(app) as bare_client:
|
|
response = bare_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_whatever"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
# ── Scope intersection ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_pat_scopes_intersect_user_permissions(client):
|
|
created = _create_pat(client, scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
permissions = response.json()["permissions"]
|
|
assert "runs:read" in permissions
|
|
assert "runs:create" not in permissions
|
|
assert "threads:read" not in permissions
|
|
|
|
|
|
# ── CSRF posture (#4849 point 4) ──────────────────────────────────────────
|
|
|
|
|
|
def test_bearer_request_skips_double_submit(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear() # no csrf_token cookie, no X-CSRF-Token header
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_garbage_bearer_riding_cookie_dies_at_auth_not_csrf(client):
|
|
_session_cookie(client)
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": "Bearer garbage"})
|
|
# 401 from AuthMiddleware (invalid credential), not 403 from CSRF.
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_empty_authorization_header_is_present_and_dies_at_auth_not_csrf(client):
|
|
_session_cookie(client)
|
|
response = client.post("/api/threads/t1/runs/stream", headers={"Authorization": ""})
|
|
# An explicitly empty header is present-but-invalid: the same 401 from
|
|
# AuthMiddleware as any other invalid credential, never a CSRF 403.
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_auth_endpoint_origin_check_not_bypassed_by_bearer(client):
|
|
response = client.post(
|
|
"/api/v1/auth/login/local",
|
|
json={"email": "a@b.c", "password": "whatever1!"},
|
|
headers={"Origin": "https://evil.example", "Authorization": "Bearer dfp_garbage"},
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.json()["detail"] == "Cross-site auth request denied."
|
|
|
|
|
|
def test_session_get_stream_action_dies_at_route_gate_not_csrf(client):
|
|
"""#5092 defence-in-depth, end-to-end through the production middleware
|
|
order: SameSite=Lax still attaches the session cookie to a cross-site
|
|
top-level GET, and CSRF exempts safe methods — so the route gate is the
|
|
only thing standing between that navigation and a run cancel. An
|
|
authenticated GET with ?action=interrupt is answered 405 + Allow: POST by
|
|
the real _reject_get_stream_action dependency, while the same
|
|
unauthenticated GET dies at AuthMiddleware's 401 before any route logic
|
|
runs."""
|
|
_session_cookie(client)
|
|
denied = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
|
|
assert denied.status_code == 405
|
|
assert denied.headers["allow"] == "POST"
|
|
assert denied.json()["detail"] == "`action` is only supported on POST requests"
|
|
|
|
client.cookies.clear()
|
|
unauthed = client.get("/api/threads/t1/runs/run-1/stream?action=interrupt")
|
|
assert unauthed.status_code == 401
|
|
|
|
|
|
# ── Management routes + self-protection (#4849 point 6) ───────────────────
|
|
|
|
|
|
def test_create_returns_show_once_token_and_list_hides_it(client):
|
|
created = _create_pat(client)
|
|
listed = client.get("/api/v1/auth/pats")
|
|
assert listed.status_code == 200
|
|
entries = listed.json()
|
|
assert [entry["id"] for entry in entries] == [created["id"]]
|
|
assert "token" not in entries[0]
|
|
assert "token_digest" not in entries[0]
|
|
|
|
|
|
def test_create_rejects_unknown_scope(client):
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
response = client.post("/api/v1/auth/pats", json={"name": "bad", "scopes": ["runs:write"]}, headers={CSRF_HEADER_NAME: csrf})
|
|
assert response.status_code == 400
|
|
assert "Unknown PAT scopes" in response.json()["detail"]
|
|
|
|
|
|
def test_create_rejects_whitespace_only_name(client):
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
for name in (" ", "\t\n"):
|
|
response = client.post("/api/v1/auth/pats", json={"name": name, "scopes": ["runs:read"]}, headers={CSRF_HEADER_NAME: csrf})
|
|
# Rejected by request validation (422) before token generation.
|
|
assert response.status_code == 422, name
|
|
assert "non-whitespace" in response.text
|
|
|
|
|
|
def test_create_trims_surrounding_whitespace_in_name(client):
|
|
created = _create_pat(client, name=" ci bot ")
|
|
assert created["name"] == "ci bot"
|
|
|
|
|
|
def test_revoke_is_scoped_to_owner(client):
|
|
created = _create_pat(client, user_id="user-1")
|
|
# user-2 tries to revoke user-1's token.
|
|
_session_cookie(client, user_id="user-2")
|
|
from app.gateway.csrf_middleware import CSRF_HEADER_NAME
|
|
|
|
response = client.delete(f"/api/v1/auth/pats/{created['id']}", headers={CSRF_HEADER_NAME: client.cookies.get("csrf_token")})
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_pat_cannot_manage_pats(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
headers = {"Authorization": f"Bearer {created['token']}"}
|
|
assert client.get("/api/v1/auth/pats", headers=headers).status_code == 403
|
|
assert client.post("/api/v1/auth/pats", json={"name": "child", "scopes": ["runs:read"]}, headers=headers).status_code == 403
|
|
assert client.delete(f"/api/v1/auth/pats/{created['id']}", headers=headers).status_code == 403
|
|
|
|
|
|
def test_pat_cannot_change_password(client):
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
response = client.post(
|
|
"/api/v1/auth/change-password",
|
|
json={"current_password": "x", "new_password": "Whatever123!"},
|
|
headers={"Authorization": f"Bearer {created['token']}"},
|
|
)
|
|
assert response.status_code == 403
|
|
# The default-deny route policy blocks the request at the middleware,
|
|
# before the route-level session-only guard gets a chance; the 403 is the
|
|
# security property either way.
|
|
assert "pat" in response.json()["detail"].lower()
|
|
|
|
|
|
def test_successful_pat_auth_stamps_last_used(client, pat_env):
|
|
_app, repo, _engine = pat_env
|
|
created = _create_pat(client)
|
|
client.cookies.clear()
|
|
assert client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {created['token']}"}).status_code == 200
|
|
|
|
records = asyncio.run(repo.list_for_user("user-1"))
|
|
assert records[0]["last_used_at"] is not None
|
|
|
|
|
|
def test_expired_pat_rejected_at_middleware(client, pat_env):
|
|
_app, repo, _engine = pat_env
|
|
from app.gateway.auth.pat import generate_pat_token, pat_token_digest
|
|
|
|
token = generate_pat_token()
|
|
asyncio.run(
|
|
repo.create(
|
|
user_id="user-1",
|
|
name="already-expired",
|
|
scopes=["runs:read"],
|
|
token_digest=pat_token_digest(token),
|
|
expires_at=datetime.now(UTC) - timedelta(seconds=1),
|
|
)
|
|
)
|
|
client.cookies.clear()
|
|
response = client.get("/api/threads/whoami", headers={"Authorization": f"Bearer {token}"})
|
|
assert response.status_code == 401
|
|
|
|
|
|
def test_create_with_expiry_returns_expires_at(client):
|
|
created = _create_pat(client, expires_in_days=30)
|
|
assert created["expires_at"] is not None
|
|
|
|
|
|
def test_pat_never_carries_admin_capability_even_for_admin_owner(client):
|
|
created = _create_pat(client, user_id="admin-1", scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
# The route-level default-deny policy blocks the PAT before the route
|
|
# runs; the is_admin_user guard inside it remains as defense in depth
|
|
# for compositions without the middleware.
|
|
response = client.get("/api/admin-check", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 403
|
|
|
|
# Control: the same admin over a session cookie keeps admin capability.
|
|
_session_cookie(client, user_id="admin-1")
|
|
control = client.get("/api/admin-check")
|
|
assert control.status_code == 200
|
|
assert control.json() == {"is_admin": True}
|
|
|
|
|
|
def test_pat_default_denied_on_route_outside_pat_policy(client):
|
|
"""P1 regression (#5041 review): a PAT holding every scope must not reach
|
|
destructive routes that have no PAT policy — scope intersection only
|
|
constrains @require_permission routes, so undecorated mutation routes
|
|
would otherwise accept a runs:read-only token."""
|
|
created = _create_pat(client, scopes=["threads:read", "threads:write", "threads:delete", "runs:create", "runs:read", "runs:cancel"])
|
|
client.cookies.clear()
|
|
response = client.delete("/api/memory", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 403
|
|
assert "PAT" in response.json()["detail"]
|
|
|
|
|
|
def test_session_cookie_reaches_route_that_denies_pat(client):
|
|
"""The default-deny is PAT-specific: the same route stays open to the
|
|
owning user's session cookie (PATs narrow, never widen, and never
|
|
restrict the interactive path)."""
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client, user_id="user-1")
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
response = client.delete("/api/memory", headers={CSRF_HEADER_NAME: csrf})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"deleted": True}
|
|
|
|
|
|
def test_pat_policy_allows_thread_lifecycle_routes(client):
|
|
created = _create_pat(client, scopes=["threads:delete"])
|
|
client.cookies.clear()
|
|
response = client.delete("/api/threads/t1", headers={"Authorization": f"Bearer {created['token']}"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"deleted": True}
|
|
|
|
|
|
def test_pat_policy_does_not_pre_authorize_unimplemented_methods():
|
|
"""Route-policy regression (#5041 review): the allowlist must not admit
|
|
methods the router does not implement. The Gateway has no GET collection
|
|
route for /api/threads — pre-authorizing it would make a future GET
|
|
collection route PAT-reachable without an explicit policy change."""
|
|
from app.gateway.auth.pat import is_pat_allowed_route
|
|
|
|
assert is_pat_allowed_route("POST", "/api/threads") is True
|
|
assert is_pat_allowed_route("GET", "/api/threads") is False
|
|
|
|
|
|
def test_pat_runs_policy_admits_exactly_the_mounted_routes():
|
|
"""The runs subtree is enumerated, not wildcarded: every GET/POST route
|
|
the thread_runs router actually implements is admitted (derived from the
|
|
mounted router, not a hand-maintained list), routes in this router
|
|
outside the runs subtree stay denied, and representative unimplemented
|
|
neighbors — including the POST-only collection names on GET — are
|
|
default-denied. A new route under /runs fails here until explicitly
|
|
allowlisted; a removed one leaves a dead rule visible."""
|
|
from fastapi.routing import APIRoute
|
|
|
|
from app.gateway.auth.pat import is_pat_allowed_route
|
|
from app.gateway.routers.thread_runs import router
|
|
|
|
def concrete(path: str) -> str:
|
|
return path.replace("{thread_id}", "t1").replace("{run_id}", "r1")
|
|
|
|
for route in router.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
path = concrete(route.path)
|
|
under_runs = route.path.startswith("/api/threads/{thread_id}/runs")
|
|
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
|
|
admitted = is_pat_allowed_route(method, path)
|
|
if under_runs:
|
|
assert admitted, f"{method} {path} is implemented but PAT-denied"
|
|
else:
|
|
# /messages, /messages/page, /token-usage sit outside the runs
|
|
# subtree and are PAT-denied pending the polling-surface
|
|
# decision — pinned here so widening it is a conscious edit.
|
|
assert not admitted, f"{method} {path} is outside the PAT policy"
|
|
|
|
for method, path in [
|
|
("GET", "/api/threads/t1/runs/stream"),
|
|
("GET", "/api/threads/t1/runs/wait"),
|
|
("GET", "/api/threads/t1/runs/regenerate"),
|
|
("GET", "/api/threads/t1/runs/edit-regenerate"),
|
|
("POST", "/api/threads/t1/runs/r1/messages"),
|
|
("DELETE", "/api/threads/t1/runs/r1"),
|
|
("POST", "/api/threads/t1/runs/summary"),
|
|
("GET", "/api/threads/t1/runs/r1/transfer"),
|
|
]:
|
|
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
|
|
|
|
|
|
def test_pat_projects_policy_admits_exactly_the_mounted_routes():
|
|
"""Projects subtree (and the thread move endpoint) follow the same
|
|
enumerated-no-dead-methods discipline as the runs subtree: every
|
|
method/path the projects router actually implements is admitted (derived
|
|
from the mounted router, not a hand-maintained list), and deliberately
|
|
unimplemented neighbors stay default-denied. A new projects route fails
|
|
here until explicitly allowlisted; a removed one leaves a dead rule
|
|
visible."""
|
|
from fastapi.routing import APIRoute
|
|
|
|
from app.gateway.auth.pat import is_pat_allowed_route
|
|
from app.gateway.routers.projects import router
|
|
|
|
def concrete(path: str) -> str:
|
|
return path.replace("{project_id}", "p1")
|
|
|
|
for route in router.routes:
|
|
if not isinstance(route, APIRoute):
|
|
continue
|
|
path = concrete(route.path)
|
|
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
|
|
assert is_pat_allowed_route(method, path), f"{method} {path} is implemented but PAT-denied"
|
|
|
|
for method, path in [
|
|
("PUT", "/api/projects"),
|
|
("DELETE", "/api/projects"),
|
|
("PUT", "/api/projects/p1"),
|
|
("GET", "/api/projects/p1/archive"),
|
|
("GET", "/api/projects/p1/restore"),
|
|
("POST", "/api/projects/p1/threads"),
|
|
]:
|
|
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
|
|
|
|
# Thread move (POST /api/threads/{id}/move) is admitted for PATs holding
|
|
# threads:write; other methods on the same path stay denied.
|
|
move_path = "/api/threads/6f1c2f0e-3b7a-4d2e-9c1a-2b5f0e8a1d3c/move"
|
|
assert is_pat_allowed_route("POST", move_path) is True
|
|
assert is_pat_allowed_route("GET", move_path) is False
|
|
|
|
|
|
def test_pat_scopes_enforced_on_stateless_run_entry(client):
|
|
"""Follow-up to the review's P1-1: the stateless run entrypoints now
|
|
carry @require_permission("runs", "create"), so a threads:read-only PAT
|
|
cannot start runs even though the route sits inside the PAT allowlist."""
|
|
read_only = _create_pat(client, scopes=["threads:read"])
|
|
client.cookies.clear()
|
|
denied = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {read_only['token']}"})
|
|
assert denied.status_code == 403
|
|
|
|
create_scope = _create_pat(client, scopes=["runs:create"])
|
|
client.cookies.clear()
|
|
allowed = client.post("/api/runs/stream", headers={"Authorization": f"Bearer {create_scope['token']}"})
|
|
assert allowed.status_code == 200
|
|
|
|
|
|
def test_runs_read_only_pat_cannot_cancel_then_stream(client):
|
|
"""Review follow-up: cancel-then-stream (`?action=interrupt|rollback`) must
|
|
require runs:cancel even though the route decorator gates at runs:read —
|
|
otherwise a read-only PAT bypasses the separate cancel scope."""
|
|
read_only = _create_pat(client, scopes=["runs:read"])
|
|
client.cookies.clear()
|
|
|
|
denied = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=interrupt",
|
|
headers={"Authorization": f"Bearer {read_only['token']}"},
|
|
)
|
|
assert denied.status_code == 403
|
|
assert denied.json()["detail"] == "Permission denied: runs:cancel"
|
|
|
|
# The same route without an action is a plain stream join: runs:read is
|
|
# sufficient there.
|
|
join = client.post(
|
|
"/api/threads/t1/runs/run-1/stream",
|
|
headers={"Authorization": f"Bearer {read_only['token']}"},
|
|
)
|
|
assert join.status_code == 200
|
|
|
|
cancel_scope = _create_pat(client, scopes=["runs:read", "runs:cancel"])
|
|
client.cookies.clear()
|
|
allowed = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=rollback",
|
|
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
|
|
)
|
|
assert allowed.status_code == 200
|
|
|
|
# Session callers keep the full permission set (with the CSRF pair their
|
|
# cookie-authenticated POST requires).
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
session_allowed = client.post(
|
|
"/api/threads/t1/runs/run-1/stream?action=interrupt",
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
)
|
|
assert session_allowed.status_code == 200
|
|
|
|
|
|
def test_runs_create_only_pat_cannot_use_mutating_multitask_strategy(client):
|
|
"""Review round 5, P1-a: interrupt/rollback multitask strategies terminate
|
|
an already-active run — runs:cancel capability, not runs:create — so a
|
|
create-only PAT must be denied; "reject" (the default) stays within
|
|
runs:create and must keep working."""
|
|
create_only = _create_pat(client, scopes=["runs:create"])
|
|
client.cookies.clear()
|
|
|
|
for strategy in ("interrupt", "rollback"):
|
|
denied = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {create_only['token']}"},
|
|
json={"multitask_strategy": strategy},
|
|
)
|
|
assert denied.status_code == 403, denied.text
|
|
assert denied.json()["detail"] == "Permission denied: runs:cancel"
|
|
|
|
# "reject" — explicitly and as the omitted default — does not touch
|
|
# existing runs and stays available to a create-only credential.
|
|
for body in ({"multitask_strategy": "reject"}, {}):
|
|
allowed = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {create_only['token']}"},
|
|
json=body,
|
|
)
|
|
assert allowed.status_code == 200
|
|
|
|
cancel_scope = _create_pat(client, scopes=["runs:create", "runs:cancel"])
|
|
client.cookies.clear()
|
|
privileged = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={"Authorization": f"Bearer {cancel_scope['token']}"},
|
|
json={"multitask_strategy": "interrupt"},
|
|
)
|
|
assert privileged.status_code == 200
|
|
|
|
# Session callers keep the full permission set (with the CSRF pair their
|
|
# cookie-authenticated POST requires).
|
|
from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token
|
|
|
|
_session_cookie(client)
|
|
csrf = generate_csrf_token()
|
|
client.cookies.set(CSRF_COOKIE_NAME, csrf)
|
|
session_allowed = client.post(
|
|
"/api/threads/t1/runs",
|
|
headers={CSRF_HEADER_NAME: csrf},
|
|
json={"multitask_strategy": "interrupt"},
|
|
)
|
|
assert session_allowed.status_code == 200
|
|
|
|
|
|
def test_start_run_gates_mutating_strategies_at_the_choke_point():
|
|
"""The strategy gate lives inside start_run itself — the single choke point
|
|
every run-creation path (all five HTTP entrypoints plus internal
|
|
launchers) flows through — so no entry point can bypass it. Mirrored
|
|
routes prove the middleware path; this anchor proves the choke point."""
|
|
import inspect
|
|
|
|
from app.gateway.services import start_run
|
|
|
|
source = inspect.getsource(start_run)
|
|
assert "require_cancel_permission_if" in source
|
|
assert "multitask_strategy" in source
|
|
|
|
|
|
def test_start_run_gate_denies_create_only_credential_behaviorally():
|
|
"""Behavioral pin on the real start_run (the mirror route and source
|
|
anchor above prove wiring, but this drives the production choke point
|
|
itself): a create-only auth context gets 403 for a mutating strategy,
|
|
and the gate never misfires on "reject" — with no cancel permission at
|
|
all, the call proceeds past the gate (failing later on missing test
|
|
wiring, never with a permission 403)."""
|
|
from fastapi import HTTPException
|
|
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from app.gateway.services import start_run
|
|
|
|
def _request(permissions):
|
|
return SimpleNamespace(state=SimpleNamespace(auth=AuthContext(user=SimpleNamespace(id="user-1"), permissions=permissions)))
|
|
|
|
async def _denied():
|
|
with pytest.raises(HTTPException) as exc:
|
|
await start_run(RunCreateRequest(multitask_strategy="interrupt"), "t1", _request(["runs:create"]))
|
|
return exc.value
|
|
|
|
exc = asyncio.run(_denied())
|
|
assert exc.status_code == 403
|
|
assert exc.detail == "Permission denied: runs:cancel"
|
|
|
|
async def _allowed_past_gate():
|
|
try:
|
|
await start_run(RunCreateRequest(), "t1", _request([]))
|
|
except HTTPException as gate_misfire:
|
|
pytest.fail(f"gate misfired on reject: {gate_misfire.status_code} {gate_misfire.detail}")
|
|
except Exception:
|
|
pass # expected wiring failure past the gate — the gate let it through
|
|
|
|
asyncio.run(_allowed_past_gate())
|
|
|
|
|
|
def test_auth_disabled_mode_ignores_bearer_header(monkeypatch, tmp_path):
|
|
"""DEER_FLOW_AUTH_DISABLED is an operator override of all authentication.
|
|
|
|
A stray Authorization header (e.g. added by a proxy in front of an E2E
|
|
sandbox) must not turn into a 401 in that mode.
|
|
"""
|
|
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: True)
|
|
app = _make_pat_app()
|
|
with TestClient(app) as disabled_client:
|
|
response = disabled_client.get("/api/threads/whoami", headers={"Authorization": "Bearer dfp_garbage"})
|
|
assert response.status_code == 200
|
|
assert response.json()["auth_source"] == "auth_disabled"
|