Files
ZY-Agent/docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md
T
2026-06-28 21:43:27 +08:00

11 KiB
Raw Blame History

API Key 控制平面 default-deny 收口 · 设计

写于 2026-06-28。收口 Stage 1 final review 记录的最小权限缺口(stage-1 design §8.1)。

承接: Stage 1 · Headless API Pattern A 鉴权地基(已完成)。本设计是轨道二的安全补丁,不是新功能。

1. 背景与问题

Stage 1 让业务系统用 workspace-scoped API keyAuthorization: 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 上。
  • mcpPUT /api/v1/mcp/config)、skillsPOST /api/v1/skills/install)、channelsrestart)、modelsagentsmemory 等路由只校验"已认证",不校验 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):modelsmcpmemoryskillschannelsagents,以及 service-accountsapi-keysauth

  • 管理类 endpointservice-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.pyAuthMiddleware.dispatch bearer 分支内,result 校验通过之后、写 contextvar / call_next 之前插入路径检查:

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,在同文件新增模块级常量与函数:

_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.pytests/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 细分"即可,不与本设计冲突。