Files
ZY-Agent/docs/superpowers/plans/2026-06-28-api-key-control-plane-default-deny.md
T
1445043649 1b7d8217dd docs(stage-1): implementation plan for control-plane default-deny
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:09:31 +08:00

20 KiB
Raw Blame History

API Key 控制平面 default-deny 收口 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 让 service principalAPI key Authorization: Bearer dfk_... 请求)只能访问数据平面(/api/threads*/api/runs*/api/assistants),任何控制平面路由(models/mcp/memory/skills/channels/agents 与管理/auth 端点)读写一律返回 403;真人 cookie 请求完全不受影响。

Architecture:AuthMiddleware.dispatch 的 bearer 分支内、token 校验通过之后、写 contextvar 之前,加一道 default-deny 路径白名单检查(_is_dataplane_path)。白名单是模块级前缀元组,新增控制平面路由自动被拦,不复现"忘了保护"的缺陷。检查只在 bearer 分支内,cookie 路径天然不进。

Tech Stack: Python 3.12 · FastAPI · Starlette BaseHTTPMiddleware · starlette.testclient.TestClient · pytest + pytest.mark.anyio

设计来源: 2026-06-28-api-key-control-plane-default-deny-design.md(策略、白名单边界、错误口径、nginx rewrite 前提皆以该 spec 为准)。

运行约定(每条命令都从 backend/ 目录执行):

  • 单测:PYTHONPATH=. uv run pytest tests/<file>.py -v
  • lintmake lintruff,行宽 240,双引号)
  • 全量回归:make test

