Compare commits

...

7 Commits

Author SHA1 Message Date
1445043649 0b866a0bee docs(stage-1): mark control-plane scope limitation resolved (Stage 1 收口)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:07:06 +08:00
1445043649 428d4e9eb5 docs(stage-1): spec — use path-boundary matching for data-plane allowlist
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:43:27 +08:00
1445043649 b26c5e4dd0 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>
2026-06-28 21:42:41 +08:00
1445043649 02628d4e08 test(auth): move bearer probe routes under /api/v1/threads (Stage 1 收口)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:27:11 +08:00
1445043649 2d982c3f0b feat(authz): data-plane allowlist helper + INSUFFICIENT_SCOPE code (Stage 1 收口)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:25:46 +08:00
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
1445043649 a173d4f93c docs(stage-1): API key control-plane default-deny design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:05:14 +08:00
8 changed files with 911 additions and 9 deletions
+1
View File
@@ -22,6 +22,7 @@ class AuthErrorCode(StrEnum):
NOT_AUTHENTICATED = "not_authenticated"
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
WORKSPACE_REQUIRED = "workspace_required"
INSUFFICIENT_SCOPE = "insufficient_scope"
class TokenError(StrEnum):
+39
View File
@@ -55,6 +55,34 @@ def _is_public(path: str) -> bool:
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
# 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.
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):
"""Strict auth gate: reject requests without a valid session.
@@ -100,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)
+195
View File
@@ -0,0 +1,195 @@
"""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 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(
"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/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()
@@ -69,7 +69,7 @@ def _make_app():
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/probe")
@app.get("/api/v1/threads/_probe")
async def probe(request: Request):
return {
"user_id": get_effective_user_id(),
@@ -84,7 +84,7 @@ async def test_valid_bearer_sets_sa_contextvars(tmp_path):
gen = await _seed_key(tmp_path)
try:
client = TestClient(_make_app())
r = client.get("/api/probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
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", "workspace_id": "w-1", "is_sa": True}
finally:
@@ -95,7 +95,7 @@ async def test_invalid_bearer_returns_401(tmp_path):
await _seed_key(tmp_path)
try:
client = TestClient(_make_app())
r = client.get("/api/probe", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"})
r = client.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"})
assert r.status_code == 401
finally:
await _cleanup()
@@ -105,7 +105,7 @@ async def test_revoked_bearer_returns_401(tmp_path):
gen = await _seed_key(tmp_path, revoke=True)
try:
client = TestClient(_make_app())
r = client.get("/api/probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
r = client.get("/api/v1/threads/_probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
assert r.status_code == 401
finally:
await _cleanup()
@@ -117,7 +117,7 @@ async def test_non_dfk_bearer_falls_through_to_cookie_path(tmp_path):
client = TestClient(_make_app())
# A non-dfk bearer is NOT the API-key path; with no cookie the
# cookie path 401s (NOT_AUTHENTICATED), proving no mis-route.
r = client.get("/api/probe", headers={"Authorization": "Bearer some.jwt.token"})
r = client.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer some.jwt.token"})
assert r.status_code == 401
assert r.json()["detail"]["code"] == "not_authenticated"
finally:
@@ -128,7 +128,7 @@ async def test_bare_prefix_bearer_returns_401(tmp_path):
await _seed_key(tmp_path)
try:
client = TestClient(_make_app())
r = client.get("/api/probe", headers={"Authorization": "Bearer dfk_"})
r = client.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer dfk_"})
assert r.status_code == 401
finally:
await _cleanup()
+3 -3
View File
@@ -92,7 +92,7 @@ def _probe_app():
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/probe")
@app.get("/api/v1/threads/_probe")
async def probe(request: Request):
return {"user_id": get_effective_user_id(), "workspace_id": get_effective_workspace_id()}
@@ -108,12 +108,12 @@ async def test_mint_use_and_cross_workspace_isolation(tmp_path):
plaintext = key["plaintext"]
probe = TestClient(_probe_app())
ok = probe.get("/api/probe", headers={"Authorization": f"Bearer {plaintext}"})
ok = probe.get("/api/v1/threads/_probe", headers={"Authorization": f"Bearer {plaintext}"})
assert ok.status_code == 200
assert ok.json() == {"user_id": sa["id"], "workspace_id": "w-1"}
# A bogus / unknown key is rejected.
bad = probe.get("/api/probe", headers={"Authorization": "Bearer dfk_live_unknown0000000000000000"})
bad = probe.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer dfk_live_unknown0000000000000000"})
assert bad.status_code == 401
finally:
await _cleanup()
@@ -171,6 +171,7 @@
- **scope 仅在 threads/runs 等 `@require_permission` 装饰的路由上生效。** `AuthContext.permissions`(由 key 的 scopes 填充)只被 `@require_permission` 读取,而该装饰器目前只挂在 threads/runs/uploads/artifacts/feedback/suggestions 上。`mcp``PUT /api/v1/mcp/config`)、`skills``POST /api/v1/skills/install`)、`channels``restart`)、`models``agents``memory` 等路由**只校验"已认证",不校验 scope/role**。后果:一把 `scopes="threads:read"` 的 key 仍能改全局 MCP 配置、装技能、重启 channel;且这些目标是**进程级全局**(非 workspace 分区),对它们而言 workspace 隔离也不成立。
- 这与现有真人模型一致(真人拿 `_ALL_PERMISSIONS`,这些路由本就无授权),且 D4 / 非目标已把 `scopes=[...]` 显式参数化升级推后——故属**设计内的已知限制,非缺陷**。
- **后续 PR 决策项**:要么把这些全局配置路由 gated 到 `require_workspace_admin` / 专门 scope,要么显式声明"Stage 1 的 API key 在未被 `@require_permission` 装饰处为全权"。在 external_user 透传 / `scopes=[...]` 升级 PR 中一并处理。
- **【已解决 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 词汇升级仍按原计划推后。
## 9. 与后续 PR 的接口
@@ -0,0 +1,484 @@
# 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 既有签名一致 ✅
@@ -0,0 +1,182 @@
# API Key 控制平面 default-deny 收口 · 设计
> 写于 2026-06-28。收口 Stage 1 final review 记录的最小权限缺口([stage-1 design §8.1](../../multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md))。
>
> **承接:** [Stage 1 · Headless API Pattern A 鉴权地基](../plans/2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md)(已完成)。本设计是轨道二的安全补丁,不是新功能。
## 1. 背景与问题
Stage 1 让业务系统用 workspace-scoped API key`Authorization: Bearer dfk_...`server-to-server 直调 Gateway。鉴权热路径在 `AuthMiddleware` 的 bearer 分支解析 token → `ServicePrincipal`,并把 `(user_id=SA.id, workspace_id)` 写进 contextvar,使下游隔离与真人同构。
落地后的整体安全复核(2026-06-28)发现一处最小权限缺口(stage-1 design §8.1):
- **scope 只在 `@require_permission` 装饰的路由上生效。** `AuthContext.permissions`(由 key 的 scopes 填充)只被 `@require_permission` 读取,而该装饰器目前只挂在 threads/runs/uploads/artifacts/feedback/suggestions 上。
- `mcp``PUT /api/v1/mcp/config`)、`skills``POST /api/v1/skills/install`)、`channels``restart`)、`models``agents``memory` 等路由**只校验"已认证",不校验 scope/role**。
- 后果:一把 `scopes="threads:read"` 的 key 仍能改全局 MCP 配置、装技能、重启 channel。
- 更严重:这些目标是**进程级全局资源**(`extensions_config.json`、磁盘上的 skills、channel 进程),不是 workspace 分区的。对它们而言 workspace 隔离也不成立——一个租户的 key 改的是所有租户共享的配置。
### 根因再定位
这不是"几条路由忘了加 scope 检查"。真正的原因是:`_ALL_PERMISSIONS` 里**只有 6 个 `threads:*` / `runs:*` 权限**——控制平面路由从来就不在权限模型里。真人能访问它们,仅仅是因为它们未被装饰。API key 出现后,这些"对所有已认证者开放"的全局控制路由,意外地也对 service principal 开放了。
## 2. 目标与非目标
**目标**
- service principalAPI key 请求)只能访问**数据平面**;任何控制平面路由 → 403。
- 真人 / cookie 请求行为**完全不变**。
- 默认拒绝(default-deny):将来新增控制平面路由,自动被拦,不复现本次"忘了保护"的缺陷。
- 读、写一律拒(不区分 method)。
**非目标(明确推后)**
- 不引入 per-scope 细粒度授权(`scopes=["mcp:write"]` 之类的词汇升级)——留到 external_user 透传 / scope 升级 PRstage-1 design D4 已推后)。
- 不把控制平面资源改成 workspace 分区(那是更大的多租户改造)。
- 不动 `@require_permission` / `_ALL_PERMISSIONS` 现有语义。
- 不处理 Pattern B(短期 JWT / service-token 分支)——但要为其留好复用接缝。
## 3. 设计
### 3.1 数据平面边界
> **关键前提(已验证):** nginx 把 `/api/langgraph/(.*)` **rewrite 成 `/api/$1`** 后才转给 gateway(见 `docker/nginx/nginx.local.conf` L48-51);IM channels 也直连 gateway 的 `/api/*`(`langgraph_url` 默认 `http://localhost:8001/api`)。因此 `AuthMiddleware` **永远看不到 `/api/langgraph` 前缀**——LangGraph-SDK 的调用到达中间件时就是 `/api/threads`、`/api/runs`、`/api/assistants` 这些真实 router 路径。gateway 本身没有挂任何 `/api/langgraph` 路由,该前缀是纯 nginx 别名,放进白名单是死代码。
对照 Gateway 路由表(`app/gateway/app.py` include_router + 各 router prefix),**数据平面 / SDK 接口只落在三个前缀下**:
| 前缀 | 覆盖的 router | 性质 |
|---|---|---|
| `threads*` | threads、thread-runs、uploads`/threads/{id}/uploads`)、artifacts、suggestions、feedback(均 `/threads/...`) | 数据平面 |
| `runs*` | 无状态 runs`/runs` | 数据平面 |
| `/api/assistants` | `assistants_compat`——**langgraph-sdk 客户端 init 必需**(`assistants.search()`/`get()`),只读元数据,单挂(无 `/api/v1` twin) | SDK init |
因此 service principal 的白名单很小且稳定:
```
/api/threads /api/v1/threads
/api/runs /api/v1/runs
/api/assistants
```
> 不放 `/api/langgraph`(死代码,见上)。`assistants` 单挂故只有一条 `/api/assistants`(无版本)。
**其余一律拒绝**(对 SA):`models``mcp``memory``skills``channels``agents`,以及 `service-accounts``api-keys``auth`
- 管理类 endpoint`service-accounts`/`api-keys`)本来就因 `require_workspace_admin`(SA role=member)对 SA 返回 403。default-deny 让它**更早、更统一**地 403,不改变可达性结论。
- `auth``/api/v1/auth/*`)当前对 SA 无意义,拒掉无副作用。
### 3.2 enforce 位置
`app/gateway/auth_middleware.py``AuthMiddleware.dispatch` bearer 分支内,`result` 校验通过之后、写 contextvar / `call_next` 之前插入路径检查:
```python
if auth_header.startswith("Bearer dfk_"):
...
if result is None:
return JSONResponse(status_code=401, ...) # 现有
# ↓ 新增:service principal 只能走数据平面
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
...
```
放在 bearer 分支内的理由:
- 真人 cookie 路径根本不进这段,天然不受影响。
- 检查发生在 `_is_public` 早退之后——公共路径(`/health` 等)即便带 bearer 头也先被 `_is_public` 放行,不进此分支,符合预期。
### 3.3 辅助函数
仿照现有 `_is_public(path)` / `_PUBLIC_PATH_PREFIXES`,在同文件新增模块级常量与函数:
```python
_DATAPLANE_PREFIXES = (
"/api/threads",
"/api/v1/threads",
"/api/runs",
"/api/v1/runs",
"/api/assistants", # langgraph-sdk client init (assistants.search/get), 只读
)
def _is_dataplane_path(path: str) -> bool:
"""service principal 允许访问的数据平面 / SDK 路由前缀。
数据平面挂在 threads / runs 下;langgraph-sdk init 需要 assistants。
其余(全局控制平面:models/mcp/memory/skills/channels/agents 与
管理/auth 端点)对 API key 一律拒绝。注意 `/api/langgraph` 被 nginx
rewrite 掉,中间件看不到,故不在表内。将来 Pattern B 的 service-token
分支可复用本函数。匹配只在路径段边界命中(精确相等或后接 `/`),
避免同名前缀路由(如未来的 `/api/threads-export`)被悄悄放进白名单。
"""
return any(path == p or path.startswith(p + "/") for p in _DATAPLANE_PREFIXES)
```
> **前缀匹配的精度(边界对齐)**:用"精确相等或 `prefix + "/"`"而非裸 `startswith`。这是 default-deny **白名单**——over-match 的方向是"误放行"(把控制平面误判成数据平面),与本设计"新增控制平面路由自动被拦"的承诺直接冲突。故即便当前路由表无同名前缀冲突,也用边界匹配把这条安全不变量钉死,让将来出现 `/api/threads-export` 之类路由时不会被悄悄纳入数据平面。(注:与 `_is_public` 的裸 `startswith` 有意不同——`_is_public` 是 Stage 1 既有代码,本次不动它。)
### 3.4 错误口径
新增 `AuthErrorCode.INSUFFICIENT_SCOPE = "insufficient_scope"``app/gateway/auth/errors.py`)。该枚举定位是"穷举所有 auth 失败条件",新增一个符合既有模式。
- HTTP 403(已认证但无权),区别于无效 key 的 401。
- 响应体 `{"detail": {"code": "insufficient_scope", "message": ...}}`,与现有 401 `AuthErrorResponse` 完全同构。
## 4. 测试影响
### 4.1 现有探针测试需调整(有意,非绕过)
`tests/test_auth_middleware_api_key.py``tests/test_headless_api_smoke.py``/api/probe` 作探针路由。该路径不属数据平面,default-deny 会把它 403——但这些探针的**本意**就是"SA 访问一个受保护的数据平面路由"。
处理:把探针路径挪到数据平面前缀下(如 `/api/v1/threads/_probe`)。这是让测试反映真实约束的正确修正。涉及:
- `test_valid_bearer_sets_sa_contextvars`(探针仍应 200,验证 contextvar)
- `test_headless_api_smoke``_probe_app`(mint→use 链路仍应 200)
- 其余 401/revoked 用例不受影响(它们本就期望非 200)
### 4.2 新增 `tests/test_api_key_control_plane.py`
| 用例 | 期望 |
|---|---|
| SA bearer → `PUT /api/v1/mcp/config` | 403 `insufficient_scope` |
| SA bearer → `GET /api/v1/models` | 403(**读也拒**) |
| SA bearer → `POST /api/v1/skills/install` | 403 |
| SA bearer → `/api/mcp`(无版本旧路径) | 403 |
| SA bearer → `/api/v1/channels/...``/api/v1/agents``/api/v1/memory` | 403 |
| SA bearer → `/api/v1/threads/_probe` | 放行(数据平面) |
| SA bearer → `/api/assistants/search`(SDK init) | 放行 |
| cookie/真人 → `/api/v1/mcp`(或任一控制平面) | 不受影响(回归守护——不进 bearer 分支) |
| `_is_dataplane_path` 表驱动单元 | threads/runs(含 `/api``/api/v1` 双形态)、`/api/assistants` → True;控制平面前缀(含 `/api/langgraph`,死代码也应 False)→ False |
### 4.3 回归
- `make test` 全绿(含 stage-1 的 13 个文件)。
- `make lint` clean、`test_harness_boundary` PASS(本改动全在 app 层)。
## 5. 文件清单
**修改**
- `backend/app/gateway/auth/errors.py` — 加 `AuthErrorCode.INSUFFICIENT_SCOPE`
- `backend/app/gateway/auth_middleware.py` — 加 `_DATAPLANE_PREFIXES` + `_is_dataplane_path` + bearer 分支 403 检查
- `backend/tests/test_auth_middleware_api_key.py` — 探针路径挪到 `/api/v1/threads/_probe`
- `backend/tests/test_headless_api_smoke.py` — 同上
**新增**
- `backend/tests/test_api_key_control_plane.py`
**文档**
- `docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md` — §8.1 从"已知限制"翻成"已解决",指向本 PR
## 6. 不可逆 / 需想清楚的点
| 决策 | 取舍 |
|---|---|
| 数据平面边界 = threads/runs/assistants 三前缀 | 业务系统已接入后收窄白名单 = 破坏调用方;故白名单只增不减。本次定的是最小集,后续按需 **加**(如 Pattern B 的 `exchange-token`)。 |
| 读也拒(`GET /api/v1/models` 对 SA 403) | 若将来业务方需要列模型选型,再单独 allowlist 该 GET;default-deny 下"放开"比"收紧"安全。 |
| 错误码 `insufficient_scope` | 接入方可能据此分支处理;改名要联调。早定。 |
## 7. 与后续 PR 的接缝
- **Pattern Bservice-token 分支)**:复用 `_is_dataplane_path`。新增的 `exchange-token` endpoint 若需 SA 调用,记得把其路径加入 `_DATAPLANE_PREFIXES`(或单独放行)。
- **scope 词汇升级**`scopes=["mcp:write"]`):若将来要让特定 SA 受控访问某控制平面路由,在本 default-deny 之上叠加"白名单内再按 scope 细分"即可,不与本设计冲突。