deer-flow/backend/tests/_router_auth_helpers.py
陈志谦 1b2898c367
fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user (#4989)
* fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user

Route permissions: the AuthorizationProvider model only applied to routes
carrying @require_permission, so POST /api/threads, /api/threads/search,
POST /api/runs/stream and /api/runs/wait (runs:create), every /api/memory
route and the custom-agent routes ran with authentication only — a
provider configured to deny threads:write/runs:create/... could not
enforce those decisions. Add the missing decorators (threads:write,
threads:read, runs:create, memory:read/write, agents:read/write) and
register the new permission names in authz.Permissions. No owner checks
are added: per-user data scoping stays in the repository layer.

User profile: GET/PUT /api/user-profile read and wrote a single global
{base_dir}/USER.md, so in a multi-user deployment any authenticated user
could overwrite the prompt context injected for everyone else (and read
it). Scope the file to the caller's bucket
({base_dir}/users/{user_id}/USER.md) like user-scoped skills; no other
consumer of the old global path exists in the tree (verified by grep and
the updated tests). test_put_user_profile asserted the wrong effective
user under the autouse conftest user fixture; fixed to test-user-autouse.

* fix(authz): bind positional args in require_permission; migrate legacy USER.md

Review follow-up on #4989 (willem-bd):

- require_permission's wrapper only looked for request in keyword
  arguments, so direct positional calls like
  create_thread(body, request) made the injected keyword stub collide
  with the positional request (TypeError: multiple values). The wrapper
  now binds the wrapped signature via inspect.signature().bind() and
  honors a positionally-passed request (and thread_id) instead of
  assuming kwargs; the test-stub injection only fires when request is
  absent everywhere. The two positional callers in
  tests/test_threads_router.py pass again (8/8 channel tests).
- migrate_user_isolation.py now claims the legacy global USER.md for
  --user-id (default 'default') like the other unowned legacy
  artifacts; without it, upgrading installs with an existing profile
  would read content: null and later strand the old file beside the
  new per-user one. Conflict handling mirrors migrate_memory (rename to
  USER.legacy.md).

* style(scripts): keep migrate_user_isolation help within the line budget

* test(authz): give internal-request stubs realistic auth fields

The create_thread permission wrapper added in this PR authenticates
the direct calls in the internal-owner tests; their SimpleNamespace
requests had state.user but no auth_source and no cookies, so
get_current_user_from_request fell through to request.cookies.get and
raised AttributeError (verified introduced by this branch: both tests
pass on the base commit).

The stubs now carry cookies={} and
state.auth_source=AUTH_SOURCE_INTERNAL, which is exactly what
AuthMiddleware stamps on real internal requests, so state.user is
honored without the JWT path. All 80 tests in the file pass.

* fix(pat): keep memory/agent permissions out of PAT scopes by design

The route permissions exist (they guard the memory/agent routers) but PATs
govern the thread/run lifecycle only: _PAT_ROUTE_RULES default-denies those
routers for PAT callers regardless of scopes. The alignment invariant
becomes a subset check plus a pinned exclusion, and pat.py documents that
opening these scopes is a product decision requiring three synchronized
changes.

* test/docs: cover migrate_user_profile; drop the unclaimed USER.md injection wording

Five-scenario test class mirroring the sibling migration steps (move,
conflict-rename, noop, both under dry-run). The GET/PUT descriptions no
longer say USER.md is 'injected into agents' — nothing at this head consumes
it for prompts; the routes are storage/retrieval only. The memory AGENTS.md
migration pointer now enumerates skills/ and the global USER.md.

* docs(paths): align the USER.md layout comment with the injection disclaimer

* test(authz): align effective permission contracts

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-23 15:37:26 +08:00

134 lines
5.1 KiB
Python

"""Helpers for router-level tests that need a stubbed auth context.
The production gateway runs ``AuthMiddleware`` (validates the JWT cookie)
ahead of every router, plus ``@require_permission(owner_check=True)``
decorators that read ``request.state.auth`` and call
``thread_store.check_access``. Router-level unit tests construct
**bare** FastAPI apps that include only one router — they have neither
the auth middleware nor a real thread_store, so the decorators raise
401 (TestClient path) or ValueError (direct-call path).
This module provides two surfaces:
1. :func:`make_authed_test_app` — wraps ``FastAPI()`` with a tiny
``BaseHTTPMiddleware`` that stamps a fake user / AuthContext on every
request, plus a permissive ``thread_store`` mock on
``app.state``. Use from TestClient-based router tests.
2. :func:`call_unwrapped` — invokes the underlying function bypassing
the ``@require_permission`` decorator chain by walking ``__wrapped__``.
Use from direct-call tests that previously imported the route
function and called it positionally.
Both helpers are deliberately permissive: they never deny a request.
Tests that want to verify the *auth boundary itself* (e.g.
``test_auth_middleware``, ``test_auth_type_system``) build their own
apps with the real middleware — those should not use this module.
"""
from __future__ import annotations
from collections.abc import Callable
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from fastapi import FastAPI, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
from app.gateway.auth.models import User
from app.gateway.authz import AuthContext, Permissions
# Default permission set granted to the stub user. Mirrors `_ALL_PERMISSIONS`
# in authz.py — kept inline so the tests don't import a private symbol.
_STUB_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
]
def _make_stub_user() -> User:
"""A deterministic test user — same shape as production, fresh UUID."""
return User(
email="router-test@example.com",
password_hash="x",
system_role="user",
id=uuid4(),
)
class _StubAuthMiddleware(BaseHTTPMiddleware):
"""Stamp a fake user / AuthContext onto every request.
Mirrors what production ``AuthMiddleware`` does after the JWT decode
+ DB lookup short-circuit, so ``@require_permission`` finds an
authenticated context and skips its own re-authentication path.
"""
def __init__(self, app: ASGIApp, user_factory: Callable[[], User]) -> None:
super().__init__(app)
self._user_factory = user_factory
async def dispatch(self, request: Request, call_next: Callable) -> Response:
user = self._user_factory()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=list(_STUB_PERMISSIONS))
return await call_next(request)
def make_authed_test_app(
*,
user_factory: Callable[[], User] | None = None,
owner_check_passes: bool = True,
) -> FastAPI:
"""Build a FastAPI test app with stub auth + permissive thread_store.
Args:
user_factory: Override the default test user. Must return a fully
populated :class:`User`. Useful for cross-user isolation tests
that need a stable id across requests.
owner_check_passes: When True (default), ``thread_store.check_access``
returns True for every call so ``@require_permission(owner_check=True)``
never blocks the route under test. Pass False to verify that
permission failures surface correctly.
Returns:
A ``FastAPI`` app with the stub middleware installed and
``app.state.thread_store`` set to a permissive mock. The
caller is still responsible for ``app.include_router(...)``.
"""
factory = user_factory or _make_stub_user
app = FastAPI()
app.add_middleware(_StubAuthMiddleware, user_factory=factory)
repo = MagicMock()
repo.check_access = AsyncMock(return_value=owner_check_passes)
app.state.thread_store = repo
return app
def call_unwrapped[*P, R](decorated: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R:
"""Invoke the underlying function of a ``@require_permission``-decorated route.
``functools.wraps`` sets ``__wrapped__`` on each layer; we walk all
the way down to the original handler, bypassing every authz +
require_auth wrapper. Use from tests that need to call route
functions directly (without TestClient) and don't want to construct
a fake ``Request`` just to satisfy the decorator. The ``ParamSpec``
propagates the wrapped route's signature so call sites still get
parameter checking despite the unwrapping.
"""
fn: Callable = decorated
while hasattr(fn, "__wrapped__"):
fn = fn.__wrapped__ # type: ignore[attr-defined]
return fn(*args, **kwargs)