关键前提(spec §3.1,已验证): nginx 把 /api/langgraph/(.*) rewrite 成 /api/$1 后才转给 gatewayIM channels 也直连 /api/*,因此 AuthMiddleware 永远看不到 /api/langgraphLangGraph-SDK 调用到达中间件时是 /api/threads/api/runs/api/assistants。白名单因此是这三个前缀,不含 /api/langgraph(死代码)。


File Structure

新增 / 修改文件一览(精确路径):

  • Modify: backend/app/gateway/auth/errors.pyAuthErrorCodeINSUFFICIENT_SCOPE
  • Modify: backend/app/gateway/auth_middleware.py — 加 _DATAPLANE_PREFIXES + _is_dataplane_pathbearer 分支加 403 检查
  • Modify: backend/tests/test_auth_middleware_api_key.py — 探针路径 /api/probe/api/v1/threads/_probe
  • Modify: backend/tests/test_headless_api_smoke.py — 同上
  • Create: backend/tests/test_api_key_control_plane.py — 单元表 + 集成 403 / 放行 / cookie 回归
  • Modify: docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md — §8.1 从"已知限制"翻成"已解决"

Task → commit 边界: 4 个 Task,顺序实现,每个 Task 末尾 commit。Task 2(挪探针路径)必须早于 Task 3(加 deny),否则 deny 落地会打断既有探针测试。


Task 1: 错误码 + 数据平面白名单辅助函数

Files:

  • Modify: backend/app/gateway/auth/errors.py
  • Modify: backend/app/gateway/auth_middleware.py
  • Test: backend/tests/test_api_key_control_plane.py

本 Task 只加错误码 + 纯函数 _is_dataplane_path(中间件尚未调用它,Task 3 才接线)。先用表驱动单测锁定边界。

  • Step 1: 写失败测试

Create backend/tests/test_api_key_control_plane.py:

"""API key control-plane default-deny tests (Stage 1 收口).

service principal (API key) 只能访问数据平面 (threads/runs/assistants);
控制平面 (models/mcp/memory/skills/channels/agents 与管理/auth) 一律 403。
真人 cookie 路径不受影响。设计见 spec
docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md。
"""

from __future__ import annotations

import pytest

from app.gateway.auth_middleware import _is_dataplane_path


@pytest.mark.parametrize(
    "path",
    [
        "/api/threads",
        "/api/threads/abc",
        "/api/v1/threads",
        "/api/v1/threads/abc/runs/xyz/feedback",
        "/api/runs",
        "/api/runs/stream",
        "/api/v1/runs/stream",
        "/api/assistants",
        "/api/assistants/search",
    ],
)
def test_dataplane_paths_allowed(path):
    assert _is_dataplane_path(path) is True


@pytest.mark.parametrize(
    "path",
    [
        "/api/models",
        "/api/v1/models",
        "/api/mcp/config",
        "/api/v1/mcp/config",
        "/api/v1/memory",
        "/api/v1/skills/install",
        "/api/v1/channels/restart",
        "/api/v1/agents",
        "/api/v1/service-accounts",
        "/api/v1/api-keys",
        "/api/v1/auth/me",
        "/api/langgraph/threads",  # nginx 死代码:中间件本看不到,真混进来也应 deny
    ],
)
def test_control_plane_paths_denied(path):
    assert _is_dataplane_path(path) is False
  • Step 2: 跑测试确认失败

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v Expected: FAIL — ImportError: cannot import name '_is_dataplane_path' from 'app.gateway.auth_middleware'

  • Step 3: 写实现 — 错误码

Modify backend/app/gateway/auth/errors.py — 在 AuthErrorCode 枚举末尾(WORKSPACE_REQUIRED 之后)加一个成员。将:

    NOT_AUTHENTICATED = "not_authenticated"
    SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
    WORKSPACE_REQUIRED = "workspace_required"

改为:

    NOT_AUTHENTICATED = "not_authenticated"
    SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
    WORKSPACE_REQUIRED = "workspace_required"
    INSUFFICIENT_SCOPE = "insufficient_scope"
  • Step 4: 写实现 — 白名单辅助函数

Modify backend/app/gateway/auth_middleware.py — 在 _is_public 函数定义之后(L55 后)追加数据平面前缀常量与辅助函数:

# Data-plane / SDK route prefixes a service principal (API key) may reach.
# Everything else (global control plane: models/mcp/memory/skills/channels/
# agents, plus management/auth endpoints) is denied by default for API keys.
# NOTE: nginx rewrites /api/langgraph/(.*) -> /api/$1 before the gateway, so
# AuthMiddleware never sees /api/langgraph; the SDK surface arrives as
# /api/threads, /api/runs, /api/assistants. assistants.search()/get() is
# required for langgraph-sdk client init, so /api/assistants is allowed.
_DATAPLANE_PREFIXES: tuple[str, ...] = (
    "/api/threads",
    "/api/v1/threads",
    "/api/runs",
    "/api/v1/runs",
    "/api/assistants",
)


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)
  • Step 5: 跑测试确认通过

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v Expected: PASS21 个 parametrize 用例)

  • Step 6: lint + commit
cd backend && make lint && git add app/gateway/auth/errors.py app/gateway/auth_middleware.py tests/test_api_key_control_plane.py
git commit -m "feat(authz): data-plane allowlist helper + INSUFFICIENT_SCOPE code (Stage 1 收口)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: 既有探针测试路径挪到数据平面

Files:

  • Modify: backend/tests/test_auth_middleware_api_key.py
  • Modify: backend/tests/test_headless_api_smoke.py

这两个文件的探针路由是 /api/probe(非数据平面)。Task 3 的 deny 落地后,valid-key 探针会被 403 打断。这些探针的本意是"SA 访问一个受保护的数据平面路由",故先把路径挪到 /api/v1/threads/_probe。本 Task 不改运行时行为(deny 尚未接线),改完探针仍应全绿。

  • Step 1: 改 test_auth_middleware_api_key.py 的探针路由定义

Modify backend/tests/test_auth_middleware_api_key.py — 把 _make_app 内的探针路由声明(约 L72)从:

    @app.get("/api/probe")

改为:

    @app.get("/api/v1/threads/_probe")
  • Step 2: 改 test_auth_middleware_api_key.py 的全部 client.get 路径

同文件,把所有 client.get("/api/probe", ...)5 处:约 L87 / L98 / L108 / L120 / L131)的路径串 "/api/probe" 全部改为 "/api/v1/threads/_probe"。其余参数(headers)不动。

校验:grep -n '/api/probe' tests/test_auth_middleware_api_key.py 应无输出。

  • Step 3: 改 test_headless_api_smoke.py 的探针路由定义 + 调用

Modify backend/tests/test_headless_api_smoke.py

  • _probe_app 内的探针路由声明(约 L95@app.get("/api/probe") 改为 @app.get("/api/v1/threads/_probe")
  • 把两处调用(约 L111 / L116probe.get("/api/probe", ...) 的路径串改为 "/api/v1/threads/_probe"

校验:grep -n '/api/probe' tests/test_headless_api_smoke.py 应无输出。

  • Step 4: 跑测试确认仍全绿(无行为变化)

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py -v Expected: PASS(与改动前相同的用例数;deny 尚未接线,valid 探针走数据平面路径仍 200,401 用例仍 401)

  • Step 5: commit
cd backend && git add tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py
git commit -m "test(auth): move bearer probe routes under /api/v1/threads (Stage 1 收口)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: AuthMiddleware bearer 分支 default-deny

Files:

  • Modify: backend/app/gateway/auth_middleware.py
  • Test: backend/tests/test_api_key_control_plane.py(追加集成测试)

deny 检查放在 bearer 分支内 if result is None: return 401 之后、写 contextvar 之前——无效 key 仍是 401(不是 403),只有 valid key 命中控制平面才 403。

  • Step 1: 写失败测试(追加到 test_api_key_control_plane.py 末尾)
import pytest
from starlette.testclient import TestClient

from deerflow.auth.tokens import generate_api_key

pytestmark = pytest.mark.anyio


@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, Request

    from app.gateway.auth_middleware import AuthMiddleware
    from deerflow.runtime.user_context import get_effective_user_id

    app = FastAPI()
    app.add_middleware(AuthMiddleware)

    @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",
    ],
)
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()
  • Step 2: 跑测试确认失败

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v Expected: FAIL — test_sa_denied_on_control_plane[...] 返回 200/404 而非 403deny 尚未接线)

  • Step 3: 写实现 — bearer 分支加 deny

Modify backend/app/gateway/auth_middleware.py — 在 bearer 分支内,if result is None: return 401 之后、request.state.user = result.principal 之前插入路径检查。将:

            if result is None:
                return JSONResponse(
                    status_code=401,
                    content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()},
                )
            request.state.user = result.principal

改为:

            if result is None:
                return JSONResponse(
                    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
  • Step 4: 跑测试确认通过

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v Expected: PASSTask 1 的 21 单元 + 本 Task 的集成/参数化用例全绿)

  • Step 5: 既有 bearer 测试回归

确认探针挪位 + deny 后既有用例不破:

Run: cd backend && PYTHONPATH=. uv run pytest tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py tests/test_auth_middleware.py tests/test_auth_middleware_workspace.py -v Expected: PASSvalid 探针走 /api/v1/threads/_probe 数据平面 → 200invalid/revoked/non-dfk → 401cookie 路径不变)

  • Step 6: lint + commit
cd backend && make lint && git add app/gateway/auth_middleware.py tests/test_api_key_control_plane.py
git commit -m "harden(gateway): API keys default-deny on control-plane routes (Stage 1 收口)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: 文档收尾 + 全量回归

Files:

  • Modify: docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md

  • Step 1: 把 spec §8.1 从"已知限制"翻成"已解决"

Modify docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md §8.1。在该节"后续 PR 决策项"那条 bullet 之后追加一行解决说明(保留原限制描述作为历史,追加 resolved 标注):

  - **【已解决 2026-06-28】** 改为 default-denyservice principal 只能访问数据平面(`/api/threads*``/api/runs*``/api/assistants`),所有控制平面路由(含 read)一律 403 `insufficient_scope`。实现见 `AuthMiddleware._is_dataplane_path`;设计见 [api-key-control-plane-default-deny-design](../../superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md)。细粒度 scope 词汇升级仍按原计划推后。
  • Step 2: 全量回归 + lint + 边界
cd backend && make lint && make test && PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v

Expected: lint cleantest 全绿(含 stage-1 的 13 个文件 + 本次新增 test_api_key_control_plane.py);boundary PASS(本改动全在 app 层,未引入 deerflow→app import

  • Step 3: commit
cd /Users/wangguixuan/work/github/deer-flow
git add docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md
git commit -m "docs(stage-1): mark control-plane scope limitation resolved (Stage 1 收口)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Self-Review

1. Spec coverage(逐条对 spec

  • §2 目标:service principal 仅数据平面、真人不受影响、default-deny、读写一律拒 → Task 3 deny + Task 1 白名单 + 参数化用例(含 GET /api/v1/models 读也拒)
  • §3.1 白名单 = threads/runs/assistants 三前缀、不含 langgraph → Task 1 _DATAPLANE_PREFIXES + 单元表(含 /api/langgraph 应 False
  • §3.2 enforce 位置(bearer 分支 None 校验之后、contextvar 之前)→ Task 3 Step 3 精确锚点
  • §3.3 _is_dataplane_path 辅助 + 复用接缝 → Task 1 Step 4
  • §3.4 错误码 INSUFFICIENT_SCOPE + 403 同构响应 → Task 1 Step 3 + Task 3 Step 3
  • §4.1 探针挪到 /api/v1/threads/_probe → Task 2
  • §4.2 新增 test_api_key_control_plane.py403 / 放行 / cookie 回归 / 单元表)→ Task 1 + Task 3
  • §4.3 回归 make test + lint + boundary → Task 4 Step 2
  • §5 文件清单 → 全覆盖(errors.py / auth_middleware.py / 两测试文件 / 新测试 / spec 文档)
  • §5 文档:§8.1 翻成已解决 → Task 4 Step 1

2. Placeholder scan: 所有 code step 含可运行实际代码;改测试路径处给了精确行号锚点 + grep 校验命令;无 TBD/TODO/“类似上文”。

3. Type consistency:

  • _is_dataplane_path(path) -> bool / _DATAPLANE_PREFIXES: tuple[str, ...] — Task 1 定义,Task 1 单元测试 + Task 3 中间件调用一致
  • AuthErrorCode.INSUFFICIENT_SCOPE = "insufficient_scope" — Task 1 定义,Task 3 响应 + 测试断言 code == "insufficient_scope" 一致
  • AuthErrorResponse(code=..., message=...).model_dump() — 与 bearer 分支既有 401 用法一致(spec §3.4
  • 探针路径 /api/v1/threads/_probe — Task 2 改既有两文件 + Task 3 新测试 app 一致
  • _seed_key 返回 GeneratedKey.plaintext/.prefix/.key_hash),ApiKeyRepository.create(*, service_account_id, key_prefix, key_hash, name, scopes) — 与 stage-1 PR1/PR2 既有签名一致