mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* 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>
134 lines
5.1 KiB
Python
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)
|