harden(gateway): API keys default-deny on control-plane routes (Stage 1 收口)
Service principals (Bearer dfk_...) may only reach the data plane (threads / runs / assistants). All control-plane routes — mcp, models, skills, channels, agents, memory, auth, management — now return 403 insufficient_scope. The check sits after the NULL/401 guard so an invalid key on a control-plane path still returns 401, not 403. Human cookie requests bypass the bearer branch entirely and are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user