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

485 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../specs/2026-06-28-api-key-control-plane-default-deny-design.md)(策略、白名单边界、错误口径、nginx rewrite 前提皆以该 spec 为准)。
**运行约定(每条命令都从 `backend/` 目录执行):**
- 单测:`PYTHONPATH=. uv run pytest tests/<file>.py -v`
- lint`make lint`ruff,行宽 240,双引号)
- 全量回归:`make test`
**关键前提(spec §3.1,已验证):** nginx 把 `/api/langgraph/(.*)` rewrite 成 `/api/$1` 后才转给 gatewayIM channels 也直连 `/api/*`,因此 `AuthMiddleware` 永远看不到 `/api/langgraph`LangGraph-SDK 调用到达中间件时是 `/api/threads``/api/runs``/api/assistants`。白名单因此是这三个前缀,**不含** `/api/langgraph`(死代码)。
---
## File Structure
新增 / 修改文件一览(精确路径):
- Modify: `backend/app/gateway/auth/errors.py``AuthErrorCode``INSUFFICIENT_SCOPE`
- Modify: `backend/app/gateway/auth_middleware.py` — 加 `_DATAPLANE_PREFIXES` + `_is_dataplane_path`bearer 分支加 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`:
```python
"""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` 之后)加一个成员。将:
```python
NOT_AUTHENTICATED = "not_authenticated"
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
WORKSPACE_REQUIRED = "workspace_required"
```
改为:
```python
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 后)追加数据平面前缀常量与辅助函数:
```python
# 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**
```bash
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)从:
```python
@app.get("/api/probe")
```
改为:
```python
@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 / L116`probe.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**
```bash
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` 末尾)
```python
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` 之前插入路径检查。将:
```python
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
```
改为:
```python
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**
```bash
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 标注):
```markdown
- **【已解决 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 + 边界**
```bash
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**
```bash
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.py`403 / 放行 / 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 既有签名一致 ✅