deer-flow/backend/tests/test_authorization_route_permissions.py
Zeren Wang 5951c89b5b
feat(projects): project workspaces with scoped chats and thread membership (#5265)
* 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
2026-09-08 17:00:26 +08:00

476 lines
17 KiB
Python

"""Route-level authorization tests for the Gateway permission decorators."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from app.gateway.auth.models import User
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.authz import (
Permissions,
_authenticate,
_get_cached_route_provider,
require_permission,
resolve_route_permissions,
)
from app.gateway.routers import runs, scheduled_tasks
from deerflow.authz.provider import AuthzDecision, AuthzReason
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
class _RecordingProvider:
name = "recording"
def __init__(
self,
*,
denied: set[str] | None = None,
errors: set[str] | None = None,
) -> None:
self.denied = denied or set()
self.errors = errors or set()
self.requests = []
def authorize(self, request):
raise AssertionError("route authorization must use the async provider API")
async def aauthorize(self, request):
self.requests.append(request)
if request.target in self.errors:
raise RuntimeError(f"provider failed for {request.target}")
allowed = request.target not in self.denied
return AuthzDecision(
allow=allowed,
reasons=[AuthzReason(code="authz.allowed" if allowed else "authz.denied")],
)
def filter_resources(self, principal, resource_type, candidates):
raise AssertionError("route authorization must preserve per-action requests")
def _user(**overrides):
values = {
"id": "user-123",
"system_role": "user",
"oauth_provider": "github",
"oauth_id": "oauth-456",
}
values.update(overrides)
return SimpleNamespace(**values)
def _enable_authorization(monkeypatch, provider, *, fail_closed: bool = True) -> None:
config = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role="user",
)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
# Bypass the provider cache so each test gets its own provider instance.
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", lambda c: provider)
@pytest.mark.asyncio
async def test_route_permissions_disabled_preserves_all_permissions(monkeypatch):
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
# Bypass cache + ensure provider is never resolved when disabled.
cached = AsyncMock(side_effect=AssertionError("disabled authorization must not resolve a provider"))
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", cached)
permissions = await resolve_route_permissions(_user(), is_internal=False)
assert permissions == [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
cached.assert_not_called()
@pytest.mark.asyncio
async def test_route_permissions_use_async_provider_and_trusted_principal(monkeypatch):
provider = _RecordingProvider(denied={Permissions.THREADS_DELETE, Permissions.RUNS_CANCEL})
_enable_authorization(monkeypatch, provider)
permissions = await resolve_route_permissions(_user(), is_internal=True)
assert permissions == [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
assert [(request.resource, request.action, request.target) for request in provider.requests] == [
("route", "read", Permissions.THREADS_READ),
("route", "write", Permissions.THREADS_WRITE),
("route", "delete", Permissions.THREADS_DELETE),
("route", "create", Permissions.RUNS_CREATE),
("route", "read", Permissions.RUNS_READ),
("route", "cancel", Permissions.RUNS_CANCEL),
("route", "read", Permissions.PROJECTS_READ),
("route", "write", Permissions.PROJECTS_WRITE),
("route", "delete", Permissions.PROJECTS_DELETE),
]
principal = provider.requests[0].principal
assert principal.user_id == "user-123"
assert principal.role == "user"
assert principal.oauth_provider == "github"
assert principal.oauth_id == "oauth-456"
assert principal.is_internal is True
@pytest.mark.asyncio
async def test_route_permissions_fail_closed_denies_only_the_failed_permission(monkeypatch):
provider = _RecordingProvider(errors={Permissions.RUNS_CANCEL})
_enable_authorization(monkeypatch, provider, fail_closed=True)
permissions = await resolve_route_permissions(_user(), is_internal=False)
assert permissions == [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
@pytest.mark.asyncio
async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypatch):
provider = _RecordingProvider(errors={Permissions.RUNS_CANCEL})
_enable_authorization(monkeypatch, provider, fail_closed=False)
permissions = await resolve_route_permissions(_user(), is_internal=False)
assert permissions == [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("fail_closed", "expected"),
[
(True, []),
(
False,
[
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
],
),
],
)
async def test_route_permissions_apply_failure_mode_to_provider_resolution(monkeypatch, fail_closed, expected):
config = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role="user",
)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
def fail_cached(c):
raise ValueError("invalid provider configuration")
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", fail_cached)
assert await resolve_route_permissions(_user(), is_internal=False) == expected
@pytest.mark.asyncio
async def test_route_permissions_use_builtin_rbac_route_policy(monkeypatch):
provider = RbacAuthorizationProvider(
roles={
"user": {
"routes": {
"allow": [Permissions.THREADS_READ, Permissions.RUNS_READ],
}
}
}
)
_enable_authorization(monkeypatch, provider)
permissions = await resolve_route_permissions(_user(), is_internal=False)
assert permissions == [Permissions.THREADS_READ, Permissions.RUNS_READ]
@pytest.mark.asyncio
async def test_authenticate_uses_route_permission_resolution(monkeypatch):
user = User(email="route-authz@example.com", password_hash="hash")
permission_resolver = AsyncMock(return_value=[Permissions.THREADS_READ])
monkeypatch.setattr("app.gateway.deps.get_optional_user_from_request", AsyncMock(return_value=user))
monkeypatch.setattr("app.gateway.authz.resolve_route_permissions", permission_resolver)
request = SimpleNamespace(state=SimpleNamespace())
auth_context = await _authenticate(request)
assert auth_context.user is user
assert auth_context.permissions == [Permissions.THREADS_READ]
permission_resolver.assert_awaited_once_with(user, is_internal=False)
def _make_middleware_app() -> FastAPI:
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/threads")
@require_permission("threads", "read")
async def read_threads(request: Request):
return {"ok": True}
@app.delete("/api/threads")
@require_permission("threads", "delete")
async def delete_threads(request: Request):
return {"ok": True}
return app
def test_auth_middleware_stamps_provider_derived_permissions(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
permission_resolver = AsyncMock(return_value=[Permissions.THREADS_READ])
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permission_resolver)
with TestClient(_make_middleware_app()) as client:
assert client.get("/api/threads").status_code == 200
assert client.delete("/api/threads").status_code == 403
assert permission_resolver.await_count == 2
for call in permission_resolver.await_args_list:
assert call.kwargs == {"is_internal": False}
def test_auth_middleware_marks_internal_route_principal(monkeypatch):
from app.gateway.internal_auth import create_internal_auth_headers
permission_resolver = AsyncMock(return_value=[Permissions.THREADS_READ])
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permission_resolver)
with TestClient(_make_middleware_app()) as client:
response = client.get("/api/threads", headers=create_internal_auth_headers())
assert response.status_code == 200
permission_resolver.assert_awaited_once()
assert permission_resolver.await_args.kwargs == {"is_internal": True}
_STATELESS_RUN_PATHS = ("/api/runs/stream", "/api/runs/wait")
def _enable_auth_disabled_for_route_test(monkeypatch) -> None:
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
monkeypatch.delenv("DEER_FLOW_ENV", raising=False)
monkeypatch.delenv("ENVIRONMENT", raising=False)
def _make_stateless_runs_app() -> FastAPI:
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.include_router(runs.router)
app.state.stream_bridge = MagicMock()
app.state.run_manager = MagicMock()
return app
@pytest.mark.parametrize("path", _STATELESS_RUN_PATHS)
def test_stateless_run_creation_requires_runs_create(monkeypatch, path):
_enable_auth_disabled_for_route_test(monkeypatch)
monkeypatch.setattr(
"app.gateway.auth_middleware.resolve_route_permissions",
AsyncMock(return_value=[Permissions.RUNS_READ]),
)
start_run = AsyncMock(side_effect=HTTPException(status_code=418, detail="run creation reached"))
monkeypatch.setattr(runs, "start_run", start_run)
with TestClient(_make_stateless_runs_app()) as client:
response = client.post(path, json={})
assert response.status_code == 403
assert response.json() == {"detail": "Permission denied: runs:create"}
start_run.assert_not_awaited()
@pytest.mark.parametrize("path", _STATELESS_RUN_PATHS)
def test_stateless_run_creation_allows_runs_create(monkeypatch, path):
_enable_auth_disabled_for_route_test(monkeypatch)
monkeypatch.setattr(
"app.gateway.auth_middleware.resolve_route_permissions",
AsyncMock(return_value=[Permissions.RUNS_CREATE]),
)
start_run = AsyncMock(side_effect=HTTPException(status_code=418, detail="run creation reached"))
monkeypatch.setattr(runs, "start_run", start_run)
with TestClient(_make_stateless_runs_app()) as client:
response = client.post(path, json={})
assert response.status_code == 418
assert response.json() == {"detail": "run creation reached"}
start_run.assert_awaited_once()
_SCHEDULED_RUN_CREATION_REQUESTS = (
(
"POST",
"/api/scheduled-tasks",
{
"title": "Daily summary",
"prompt": "Summarize the latest activity",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
},
),
("PATCH", "/api/scheduled-tasks/task-1", {"title": "Updated summary"}),
("POST", "/api/scheduled-tasks/task-1/resume", None),
("POST", "/api/scheduled-tasks/task-1/trigger", None),
)
def _make_scheduled_tasks_app() -> FastAPI:
app = FastAPI()
app.add_middleware(AuthMiddleware)
app.include_router(scheduled_tasks.router)
return app
@pytest.mark.parametrize(("method", "path", "payload"), _SCHEDULED_RUN_CREATION_REQUESTS)
@pytest.mark.parametrize(
("permissions", "denied_permission"),
[
([Permissions.THREADS_WRITE], Permissions.RUNS_CREATE),
([Permissions.RUNS_CREATE], Permissions.THREADS_WRITE),
],
)
def test_scheduled_run_creation_requires_thread_write_and_runs_create(monkeypatch, method, path, payload, permissions, denied_permission):
_enable_auth_disabled_for_route_test(monkeypatch)
monkeypatch.setattr(
"app.gateway.auth_middleware.resolve_route_permissions",
AsyncMock(return_value=permissions),
)
with TestClient(_make_scheduled_tasks_app()) as client:
response = client.request(method, path, json=payload)
assert response.status_code == 403
assert response.json() == {"detail": f"Permission denied: {denied_permission}"}
# ── Provider cache tests ────────────────────────────────────────────────
class TestRouteProviderCache:
"""Verify the provider cache returns the same instance for unchanged config
and re-resolves when config content changes."""
def test_same_config_returns_same_provider(self):
"""Calling twice with the same config object returns the same instance."""
import app.gateway.authz as authz_module
# Reset cache
authz_module._route_provider_cache.clear()
authz_module._route_provider_config_id = None
authz_module._route_provider_config_sig = None
config = AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"routes": {"allow": "*"}}}},
),
)
p1 = _get_cached_route_provider(config)
p2 = _get_cached_route_provider(config)
assert p1 is not None
assert p2 is p1
def test_changed_config_returns_new_provider(self):
"""A config with different content triggers re-resolution."""
import app.gateway.authz as authz_module
authz_module._route_provider_cache.clear()
authz_module._route_provider_config_id = None
authz_module._route_provider_config_sig = None
config1 = AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"routes": {"allow": "*"}}}},
),
)
config2 = AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"routes": {"allow": []}}}},
),
)
p1 = _get_cached_route_provider(config1)
p2 = _get_cached_route_provider(config2)
assert p1 is not None
assert p2 is not None
assert p1 is not p2
def test_same_content_different_object_reuses_provider(self):
"""Same content in a new object (e.g. hot-reload with no changes) reuses provider."""
import app.gateway.authz as authz_module
authz_module._route_provider_cache.clear()
authz_module._route_provider_config_id = None
authz_module._route_provider_config_sig = None
config1 = AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"routes": {"allow": "*"}}}},
),
)
# Same content, different object
config2 = AuthorizationConfig(
enabled=True,
provider=AuthorizationProviderConfig(
use="deerflow.authz.rbac:RbacAuthorizationProvider",
config={"roles": {"user": {"routes": {"allow": "*"}}}},
),
)
p1 = _get_cached_route_provider(config1)
p2 = _get_cached_route_provider(config2)
assert p1 is p2