Compare commits
7 Commits
cdd9d6701f
...
0b866a0bee
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b866a0bee | |||
| 428d4e9eb5 | |||
| b26c5e4dd0 | |||
| 02628d4e08 | |||
| 2d982c3f0b | |||
| 1b7d8217dd | |||
| a173d4f93c |
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
@@ -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-deny:service 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 principal(API 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` 后才转给 gateway,IM 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: PASS(21 个 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 而非 403(deny 尚未接线)
|
||||
|
||||
- [ ] **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: PASS(Task 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: PASS(valid 探针走 `/api/v1/threads/_probe` 数据平面 → 200;invalid/revoked/non-dfk → 401;cookie 路径不变)
|
||||
|
||||
- [ ] **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-deny:service 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 clean;test 全绿(含 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 principal(API key 请求)只能访问**数据平面**;任何控制平面路由 → 403。
|
||||
- 真人 / cookie 请求行为**完全不变**。
|
||||
- 默认拒绝(default-deny):将来新增控制平面路由,自动被拦,不复现本次"忘了保护"的缺陷。
|
||||
- 读、写一律拒(不区分 method)。
|
||||
|
||||
**非目标(明确推后)**
|
||||
- 不引入 per-scope 细粒度授权(`scopes=["mcp:write"]` 之类的词汇升级)——留到 external_user 透传 / scope 升级 PR(stage-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 B(service-token 分支)**:复用 `_is_dataplane_path`。新增的 `exchange-token` endpoint 若需 SA 调用,记得把其路径加入 `_DATAPLANE_PREFIXES`(或单独放行)。
|
||||
- **scope 词汇升级**(`scopes=["mcp:write"]`):若将来要让特定 SA 受控访问某控制平面路由,在本 default-deny 之上叠加"白名单内再按 scope 细分"即可,不与本设计冲突。
|
||||
Reference in New Issue
Block a user