diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 8abf4e2c..bde95057 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -73,8 +73,14 @@ _DATAPLANE_PREFIXES: tuple[str, ...] = ( def _is_dataplane_path(path: str) -> bool: """True if an API key request may reach this path. Reusable by a future - Pattern B service-token branch.""" - return any(path.startswith(prefix) for prefix in _DATAPLANE_PREFIXES) + Pattern B service-token branch. + + Matches a prefix only at a path-segment boundary (exact match, or the + prefix immediately followed by ``/``), so the allowlist can't be silently + widened by a similarly-named route — e.g. ``/api/threads-export`` shares + the ``/api/threads`` prefix but crosses no segment boundary, so it stays + denied.""" + return any(path == prefix or path.startswith(prefix + "/") for prefix in _DATAPLANE_PREFIXES) class AuthMiddleware(BaseHTTPMiddleware): @@ -122,6 +128,17 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=401, content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()}, ) + # Default-deny: a service principal may only reach the data plane + # (threads/runs/assistants). Control-plane routes (mcp/skills/ + # channels/models/agents/memory + management/auth) are global, + # un-partitioned config — never reachable by an API key. New + # control-plane routes are denied automatically (allowlist, not + # blocklist). Humans (cookie path) never enter this branch. + if not _is_dataplane_path(request.url.path): + return JSONResponse( + status_code=403, + content={"detail": AuthErrorResponse(code=AuthErrorCode.INSUFFICIENT_SCOPE, message="API keys cannot access this endpoint").model_dump()}, + ) request.state.user = result.principal request.state.auth = AuthContext(user=result.principal, permissions=result.permissions) user_token = set_current_user(result.principal) diff --git a/backend/tests/test_api_key_control_plane.py b/backend/tests/test_api_key_control_plane.py index dc2351b4..54bfc1ee 100644 --- a/backend/tests/test_api_key_control_plane.py +++ b/backend/tests/test_api_key_control_plane.py @@ -9,8 +9,13 @@ docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md from __future__ import annotations import pytest +from fastapi import Request +from starlette.testclient import TestClient from app.gateway.auth_middleware import _is_dataplane_path +from deerflow.auth.tokens import generate_api_key + +pytestmark = pytest.mark.anyio @pytest.mark.parametrize( @@ -47,7 +52,144 @@ def test_dataplane_paths_allowed(path): "/api/v1/auth/me", "/api/v1/assistants", # assistants 是 LangGraph 兼容 shim,无 /api/v1 孪生:只放行 /api/assistants,缺 v1 变体是有意为之 "/api/langgraph/threads", # nginx 死代码:中间件本看不到,真混进来也应 deny + "/api/threads-export", # boundary guard — prefix must end at a path segment + "/api/runsX", # boundary guard — prefix must end at a path segment ], ) def test_control_plane_paths_denied(path): assert _is_dataplane_path(path) is False + + +# --------------------------------------------------------------------------- +# Integration tests: AuthMiddleware bearer default-deny (Task 3) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +async def _seed_key(tmp_path, *, scopes="threads:read"): + from deerflow.persistence.api_key import ApiKeyRepository + from deerflow.persistence.engine import get_session_factory, init_engine + from deerflow.persistence.service_account.model import ServiceAccountRow + from deerflow.persistence.user.model import UserRow + from deerflow.persistence.workspace.model import WorkspaceRow + + url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + sf = get_session_factory() + async with sf() as session: + session.add(UserRow(id="u-alice", email="alice@example.com")) + await session.commit() + async with sf() as session: + session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice")) + await session.commit() + async with sf() as session: + session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice")) + await session.commit() + repo = ApiKeyRepository(sf) + gen = generate_api_key("live") + await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes) + return gen + + +async def _cleanup(): + from deerflow.persistence.engine import close_engine + + await close_engine() + + +def _make_app(): + from fastapi import FastAPI + + from app.gateway.auth_middleware import AuthMiddleware + from deerflow.runtime.user_context import get_effective_user_id + + app = FastAPI() + app.add_middleware(AuthMiddleware) + + # NOTE: Request must NOT be imported locally here. With `from __future__ import + # annotations` active, local imports are invisible to get_type_hints, causing + # FastAPI to treat `request: Request` as a query param → 422. Module-level + # import (above) makes it resolvable. See test_auth_middleware_api_key.py docstring. + @app.get("/api/v1/threads/_probe") + async def threads_probe(request: Request): + return {"user_id": get_effective_user_id()} + + @app.get("/api/assistants/search") + async def assistants_probe(): + return {"ok": True} + + return app + + +async def test_sa_allowed_on_dataplane(tmp_path): + gen = await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get("/api/v1/threads/_probe", headers={"Authorization": f"Bearer {gen.plaintext}"}) + assert r.status_code == 200 + assert r.json() == {"user_id": "sa-1"} + finally: + await _cleanup() + + +async def test_sa_allowed_on_assistants_init(tmp_path): + gen = await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get("/api/assistants/search", headers={"Authorization": f"Bearer {gen.plaintext}"}) + assert r.status_code == 200 + finally: + await _cleanup() + + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/mcp/config", + "/api/mcp/config", + "/api/v1/models", + "/api/v1/skills/install", + "/api/v1/channels/restart", + "/api/v1/agents", + "/api/v1/memory", + "/api/v1/service-accounts", + ], +) +async def test_sa_denied_on_control_plane(tmp_path, path): + gen = await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get(path, headers={"Authorization": f"Bearer {gen.plaintext}"}) + assert r.status_code == 403 + assert r.json()["detail"]["code"] == "insufficient_scope" + finally: + await _cleanup() + + +async def test_invalid_key_still_401_not_403(tmp_path): + # 无效 key 命中控制平面路径,应是 401 (TOKEN_INVALID),不是 403 —— + # deny 检查在 None 校验之后。 + await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get("/api/v1/mcp/config", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"}) + assert r.status_code == 401 + finally: + await _cleanup() + + +async def test_cookie_path_unaffected_by_deny(tmp_path): + # 非 bearer-dfk 请求不进 bearer 分支:控制平面路径走 cookie 路径, + # 无 cookie → 401 not_authenticated,绝不会拿到 403 insufficient_scope。 + await _seed_key(tmp_path) + try: + client = TestClient(_make_app()) + r = client.get("/api/v1/mcp/config") + assert r.status_code == 401 + assert r.json()["detail"]["code"] != "insufficient_scope" + finally: + await _cleanup()