From a173d4f93ce321c8d4462a8114f2da4ddcfb16c2 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sun, 28 Jun 2026 21:05:14 +0800 Subject: [PATCH] docs(stage-1): API key control-plane default-deny design spec Co-Authored-By: Claude Opus 4.8 (1M context) --- ...i-key-control-plane-default-deny-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md diff --git a/docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md b/docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md new file mode 100644 index 00000000..4e5694ac --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md @@ -0,0 +1,181 @@ +# 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 + 分支可复用本函数。 + """ + return any(path.startswith(p) for p in _DATAPLANE_PREFIXES) +``` + +> **前缀匹配的精度**:用 `startswith`,与 `_is_public` 一致。`/api/threads` 前缀不会误放行 `/api/threads-foo` 之类的路径吗?当前路由表无此类同名前缀冲突(控制平面均为独立段:`/api/models`、`/api/mcp` 等),故 `startswith` 安全。若未来出现冲突,改为带边界的匹配即可——本设计不预防尚不存在的冲突(YAGNI)。 + +### 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 细分"即可,不与本设计冲突。