Compare commits

...

41 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
1445043649 cdd9d6701f docs(stage-1): tick all plan checkboxes to match landed commits (Stage 1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:49:34 +08:00
1445043649 4cd2acc192 feat(apps): 新增 headless(API Key) 多租户冒烟测试,并登记进 apps/README
multi_tenant.py 的「server-to-server / 无人值守」版:每个租户先由人类 owner
(cookie 会话)建 service account 并 mint 一把 workspace-scoped key
(POST /api/v1/service-accounts → POST /api/v1/api-keys,plaintext 仅返回一次),
之后所有对话只用 Authorization: Bearer dfk_live_...(独立 Session,免 cookie/CSRF)。

除并发 / 多轮上下文 / 租户隔离(同 cookie 版)外,额外校验两条 headless 专属性质:
- scope 强制:缺 runs:create 的 key 发起 stream 返回 403
- 撤销即失效:DELETE /api/v1/api-keys/{id} 后该 key 立即 401

对 :8001 实跑(DF_TENANTS=4 DF_TURNS=10)全绿。同时更正 apps/README 中
「API Key 鉴权尚未接入」的过时说明。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:45:37 +08:00
1445043649 37c3417bfb docs(stage-1): record known scope/least-privilege limitation from final review (Stage 1 PR5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:20:36 +08:00
1445043649 938c2eb0be harden(gateway): uniform 404 on API key revoke to hide cross-tenant existence (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:19:20 +08:00
1445043649 dee1eb2374 docs(stage-1): backfill plan link in spec; sync plan with review-driven changes (Stage 1 PR5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:12:07 +08:00
1445043649 190c1dc3c8 feat(frontend): migrate API calls to /api/v1 (Stage 1 PR5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:07:20 +08:00
1445043649 e6a12b9a7c test(gateway): strengthen dual-mount coverage to a v1-twin invariant (Stage 1 PR5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:01:29 +08:00
1445043649 2461a923be feat(gateway): dual-mount legacy routers on /api and /api/v1 (Stage 1 PR5)
Strip /api prefix from 13 legacy router APIRouter() declarations and dual-mount
each on prefix="/api" (backward compat) and prefix="/api/v1" (versioned surface)
in app.py. Auth, service-accounts, api-keys, assistants-compat remain single-mount.
Update 10 test files to pass prefix="/api" when directly including stripped routers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:49:05 +08:00
1445043649 3f0d5c8c96 feat(gateway): X-API-Deprecated header for legacy /api/* paths (Stage 1 PR5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:37:36 +08:00
1445043649 9d6decd91b test(gateway): end-to-end headless API mint/use/isolation smoke (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:34:56 +08:00
1445043649 c3c4e57416 harden(gateway): block API key mint on inactive SA; cover cross-workspace IDOR (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:25:17 +08:00
1445043649 950964e0ef feat(gateway): api-keys management endpoints with one-time plaintext (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:19:32 +08:00
1445043649 84e06ca396 harden(gateway): constrain service-account role/identity_mode at API boundary (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:15:40 +08:00
1445043649 d16e294185 feat(gateway): service-accounts management endpoints (Stage 1 PR4)
Owner/admin self-service CRUD for service accounts: POST create,
GET list, PATCH status; workspace-scoped with 404 existence hiding
for cross-workspace targets. Gated by require_workspace_admin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:11:13 +08:00
1445043649 e92fe0d7fb feat(authz): require_workspace_admin dependency (Stage 1 PR4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:07:55 +08:00
1445043649 9eb6103a4d feat(csrf): skip CSRF for bearer-header requests (Stage 1 PR3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:06:23 +08:00
1445043649 78359c3fd8 harden(auth): guard bearer auth errors as 503; widen AuthContext to ServicePrincipal (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:04:19 +08:00
1445043649 3ec4fb8537 feat(auth): AuthMiddleware bearer dfk_ path (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:58:14 +08:00
1445043649 ac8b37bd27 test(auth): cover expired-key rejection in APIKeyAuthBackend (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:44:41 +08:00
1445043649 d9a86878f4 feat(auth): APIKeyAuthBackend resolves token to SA principal (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:40:27 +08:00
1445043649 4f9116e3fe fix(auth): keep CurrentUser protocol id-only; is_service_account is opt-in (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:37:06 +08:00
1445043649 ec4769a33f feat(auth): ServicePrincipal + is_service_account discriminator (Stage 1 PR2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:31:49 +08:00
1445043649 978b0cf24d test(persistence): cover ExternalUserRepository.get + document upsert semantics (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:29:44 +08:00
1445043649 5093d3d123 feat(persistence): ExternalUserRepository scaffold (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:26:34 +08:00
1445043649 ac2ab26a7e refactor(persistence): ApiKeyRepository hot path via indexed prefix + constant-time hash verify (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:24:06 +08:00
1445043649 ab40a4a17e feat(persistence): ApiKeyRepository with active-key hot path (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:17:07 +08:00
1445043649 04170205dd fix(persistence): validate status in ServiceAccountRepository.create (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:13:14 +08:00
1445043649 9166ab205d feat(persistence): ServiceAccountRepository (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:08:20 +08:00
1445043649 427709e0a8 test(auth): polish token utility tests + docstring (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:05:20 +08:00
1445043649 e5ff6e74f9 feat(auth): API key token generation/hashing utilities (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:01:03 +08:00
1445043649 ef32a6de0f docs(stage-1): headless API Pattern A auth foundation implementation plan
5-PR TDD plan derived from the Stage 1 spec: token utils + 3-table repos
(deerflow layer), APIKeyAuthBackend + AuthMiddleware bearer path, CSRF
bearer skip, owner/admin mint endpoints with cross-workspace 404, and
/api/v1 dual-mount migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:57:24 +08:00
1445043649 240c6bd0e2 docs(mt): 统一 multi-tenant-redesign 命名约定 + README 补执行层索引
命名统一为 .zh-CN.md 后缀(与既有 17 个文件 + README 一致):
- 03-impl/{pr1-8,STATUS}.md → *.zh-CN.md
- Stage 1 spec 去日期前缀、加 .zh-CN,对齐 01-redesign 语义命名

README.zh-CN.md 修复 4 处不统一:
- 顶部加进度指引(现状只信 STATUS,本文是设计/路线导航)
- §0 文档总图补 03-impl 层 + Stage 1 spec + 命名约定注
- §1 表加 Stage 1 spec 行;新增 §1.1 执行记录层(STATUS + 8 impl note 索引)
- §7 阅读路径首次进项目/Stage 1 均加 STATUS + spec 入口

同步更新所有交叉链接(STATUS/pr/spec 自引用、database-schema-as-built、
根 README_zh.md、Stage 0 master plan);全树相对链接校验可达。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:40:42 +08:00
1445043649 8ea5507d27 docs(spec): 将 Stage 1 鉴权地基 spec 移入 multi-tenant-redesign/01-redesign
与 Stage 0 设计(ADR / schema-design)并排,统一策展主线;
相对链接从 ../../multi-tenant-redesign/ 改为 ../。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:30:25 +08:00
1445043649 45b019dae8 docs(spec): Stage 1 headless API Pattern A 鉴权地基设计
承接 Stage 0(PR1-8 全 merge)与 headless-api-track 轨道二。
锁定 5 个 PR:三表仓储+token 工具 / APIKeyAuthBackend 双路径 /
CSRF skip on bearer / 管理 endpoint mint 闭环 / api/v1 全量迁移。
含 5 项 brainstorm 决策(D1 SA→CurrentUser 映射 / D2 管理 endpoint /
D3 全量迁 v1 / D4 scope 白捡 / D5 dfk_ key 格式)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:19:26 +08:00
87 changed files with 6720 additions and 147 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ DeerFlow 原本面向"单机可信环境、单用户"。本分支按"以个人
- **决策(7 份 ADR + spike + 审计)**[`01-redesign/`](docs/multi-tenant-redesign/01-redesign/)
- **Stage 0 schema 锁定版 / 落地版**[`workspace-schema-design`](docs/multi-tenant-redesign/01-redesign/workspace-schema-design.zh-CN.md) · [`database-schema-as-built`](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md)
- **落地路线 + 集成轨道**[`02-rollout/`](docs/multi-tenant-redesign/02-rollout/)
- **Stage 0 进度面板(权威"现在到哪了"**[`03-impl/STATUS.md`](docs/multi-tenant-redesign/03-impl/STATUS.md)
- **Stage 0 进度面板(权威"现在到哪了"**[`03-impl/STATUS.zh-CN.md`](docs/multi-tenant-redesign/03-impl/STATUS.zh-CN.md)
## 官网
@@ -656,7 +656,7 @@ DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM
## 文档
- [多租户改造汇总索引](docs/multi-tenant-redesign/README.zh-CN.md) - workspace / Postgres / RLS / Headless API 的决策与路线
- [Stage 0 进度面板](docs/multi-tenant-redesign/03-impl/STATUS.md) - "现在到哪了"的权威来源
- [Stage 0 进度面板](docs/multi-tenant-redesign/03-impl/STATUS.zh-CN.md) - "现在到哪了"的权威来源
- [数据库设计落地版](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md) - 10 张表全字段 / 外键 / 索引参考
- [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程
- [配置指南](backend/docs/CONFIGURATION.md) - 安装与配置说明
+13 -1
View File
@@ -49,6 +49,18 @@ DF_BASE=http://localhost:8001 DF_TENANTS=4 DF_TURNS=10 \
环境变量:`DF_BASE`(网关地址,默认 :8001)、`DF_TENANTS`(并发租户数,默认 3)、`DF_TURNS`(每租户轮数,默认 10)。每次运行用唯一邮箱新建租户,可重复跑,不撞 email、也不触发登录限流(`/register` 不限流;`setup-status` 全程只调一次以避开 60s/IP 限流)。
### 多租户验证(Headless / API Key 模式)
[`examples/http-chat/multi_tenant_headless.py`](examples/http-chat/multi_tenant_headless.py) 是上面那个测试的 **server-to-server(无人值守)** 版,验证 Stage 1 的 API Key 鉴权:每个租户先由一个人类 owner(cookie 会话)建 service account 并 mint 一把 workspace-scoped key`POST /api/v1/service-accounts``POST /api/v1/api-keys`,plaintext 仅返回一次),之后所有对话只用 `Authorization: Bearer dfk_live_...`(独立 Session、**不带 cookie / CSRF**)。除并发 / 上下文 / 隔离(同 cookie 版)外,额外校验两条 headless 专属性质:④ **scope 强制**——一把缺 `runs:create` 的 key 发起对话返回 403;⑤ **撤销即失效**——`DELETE /api/v1/api-keys/{id}` 后该 key 立即 401。
```bash
# 前提:已起 Gatewaydev-gateway 或 dev-full
DF_BASE=http://localhost:8001 DF_TENANTS=4 DF_TURNS=10 \
uv run --no-project --with requests python apps/examples/http-chat/multi_tenant_headless.py
```
环境变量同上,外加 `DF_EXTRA=0` 可跳过 scope / 撤销专项检查。实测(`:8001``DF_TENANTS=2 DF_TURNS=3`)全绿:真并发、多轮上下文保持、跨租户 `GET` 均 404、只读 key stream 403、撤销后 401。
## 前置:先把 DeerFlow 跑起来
在**仓库根目录**
@@ -91,7 +103,7 @@ Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要
3. 成功后 Session 里有 `access_token`(HttpOnly) + `csrf_token` 两个 cookie
4. **所有写请求**POST/PUT/DELETE/PATCH)必须带 `X-CSRF-Token` 头 = `csrf_token`
> 多租户:当前 `docs/multi-tenant-redesign` 分支的 API Key 鉴权中间件尚未接入,外部系统暂时只能走会话 cookie。等 `Authorization: Bearer dfk_live_...` 落地后再补无人值守接入。每个注册用户即一个独立租户(自带 workspace);并发与隔离行为可用上面的 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证
> 多租户:浏览器式接入走会话 cookie(每个注册用户即一个独立租户,自带 workspace),并发与隔离可用 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证。**无人值守 / 业务后端**接入已支持 API KeyStage 1):owner 经 `POST /api/v1/service-accounts` + `POST /api/v1/api-keys` mint 一把 key,业务侧用 `Authorization: Bearer dfk_live_...` 直连(免 cookie / CSRF),端到端示例见 [`multi_tenant_headless.py`](examples/http-chat/multi_tenant_headless.py)。
## 新建一个应用
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""
Headless 多租户测试:以 **API KeyAuthorization: Bearer dfk_...** 的 server-to-server
方式跑多租户并发对话并验证隔离 —— 即 [multi_tenant.py] 的「无人值守 / 业务后端」版。
与 multi_tenant.py(浏览器式 cookie + CSRF)的区别:
- 每个租户先由一个 **人类 owner**cookie 会话)创建 service account 并 mint 一把
workspace-scoped API keyplaintext 仅返回一次);
- 之后所有对话只用 **Bearer key**(独立 Session、不带任何 cookie / CSRF),
模拟业务系统 backend 直连 Gateway。
运行(前提:仓库根已起 Gateway,如 `./scripts/dev-gateway.sh start`):
DF_BASE=http://localhost:8001 DF_TENANTS=4 \
uv run --no-project --with requests python multi_tenant_headless.py
# 没有 uv 时:pip install -r requirements.txt && python multi_tenant_headless.py
环境变量:DF_BASE(网关地址,默认 :8001)、DF_TENANTS(并发租户数,默认 3)、
DF_TURNS(每租户链式对话轮数,默认 10)、DF_EXTRA=0 可跳过 scope/撤销专项检查。
字段 / 端点(已对照 Stage 1 headless-api 实现):
人类鉴权 POST /api/v1/auth/{register,me} cookie + CSRF
建 SA POST /api/v1/service-accounts owner cookie + CSRF
mint key POST /api/v1/api-keys → {plaintext, key_prefix, id, ...}(仅此一次返 plaintext
撤销 key DELETE /api/v1/api-keys/{id} → 204
对话 POST /api/v1/threads /threads/search /threads/{id}/runs/streamBearer,无 CSRF
校验信号(来自实现):
- Bearer 路径下 thread 归属 user_id = service_account.id + workspace_id,与真人同构 → 隔离一致
- 跨 workspace 访问线程返回 404(藏存在性,非 403)
- key.scopes 经 AuthContext.permissions 灌入 @require_permission:缺 runs:create → stream 403
- 撤销后的 key → 401
"""
import json
import os
import re
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
BASE = os.environ.get("DF_BASE", "http://localhost:8001")
N_TENANTS = int(os.environ.get("DF_TENANTS", "3"))
N_TURNS = int(os.environ.get("DF_TURNS", "10")) # 每租户的对话轮数
EXTRA_CHECKS = os.environ.get("DF_EXTRA", "1") != "0" # scope / 撤销专项检查
RUN_ID = uuid.uuid4().hex[:8]
PASSWORD = "DeerTenant-7x9q!" # ≥8 位且不在弱口令黑名单
# 一把「全权」key 的 scope —— 覆盖 chat 链路需要的 runs:create / threads:read 等。
FULL_SCOPES = "threads:read,threads:write,threads:delete,runs:create,runs:read,runs:cancel"
_print_lock = threading.Lock()
def log(msg: str) -> None:
with _print_lock:
print(msg, flush=True)
# ── 人类 owner 侧(cookie + CSRF):注册 → 建 SA → mint key ─────────────
def _csrf(s: requests.Session) -> dict:
token = s.cookies.get("csrf_token")
if not token:
raise RuntimeError("缺少 csrf_token cookie —— 鉴权可能失败")
return {"X-CSRF-Token": token}
def register(s: requests.Session, email: str) -> dict:
r = s.post(f"{BASE}/api/v1/auth/register", json={"email": email, "password": PASSWORD})
r.raise_for_status()
return r.json() # {id, email, system_role}
def whoami(s: requests.Session) -> dict:
r = s.get(f"{BASE}/api/v1/auth/me")
r.raise_for_status()
return r.json() # {id, email, default_workspace_id, workspaces:[...]}
def create_service_account(s: requests.Session, name: str) -> dict:
r = s.post(f"{BASE}/api/v1/service-accounts", json={"name": name}, headers=_csrf(s))
r.raise_for_status()
return r.json() # {id, workspace_id, name, role, status, ...}
def mint_key(s: requests.Session, sa_id: str, name: str, scopes: str) -> dict:
r = s.post(
f"{BASE}/api/v1/api-keys",
json={"service_account_id": sa_id, "name": name, "scopes": scopes, "env": "live"},
headers=_csrf(s),
)
r.raise_for_status()
return r.json() # {id, key_prefix, plaintext, scopes, ...} ← plaintext 仅此一次
def revoke_key(s: requests.Session, key_id: str) -> int:
r = s.delete(f"{BASE}/api/v1/api-keys/{key_id}", headers=_csrf(s))
return r.status_code # 204 = 成功
# ── 业务后端侧(Bearer key,无 cookie / 无 CSRF)──────────────────────
def bearer_session(plaintext: str) -> requests.Session:
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {plaintext}"})
return s
def create_thread(s: requests.Session) -> str:
r = s.post(f"{BASE}/api/v1/threads", json={})
r.raise_for_status()
return r.json()["thread_id"]
def stream_answer(s: requests.Session, thread_id: str, message: str) -> dict:
"""发一条消息,按 message-id 分组收集 AI 增量文本(TitleMiddleware 会另起一条
AI 消息生成标题,必须按 id 分组,否则正文数字会和标题数字粘连导致误判)。"""
body = {
"assistant_id": "lead_agent",
"input": {"messages": [{"role": "user", "content": message}]},
"stream_mode": ["messages-tuple", "values"],
}
headers = {"Accept": "text/event-stream"} # Bearer 已在 session.headers
by_id: dict[str, str] = {}
with s.post(f"{BASE}/api/v1/threads/{thread_id}/runs/stream", json=body, headers=headers, stream=True) as resp:
resp.raise_for_status()
event, buf = None, []
for raw in resp.iter_lines(decode_unicode=True):
if raw is None:
continue
line = raw.strip()
if line == "":
if event == "messages" and buf:
_collect(by_id, "\n".join(buf))
event, buf = None, []
elif line.startswith(":"):
continue
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
buf.append(line[5:].strip())
return by_id # {message_id: text}
def _collect(by_id: dict, data: str) -> None:
try:
payload = json.loads(data)
except json.JSONDecodeError:
return
chunk = payload[0] if isinstance(payload, list) and payload else {}
if chunk.get("type") in ("ai", "AIMessageChunk"):
content = chunk.get("content")
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "".join(b.get("text", "") for b in content if isinstance(b, dict))
else:
text = ""
mid = chunk.get("id") or "_"
by_id[mid] = by_id.get(mid, "") + text
def search_threads(s: requests.Session) -> list:
r = s.post(f"{BASE}/api/v1/threads/search", json={"limit": 100, "offset": 0})
r.raise_for_status()
return r.json() # bare array of ThreadResponse
def get_thread_status(s: requests.Session, thread_id: str) -> int:
return s.get(f"{BASE}/api/v1/threads/{thread_id}").status_code
def _contains(by_id: dict, n: int) -> bool:
return any(str(n) in re.findall(r"\d+", text.replace(",", "")) for text in by_id.values())
def _main_text(by_id: dict) -> str:
return (max(by_id.values(), key=len) if by_id else "").strip()
# ── provisioning:人类 owner 建租户 + SA + key(顺序,cookie 侧)──────────
def provision_tenant(idx: int) -> dict:
email = f"htenant-{RUN_ID}-{idx}@example.com"
owner = requests.Session()
register(owner, email)
me = whoami(owner)
sa = create_service_account(owner, name=f"ci-bot-{idx}")
key = mint_key(owner, sa["id"], name="prod", scopes=FULL_SCOPES)
rec = {
"idx": idx,
"email": email,
"owner": owner,
"workspace_id": me.get("default_workspace_id"),
"sa_id": sa["id"],
"sa_role": sa.get("role"),
"key_id": key["id"],
"key_prefix": key["key_prefix"],
"plaintext": key["plaintext"],
"bearer": bearer_session(key["plaintext"]),
}
log(
f"[租户{idx}] 已开通 ws={str(rec['workspace_id'])[:8]} sa={sa['id'][:8]} "
f"key_prefix={key['key_prefix']} (sa_role={rec['sa_role']})"
)
return rec
# ── 一个租户的并发链路(Bearer key,独立线程)──────────────────────────
def run_tenant(prov: dict) -> dict:
idx = prov["idx"]
s = prov["bearer"]
a, b = 11 + idx, 13 + idx * 2 # 每租户不同算式
d = 2 + idx # 每租户不同步长,坐实无串扰
expected = [a * b]
for _ in range(1, N_TURNS):
expected.append(expected[-1] + d)
rec = {**prov, "d": d, "expected": expected, "turns": [], "ok": False}
t0 = time.time()
try:
rec["t_start"] = t0
tid = create_thread(s)
rec["thread_id"] = tid
for k in range(N_TURNS):
if k == 0:
q = f"只回答最终数字:{a} 乘以 {b} 等于多少?"
else:
q = f"把你上一条回答的那个数字再加 {d},只回答最终数字。"
by_id = stream_answer(s, tid, q)
hit = _contains(by_id, expected[k])
rec["turns"].append({"k": k + 1, "expected": expected[k], "text": _main_text(by_id), "ok": hit})
mark = "" if hit else ""
log(f"[租户{idx}] T{k + 1:>2}/{N_TURNS} 期望 {expected[k]:>5}{mark} {rec['turns'][-1]['text'][:24]!r}")
rec["turns_passed"] = sum(t["ok"] for t in rec["turns"])
rec["all_turns_ok"] = rec["turns_passed"] == N_TURNS
rec["context_ok"] = all(t["ok"] for t in rec["turns"][1:])
rec["t_end"] = time.time()
rec["ok"] = True
log(f"[租户{idx}] ✓ 完成 {rec['turns_passed']}/{N_TURNS}")
except Exception as e: # noqa: BLE001
rec["error"] = f"{type(e).__name__}: {e}"
log(f"[租户{idx}] ✗ 失败:{rec['error']}")
return rec
# ── headless 专项:scope 强制 + 撤销(在租户 0 的 owner 上做)──────────
def extra_checks(prov0: dict) -> dict:
owner = prov0["owner"]
sa_id = prov0["sa_id"]
out = {"scope_enforced": None, "revocation_401": None}
# 1) scope 强制:mint 一把只有 threads:read(无 runs:create)的 key → stream 应 403
try:
limited = mint_key(owner, sa_id, name="readonly", scopes="threads:read")
ls = bearer_session(limited["plaintext"])
tid = create_thread(ls) # 建线程不需要 scope(仅鉴权),应成功
body = {
"assistant_id": "lead_agent",
"input": {"messages": [{"role": "user", "content": "hi"}]},
"stream_mode": ["messages-tuple", "values"],
}
r = ls.post(f"{BASE}/api/v1/threads/{tid}/runs/stream", json=body, headers={"Accept": "text/event-stream"})
out["scope_enforced"] = r.status_code == 403
log(f" scope 强制:只读 key 发起 stream → HTTP {r.status_code}(期望 403{'' if out['scope_enforced'] else ''}")
revoke_key(owner, limited["id"])
except Exception as e: # noqa: BLE001
out["scope_error"] = f"{type(e).__name__}: {e}"
log(f" scope 强制:检查异常 {out['scope_error']}")
# 2) 撤销:mint 一把临时 key,验证可用 → 撤销 → 再用应 401
try:
tmp = mint_key(owner, sa_id, name="throwaway", scopes=FULL_SCOPES)
ts = bearer_session(tmp["plaintext"])
before = ts.post(f"{BASE}/api/v1/threads", json={}).status_code # 撤销前可建线程
code = revoke_key(owner, tmp["id"])
after = ts.post(f"{BASE}/api/v1/threads", json={}).status_code # 撤销后应 401
out["revocation_401"] = before in (200, 201) and code == 204 and after == 401
log(
f" 撤销:撤销前建线程 HTTP {before} → DELETE {code} → 撤销后 HTTP {after}"
f"(期望 2xx→204→401{'' if out['revocation_401'] else ''}"
)
except Exception as e: # noqa: BLE001
out["revocation_error"] = f"{type(e).__name__}: {e}"
log(f" 撤销:检查异常 {out['revocation_error']}")
return out
def main() -> None:
log(f"=== Headless 多租户测试 BASE={BASE} 租户数={N_TENANTS} 轮数={N_TURNS} 批次={RUN_ID} ===\n")
# setup-status 只调用一次(60s 限流)
try:
st = requests.get(f"{BASE}/api/v1/auth/setup-status", timeout=5)
if st.status_code == 200:
log(f"setup-status: {st.json()}")
if st.json().get("needs_setup"):
log("⚠ 系统尚未初始化管理员。请先创建管理员(app.py 首启会建),再跑本测试。")
return
else:
log(f"setup-status: HTTP {st.status_code}(限流则忽略,按已初始化处理)")
except Exception as e: # noqa: BLE001
log(f"setup-status 请求失败:{e}")
# 1) 顺序开通每个租户(人类 owner 建 SA + mint key
log(f"\n── 开通 {N_TENANTS} 个租户(owner cookie → SA → API key)──")
provs = []
for i in range(N_TENANTS):
try:
provs.append(provision_tenant(i))
except Exception as e: # noqa: BLE001
log(f"[租户{i}] ✗ 开通失败:{type(e).__name__}: {e}")
if not provs:
log("没有成功开通的租户,终止。")
return
# 2) 并发跑所有租户(只用 Bearer key)
log(f"\n── 并发启动 {len(provs)} 个租户的 Bearer 对话 ──")
results = []
with ThreadPoolExecutor(max_workers=len(provs)) as ex:
futs = [ex.submit(run_tenant, p) for p in provs]
for f in as_completed(futs):
results.append(f.result())
results.sort(key=lambda r: r["idx"])
ok = [r for r in results if r.get("ok")]
# 并发证据:对话时间窗是否重叠
log("\n── 并发证据(对话时间窗,相对秒)──")
if ok:
base_t = min(r["t_start"] for r in ok)
for r in ok:
s_off, e_off = r["t_start"] - base_t, r["t_end"] - base_t
bar = " " * int(s_off * 4) + "" * max(1, int((e_off - s_off) * 4))
log(f" 租户{r['idx']}: [{s_off:5.1f}s → {e_off:5.1f}s] {bar}")
spans = [(r["t_start"], r["t_end"]) for r in ok]
overlapped = any(a[0] < b[1] and b[0] < a[1] for i, a in enumerate(spans) for b in spans[i + 1 :])
log(f" → 存在时间窗重叠(真并发):{overlapped}")
# 3) 隔离校验(Bearer key 之间)
log("\n── 隔离校验(跨租户 Bearer)──")
iso_pass = True
own_thread = {r["idx"]: r["thread_id"] for r in ok}
for r in ok:
s = r["bearer"]
mine = {t["thread_id"] for t in search_threads(s)}
only_own = mine == {r["thread_id"]} if mine else False
leaked = {own_thread[j] for j in own_thread if j != r["idx"]} & mine
cross_ok = True
for j, tid in own_thread.items():
if j == r["idx"]:
continue
code = get_thread_status(s, tid)
if code != 404:
cross_ok = False
log(f" ✗ 租户{r['idx']} 的 key 访问 租户{j} 的线程返回 {code}(期望 404")
if leaked:
iso_pass = False
log(f" ✗ 租户{r['idx']} 的 search 里出现了别人的线程:{leaked}")
if not cross_ok:
iso_pass = False
if only_own and cross_ok and not leaked:
log(f" ✓ 租户{r['idx']}:search 仅见己有线程,跨租户 GET 均 404")
# 4) headless 专项(scope 强制 + 撤销)
extra = {}
if EXTRA_CHECKS and ok:
log("\n── Headless 专项检查(scope 强制 + 撤销)──")
extra = extra_checks(provs[0])
# 汇总
log(f"\n── 汇总(每租户 {N_TURNS} 轮链式对话:T1=a×b,之后每轮 +d)──")
log(f"{'租户':<6}{'sa_id':<12}{'key_prefix':<20}{'thread':<14}{'步长d':<8}{'通过轮数':<12}{'逐轮':<14}{'状态'}")
for r in results:
if r.get("ok"):
seq = "".join("" if t["ok"] else "" for t in r["turns"])
passed = f"{r['turns_passed']}/{N_TURNS}"
log(f"{r['idx']:<6}{r['sa_id'][:8]:<12}{r['key_prefix']:<20}{r['thread_id'][:10]:<14}{r['d']:<8}{passed:<12}{seq:<14}OK")
else:
log(f"{r['idx']:<6}{'-':<12}{'-':<20}{'-':<14}{'-':<8}{'-':<12}{'-':<14}FAIL: {r.get('error')}")
all_ok = len(ok) == len(provs) and len(provs) == N_TENANTS
turn1_ok = all(r["turns"][0]["ok"] for r in ok) if ok else False
context_ok = all(r.get("context_ok") for r in ok) if ok else False
log("\n=== 结果 ===")
log(f" 租户全部开通+成功: {all_ok} ({len(ok)}/{N_TENANTS})")
log(f" 首轮答复无串扰: {turn1_ok}")
log(f" 多轮上下文保持: {context_ok} ← 第 2~{N_TURNS} 轮每轮都依赖上一轮结果")
log(f" 租户隔离(Bearer): {iso_pass}")
if EXTRA_CHECKS:
log(f" scope 强制(403): {extra.get('scope_enforced')} ← 缺 runs:create 的 key 不能 stream")
log(f" 撤销即失效(401): {extra.get('revocation_401')}")
verdict = all_ok and turn1_ok and context_ok and iso_pass
if EXTRA_CHECKS:
verdict = verdict and extra.get("scope_enforced") and extra.get("revocation_401")
log(f" >>> {'PASS ✅' if verdict else 'FAIL ❌'}")
if __name__ == "__main__":
main()
+56 -28
View File
@@ -10,9 +10,11 @@ from fastapi.middleware.cors import CORSMiddleware
from app.gateway.auth_middleware import AuthMiddleware
from app.gateway.config import get_gateway_config
from app.gateway.csrf_middleware import CSRFMiddleware
from app.gateway.deprecation_middleware import ApiDeprecationMiddleware
from app.gateway.deps import langgraph_runtime
from app.gateway.routers import (
agents,
api_keys,
artifacts,
assistants_compat,
auth,
@@ -22,6 +24,7 @@ from app.gateway.routers import (
memory,
models,
runs,
service_accounts,
skills,
suggestions,
thread_runs,
@@ -354,6 +357,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# CSRF: Double Submit Cookie pattern for state-changing requests
app.add_middleware(CSRFMiddleware)
# Deprecation: stamp X-API-Deprecated on unversioned /api/* responses
app.add_middleware(ApiDeprecationMiddleware)
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware.
# In production, nginx handles CORS and no middleware is needed.
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "")
@@ -375,50 +381,72 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
)
# Include routers
# Models API is mounted at /api/models
app.include_router(models.router)
# Legacy routers are dual-mounted on /api (backward compat) and /api/v1 (versioned).
# The deprecation middleware (Task 5.1) stamps X-API-Deprecated on /api responses.
# MCP API is mounted at /api/mcp
app.include_router(mcp.router)
# Models API — /api/models and /api/v1/models
app.include_router(models.router, prefix="/api")
app.include_router(models.router, prefix="/api/v1")
# Memory API is mounted at /api/memory
app.include_router(memory.router)
# MCP API — /api/mcp and /api/v1/mcp
app.include_router(mcp.router, prefix="/api")
app.include_router(mcp.router, prefix="/api/v1")
# Skills API is mounted at /api/skills
app.include_router(skills.router)
# Memory API — /api/memory and /api/v1/memory
app.include_router(memory.router, prefix="/api")
app.include_router(memory.router, prefix="/api/v1")
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router)
# Skills API — /api/skills and /api/v1/skills
app.include_router(skills.router, prefix="/api")
app.include_router(skills.router, prefix="/api/v1")
# Uploads API is mounted at /api/threads/{thread_id}/uploads
app.include_router(uploads.router)
# Artifacts API — /api/threads/{thread_id}/artifacts and /api/v1/threads/{thread_id}/artifacts
app.include_router(artifacts.router, prefix="/api")
app.include_router(artifacts.router, prefix="/api/v1")
# Thread cleanup API is mounted at /api/threads/{thread_id}
app.include_router(threads.router)
# Uploads API — /api/threads/{thread_id}/uploads and /api/v1/threads/{thread_id}/uploads
app.include_router(uploads.router, prefix="/api")
app.include_router(uploads.router, prefix="/api/v1")
# Agents API is mounted at /api/agents
app.include_router(agents.router)
# Threads API — /api/threads/{thread_id} and /api/v1/threads/{thread_id}
app.include_router(threads.router, prefix="/api")
app.include_router(threads.router, prefix="/api/v1")
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
# Agents API — /api/agents and /api/v1/agents
app.include_router(agents.router, prefix="/api")
app.include_router(agents.router, prefix="/api/v1")
# Channels API is mounted at /api/channels
app.include_router(channels.router)
# Suggestions API — /api/threads/{thread_id}/suggestions and /api/v1/threads/{thread_id}/suggestions
app.include_router(suggestions.router, prefix="/api")
app.include_router(suggestions.router, prefix="/api/v1")
# Assistants compatibility API (LangGraph Platform stub)
# Channels API — /api/channels and /api/v1/channels
app.include_router(channels.router, prefix="/api")
app.include_router(channels.router, prefix="/api/v1")
# Assistants compatibility API (LangGraph Platform stub) — intentionally NOT dual-mounted
app.include_router(assistants_compat.router)
# Auth API is mounted at /api/v1/auth
# Auth API — /api/v1/auth only (already versioned; must NOT get an /api/auth twin)
app.include_router(auth.router)
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
app.include_router(feedback.router)
# Service Accounts API — /api/v1/service-accounts only (already versioned)
app.include_router(service_accounts.router)
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router)
# API Keys API — /api/v1/api-keys only (already versioned)
app.include_router(api_keys.router)
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
# Feedback API — /api/threads/{thread_id}/runs/{run_id}/feedback and /api/v1/... twin
app.include_router(feedback.router, prefix="/api")
app.include_router(feedback.router, prefix="/api/v1")
# Thread Runs API — /api/threads/{thread_id}/runs and /api/v1/... twin
app.include_router(thread_runs.router, prefix="/api")
app.include_router(thread_runs.router, prefix="/api/v1")
# Stateless Runs API — /api/runs and /api/v1/runs
app.include_router(runs.router, prefix="/api")
app.include_router(runs.router, prefix="/api/v1")
@app.get("/health", tags=["health"])
async def health_check() -> dict:
@@ -0,0 +1,97 @@
"""API key authentication backend (Stage 1 PR2).
Resolves an ``Authorization: Bearer dfk_...`` token into a
``ServicePrincipal`` + workspace + scopes, so ``AuthMiddleware`` can
stamp the same contextvars a cookie-authenticated human would set
(spec D1: user_id = SA.id).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from deerflow.auth.tokens import hash_api_key, split_prefix
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ServicePrincipal:
"""Non-human principal backing an API key. Satisfies the
``deerflow.runtime.user_context.CurrentUser`` protocol."""
id: str
is_service_account: bool = True
def parse_scopes(scopes: str) -> list[str]:
"""Parse a comma-separated scope string into a permission list.
``"threads:read, threads:write"`` -> ``["threads:read", "threads:write"]``.
Empty / whitespace-only segments are dropped.
"""
return [s.strip() for s in scopes.split(",") if s.strip()]
@dataclass(frozen=True)
class ApiKeyAuthResult:
"""Everything ``AuthMiddleware`` needs to stamp request state +
contextvars from a verified API key."""
principal: ServicePrincipal
workspace_id: str
role: str
permissions: list[str]
class APIKeyAuthBackend:
def __init__(self, *, api_key_repo, service_account_repo, workspace_repo) -> None:
self._api_key_repo = api_key_repo
self._service_account_repo = service_account_repo
self._workspace_repo = workspace_repo
async def authenticate(self, token: str) -> ApiKeyAuthResult | None:
"""Resolve a plaintext token to an auth result, or None (→ 401)."""
# Look up by the indexed public prefix; the repo constant-time
# verifies the full hash.
key = await self._api_key_repo.get_active_by_hash(hash_api_key(token), key_prefix=split_prefix(token))
if key is None:
return None
sa = await self._service_account_repo.get_active(key["service_account_id"])
if sa is None:
return None
# SA is not a workspace *member* — bypass the membership filter
# with the documented user_id=None admin/migration path.
workspace = await self._workspace_repo.get(sa["workspace_id"], user_id=None)
if workspace is None or workspace["status"] != "active":
return None
# Best-effort: never block the request if the timestamp write fails.
try:
await self._api_key_repo.touch_last_used(key["id"])
except Exception: # noqa: BLE001 — best-effort, log and continue
logger.warning("touch_last_used failed for api_key %s", key["id"], exc_info=True)
return ApiKeyAuthResult(
principal=ServicePrincipal(id=sa["id"]),
workspace_id=sa["workspace_id"],
role=sa["role"],
permissions=parse_scopes(key["scopes"]),
)
def build_api_key_backend() -> APIKeyAuthBackend | None:
"""Construct a backend from the global session factory, or None when
persistence is the in-memory backend (no DB → no API keys)."""
from deerflow.persistence.api_key import ApiKeyRepository
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.service_account import ServiceAccountRepository
from deerflow.persistence.workspace import WorkspaceRepository
sf = get_session_factory()
if sf is None:
return None
return APIKeyAuthBackend(api_key_repo=ApiKeyRepository(sf), service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf))
+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):
+5
View File
@@ -31,6 +31,11 @@ class User(BaseModel):
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs")
# Headless API discriminator (Stage 1 PR2). Always False for human
# users; ServicePrincipal sets it True. Lets downstream code branch
# on principal kind without isinstance gymnastics.
is_service_account: bool = Field(default=False, description="True only for API-key service accounts, never for human users")
# Workspace linkage (Stage 0 PR4)
default_workspace_id: str | None = Field(
default=None,
+71
View File
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
Fine-grained permission checks remain in authz.py decorators.
"""
import logging
from collections.abc import Callable
from fastapi import HTTPException, Request, Response
@@ -16,6 +17,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.types import ASGIApp
from app.gateway.auth.api_key_backend import build_api_key_backend
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth.models import ActiveWorkspace
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
@@ -23,6 +25,8 @@ from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_us
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
logger = logging.getLogger(__name__)
# Paths that never require authentication.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health",
@@ -51,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.
@@ -78,6 +110,45 @@ class AuthMiddleware(BaseHTTPMiddleware):
if _is_public(request.url.path):
return await call_next(request)
# API key path: "Authorization: Bearer dfk_..." authenticates a
# service account. Resolved principal is mapped to the same
# (user_id, workspace_id) contextvars a human would set (spec D1),
# so all downstream isolation works unchanged.
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer dfk_"):
token = auth_header[len("Bearer ") :]
backend = build_api_key_backend()
try:
result = await backend.authenticate(token) if backend is not None else None
except Exception:
logger.exception("API key authentication failed unexpectedly")
return JSONResponse(status_code=503, content={"detail": "Authentication service unavailable"})
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
request.state.auth = AuthContext(user=result.principal, permissions=result.permissions)
user_token = set_current_user(result.principal)
ws_token = set_current_workspace(ActiveWorkspace(id=result.workspace_id, role=result.role))
try:
return await call_next(request)
finally:
reset_current_workspace(ws_token)
reset_current_user(user_token)
internal_user = None
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
internal_user = get_internal_user()
+24 -4
View File
@@ -38,6 +38,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException, Request
if TYPE_CHECKING:
from app.gateway.auth.api_key_backend import ServicePrincipal
from app.gateway.auth.models import User
P = ParamSpec("P")
@@ -65,13 +66,14 @@ class AuthContext:
Stored in request.state.auth after require_auth decoration.
Attributes:
user: The authenticated user, or None if anonymous
user: The authenticated principal (human ``User`` or
``ServicePrincipal`` for API-key requests), or None if anonymous
permissions: List of permission strings (e.g., "threads:read")
"""
__slots__ = ("user", "permissions")
def __init__(self, user: User | None = None, permissions: list[str] | None = None):
def __init__(self, user: User | ServicePrincipal | None = None, permissions: list[str] | None = None):
self.user = user
self.permissions = permissions or []
@@ -93,8 +95,11 @@ class AuthContext:
permission = f"{resource}:{action}"
return permission in self.permissions
def require_user(self) -> User:
"""Get user or raise 401.
def require_user(self) -> User | ServicePrincipal:
"""Get the authenticated principal or raise 401.
Returns the human ``User`` or the ``ServicePrincipal`` backing an
API key, depending on how the request authenticated.
Raises:
HTTPException 401 if not authenticated
@@ -302,3 +307,18 @@ def require_permission(
return wrapper
return decorator
def require_workspace_admin() -> None:
"""FastAPI dependency: require the caller's workspace role to be
owner or admin. Reads the role from the workspace contextvar that
AuthMiddleware stamps per request.
Raises HTTPException 403 if no workspace is in context or the role is
below admin. Use on management endpoints (service accounts, API keys).
"""
from deerflow.runtime.workspace_context import get_current_workspace
workspace = get_current_workspace()
if workspace is None or getattr(workspace, "role", None) not in ("owner", "admin"):
raise HTTPException(status_code=403, detail="workspace owner/admin role required")
+14 -1
View File
@@ -29,15 +29,28 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
def has_bearer_header(request: Request) -> bool:
"""True if the request carries an ``Authorization: Bearer ...`` header.
Bearer requests authenticate via header, not cookie, so they are not
vulnerable to CSRF (the browser never auto-attaches a bearer header).
"""
return request.headers.get("authorization", "").startswith("Bearer ")
def should_check_csrf(request: Request) -> bool:
"""Determine if a request needs CSRF validation.
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231.
GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. Bearer-header
(API key / token) requests are exempt — they don't ride on cookies.
"""
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False
if has_bearer_header(request):
return False
path = request.url.path.rstrip("/")
# Exempt /api/v1/auth/me endpoint
if path == "/api/v1/auth/me":
@@ -0,0 +1,31 @@
"""Marks responses to legacy unversioned /api/* paths as deprecated.
Stamps ``X-API-Deprecated: <sunset-date>`` on any /api/* response that is
neither versioned (/api/v1/*) nor the LangGraph SDK surface
(/api/langgraph/*). Sunset date is the track-2 contract (2027-01-01).
"""
from __future__ import annotations
from collections.abc import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
API_SUNSET_DATE = "2027-01-01"
def _is_deprecated_path(path: str) -> bool:
return path.startswith("/api/") and not path.startswith("/api/v1/") and not path.startswith("/api/langgraph/")
class ApiDeprecationMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp) -> None:
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
if _is_deprecated_path(request.url.path):
response.headers["X-API-Deprecated"] = API_SUNSET_DATE
return response
+1 -1
View File
@@ -14,7 +14,7 @@ from deerflow.config.paths import get_paths
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["agents"])
router = APIRouter(tags=["agents"])
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
+111
View File
@@ -0,0 +1,111 @@
"""API key management endpoints (Stage 1 PR4).
Owner/admin mint / list / revoke API keys for a service account in the
caller's workspace. The plaintext token is returned exactly once, at
create time; list responses never include plaintext or the hash. The
target service account must belong to the caller's workspace, else 404.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_workspace_admin
from deerflow.auth.tokens import generate_api_key
from deerflow.persistence.api_key import ApiKeyRepository
from deerflow.persistence.service_account import ServiceAccountRepository
from deerflow.runtime.workspace_context import get_current_workspace
router = APIRouter(prefix="/api/v1/api-keys", tags=["api-keys"])
class CreateApiKeyRequest(BaseModel):
service_account_id: str
name: str = Field(..., min_length=1, max_length=64)
scopes: str = Field(default="")
env: Literal["live", "test"] = "live"
expires_at: datetime | None = None
def get_api_key_repo() -> ApiKeyRepository:
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise HTTPException(status_code=503, detail="persistence backend not available")
return ApiKeyRepository(sf)
def get_service_account_repo() -> ServiceAccountRepository:
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise HTTPException(status_code=503, detail="persistence backend not available")
return ServiceAccountRepository(sf)
def _current_workspace_id() -> str:
ws = get_current_workspace()
if ws is None:
raise HTTPException(status_code=403, detail="no workspace in context")
return str(ws.id)
async def _require_sa_in_workspace(sa_id: str, sa_repo: ServiceAccountRepository) -> dict:
sa = await sa_repo.get(sa_id)
if sa is None or sa["workspace_id"] != _current_workspace_id():
raise HTTPException(status_code=404, detail="service account not found")
return sa
@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)])
async def create_api_key(
body: CreateApiKeyRequest,
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
):
sa = await _require_sa_in_workspace(body.service_account_id, sa_repo)
if sa["status"] != "active":
raise HTTPException(status_code=409, detail="service account is not active")
gen = generate_api_key(body.env)
created = await key_repo.create(
service_account_id=body.service_account_id,
key_prefix=gen.prefix,
key_hash=gen.key_hash,
name=body.name,
scopes=body.scopes,
expires_at=body.expires_at,
)
# plaintext returned exactly once; never persisted, never re-served.
return {**created, "plaintext": gen.plaintext}
@router.get("", dependencies=[Depends(require_workspace_admin)])
async def list_api_keys(
service_account_id: str,
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
):
await _require_sa_in_workspace(service_account_id, sa_repo)
return await key_repo.list_by_service_account(service_account_id)
@router.delete("/{key_id}", status_code=204, dependencies=[Depends(require_workspace_admin)])
async def revoke_api_key(
key_id: str,
key_repo: ApiKeyRepository = Depends(get_api_key_repo),
sa_repo: ServiceAccountRepository = Depends(get_service_account_repo),
):
key = await key_repo.get(key_id)
if key is None:
raise HTTPException(status_code=404, detail="api key not found")
sa = await sa_repo.get(key["service_account_id"])
if sa is None or sa["workspace_id"] != _current_workspace_id():
raise HTTPException(status_code=404, detail="api key not found")
await key_repo.revoke(key_id)
return Response(status_code=204)
+1 -1
View File
@@ -12,7 +12,7 @@ from app.gateway.path_utils import resolve_thread_virtual_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["artifacts"])
router = APIRouter(tags=["artifacts"])
ACTIVE_CONTENT_MIME_TYPES = {
"text/html",
+1 -1
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/channels", tags=["channels"])
router = APIRouter(prefix="/channels", tags=["channels"])
class ChannelStatusResponse(BaseModel):
+1 -1
View File
@@ -16,7 +16,7 @@ from app.gateway.authz import require_permission
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["feedback"])
router = APIRouter(prefix="/threads", tags=["feedback"])
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["mcp"])
router = APIRouter(tags=["mcp"])
class McpOAuthConfigResponse(BaseModel):
+1 -1
View File
@@ -15,7 +15,7 @@ from deerflow.agents.memory.updater import (
from deerflow.config.memory_config import get_memory_config
from deerflow.runtime.user_context import get_effective_user_id
router = APIRouter(prefix="/api", tags=["memory"])
router = APIRouter(tags=["memory"])
class ContextSection(BaseModel):
+1 -1
View File
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
router = APIRouter(prefix="/api", tags=["models"])
router = APIRouter(tags=["models"])
class ModelResponse(BaseModel):
+1 -1
View File
@@ -21,7 +21,7 @@ from app.gateway.services import sse_consumer, start_run
from deerflow.runtime import serialize_channel_values
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/runs", tags=["runs"])
router = APIRouter(prefix="/runs", tags=["runs"])
def _resolve_thread_id(body: RunCreateRequest) -> str:
@@ -0,0 +1,81 @@
"""Service account management endpoints (Stage 1 PR4).
Owner/admin self-service: create / list / suspend service accounts in
the caller's current workspace. All operations are workspace-scoped;
cross-workspace targets return 404 (existence hidden).
"""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_workspace_admin
from deerflow.persistence.service_account import ServiceAccountRepository
from deerflow.runtime.workspace_context import get_current_workspace
router = APIRouter(prefix="/api/v1/service-accounts", tags=["service-accounts"])
class CreateServiceAccountRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
# Constrained to Stage-1-supported values; widen these Literals in
# future stages alongside the behavior (Stage 2 RBAC opens role
# admin/viewer; the passthrough PR opens identity_mode).
role: Literal["member"] = "member"
identity_mode: Literal["collapsed"] = "collapsed"
class UpdateServiceAccountRequest(BaseModel):
status: str = Field(..., pattern="^(active|suspended|deleted)$")
def get_service_account_repo() -> ServiceAccountRepository:
from deerflow.persistence.engine import get_session_factory
sf = get_session_factory()
if sf is None:
raise HTTPException(status_code=503, detail="persistence backend not available")
return ServiceAccountRepository(sf)
def _current_workspace_id() -> str:
ws = get_current_workspace()
if ws is None:
raise HTTPException(status_code=403, detail="no workspace in context")
return str(ws.id)
@router.post("", status_code=201, dependencies=[Depends(require_workspace_admin)])
async def create_service_account(
body: CreateServiceAccountRequest,
request: Request,
repo: ServiceAccountRepository = Depends(get_service_account_repo),
):
return await repo.create(
workspace_id=_current_workspace_id(),
name=body.name,
created_by=str(request.state.user.id),
role=body.role,
identity_mode=body.identity_mode,
)
@router.get("", dependencies=[Depends(require_workspace_admin)])
async def list_service_accounts(repo: ServiceAccountRepository = Depends(get_service_account_repo)):
return await repo.list_by_workspace(_current_workspace_id())
@router.patch("/{sa_id}", dependencies=[Depends(require_workspace_admin)])
async def update_service_account(
sa_id: str,
body: UpdateServiceAccountRequest,
repo: ServiceAccountRepository = Depends(get_service_account_repo),
):
sa = await repo.get(sa_id)
if sa is None or sa["workspace_id"] != _current_workspace_id():
raise HTTPException(status_code=404, detail="service account not found")
await repo.update_status(sa_id, body.status)
return await repo.get(sa_id)
+1 -1
View File
@@ -18,7 +18,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["skills"])
router = APIRouter(tags=["skills"])
class SkillResponse(BaseModel):
+1 -1
View File
@@ -12,7 +12,7 @@ from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["suggestions"])
router = APIRouter(tags=["suggestions"])
class SuggestionMessage(BaseModel):
+1 -1
View File
@@ -25,7 +25,7 @@ from app.gateway.services import sse_consumer, start_run
from deerflow.runtime import RunRecord, serialize_channel_values
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["runs"])
router = APIRouter(prefix="/threads", tags=["runs"])
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -29,7 +29,7 @@ from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.time import coerce_iso, now_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["threads"])
router = APIRouter(prefix="/threads", tags=["threads"])
# Metadata keys that the server controls; clients are not allowed to set
+1 -1
View File
@@ -30,7 +30,7 @@ from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads/{thread_id}/uploads", tags=["uploads"])
router = APIRouter(prefix="/threads/{thread_id}/uploads", tags=["uploads"])
UPLOAD_CHUNK_SIZE = 8192
DEFAULT_MAX_FILES = 10
@@ -0,0 +1,13 @@
"""Auth primitives shared by the headless API (Stage 1).
Lives in the ``deerflow`` (harness) layer because both the persistence
hot path (``ApiKeyRepository.get_active_by_hash``) and the app-layer
mint endpoint need token generation/hashing, and the harness boundary
forbids ``deerflow`` importing ``app``.
"""
from __future__ import annotations
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
__all__ = ["GeneratedKey", "generate_api_key", "hash_api_key", "split_prefix"]
@@ -0,0 +1,51 @@
"""API key generation, hashing, and prefix extraction (Stage 1 PR1).
Format is irreversible once business systems integrate (spec D5):
``dfk_live_<24>`` / ``dfk_test_<24>``. The public ``key_prefix`` is the
leading slice of the plaintext (``dfk_live_`` plus a few random chars,
length ``_PREFIX_LEN``) and is stored UNIQUE for audit logging; the DB
only ever stores ``sha256(plaintext)`` hex, never the plaintext.
"""
from __future__ import annotations
import hashlib
import secrets
from dataclasses import dataclass
from typing import Literal
_PREFIX_LEN = 16
# token_urlsafe(18) yields ceil(18 * 4 / 3) = 24 url-safe chars.
_RANDOM_BYTES = 18
@dataclass(frozen=True)
class GeneratedKey:
"""A freshly minted key. ``plaintext`` is returned to the caller
exactly once; only ``prefix`` + ``key_hash`` are persisted."""
plaintext: str
prefix: str
key_hash: str
def hash_api_key(plaintext: str) -> str:
"""Return the sha-256 hex digest of a plaintext token."""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def split_prefix(plaintext: str) -> str:
"""Return the public, loggable prefix (first 16 chars) of a token."""
return plaintext[:_PREFIX_LEN]
def generate_api_key(env: Literal["live", "test"]) -> GeneratedKey:
"""Generate a new API key for the given environment.
Raises ``ValueError`` for any env other than ``"live"`` / ``"test"``.
"""
if env not in ("live", "test"):
raise ValueError(f"env must be 'live' or 'test', got {env!r}")
random_part = secrets.token_urlsafe(_RANDOM_BYTES)
plaintext = f"dfk_{env}_{random_part}"
return GeneratedKey(plaintext=plaintext, prefix=split_prefix(plaintext), key_hash=hash_api_key(plaintext))
@@ -14,5 +14,6 @@ middleware live in Stage 1 alongside the headless API surface.
from __future__ import annotations
from deerflow.persistence.api_key.model import ApiKeyRow
from deerflow.persistence.api_key.sql import ApiKeyRepository
__all__ = ["ApiKeyRow"]
__all__ = ["ApiKeyRepository", "ApiKeyRow"]
@@ -0,0 +1,116 @@
"""SQLAlchemy-backed API key repository (Stage 1 PR1).
``get_active_by_hash`` is the auth hot path: it looks the key up by its
public ``key_prefix`` — UNIQUE and covered by the partial index
``idx_api_keys_active`` (WHERE revoked_at IS NULL) — then verifies the
full ``key_hash`` with a constant-time compare. Expiry is filtered in
Python so the behaviour is identical across sqlite/postgres drivers.
``_row_to_dict`` deliberately omits ``key_hash`` — no dict this
repository returns ever carries the secret material.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.api_key.model import ApiKeyRow
class ApiKeyRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ApiKeyRow) -> dict[str, Any]:
return {
"id": row.id,
"service_account_id": row.service_account_id,
"key_prefix": row.key_prefix,
"name": row.name,
"scopes": row.scopes,
"rate_limit_rpm": row.rate_limit_rpm,
"expires_at": row.expires_at.isoformat() if row.expires_at else None,
"last_used_at": row.last_used_at.isoformat() if row.last_used_at else None,
"revoked_at": row.revoked_at.isoformat() if row.revoked_at else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
async def create(
self,
*,
service_account_id: str,
key_prefix: str,
key_hash: str,
name: str,
scopes: str,
expires_at: datetime | None = None,
) -> dict[str, Any]:
row = ApiKeyRow(
id=str(uuid.uuid4()),
service_account_id=service_account_id,
key_prefix=key_prefix,
key_hash=key_hash,
name=name,
scopes=scopes,
expires_at=expires_at,
created_at=datetime.now(UTC),
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, key_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ApiKeyRow, key_id)
return self._row_to_dict(row) if row else None
async def get_active_by_hash(self, key_hash: str, *, key_prefix: str) -> dict[str, Any] | None:
"""Auth hot path: resolve an active, unexpired key.
Looks the key up by its public ``key_prefix`` — UNIQUE and covered
by the partial index ``idx_api_keys_active`` (WHERE revoked_at IS
NULL) — then verifies the full ``key_hash`` with a constant-time
compare. Returns None on miss / hash mismatch / revoked / expired.
Expiry is filtered in Python so behaviour is driver-agnostic
(sqlite returns naive datetimes; postgres returns aware).
"""
async with self._sf() as session:
result = await session.execute(select(ApiKeyRow).where(ApiKeyRow.key_prefix == key_prefix, ApiKeyRow.revoked_at.is_(None)))
row = result.scalar_one_or_none()
if row is None:
return None
if not secrets.compare_digest(row.key_hash, key_hash):
return None
expires_at = row.expires_at
if expires_at is not None:
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at <= datetime.now(UTC):
return None
return self._row_to_dict(row)
async def list_by_service_account(self, service_account_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(select(ApiKeyRow).where(ApiKeyRow.service_account_id == service_account_id).order_by(ApiKeyRow.created_at.desc()))
return [self._row_to_dict(r) for r in result.scalars()]
async def revoke(self, key_id: str) -> None:
"""Soft-revoke: set ``revoked_at`` (row is kept for audit)."""
async with self._sf() as session:
await session.execute(update(ApiKeyRow).where(ApiKeyRow.id == key_id, ApiKeyRow.revoked_at.is_(None)).values(revoked_at=datetime.now(UTC)))
await session.commit()
async def touch_last_used(self, key_id: str) -> None:
"""Best-effort: stamp ``last_used_at`` after a successful auth."""
async with self._sf() as session:
await session.execute(update(ApiKeyRow).where(ApiKeyRow.id == key_id).values(last_used_at=datetime.now(UTC)))
await session.commit()
@@ -12,5 +12,6 @@ header parsing, and quota attribution all live in Stage 1.
from __future__ import annotations
from deerflow.persistence.external_user.model import ExternalUserRow
from deerflow.persistence.external_user.sql import ExternalUserRepository
__all__ = ["ExternalUserRow"]
__all__ = ["ExternalUserRepository", "ExternalUserRow"]
@@ -0,0 +1,106 @@
"""SQLAlchemy-backed external user repository (Stage 1 PR1).
Built but NOT yet wired to any auth path — the X-External-User-Id
passthrough that calls ``upsert`` lands in a later track-2 PR. ``upsert``
is idempotent on (service_account_id, external_id).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.external_user.model import ExternalUserRow
class ExternalUserRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ExternalUserRow) -> dict[str, Any]:
return {
"id": row.id,
"workspace_id": row.workspace_id,
"service_account_id": row.service_account_id,
"external_id": row.external_id,
"display_name": row.display_name,
"metadata": dict(row.metadata_json or {}),
"created_at": row.created_at.isoformat() if row.created_at else None,
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
}
async def get(self, external_user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ExternalUserRow, external_user_id)
return self._row_to_dict(row) if row else None
async def get_by_external_id(self, *, service_account_id: str, external_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(ExternalUserRow).where(
ExternalUserRow.service_account_id == service_account_id,
ExternalUserRow.external_id == external_id,
)
)
row = result.scalar_one_or_none()
return self._row_to_dict(row) if row else None
async def list_by_workspace(self, workspace_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(select(ExternalUserRow).where(ExternalUserRow.workspace_id == workspace_id).order_by(ExternalUserRow.created_at.desc()))
return [self._row_to_dict(r) for r in result.scalars()]
async def upsert(
self,
*,
workspace_id: str,
service_account_id: str,
external_id: str,
display_name: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Insert a new external user or refresh ``last_seen_at`` on an
existing (service_account_id, external_id) row.
``display_name`` and ``metadata`` are only written when explicitly
passed (non-None); ``None`` means "leave unchanged" — you cannot
clear ``display_name`` back to None via this method. ``workspace_id``
is only used on insert; it is ignored on update."""
now = datetime.now(UTC)
async with self._sf() as session:
result = await session.execute(
select(ExternalUserRow).where(
ExternalUserRow.service_account_id == service_account_id,
ExternalUserRow.external_id == external_id,
)
)
row = result.scalar_one_or_none()
if row is None:
row = ExternalUserRow(
id=str(uuid.uuid4()),
workspace_id=workspace_id,
service_account_id=service_account_id,
external_id=external_id,
display_name=display_name,
metadata_json=metadata or {},
created_at=now,
last_seen_at=now,
)
# NOTE: concurrent inserts of the same pair will raise IntegrityError
# from uq_external_users_sa_external — the future auth caller should
# catch it and re-read rather than treat it as fatal.
session.add(row)
else:
row.last_seen_at = now
if display_name is not None:
row.display_name = display_name
if metadata is not None:
row.metadata_json = metadata
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
@@ -13,5 +13,6 @@ upgrade live in Stage 1 alongside the headless API surface.
from __future__ import annotations
from deerflow.persistence.service_account.model import ServiceAccountRow
from deerflow.persistence.service_account.sql import ServiceAccountRepository, ServiceAccountValidationError
__all__ = ["ServiceAccountRow"]
__all__ = ["ServiceAccountRepository", "ServiceAccountRow", "ServiceAccountValidationError"]
@@ -0,0 +1,98 @@
"""SQLAlchemy-backed service account repository (Stage 1 PR1).
Mirrors :class:`WorkspaceRepository`: fresh session per method,
``_row_to_dict`` static helper. Workspace scoping is enforced by the
caller (route layer reads the workspace contextvar); the repository
takes ``workspace_id`` explicitly.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.service_account.model import ServiceAccountRow
_VALID_STATUSES = frozenset({"active", "suspended", "deleted"})
class ServiceAccountValidationError(ValueError):
"""Raised when service account input fails application-layer validation."""
class ServiceAccountRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ServiceAccountRow) -> dict[str, Any]:
return {
"id": row.id,
"workspace_id": row.workspace_id,
"name": row.name,
"role": row.role,
"identity_mode": row.identity_mode,
"status": row.status,
"created_by": row.created_by,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
async def create(
self,
*,
workspace_id: str,
name: str,
created_by: str,
role: str = "member",
identity_mode: str = "collapsed",
status: str = "active",
) -> dict[str, Any]:
if status not in _VALID_STATUSES:
raise ServiceAccountValidationError(f"status {status!r} not in {_VALID_STATUSES!r}")
now = datetime.now(UTC)
row = ServiceAccountRow(
id=str(uuid.uuid4()),
workspace_id=workspace_id,
name=name,
role=role,
identity_mode=identity_mode,
status=status,
created_by=created_by,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, sa_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ServiceAccountRow, sa_id)
return self._row_to_dict(row) if row else None
async def get_active(self, sa_id: str) -> dict[str, Any] | None:
"""Return the row only when ``status == 'active'`` (auth hot path)."""
async with self._sf() as session:
row = await session.get(ServiceAccountRow, sa_id)
if row is None or row.status != "active":
return None
return self._row_to_dict(row)
async def list_by_workspace(self, workspace_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(select(ServiceAccountRow).where(ServiceAccountRow.workspace_id == workspace_id).order_by(ServiceAccountRow.created_at.desc()))
return [self._row_to_dict(r) for r in result.scalars()]
async def update_status(self, sa_id: str, status: str) -> None:
if status not in _VALID_STATUSES:
raise ServiceAccountValidationError(f"status {status!r} not in {_VALID_STATUSES!r}")
async with self._sf() as session:
await session.execute(update(ServiceAccountRow).where(ServiceAccountRow.id == sa_id).values(status=status, updated_at=datetime.now(UTC)))
await session.commit()
@@ -42,8 +42,15 @@ from typing import Final, Protocol, runtime_checkable
class CurrentUser(Protocol):
"""Structural type for the current authenticated user.
Any object with an ``.id: str`` attribute satisfies this protocol.
Concrete implementations live in ``app.gateway.auth.models.User``.
Requires only ``.id: str`` — the persistence layer reads nothing else,
and keeping the contract minimal lets any ``.id``-bearing object (incl.
test fixtures) satisfy it. A principal MAY additionally carry
``.is_service_account: bool`` to distinguish a headless service account
(API key) from a human; concrete carriers are
``app.gateway.auth.models.User`` (False) and
``app.gateway.auth.api_key_backend.ServicePrincipal`` (True). Since that
attribute is NOT part of this structural contract, app-layer readers
must access it defensively: ``getattr(user, "is_service_account", False)``.
"""
id: str
@@ -0,0 +1,59 @@
"""Deprecation header tests (Stage 1 PR5)."""
from __future__ import annotations
from starlette.testclient import TestClient
def _make_app():
from fastapi import FastAPI
from app.gateway.deprecation_middleware import ApiDeprecationMiddleware
app = FastAPI()
app.add_middleware(ApiDeprecationMiddleware)
@app.get("/api/threads")
async def legacy():
return {"ok": True}
@app.get("/api/v1/threads")
async def versioned():
return {"ok": True}
@app.get("/api/langgraph/info")
async def lg():
return {"ok": True}
@app.get("/api/assistants/info")
async def assistants():
return {"ok": True}
return app
def test_legacy_path_gets_deprecation_header():
client = TestClient(_make_app())
r = client.get("/api/threads")
assert r.headers.get("X-API-Deprecated") == "2027-01-01"
def test_versioned_path_no_header():
client = TestClient(_make_app())
r = client.get("/api/v1/threads")
assert "X-API-Deprecated" not in r.headers
def test_langgraph_path_no_header():
client = TestClient(_make_app())
r = client.get("/api/langgraph/info")
assert "X-API-Deprecated" not in r.headers
def test_assistants_compat_path_gets_deprecation_header():
# assistants_compat is an un-versioned LangGraph-platform stub; it
# intentionally carries the deprecation header (it is /api/, not
# /api/v1 or /api/langgraph). Documented here to prevent confusion.
client = TestClient(_make_app())
r = client.get("/api/assistants/info")
assert r.headers.get("X-API-Deprecated") == "2027-01-01"
+137
View File
@@ -0,0 +1,137 @@
"""Tests for the API key auth backend (Stage 1 PR2)."""
from __future__ import annotations
import dataclasses
import pytest
from app.gateway.auth.api_key_backend import ServicePrincipal, parse_scopes
from deerflow.auth.tokens import generate_api_key
pytestmark = pytest.mark.anyio
def test_parse_scopes_splits_and_strips():
assert parse_scopes("threads:read, threads:write") == ["threads:read", "threads:write"]
def test_parse_scopes_empty_string_is_empty_list():
assert parse_scopes("") == []
assert parse_scopes(" ") == []
def test_parse_scopes_drops_empty_segments():
assert parse_scopes("threads:read,,runs:create,") == ["threads:read", "runs:create"]
def test_service_principal_is_service_account_true_by_default():
p = ServicePrincipal(id="sa-1")
assert p.id == "sa-1"
assert p.is_service_account is True
def test_service_principal_is_frozen():
p = ServicePrincipal(id="sa-1")
with pytest.raises(dataclasses.FrozenInstanceError):
p.id = "other" # type: ignore[misc]
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _setup_backend(tmp_path, *, sa_status="active", ws_status="active", scopes="threads:read", expires_at=None, revoke=False):
from app.gateway.auth.api_key_backend import APIKeyAuthBackend
from deerflow.persistence.api_key import ApiKeyRepository
from deerflow.persistence.engine import get_session_factory, init_engine
from deerflow.persistence.service_account import ServiceAccountRepository
from deerflow.persistence.service_account.model import ServiceAccountRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace import WorkspaceRepository
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", status=ws_status))
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=sa_status, created_by="u-alice"))
await session.commit()
api_key_repo = ApiKeyRepository(sf)
gen = generate_api_key("live")
created = await api_key_repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes, expires_at=expires_at)
if revoke:
await api_key_repo.revoke(created["id"])
backend = APIKeyAuthBackend(api_key_repo=api_key_repo, service_account_repo=ServiceAccountRepository(sf), workspace_repo=WorkspaceRepository(sf))
return backend, gen
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
async def test_authenticate_valid_key(tmp_path):
backend, gen = await _setup_backend(tmp_path, scopes="threads:read,threads:write")
try:
result = await backend.authenticate(gen.plaintext)
assert result is not None
assert result.principal.id == "sa-1"
assert result.principal.is_service_account is True
assert result.workspace_id == "w-1"
assert result.role == "member"
assert result.permissions == ["threads:read", "threads:write"]
finally:
await _cleanup()
async def test_authenticate_unknown_token_returns_none(tmp_path):
backend, _ = await _setup_backend(tmp_path)
try:
assert await backend.authenticate("dfk_live_doesnotexist000000000000") is None
finally:
await _cleanup()
async def test_authenticate_revoked_key_returns_none(tmp_path):
backend, gen = await _setup_backend(tmp_path, revoke=True)
try:
assert await backend.authenticate(gen.plaintext) is None
finally:
await _cleanup()
async def test_authenticate_suspended_sa_returns_none(tmp_path):
backend, gen = await _setup_backend(tmp_path, sa_status="suspended")
try:
assert await backend.authenticate(gen.plaintext) is None
finally:
await _cleanup()
async def test_authenticate_suspended_workspace_returns_none(tmp_path):
backend, gen = await _setup_backend(tmp_path, ws_status="suspended")
try:
assert await backend.authenticate(gen.plaintext) is None
finally:
await _cleanup()
async def test_authenticate_expired_key_returns_none(tmp_path):
from datetime import UTC, datetime
backend, gen = await _setup_backend(tmp_path, expires_at=datetime(2000, 1, 1, tzinfo=UTC))
try:
assert await backend.authenticate(gen.plaintext) is None
finally:
await _cleanup()
+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()
+168
View File
@@ -0,0 +1,168 @@
"""Tests for ApiKeyRepository (Stage 1 PR1).
get_active_by_hash is the auth hot path: must return None for revoked
and expired keys. Expiry is filtered in Python (driver-agnostic) while
revoked_at IS NULL rides the partial index idx_api_keys_active.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from deerflow.auth.tokens import generate_api_key
from deerflow.persistence.api_key import ApiKeyRepository
from deerflow.persistence.service_account.model import ServiceAccountRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _make_repo(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return ApiKeyRepository(get_session_factory())
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
async def _seed_sa(repo, *, sa_id="sa-1") -> None:
async with repo._sf() as session:
session.add(UserRow(id="u-alice", email="alice@example.com"))
await session.commit()
async with repo._sf() as session:
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
await session.commit()
async with repo._sf() as session:
session.add(ServiceAccountRow(id=sa_id, workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
await session.commit()
async def _mint(repo, *, expires_at=None, scopes="threads:read"):
gen = generate_api_key("live")
created = await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes, expires_at=expires_at)
return gen, created
async def test_create_then_get_active_by_hash(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
gen, created = await _mint(repo)
assert created["key_prefix"] == gen.prefix
assert "key_hash" not in created # never expose the hash in dicts
found = await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix)
assert found is not None
assert found["id"] == created["id"]
assert found["scopes"] == "threads:read"
finally:
await _cleanup()
async def test_get_active_by_hash_miss_returns_none(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
assert await repo.get_active_by_hash("deadbeef", key_prefix="dfk_live_nomatch0") is None
finally:
await _cleanup()
async def test_wrong_hash_for_valid_prefix_returns_none(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
gen, _ = await _mint(repo)
assert await repo.get_active_by_hash("0" * 64, key_prefix=gen.prefix) is None
finally:
await _cleanup()
async def test_revoked_key_not_active(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
gen, created = await _mint(repo)
await repo.revoke(created["id"])
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is None
finally:
await _cleanup()
async def test_expired_key_not_active(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
past = datetime.now(UTC) - timedelta(hours=1)
gen, _ = await _mint(repo, expires_at=past)
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is None
finally:
await _cleanup()
async def test_future_expiry_still_active(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
future = datetime.now(UTC) + timedelta(hours=1)
gen, _ = await _mint(repo, expires_at=future)
assert await repo.get_active_by_hash(gen.key_hash, key_prefix=gen.prefix) is not None
finally:
await _cleanup()
async def test_touch_last_used_sets_timestamp(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
gen, created = await _mint(repo)
assert created["last_used_at"] is None
await repo.touch_last_used(created["id"])
refetched = await repo.get(created["id"])
assert refetched["last_used_at"] is not None
finally:
await _cleanup()
async def test_list_by_service_account(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
await _mint(repo)
await _mint(repo)
rows = await repo.list_by_service_account("sa-1")
assert len(rows) == 2
assert all("key_hash" not in r for r in rows)
finally:
await _cleanup()
async def test_list_by_service_account_excludes_other_sa(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
async with repo._sf() as session:
from deerflow.persistence.service_account.model import ServiceAccountRow
session.add(ServiceAccountRow(id="sa-2", workspace_id="w-1", name="bot2", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
await session.commit()
await _mint(repo) # belongs to sa-1
g2 = generate_api_key("live")
await repo.create(service_account_id="sa-2", key_prefix=g2.prefix, key_hash=g2.key_hash, name="k2", scopes="")
rows = await repo.list_by_service_account("sa-1")
assert len(rows) == 1
finally:
await _cleanup()
+175
View File
@@ -0,0 +1,175 @@
"""api-keys router tests (Stage 1 PR4).
plaintext is returned exactly once at create time; never on list.
"""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _init_db_with_sa(tmp_path, *, sa_id="sa-1", workspace_id="w-1"):
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=workspace_id, name="WS", slug=f"ws-{workspace_id}", owner_id="u-alice"))
await session.commit()
async with sf() as session:
session.add(ServiceAccountRow(id=sa_id, workspace_id=workspace_id, name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
await session.commit()
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
def _make_app(*, role="owner", workspace_id="w-1"):
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.routers import api_keys
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
class _Stamp(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
user = type("U", (), {"id": "u-alice", "is_service_account": False})()
ws = type("W", (), {"id": workspace_id, "role": role})()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
ut = set_current_user(user)
wt = set_current_workspace(ws)
try:
return await call_next(request)
finally:
reset_current_workspace(wt)
reset_current_user(ut)
app = FastAPI()
app.add_middleware(_Stamp)
app.include_router(api_keys.router)
return app
async def test_create_returns_plaintext_once(tmp_path):
await _init_db_with_sa(tmp_path)
try:
client = TestClient(_make_app())
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "ci", "scopes": "threads:read"})
assert r.status_code == 201, r.text
body = r.json()
assert body["plaintext"].startswith("dfk_live_")
assert body["key_prefix"] == body["plaintext"][:16]
lst = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"})
assert lst.status_code == 200
rows = lst.json()
assert len(rows) == 1
assert "plaintext" not in rows[0]
assert "key_hash" not in rows[0]
finally:
await _cleanup()
async def test_create_for_other_workspace_sa_404(tmp_path):
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
try:
# Caller is in w-2 but targets sa-1 which lives in w-1 → 404.
client = TestClient(_make_app(workspace_id="w-2"))
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
assert r.status_code == 404
finally:
await _cleanup()
async def test_revoke_key(tmp_path):
await _init_db_with_sa(tmp_path)
try:
client = TestClient(_make_app())
created = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
r = client.delete(f"/api/v1/api-keys/{created['id']}")
assert r.status_code == 204
rows = client.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}).json()
assert rows[0]["revoked_at"] is not None
finally:
await _cleanup()
async def test_member_cannot_create_key(tmp_path):
await _init_db_with_sa(tmp_path)
try:
client = TestClient(_make_app(role="member"))
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
assert r.status_code == 403
finally:
await _cleanup()
async def test_revoke_other_workspace_key_404(tmp_path):
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
try:
client_a = TestClient(_make_app(workspace_id="w-1"))
created = client_a.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
client_b = TestClient(_make_app(workspace_id="w-2"))
assert client_b.delete(f"/api/v1/api-keys/{created['id']}").status_code == 404
finally:
await _cleanup()
async def test_list_other_workspace_sa_404(tmp_path):
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
try:
client_b = TestClient(_make_app(workspace_id="w-2"))
assert client_b.get("/api/v1/api-keys", params={"service_account_id": "sa-1"}).status_code == 404
finally:
await _cleanup()
async def test_create_for_suspended_sa_409(tmp_path):
await _init_db_with_sa(tmp_path)
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.service_account import ServiceAccountRepository
await ServiceAccountRepository(get_session_factory()).update_status("sa-1", "suspended")
client = TestClient(_make_app())
r = client.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "x", "scopes": ""})
assert r.status_code == 409
finally:
await _cleanup()
async def test_revoke_404_bodies_are_indistinguishable(tmp_path):
await _init_db_with_sa(tmp_path, sa_id="sa-1", workspace_id="w-1")
try:
client_a = TestClient(_make_app(workspace_id="w-1"))
created = client_a.post("/api/v1/api-keys", json={"service_account_id": "sa-1", "name": "k", "scopes": ""}).json()
client_b = TestClient(_make_app(workspace_id="w-2"))
# cross-workspace existing key, and a non-existent key, must return identical 404 bodies
cross = client_b.delete(f"/api/v1/api-keys/{created['id']}")
missing = client_b.delete("/api/v1/api-keys/does-not-exist")
assert cross.status_code == 404
assert missing.status_code == 404
assert cross.json() == missing.json()
finally:
await _cleanup()
+56
View File
@@ -0,0 +1,56 @@
"""Dual-mount /api + /api/v1 tests (Stage 1 PR5).
Every migrated legacy router must answer on BOTH /api/<x> and /api/v1/<x>.
Asserts route presence on the OpenAPI schema (independent of per-route auth).
"""
from __future__ import annotations
from app.gateway.app import create_app
def _paths():
return set(create_app().openapi()["paths"].keys())
def test_models_dual_mounted():
paths = _paths()
assert "/api/models" in paths
assert "/api/v1/models" in paths
def test_runs_dual_mounted():
paths = _paths()
assert "/api/runs/stream" in paths
assert "/api/v1/runs/stream" in paths
def test_every_legacy_api_path_has_v1_twin():
"""Strong invariant: every unversioned /api/* path (except the
intentionally-excluded surfaces) must also exist under /api/v1/*.
Catches any single legacy router losing its v1 mount."""
paths = _paths()
excluded_prefixes = ("/api/v1/", "/api/langgraph/", "/api/assistants")
legacy = {p for p in paths if p.startswith("/api/") and not p.startswith(excluded_prefixes)}
assert legacy, "expected some unversioned /api/* paths"
missing = sorted(p for p in legacy if ("/api/v1/" + p[len("/api/") :]) not in paths)
assert missing == [], f"legacy /api paths without an /api/v1 twin: {missing}"
def test_uploads_dual_mounted():
paths = _paths()
assert any(p.startswith("/api/threads/") and "/uploads" in p for p in paths)
assert any(p.startswith("/api/v1/threads/") and "/uploads" in p for p in paths)
def test_auth_only_v1_not_dual():
# auth stays v1-only — must NOT acquire an /api/auth twin.
paths = _paths()
assert "/api/v1/auth/me" in paths
assert "/api/auth/me" not in paths
def test_no_v1_langgraph_twins():
paths = _paths()
assert not any(p.startswith("/api/v1/langgraph") for p in paths)
assert not any(p.startswith("/api/v1/assistants") for p in paths)
+2 -2
View File
@@ -76,7 +76,7 @@ def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeyp
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
app.include_router(artifacts_router.router, prefix="/api")
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt?download=false")
@@ -94,7 +94,7 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: skill_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
app.include_router(artifacts_router.router, prefix="/api")
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/notes.txt?download=true")
@@ -0,0 +1,134 @@
"""AuthMiddleware bearer-path integration tests (Stage 1 PR2).
Drives the real middleware via a minimal app with a probe route that
echoes the resolved contextvars, proving user_id=SA.id / workspace_id
are stamped identically to a human request.
Note: ``from __future__ import annotations`` is intentionally absent here.
The probe route's ``request: Request`` annotation must resolve at class-definition
time (inside ``_make_app``) so FastAPI recognises it as the special ASGI
injection type, not a query parameter. With the futures import active the
annotation becomes the string ``"Request"`` and ``get_type_hints`` cannot
resolve it from the module's global namespace (the import lives in a local
scope inside ``_make_app``), causing FastAPI to emit a 422.
"""
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", revoke=False):
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")
created = await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes)
if revoke:
await repo.revoke(created["id"])
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
from deerflow.runtime.workspace_context import get_effective_workspace_id
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/v1/threads/_probe")
async def probe(request: Request):
return {
"user_id": get_effective_user_id(),
"workspace_id": get_effective_workspace_id(),
"is_sa": getattr(request.state.user, "is_service_account", None),
}
return app
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/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:
await _cleanup()
async def test_invalid_bearer_returns_401(tmp_path):
await _seed_key(tmp_path)
try:
client = TestClient(_make_app())
r = client.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"})
assert r.status_code == 401
finally:
await _cleanup()
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/v1/threads/_probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
assert r.status_code == 401
finally:
await _cleanup()
async def test_non_dfk_bearer_falls_through_to_cookie_path(tmp_path):
await _seed_key(tmp_path)
try:
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/v1/threads/_probe", headers={"Authorization": "Bearer some.jwt.token"})
assert r.status_code == 401
assert r.json()["detail"]["code"] == "not_authenticated"
finally:
await _cleanup()
async def test_bare_prefix_bearer_returns_401(tmp_path):
await _seed_key(tmp_path)
try:
client = TestClient(_make_app())
r = client.get("/api/v1/threads/_probe", headers={"Authorization": "Bearer dfk_"})
assert r.status_code == 401
finally:
await _cleanup()
+49
View File
@@ -0,0 +1,49 @@
"""CSRF bearer-skip tests (Stage 1 PR3)."""
from __future__ import annotations
from starlette.testclient import TestClient
def _make_app():
from fastapi import FastAPI
from app.gateway.csrf_middleware import CSRFMiddleware
app = FastAPI()
app.add_middleware(CSRFMiddleware)
@app.post("/api/echo")
async def echo():
return {"ok": True}
return app
def test_bearer_post_skips_csrf():
client = TestClient(_make_app())
# No X-CSRF-Token / csrf cookie, but bearer header present → allowed.
r = client.post("/api/echo", headers={"Authorization": "Bearer dfk_live_anything"})
assert r.status_code == 200
def test_cookie_post_still_requires_csrf():
client = TestClient(_make_app())
# No bearer, no CSRF token → 403 (regression: cookie path unchanged).
r = client.post("/api/echo")
assert r.status_code == 403
assert "CSRF token missing" in r.json()["detail"]
def test_has_bearer_header_detection():
from starlette.requests import Request
from app.gateway.csrf_middleware import has_bearer_header
def _req(headers):
scope = {"type": "http", "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()]}
return Request(scope)
assert has_bearer_header(_req({"authorization": "Bearer x"})) is True
assert has_bearer_header(_req({"authorization": "Basic x"})) is False
assert has_bearer_header(_req({})) is False
+1 -1
View File
@@ -382,7 +382,7 @@ def _make_test_app(tmp_path: Path):
from app.gateway.routers.agents import router
app = FastAPI()
app.include_router(router)
app.include_router(router, prefix="/api")
return app
+90
View File
@@ -0,0 +1,90 @@
"""Tests for ExternalUserRepository (Stage 1 PR1).
Repository is built but not yet wired to any auth path. upsert is
idempotent on (service_account_id, external_id) per the table's
UniqueConstraint uq_external_users_sa_external.
"""
from __future__ import annotations
import pytest
from deerflow.persistence.external_user import ExternalUserRepository
from deerflow.persistence.service_account.model import ServiceAccountRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _make_repo(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return ExternalUserRepository(get_session_factory())
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
async def _seed_sa(repo) -> None:
async with repo._sf() as session:
session.add(UserRow(id="u-alice", email="alice@example.com"))
await session.commit()
async with repo._sf() as session:
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
await session.commit()
async with repo._sf() as session:
session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="external_passthrough", status="active", created_by="u-alice"))
await session.commit()
async def test_upsert_inserts_then_updates_same_row(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
first = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-42", display_name="alice")
assert first["external_id"] == "ext-42"
assert first["last_seen_at"] is not None
second = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-42")
# same logical row (no duplicate)
assert second["id"] == first["id"]
rows = await repo.list_by_workspace("w-1")
assert len(rows) == 1
finally:
await _cleanup()
async def test_get_by_id_hit_and_miss(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
created = await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-9")
fetched = await repo.get(created["id"])
assert fetched is not None
assert fetched["id"] == created["id"]
assert await repo.get("nonexistent") is None
finally:
await _cleanup()
async def test_get_by_external_id(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_sa(repo)
await repo.upsert(workspace_id="w-1", service_account_id="sa-1", external_id="ext-7")
found = await repo.get_by_external_id(service_account_id="sa-1", external_id="ext-7")
assert found is not None
assert found["external_id"] == "ext-7"
assert await repo.get_by_external_id(service_account_id="sa-1", external_id="nope") is None
finally:
await _cleanup()
+119
View File
@@ -0,0 +1,119 @@
"""End-to-end headless API smoke (Stage 1 PR4).
Mints a real key through the management endpoints, then calls a protected
probe route through the real AuthMiddleware using that key. Proves the
full chain and the cross-workspace 404 isolation guarantee.
Note: ``from __future__ import annotations`` is intentionally absent.
The probe route's ``request: Request`` annotation must resolve at
class-definition time (inside ``_probe_app``) so FastAPI recognises it
as the special ASGI injection type, not a query parameter. With the
futures import active the annotation becomes the string ``"Request"``
and ``get_type_hints`` cannot resolve it from the module's global
namespace (the import lives in a local scope inside ``_probe_app``),
causing FastAPI to emit a 422.
"""
import pytest
from starlette.testclient import TestClient
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _init_db(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
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"))
session.add(UserRow(id="u-bob", email="bob@example.com"))
session.add(UserRow(id="u-owner", email="owner@example.com"))
await session.commit()
async with sf() as session:
session.add(WorkspaceRow(id="w-1", name="Alice WS", slug="alice", owner_id="u-alice"))
session.add(WorkspaceRow(id="w-2", name="Bob WS", slug="bob", owner_id="u-bob"))
await session.commit()
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
def _mgmt_app(*, workspace_id):
"""Management app: stamps a fixed owner principal + workspace."""
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.routers import api_keys, service_accounts
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
class _Stamp(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
user = type("U", (), {"id": "u-owner", "is_service_account": False})()
ws = type("W", (), {"id": workspace_id, "role": "owner"})()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
ut = set_current_user(user)
wt = set_current_workspace(ws)
try:
return await call_next(request)
finally:
reset_current_workspace(wt)
reset_current_user(ut)
app = FastAPI()
app.add_middleware(_Stamp)
app.include_router(service_accounts.router)
app.include_router(api_keys.router)
return app
def _probe_app():
"""Protected app behind the REAL AuthMiddleware with a probe route."""
from fastapi import FastAPI, Request
from app.gateway.auth_middleware import AuthMiddleware
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.runtime.workspace_context import get_effective_workspace_id
app = FastAPI()
app.add_middleware(AuthMiddleware)
@app.get("/api/v1/threads/_probe")
async def probe(request: Request):
return {"user_id": get_effective_user_id(), "workspace_id": get_effective_workspace_id()}
return app
async def test_mint_use_and_cross_workspace_isolation(tmp_path):
await _init_db(tmp_path)
try:
mgmt = TestClient(_mgmt_app(workspace_id="w-1"))
sa = mgmt.post("/api/v1/service-accounts", json={"name": "ci"}).json()
key = mgmt.post("/api/v1/api-keys", json={"service_account_id": sa["id"], "name": "k", "scopes": "threads:read"}).json()
plaintext = key["plaintext"]
probe = TestClient(_probe_app())
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/v1/threads/_probe", headers={"Authorization": "Bearer dfk_live_unknown0000000000000000"})
assert bad.status_code == 401
finally:
await _cleanup()
+12 -12
View File
@@ -26,7 +26,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict:
def test_export_memory_route_returns_current_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
exported_memory = _sample_memory(
facts=[
{
@@ -50,7 +50,7 @@ def test_export_memory_route_returns_current_memory() -> None:
def test_import_memory_route_returns_imported_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
imported_memory = _sample_memory(
facts=[
{
@@ -74,7 +74,7 @@ def test_import_memory_route_returns_imported_memory() -> None:
def test_export_memory_route_preserves_source_error() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
exported_memory = _sample_memory(
facts=[
{
@@ -99,7 +99,7 @@ def test_export_memory_route_preserves_source_error() -> None:
def test_import_memory_route_preserves_source_error() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
imported_memory = _sample_memory(
facts=[
{
@@ -124,7 +124,7 @@ def test_import_memory_route_preserves_source_error() -> None:
def test_clear_memory_route_returns_cleared_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
with patch("app.gateway.routers.memory.clear_memory_data", return_value=_sample_memory()):
with TestClient(app) as client:
@@ -136,7 +136,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
def test_create_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
updated_memory = _sample_memory(
facts=[
{
@@ -167,7 +167,7 @@ def test_create_memory_fact_route_returns_updated_memory() -> None:
def test_delete_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
updated_memory = _sample_memory(
facts=[
{
@@ -191,7 +191,7 @@ def test_delete_memory_fact_route_returns_updated_memory() -> None:
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
with patch("app.gateway.routers.memory.delete_memory_fact", side_effect=KeyError("fact_missing")):
with TestClient(app) as client:
@@ -203,7 +203,7 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
def test_update_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
updated_memory = _sample_memory(
facts=[
{
@@ -234,7 +234,7 @@ def test_update_memory_fact_route_returns_updated_memory() -> None:
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
updated_memory = _sample_memory(
facts=[
{
@@ -270,7 +270,7 @@ def test_update_memory_fact_route_preserves_omitted_fields() -> None:
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")):
with TestClient(app) as client:
@@ -289,7 +289,7 @@ def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
app = FastAPI()
app.include_router(memory.router)
app.include_router(memory.router, prefix="/api")
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")):
with TestClient(app) as client:
@@ -0,0 +1,41 @@
"""Tests for require_workspace_admin dependency (Stage 1 PR4)."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from app.gateway.authz import require_workspace_admin
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
class _WS:
def __init__(self, role):
self.id = "w-1"
self.role = role
@pytest.mark.parametrize("role", ["owner", "admin"])
def test_allows_owner_admin(role):
token = set_current_workspace(_WS(role))
try:
require_workspace_admin() # no raise
finally:
reset_current_workspace(token)
def test_rejects_member():
token = set_current_workspace(_WS("member"))
try:
with pytest.raises(HTTPException) as exc:
require_workspace_admin()
assert exc.value.status_code == 403
finally:
reset_current_workspace(token)
@pytest.mark.no_auto_workspace
def test_rejects_no_workspace():
with pytest.raises(HTTPException) as exc:
require_workspace_admin()
assert exc.value.status_code == 403
+1 -1
View File
@@ -17,7 +17,7 @@ from app.gateway.routers import runs
def _make_app(run_store=None, event_store=None, feedback_repo=None):
"""Build a test FastAPI app with stub auth and mocked state."""
app = make_authed_test_app()
app.include_router(runs.router)
app.include_router(runs.router, prefix="/api")
if run_store is not None:
app.state.run_store = run_store
+118
View File
@@ -0,0 +1,118 @@
"""Tests for ServiceAccountRepository (Stage 1 PR1).
SQLite ephemeral DB per test, mirroring test_workspace_repo / test_api_key_schema.
"""
from __future__ import annotations
import pytest
from deerflow.persistence.service_account import ServiceAccountRepository
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.workspace.model import WorkspaceRow
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _make_repo(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return ServiceAccountRepository(get_session_factory())
async def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
async def _seed_parents(repo, *, user_id="u-alice", workspace_id="w-1") -> None:
async with repo._sf() as session:
session.add(UserRow(id=user_id, email=f"{user_id}@example.com"))
await session.commit()
async with repo._sf() as session:
session.add(WorkspaceRow(id=workspace_id, name="WS", slug=workspace_id, owner_id=user_id))
await session.commit()
async def test_create_then_get_roundtrip(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_parents(repo)
created = await repo.create(workspace_id="w-1", name="ci-bot", created_by="u-alice")
assert created["workspace_id"] == "w-1"
assert created["name"] == "ci-bot"
assert created["role"] == "member"
assert created["identity_mode"] == "collapsed"
assert created["status"] == "active"
assert len(created["id"]) == 36
fetched = await repo.get(created["id"])
assert fetched is not None
assert fetched["id"] == created["id"]
finally:
await _cleanup()
async def test_get_returns_none_when_missing(tmp_path):
repo = await _make_repo(tmp_path)
try:
assert await repo.get("nope") is None
finally:
await _cleanup()
async def test_get_active_excludes_suspended(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_parents(repo)
sa = await repo.create(workspace_id="w-1", name="bot", created_by="u-alice")
assert await repo.get_active(sa["id"]) is not None
await repo.update_status(sa["id"], "suspended")
assert await repo.get_active(sa["id"]) is None
# get() still returns the row regardless of status
assert await repo.get(sa["id"]) is not None
finally:
await _cleanup()
async def test_list_by_workspace(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_parents(repo)
await _seed_parents(repo, user_id="u-bob", workspace_id="w-2")
await repo.create(workspace_id="w-1", name="a", created_by="u-alice")
await repo.create(workspace_id="w-1", name="b", created_by="u-alice")
await repo.create(workspace_id="w-2", name="c", created_by="u-bob")
rows = await repo.list_by_workspace("w-1")
assert {r["name"] for r in rows} == {"a", "b"}
finally:
await _cleanup()
async def test_create_rejects_unknown_status(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_parents(repo)
with pytest.raises(ValueError):
await repo.create(workspace_id="w-1", name="x", created_by="u-alice", status="bogus")
finally:
await _cleanup()
async def test_update_status_rejects_unknown_value(tmp_path):
repo = await _make_repo(tmp_path)
try:
await _seed_parents(repo)
sa = await repo.create(workspace_id="w-1", name="x", created_by="u-alice")
with pytest.raises(ValueError):
await repo.update_status(sa["id"], "not-a-status")
finally:
await _cleanup()
@@ -0,0 +1,146 @@
"""service-accounts router tests (Stage 1 PR4)."""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
async def _init_db(tmp_path):
from deerflow.persistence.engine import get_session_factory, init_engine
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 def _cleanup():
from deerflow.persistence.engine import close_engine
await close_engine()
def _make_app(*, role="owner", user_id="u-alice", workspace_id="w-1"):
"""App that stamps a fixed principal + workspace, then mounts the router.
A tiny inline middleware substitutes for AuthMiddleware so the test
controls role/user/workspace directly.
"""
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.routers import service_accounts
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
class _Stamp(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
user = type("U", (), {"id": user_id, "is_service_account": False})()
ws = type("W", (), {"id": workspace_id, "role": role})()
request.state.user = user
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
ut = set_current_user(user)
wt = set_current_workspace(ws)
try:
return await call_next(request)
finally:
reset_current_workspace(wt)
reset_current_user(ut)
app = FastAPI()
app.add_middleware(_Stamp)
app.include_router(service_accounts.router)
return app
async def test_owner_creates_and_lists_sa(tmp_path):
await _init_db(tmp_path)
try:
client = TestClient(_make_app(role="owner"))
r = client.post("/api/v1/service-accounts", json={"name": "ci-bot"})
assert r.status_code == 201, r.text
sa = r.json()
assert sa["name"] == "ci-bot"
assert sa["workspace_id"] == "w-1"
assert sa["created_by"] == "u-alice"
lst = client.get("/api/v1/service-accounts")
assert lst.status_code == 200
assert [s["id"] for s in lst.json()] == [sa["id"]]
finally:
await _cleanup()
async def test_member_cannot_create_sa(tmp_path):
await _init_db(tmp_path)
try:
client = TestClient(_make_app(role="member"))
r = client.post("/api/v1/service-accounts", json={"name": "x"})
assert r.status_code == 403
finally:
await _cleanup()
async def test_patch_status_suspend(tmp_path):
await _init_db(tmp_path)
try:
client = TestClient(_make_app(role="admin"))
sa = client.post("/api/v1/service-accounts", json={"name": "bot"}).json()
r = client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"})
assert r.status_code == 200
assert r.json()["status"] == "suspended"
finally:
await _cleanup()
async def test_patch_other_workspace_sa_404(tmp_path):
await _init_db(tmp_path)
try:
# SA created in w-1
owner_client = TestClient(_make_app(role="owner", workspace_id="w-1"))
sa = owner_client.post("/api/v1/service-accounts", json={"name": "bot"}).json()
# Caller in a different workspace tries to patch it → 404
other_client = TestClient(_make_app(role="owner", workspace_id="w-2"))
r = other_client.patch(f"/api/v1/service-accounts/{sa['id']}", json={"status": "suspended"})
assert r.status_code == 404
finally:
await _cleanup()
async def test_list_is_scoped_to_current_workspace(tmp_path):
await _init_db(tmp_path)
try:
w1 = TestClient(_make_app(role="owner", workspace_id="w-1"))
w1.post("/api/v1/service-accounts", json={"name": "bot-w1"})
# A caller in w-2 must NOT see w-1's service accounts.
w2 = TestClient(_make_app(role="owner", workspace_id="w-2"))
rows = w2.get("/api/v1/service-accounts").json()
assert rows == []
finally:
await _cleanup()
async def test_create_rejects_unknown_role(tmp_path):
await _init_db(tmp_path)
try:
client = TestClient(_make_app(role="owner"))
r = client.post("/api/v1/service-accounts", json={"name": "x", "role": "superadmin"})
assert r.status_code == 422
finally:
await _cleanup()
+1 -1
View File
@@ -39,7 +39,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
def _make_test_app(config) -> FastAPI:
app = FastAPI()
app.state.config = config
app.include_router(skills_router.router)
app.include_router(skills_router.router, prefix="/api")
return app
@@ -17,7 +17,7 @@ from app.gateway.routers import thread_runs
def _make_app(event_store=None):
"""Build a test FastAPI app with stub auth and mocked state."""
app = make_authed_test_app()
app.include_router(thread_runs.router)
app.include_router(thread_runs.router, prefix="/api")
if event_store is not None:
app.state.run_event_store = event_store
+1 -1
View File
@@ -37,7 +37,7 @@ def _build_app(workspace_id: str):
app.state.store = store
app.state.checkpointer = checkpointer
app.state.thread_store = MemoryThreadMetaStore(store)
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
return app, store
+4 -4
View File
@@ -59,7 +59,7 @@ def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
app.state.store = store
app.state.checkpointer = checkpointer
app.state.thread_store = _PermissiveThreadMetaStore(store)
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
return app, store, checkpointer
@@ -113,7 +113,7 @@ def test_delete_thread_route_cleans_thread_directory(tmp_path):
(paths.sandbox_work_dir("thread-route", user_id=user_id) / "notes.txt").write_text("hello", encoding="utf-8")
app = make_authed_test_app()
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
with TestClient(app) as client:
@@ -128,7 +128,7 @@ def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
paths = Paths(tmp_path)
app = make_authed_test_app()
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
with TestClient(app) as client:
@@ -141,7 +141,7 @@ def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path):
paths = Paths(tmp_path)
app = make_authed_test_app()
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
with patch("app.gateway.routers.threads.get_paths", return_value=paths):
with TestClient(app) as client:
+57
View File
@@ -0,0 +1,57 @@
"""Tests for deerflow.auth.tokens (Stage 1 PR1).
API key 格式 / 哈希 / prefix 截取。格式锁定 dfk_{live,test}_<24>
prefix = 前 16 字符(含 dfk_live_),sha256 hex 存储(spec D5)。
"""
from __future__ import annotations
import hashlib
import pytest
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
def test_generate_live_key_shape():
key = generate_api_key("live")
assert isinstance(key, GeneratedKey)
assert key.plaintext.startswith("dfk_live_")
# dfk_live_ (9) + token_urlsafe(18) (24) = 33 chars
assert len(key.plaintext) == 33
assert key.prefix == key.plaintext[:16]
assert len(key.prefix) == 16
assert key.key_hash == hashlib.sha256(key.plaintext.encode("utf-8")).hexdigest()
assert len(key.key_hash) == 64
def test_generate_test_key_prefix_env():
key = generate_api_key("test")
assert key.plaintext.startswith("dfk_test_")
assert len(key.plaintext) == 33
assert key.prefix.startswith("dfk_test_")
def test_generate_rejects_bad_env():
with pytest.raises(ValueError):
generate_api_key("prod") # type: ignore[arg-type]
def test_two_keys_are_unique():
a = generate_api_key("live")
b = generate_api_key("live")
assert a.plaintext != b.plaintext
assert a.key_hash != b.key_hash
def test_hash_api_key_is_sha256_and_deterministic():
plaintext = "dfk_live_abcdefghijklmnopqrstuvwx"
h1 = hash_api_key(plaintext)
h2 = hash_api_key(plaintext)
assert h1 == h2
assert h1 != plaintext
assert len(h1) == 64
def test_split_prefix_takes_first_16():
assert split_prefix("dfk_live_abcdefghijklmnop") == "dfk_live_abcdefg"
+1 -1
View File
@@ -598,7 +598,7 @@ def test_upload_limits_endpoint_requires_thread_access():
cfg.uploads = {}
app = make_authed_test_app(owner_check_passes=False)
app.state.config = cfg
app.include_router(uploads.router)
app.include_router(uploads.router, prefix="/api")
with TestClient(app) as client:
response = client.get("/api/threads/thread-local/uploads/limits")
@@ -80,7 +80,7 @@ def _build_app(*, user_id: str, workspace_id: str):
app.state.store = store
app.state.checkpointer = InMemorySaver()
app.state.thread_store = MemoryThreadMetaStore(store)
app.include_router(threads.router)
app.include_router(threads.router, prefix="/api")
return app, store
@@ -305,4 +305,4 @@ erDiagram
- [adr-001-data-isolation.zh-CN.md](./adr-001-data-isolation.zh-CN.md) — 行级 `workspace_id` + Postgres RLS + LangGraph 表两层模型
- [adr-004-tenant-rbac.zh-CN.md](./adr-004-tenant-rbac.zh-CN.md) — RBAC + JWT 设计
- [adr-spike-langgraph-postgres.zh-CN.md](./adr-spike-langgraph-postgres.zh-CN.md) — 为何 LangGraph 表不归 ORM 管
- [03-impl/STATUS.md](../03-impl/STATUS.md) + `03-impl/pr8-headless-api-schema.md` — PR 级实现进度
- [03-impl/STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md) + `03-impl/pr8-headless-api-schema.zh-CN.md` — PR 级实现进度
@@ -0,0 +1,189 @@
# Stage 1 · Headless API Pattern A 鉴权地基 — 设计
> 设计稿。日期 2026-06-28。承接 Stage 0PR1-PR8 全部 merge,见 [STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md))与 headless API 轨道设计 [headless-api-track.zh-CN.md](../02-rollout/headless-api-track.zh-CN.md)。
>
> 范围:headless-api-track **轨道二(Pattern A** 的鉴权地基。让业务系统 backend 能用 API key`Authorization: Bearer dfk_...`)调通 DeerFlow Gateway——server-to-server。Pattern B(浏览器直连 + 短期 JWT)、external_user 透传、identity_mode 三态行为、rate limit 不在本 spec 范围(轨道二后续 PR / 轨道三)。
## 1. 目标与非目标
### 目标
业务系统 backend 拿一把 workspace-scoped API key`Authorization: Bearer dfk_live_<...>` 直调 `/api/threads` 等现有 REST endpoint,请求被正确归属到该 key 背后的 service account + workspace,并被 workspace 隔离(跨 workspace 资源一律 404)。形成可自助的最小闭环:workspace owner 经管理 endpoint 建 service account → 建 key → 业务侧用 key 调通。
### 非目标(明确推后)
- **Pattern B**`exchange-token` / 短期 JWT / CORS / `allowed_origins`)— 轨道三。
- **external_user 透传**`X-External-User-Id` upsert、ghost user、per-external-user memory)— 轨道二后续 PR。本 spec 只到 `collapsed` 语义(一切归 SA)。
- **identity_mode 三态行为分支** — 同上。SA 的 `identity_mode` 列存在(PR8 schema),但本 spec 一律按 collapsed 处理,不读该列做分支。
- **rate limit / idempotency** — 轨道二后续 PR。
- **API key 管理前端 UI** — 本 spec 只做管理 **endpoint**UI 接这些 endpoint 是后续 PR。
- **`@require_permission``scopes=[...]` 显式参数升级** — 本 spec 用"把 scopes 灌进现有 `AuthContext.permissions`"取得 scope 校验,decorator 签名不改。
## 2. 现状锚点(实现时镜像,防漂移)
| 组件 | 文件 | 关键符号 |
|---|---|---|
| Auth 中间件 | `backend/app/gateway/auth_middleware.py` | `AuthMiddleware.dispatch`(检 `_is_public` → cookie/JWT → 设 contextvarL77-143 |
| JWT / TokenPayload | `backend/app/gateway/auth/jwt.py` | `TokenPayload``create_access_token``decode_token` |
| 用户解析 | `backend/app/gateway/deps.py` | `get_current_user_from_request`L186)、`get_optional_user_from_request`L230 |
| CSRF | `backend/app/gateway/csrf_middleware.py` | `CSRFMiddleware``should_check_csrf`L32)、`is_auth_endpoint`L58 |
| user contextvar | `backend/packages/harness/deerflow/runtime/user_context.py` | `CurrentUser``set_current_user``get_effective_user_id` |
| workspace contextvar | `backend/packages/harness/deerflow/runtime/workspace_context.py` | `CurrentWorkspace``set_current_workspace``get_effective_workspace_id` |
| 授权装饰器 | `backend/app/gateway/authz.py` | `require_permission`L197)、`AuthContext`L62,含 `permissions: list[str]` + `has_permission` |
| 仓储样板 | `backend/packages/harness/deerflow/persistence/workspace/sql.py` | `WorkspaceRepository`(构造收 `session_factory`,每方法开 fresh session`_row_to_dict` |
| session 工厂 | `backend/packages/harness/deerflow/persistence/engine.py` | `get_session_factory()` |
| PR8 ORM | `persistence/{service_account,api_key,external_user}/model.py` | `ServiceAccountRow` / `ApiKeyRow` / `ExternalUserRow`schema 已落,见 [pr8 impl note](../03-impl/pr8-headless-api-schema.zh-CN.md) |
| 路由挂载 | `backend/app/gateway/app.py` | `create_app()` 内 15 个 `include_router`L379-421 |
| 路由前缀约定 | `backend/app/gateway/routers/*.py` | 前缀**写死在 `APIRouter(prefix=...)`**`auth.py` 已用 `/api/v1/auth`,证明 v1 与旧前缀共存 |
| 前端 API 路径 | `frontend/src/core/*/api.ts``src/core/threads/hooks.ts` 等 | `getBackendBaseURL()` + 路径串;auth 已用 `/api/v1/auth`langgraph-sdk 走 `/api/langgraph/*` |
| 测试夹具 | `backend/tests/conftest.py` | autouse `_auto_user_context` / `_auto_workspace_context`(可 `@pytest.mark.no_auto_*` opt-out |
| 中间件测试样板 | `backend/tests/test_auth_middleware.py``test_csrf_middleware.py` | `_make_app()` + `starlette.testclient.TestClient` |
## 3. 已锁的设计决策(来自 brainstorm)
| # | 决策 | 取舍 |
|---|---|---|
| **D1** | **SA 身份映射为 `CurrentUser`** | SA 鉴权后 `set_current_user(CurrentUser(id=SA.id, is_service_account=True))` + `set_current_workspace(SA.workspace_id)`。thread 归属落 `user_id = SA.id`,复用现有 `workspace_id + user_id` 隔离逻辑,**不改业务表 schema**。代价:SA id 坐在 user_id 列,将来拆 external_user 要迁移——可接受,因为 external_user 透传是后续 PR 且届时本就要动归属维度。**否决**「独立 `service_account` contextvar + thread 加 `service_account_id` 列」方案,因其要改业务表 schema + 所有读 `user_id` 的 consumer,超出地基范围。 |
| **D2** | **mint key 走管理 endpoint** | `/api/v1/service-accounts` + `/api/v1/api-keys`gated 到 workspace owner/admin(复用 `@require_permission`)。业务侧可自助;plaintext 仅创建时返一次。**否决**纯 CLI(业务侧拿不到自助接口)。CLI 包装留作 on-prem bootstrap 的后续可选项。 |
| **D3** | **旧路由现在就全量迁 `/api/v1`** | 所有 router 双挂 `/api` + `/api/v1`,前端同步迁 `/api/v1`。**否决**「只新 surface 用 v1」。因面较广、回归风险较大,本 PR 放在**最后**(PR5),让 PR1-4 的鉴权地基能独立证伪。 |
| **D4** | **scope 校验"白捡"进 PR2** | API key 路径把 `key.scopes`(逗号分隔)解析进 `AuthContext.permissions`,现有 `@require_permission``has_permission(resource, action)` 即对 API key 生效——无需改 decorator 签名。完整 `scopes=[...]` 参数化升级推后。 |
| **D5** | **key 格式锁 `dfk_live_<24>` / `dfk_test_<24>`** | 沿用 track 文档不可逆决策。`key_prefix` = 前 16 字符(含 `dfk_live_`),全局 UNIQUEPR8 schema 已定)。DB 只存 `sha256(plaintext)` hex。 |
## 4. 架构与数据流
```
业务系统 backend ──▶ AuthMiddleware ──┬── "Bearer dfk_..." ──▶ APIKeyAuthBackend
│ │
│ ├─ sha256(token) → ApiKeyRepository.get_active_by_hash
│ ├─ 校 key.expires_at 未过期;load SA 校 status=activeload workspace
│ ├─ set_current_user(CurrentUser(id=SA.id, is_service_account=True))
│ ├─ set_current_workspace(SA.workspace_id)
│ ├─ AuthContext.permissions = parse(key.scopes)
│ └─ touch_last_used(key.id)(异步/best-effort
└── "Cookie: access_token=..." ──▶ 现有 cookie 路径(不变)
CSRFMiddleware: 见 Authorization: Bearer 即 skipPR3
路由 + @require_permissionscope via permissions, owner_check via workspace_id+user_id
thread store / sandbox(按 user_id=SA.id + workspace_id 隔离,零改动)
```
**关键不变量**:API key 路径走完后,下游(thread store、sandbox、`@require_permission` 的 owner_check)看到的 `(user_id, workspace_id)` 与一个真人用户在该 workspace 下完全同构——这正是 D1 让地基零改动下游的原因。
## 5. 逐 PR 设计
### PR1 · 三表仓储 + token 工具(`deerflow` 层)
**新增**
- `persistence/service_account/sql.py``ServiceAccountRepository``create(*, workspace_id, name, created_by, role="member", identity_mode="collapsed", status="active")``get(sa_id)``get_active(sa_id)`status==active 才返)、`list_by_workspace(workspace_id)``update_status(sa_id, status)`
- `persistence/api_key/sql.py``ApiKeyRepository``create(*, service_account_id, key_prefix, key_hash, name, scopes, expires_at=None)``get_active_by_hash(key_hash)`**热路径**`revoked_at IS NULL` 且未过期 → 走 `idx_api_keys_active`)、`list_by_service_account(sa_id)``revoke(key_id)`(设 `revoked_at`)、`touch_last_used(key_id)`
- `persistence/external_user/sql.py``ExternalUserRepository`:本 PR 仅建 `get`/`list_by_workspace` 等读方法 + 基础 `upsert(*, workspace_id, service_account_id, external_id, ...)` 骨架;**透传调用方留到后续 PR**(建仓储不接业务,与 PR8 建 schema 不接路由同思路)。
- `deerflow/auth/tokens.py``generate_api_key(env: Literal["live","test"]) -> GeneratedKey(plaintext, prefix, key_hash)``hash_api_key(plaintext) -> str`sha256 hex);`split_prefix(plaintext) -> str`(前 16)。用 `secrets.token_urlsafe`。**落 `deerflow` 层**(不是 `app`)——因仓储的 `get_active_by_hash`deerflow)与管理 endpoint 的建 keyapp)都要用,按 harness boundaryapp 可 import deerflow,反之不可)必须在 deerflow 侧。
**测试**(严格 TDD 红→绿):每仓储 CRUD round-trip`get_active_by_hash` 对 revoked / expired key 返 Nonetoken 生成格式(前缀、长度、prefix 截取)、`hash_api_key` 确定性、plaintext 不可从 hash 反推(仅断言 hash≠plaintext + 长度)。镜像 `test_*_schema.py` 与 workspace 仓储测试风格。
**不在范围**:任何路由、中间件、contextvar。
### PR2 · APIKeyAuthBackend + AuthMiddleware 双路径(`app` 层)
**改动**
- `CurrentUser``runtime/user_context.py`)加 `is_service_account: bool = False` 字段(默认 False,cookie 路径不受影响)。
- 新 `app/gateway/auth/api_key_backend.py``APIKeyAuthBackend.authenticate(token: str) -> AuthResult | None`
1. `hash = hash_api_key(token)``ApiKeyRepository.get_active_by_hash(hash)`;未命中/已撤销/已过期 → None(→ 401)。
2. `ServiceAccountRepository.get_active(key.service_account_id)`;非 active → None(→ 401/403)。
3. load workspace(校 status)。
4. 返回足以让中间件设 contextvar 的结构:`(CurrentUser(id=SA.id, is_service_account=True), workspace_id, role, permissions=parse_scopes(key.scopes))`
5. best-effort `touch_last_used(key.id)`(失败不阻断请求)。
- `AuthMiddleware.dispatch`:在 cookie 分支**之前**插入 bearer 分支——`Authorization` 头以 `Bearer dfk_` 开头 → 走 `APIKeyAuthBackend` → 设 `request.state.user` / `request.state.auth`(含 permissions+ 两个 contextvar;否则落回现有 cookie 流程。`_is_public` / 内部 auth 头逻辑不变。
- `AuthContext.permissions` 由 API key 的 scopes 填充(D4)。
**测试**:有效 key → 200 且 contextvar 正确(SA.id / workspace_id);无效/撤销/过期 key → 401;非 `dfk_` 的 Bearer → 不误入此路径;有 scope 的 key 调对应 endpoint 通过、无 scope 被 `@require_permission` 拒;cookie 路径回归不破。用 `_make_app()` 样板 + 直接 insert 的 keyPR1 仓储)构造。
### PR3 · CSRF skip on bearer
**改动**
- `csrf_middleware.py``has_bearer_header(request) -> bool``Authorization` 头存在且以 `Bearer ` 起);`should_check_csrf` 在其为真时返 False。cookie 路径 CSRF 行为完全不变。
**测试**:带 `Authorization: Bearer ...` 的 POST 跳过 CSRF(无 `X-CSRF-Token` 也 200);cookie POST 仍要 CSRF token(回归)。
### PR4 · 管理 endpointmint 闭环)
**新增**
- `app/gateway/routers/service_accounts.py``APIRouter(prefix="/api/v1/service-accounts")`
- `POST /` 建 SAbody: name, role?, identity_mode?)→ 归当前 workspace`created_by` = 当前 user。`@require_permission` gated 到 owner/admin。
- `GET /` 列当前 workspace 的 SA。
- `PATCH /{sa_id}` 改 statussuspend/active)。
- `app/gateway/routers/api_keys.py``APIRouter(prefix="/api/v1/api-keys")`
- `POST /` 为指定 SA 建 keybody: service_account_id, name, scopes, env?, expires_at?)→ 调 `generate_api_key` → 存 hash+prefix → **响应体含 plaintext,仅此一次**
- `GET /?service_account_id=` 列 key(只返 prefix / name / scopes / 时间戳,**绝不**返 hash/plaintext)。
- `DELETE /{key_id}` revoke(设 `revoked_at`)。
- 两 router 在 `app.py` `include_router`。owner/admin gating 复用现有 authzSA / key 必须属于当前 workspace(跨 workspace 操作 → 404)。
**测试 + 端到端 smoke**owner 建 SA → 建 key(断言 plaintext 仅返一次、再查不含 plaintext)→ 用该 key 调 `/api/threads` 跑通 → 另一 workspace 的 key 访问首 workspace 资源得 404 → member(非 owner/admin)建 SA 被拒。
### PR5 · `/api/v1` 全量迁移 + 旧路径兼容(最后做)
**后端**
- 把 15 个 router 的写死前缀从 `APIRouter(prefix="/api...")` 改为相对前缀(如 `/threads`),在 `app.py` include 时**双挂**:一次 `/api` + 一次 `/api/v1`(保持向后兼容)。`auth.py`(已 `/api/v1/auth`)与 PR4 新 router(已 `/api/v1/*`)按需统一。
- `/api/langgraph/*`LangGraph SDK 兼容路径)**不版本化**,不动。
- 旧 `/api/*`(无版本)响应加 `X-API-Deprecated` headersunset 日期取 track 约定 `2027-01-01`)。
**前端**
- `frontend/src/core/*/api.ts``src/core/threads/hooks.ts``src/core/artifacts/utils.ts``src/core/uploads/api.ts``src/core/api/feedback.ts``src/core/models/api.ts` 等处的 `/api/...` 路径串迁到 `/api/v1/...`。langgraph-sdk 客户端路径(`/api/langgraph/*`)不动。
- 验证:`pnpm lint && pnpm typecheck`;若动到 env/auth/routing 则 `pnpm build`
**测试**:旧 `/api/threads` 与新 `/api/v1/threads` 均 200 且行为一致;旧路径带 `X-API-Deprecated`;langgraph 路径不受影响。后端全量 `make lint && make test` 回归。
## 6. 错误处理约定
| 情况 | 响应 |
|---|---|
| key 不存在 / hash 不匹配 / 已 revoke / 已过期 | 401 |
| SA suspended/deleted、workspace 非 active | 401(不泄漏"key 有效但账户停用"细节,保守口径;如业务方需区分再放宽到 403) |
| 有效 key 但缺 scope | 403(沿用 `@require_permission` 现有语义) |
| 跨 workspace 访问资源(owner_check) | 404(沿用现有租户隔离:藏存在性,非 403) |
| 非 owner/admin 调管理 endpoint | 403 |
## 7. 测试策略
- 每 PR 严格 TDD(红→绿),镜像现有 `test_auth_middleware.py` / `test_csrf_middleware.py` / 仓储测试风格。
- 仓储层用 SQLiteautouse fixture),鉴权热路径的 partial index 行为不依赖驱动(逻辑层过滤)。
- PR4 的端到端 smoke 是地基的"活体证明"——比单测更有说服力(仿 Stage 0 `multi_tenant.py` 思路)。
- 全程不引入新 caplog flake;既有 18 个 flake 不在本 spec 处理范围。
## 8. 不可逆决策清单(动手前确认,沿用 track 已锁口径)
| 决策 | 不可逆原因 | 本 spec 取值 |
|---|---|---|
| API key 格式 | 业务接入后改格式所有 key 失效 | `dfk_live_<24>` / `dfk_test_<24>`prefix 16sha256 存储(D5 |
| SA 归属映射 | 改了 thread 归属语义 | `user_id = SA.id`D1);external_user 维度留后续 |
| 管理 endpoint 路径 | 业务/前端接入后改 path 要联调 | `/api/v1/service-accounts``/api/v1/api-keys`D2 |
| `/api/v1` 启用与 deprecation | 业务接了再换 prefix 不友好 | 全量双挂 + 旧路径 `X-API-Deprecated: 2027-01-01`D3 |
| scope 字符串格式 | 存量 key 的 scopes 解析依赖它 | 逗号分隔 `resource:action`(沿用 PR8 schema + 现有 permission 串) |
## 8.1 已知限制(落地后复核确认,需后续 PR 决策)
> 实现完成后的整体安全复核(2026-06-28)发现一处**符合本 spec 范围但值得显式记录**的最小权限缺口:
- **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 的接口
本 spec 的地基为轨道二后续 / 轨道三留好接缝:
- `ExternalUserRepository`PR1 建好)+ `is_service_account` 标记 → external_user 透传 PR 直接接。
- `APIKeyAuthBackend` 返回结构里已带 scopes/permissions → `@require_permission``scopes=[...]` 参数化升级可平滑替换。
- 管理 endpoint → 前端 API key 管理 UI 直接消费。
- `/api/v1` 命名空间 → Pattern B 的 `/api/v1/auth/exchange-token` 落在同一前缀。
## 10. 阅读路径
- 宏观背景 → [headless-api-track.zh-CN.md](../02-rollout/headless-api-track.zh-CN.md)
- Stage 0 现状 / 测试基线 → [STATUS.zh-CN.md](../03-impl/STATUS.zh-CN.md)
- PR8 三表 schema → [pr8-headless-api-schema.zh-CN.md](../03-impl/pr8-headless-api-schema.zh-CN.md)
- 本 spec 的实现计划 → [2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md](../../superpowers/plans/2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md)
@@ -28,14 +28,14 @@
| PR | 状态 | Commits | 分支 / 落点 | impl note |
|---|---|---|---|---|
| **PR0** | ✅ merged | 1 | `a74b88a4` on docs branch | — |
| **PR1** | ✅ merged | 8 (T1.1-T1.10) | merged into docs branch (`fab85b14..85a14f4c`) | [pr1-postgres-setup.md](./pr1-postgres-setup.md) |
| **PR2** | ✅ merged | 8 (T2.1-T2.10) | merged into docs branch (`404135a1..1112a197`) | [pr2-postgres-default.md](./pr2-postgres-default.md) |
| **PR3** | ✅ merged | 7 (T3.1-T3.10) | merged into docs branch (`f63089ae..dda82640`) | [pr3-workspaces.md](./pr3-workspaces.md) |
| **PR4** | ✅ merged | 14 (T4.1-T4.14) | merged into docs branch (`d98498b7..5c7753c0`) | [pr4-auth-workspace.md](./pr4-auth-workspace.md) |
| **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.md](./pr5-business-workspace-id.md) |
| **PR6** | ✅ merged | 13 (T5.11 + T6.1-T6.15) | merged into docs branch (`361e653d..87ea715c`) | [pr6-routes-paths-workspace.md](./pr6-routes-paths-workspace.md) |
| **PR7** | ✅ merged | 4 (T7.1-T7.3 + T7.5; T7.4 是反注入验证无代码改动) | merged into docs branch (`1a6ccc9a..d8b13afc`) | [pr7-ci-boundary-scan.md](./pr7-ci-boundary-scan.md) |
| **PR8** | ✅ merged | 5 (T8.1 + T8.2/T8.3 合并 + T8.4 + T8.5 + T8.6) | merged into docs branch (`1fb07e48..f803f393`) | [pr8-headless-api-schema.md](./pr8-headless-api-schema.md) |
| **PR1** | ✅ merged | 8 (T1.1-T1.10) | merged into docs branch (`fab85b14..85a14f4c`) | [pr1-postgres-setup.zh-CN.md](./pr1-postgres-setup.zh-CN.md) |
| **PR2** | ✅ merged | 8 (T2.1-T2.10) | merged into docs branch (`404135a1..1112a197`) | [pr2-postgres-default.zh-CN.md](./pr2-postgres-default.zh-CN.md) |
| **PR3** | ✅ merged | 7 (T3.1-T3.10) | merged into docs branch (`f63089ae..dda82640`) | [pr3-workspaces.zh-CN.md](./pr3-workspaces.zh-CN.md) |
| **PR4** | ✅ merged | 14 (T4.1-T4.14) | merged into docs branch (`d98498b7..5c7753c0`) | [pr4-auth-workspace.zh-CN.md](./pr4-auth-workspace.zh-CN.md) |
| **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.zh-CN.md](./pr5-business-workspace-id.zh-CN.md) |
| **PR6** | ✅ merged | 13 (T5.11 + T6.1-T6.15) | merged into docs branch (`361e653d..87ea715c`) | [pr6-routes-paths-workspace.zh-CN.md](./pr6-routes-paths-workspace.zh-CN.md) |
| **PR7** | ✅ merged | 4 (T7.1-T7.3 + T7.5; T7.4 是反注入验证无代码改动) | merged into docs branch (`1a6ccc9a..d8b13afc`) | [pr7-ci-boundary-scan.zh-CN.md](./pr7-ci-boundary-scan.zh-CN.md) |
| **PR8** | ✅ merged | 5 (T8.1 + T8.2/T8.3 合并 + T8.4 + T8.5 + T8.6) | merged into docs branch (`1fb07e48..f803f393`) | [pr8-headless-api-schema.zh-CN.md](./pr8-headless-api-schema.zh-CN.md) |
**测试基线****PR8 末 3250 passed + 31 skipped**PR7 末 3241 + 31+9 passedPR8 新增 3 + 3 + 2 + 1 = 9 个 schema 测试)。PR6 末 3214 + 30PR5 末 3150 + 30PR4 末 3136 + 26PR3 末 3134 + 25PR2 末 3087。**18 个 caplog 排序 flake 持续存在**17 个 pre-existing + 1 PR6 引入,PR7/PR8 均未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。
@@ -66,9 +66,9 @@ PR1 起到 PR8 末,从既有 ~3087 增到 3250 passed+163 测试,覆盖
| PR4 follow-up | Regular user pre-PR4 backfill 脚本 | login 路径已 lazy backfill 覆盖;如果生产有大量预存 regular user,可补 batch 脚本 | 等真出现这个场景再写 |
| PR4 follow-up | 17 个 pre-existing caplog flake 集中清理 | 跨多个 test 文件的 propagation 问题,与 PR4/5/6 无关 | 单独 follow-up 处理 |
| ~~PR5 T5.11~~ | ~~ORM model.py `nullable=False` 翻转~~ | **PR6 已落** (commit `87ea715c`) | — |
| PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.md "Live smoke 命令"](./pr5-business-workspace-id.md#live-smoke-命令用户跟进) |
| PR6 T6.15 真机迁移 smoke | `make migrate-paths --dry-run` → 真迁移 → lifespan warning 消失 → 双账户互访 404 | agent 起不了 dev 服务 | 🟡 部分 done——「双账户互访 404」✅ 由 `multi_tenant.py`2026-06-27 PASS)覆盖;文件迁移 `make migrate-paths` 部分仍 ⏳。命令清单见 [pr6-routes-paths-workspace.md "Live smoke 命令"](./pr6-routes-paths-workspace.md#live-smoke-命令用户跟进) |
| PR8 RDS 三张表存在 | `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` 看 3 行;`\d+ api_keys``idx_api_keys_active ... WHERE revoked_at IS NULL` | agent 没 RDS 凭证 | 用户跟进;命令清单见 [pr8-headless-api-schema.md "Live smoke 命令"](./pr8-headless-api-schema.md#live-smoke-命令用户跟进) |
| PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.zh-CN.md "Live smoke 命令"](./pr5-business-workspace-id.zh-CN.md#live-smoke-命令用户跟进) |
| PR6 T6.15 真机迁移 smoke | `make migrate-paths --dry-run` → 真迁移 → lifespan warning 消失 → 双账户互访 404 | agent 起不了 dev 服务 | 🟡 部分 done——「双账户互访 404」✅ 由 `multi_tenant.py`2026-06-27 PASS)覆盖;文件迁移 `make migrate-paths` 部分仍 ⏳。命令清单见 [pr6-routes-paths-workspace.zh-CN.md "Live smoke 命令"](./pr6-routes-paths-workspace.zh-CN.md#live-smoke-命令用户跟进) |
| PR8 RDS 三张表存在 | `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` 看 3 行;`\d+ api_keys``idx_api_keys_active ... WHERE revoked_at IS NULL` | agent 没 RDS 凭证 | 用户跟进;命令清单见 [pr8-headless-api-schema.zh-CN.md "Live smoke 命令"](./pr8-headless-api-schema.zh-CN.md#live-smoke-命令用户跟进) |
## 即将遇到的开放问题(plan 末尾列的,下个 session 处理)
@@ -145,4 +145,4 @@ make stop && make dev
- 跨 workspace 必 404 怎么实现的 → 看 `check_access` 改造(commit `05be7f9a`+ `@require_permission` 装饰器
- 路径新形态 → `Paths.thread_dir` 三档优先级(commit `f013fc1a`
- 仓储 workspace_id 哨兵模式 → 任一 `*/sql.py` 看 create/get/search 签名
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.md`
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.zh-CN.md`
@@ -37,7 +37,7 @@
- [x] **scanner 红→绿循环**empty allowlist → 14 violations across 4 filesthreads / async_provider / provider / workerTYPE_CHECKING-only 的 factory.py 正确不在内);填入 4 entry → PASS
- [x] **scanner self-test 9 个全过**(防静默空跑)
- [x] **T7.4 反注入实验**:往 `feedback.py:13` 加一行违规 import → `pytest tests/test_workspace_boundary.py` 单条 failerror 精准指 `app/gateway/routers/feedback.py:13 imports langgraph.checkpoint.postgres`revert 后立即返绿
- [x] **全套 `make test` 3241 passed + 31 skipped + 18 caplog flake**PR6 末 3214 + 30 + 17+27 passed / +1 skip / +1 flake — passed delta 包含 PR7 新增 10 个测试以及环境差异导致的 17 个之前 flake 这次稳过,flake 列表形态与 STATUS.md 既有 17 项 + PR6 引入的 `test_path_migration_pending_warning` 一致,与 PR7 改动无关)
- [x] **全套 `make test` 3241 passed + 31 skipped + 18 caplog flake**PR6 末 3214 + 30 + 17+27 passed / +1 skip / +1 flake — passed delta 包含 PR7 新增 10 个测试以及环境差异导致的 17 个之前 flake 这次稳过,flake 列表形态与 STATUS.zh-CN.md 既有 17 项 + PR6 引入的 `test_path_migration_pending_warning` 一致,与 PR7 改动无关)
- [x] **CI workflow 接入**:扫描器是普通 pytest,已被 `.github/workflows/backend-unit-tests.yml` 全套 run 覆盖;无需新 workflow
## 文件结构
@@ -46,7 +46,7 @@
- `backend/tests/test_workspace_boundary.py` — AST 扫描器(127 行)
- `backend/tests/test_workspace_boundary_self.py` — 扫描器 self-test93 行)
- `backend/tests/boundary_allowlist.toml` — 4 个合法 importer + 每行注释(28 行)
- `docs/multi-tenant-redesign/03-impl/pr7-ci-boundary-scan.md` — 本文件
- `docs/multi-tenant-redesign/03-impl/pr7-ci-boundary-scan.zh-CN.md` — 本文件
**修改**
- `backend/CLAUDE.md` — Boundary check 段 +2 行
@@ -47,7 +47,7 @@
- `backend/tests/test_api_key_schema.py`3 cases
- `backend/tests/test_external_user_schema.py`2 cases
- `backend/tests/test_pr8_metadata_registration.py`1 case
- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md` — 本文件
- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.zh-CN.md` — 本文件
**修改**
- `backend/packages/harness/deerflow/persistence/models/__init__.py` — 加 3 行 import + `__all__` 注册
@@ -89,7 +89,7 @@ PYTHONPATH=. uv run pytest -m postgres -v
## Stage 0 退出门
PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.md](./STATUS.md)"用户必须跟进的事"
PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.zh-CN.md](./STATUS.zh-CN.md)"用户必须跟进的事"
- [ ] RDS 上 `service_accounts` / `api_keys` / `external_users` 三张表 `\dt`
- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空
- [ ] testcontainers ephemeral PG smoke 跑过一次
+45 -13
View File
@@ -1,6 +1,8 @@
# 多租户改造 · 总览与汇总索引
> 写于 2026-05-10。把 7 份 ADR + 2 份 spike/审计 + 4 份 rollout / schema 文档,按"ADR 状态 + 5 阶段(Stage 0–4)的业务目标 / 技术路径 / 验证方式"重新串一遍,让团队从任何角度切入都能找到对应位置
> ⚠️ **本文是「设计 / 路线」导航,不反映执行进度**(写于 2026-05-10 设计期)。**想知道「现在到哪了」永远先读 [`03-impl/STATUS.zh-CN.md`](./03-impl/STATUS.zh-CN.md)**——那是唯一动态的进度权威。本 README 后续小节里 Stage 0/1 多以 forward-looking 口径描述,与 STATUS 的"已完成"口径并存属正常分工
>
> 写于 2026-05-10。把 7 份 ADR + 2 份 spike/审计 + 4 份 rollout / schema 文档 + 1 份 Stage 1 spec,按"ADR 状态 + 5 阶段(Stage 0–4)的业务目标 / 技术路径 / 验证方式"重新串一遍,让团队从任何角度切入都能找到对应位置。执行记录(STATUS + 各 PR impl note)见 `03-impl/`
>
> **范围**:仅汇总与导航,不引入新决策。具体决策正文在各自的 ADR / rollout 文档里。
>
@@ -27,13 +29,19 @@ docs/multi-tenant-redesign/
│ ├── adr-vs-code-audit 审计:ADR vs 现状代码
│ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物
│ ├── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点)
── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
└── 02-rollout/ 落地路线 + 集成轨道
├── phased-rollout-by-scale **Stage 04 主线** 路线图
├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
── headless-api-track 业务系统集成轨道(Pattern A / B
── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
│ └── stage-1-headless-api-pattern-a-auth-foundation-design **Stage 1 鉴权地基设计**Pattern A
├── 02-rollout/ 落地路线 + 集成轨道
├── phased-rollout-by-scale **Stage 04 主线** 路线图
── stage-0-code-map Stage 0 现状代码地图(行号锚点
│ └── headless-api-track 业务系统集成轨道(Pattern A / B
└── 03-impl/ **执行记录层**(进度 + 各 PR 落地笔记)
├── STATUS ★ **唯一进度权威**"现在到哪了"
└── pr1..pr8 Stage 0 各 PR impl notepostgres / workspaces / auth / 业务表 / 路由 / boundary / 三表 schema
```
> 命名约定:全部 `.zh-CN.md` 后缀;`01-redesign` 用语义名(`adr-*` / `*-design`),`03-impl``prN-*` / `STATUS` 顺序名。
---
## 1. ADR 与配套文档状态表
@@ -51,6 +59,7 @@ docs/multi-tenant-redesign/
| 审计 | [ADR vs 代码](./01-redesign/adr-vs-code-audit.zh-CN.md) | 已结论 | 2026-05-09 | 代码库 0 处 `tenant`Better Auth 不存在;ObjectStorage / KMS / Postgres 测试夹具全缺;底座先行 §3.5 |
| 锁定 | [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) | **Stage 0 锁定版** | 2026-05-10 | `workspace_id` 命名 + 7 项不可逆决策;Stage 0 PR1 动手前必读 |
| 参考 | [database-schema-as-built](./01-redesign/database-schema-as-built.zh-CN.md) | **落地版(as-built** | 2026-06-27 | 对照实现代码的 10 张表全字段 / 外键 / 索引 / 迁移参考;与锁定版冲突以本文为准 |
| 设计 | [stage-1-headless-api-…-design](./01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md) | **Stage 1 设计稿** | 2026-06-28 | Pattern A 鉴权地基 5 PR(三表仓储+token / APIKeyAuthBackend 双路径 / CSRF skip on bearer / 管理 endpoint / `/api/v1` 全量迁移)+ 5 项决策(D1-D5) |
| 计划 | [phase-0-plan](./01-redesign/multi-tenant-phase-0-plan.zh-CN.md) | 计划 | 2026-05-09 | Phase-0 时间盒 3 周;含底座先行(§3.5) |
| 路线 | [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) | **当前主线路线图** | 2026-05-09 | Stage 04 + 触发/退出/时间盒/Go-No-Go |
| 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 |
@@ -58,6 +67,26 @@ docs/multi-tenant-redesign/
---
## 1.1 执行记录层(`03-impl/`
> 上面 §1 是"设计 / 路线"(相对静态);本层是"实际落了什么"(随执行更新)。**进度只信 STATUS,本表只是 impl note 索引。**
| 文档 | 类型 | 作用 |
|---|---|---|
| [STATUS](./03-impl/STATUS.zh-CN.md) | ★ 进度权威 | "现在到哪了"唯一来源:8 PR 状态表、测试基线、用户必跟进项、跳过/推迟项、下一步建议。**进新 session 第一件事读它** |
| [pr1-postgres-setup](./03-impl/pr1-postgres-setup.zh-CN.md) | impl note | Postgres 接入 + testcontainers fixture |
| [pr2-postgres-default](./03-impl/pr2-postgres-default.zh-CN.md) | impl note | 默认 backend 切 Postgres |
| [pr3-workspaces](./03-impl/pr3-workspaces.zh-CN.md) | impl note | `workspaces` + `workspace_memberships` 表 + 仓储 |
| [pr4-auth-workspace](./03-impl/pr4-auth-workspace.zh-CN.md) | impl note | 注册自建 workspace + JWT 扩 `wid`/`role` |
| [pr5-business-workspace-id](./03-impl/pr5-business-workspace-id.zh-CN.md) | impl note | 业务表加 `workspace_id` + alembic + 回填 |
| [pr6-routes-paths-workspace](./03-impl/pr6-routes-paths-workspace.zh-CN.md) | impl note | 入口路由 + Paths 系统 workspace 化 |
| [pr7-ci-boundary-scan](./03-impl/pr7-ci-boundary-scan.zh-CN.md) | impl note | langgraph.checkpoint boundary CI 围栏 |
| [pr8-headless-api-schema](./03-impl/pr8-headless-api-schema.zh-CN.md) | impl note | `service_accounts`/`api_keys`/`external_users` schema onlyStage 1 地基) |
> Stage 1 的逐 task **实现计划**writing-plans 产出)将落在 `docs/superpowers/plans/`,与 Stage 0 master plan 一致;其设计稿见 §1 的 stage-1 spec 行。
---
## 2. Stage 04 速览矩阵
| Stage | 触发 | 退出 | 时间盒 | 主要 ADR 章节 |
@@ -256,9 +285,10 @@ docs/multi-tenant-redesign/
## 7. 阅读路径建议
**第一次进项目(30 min**
1. 本 README
2. [00-current-state/architecture-overview](./00-current-state/architecture-overview.zh-CN.md) — 现状是什么样的
3. [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) §0 + §总览 + §Stage 0 — 现在在哪、下一步做什么
1. 本 README(设计 / 路线导航)
2. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — **现在到哪了**(先看这个,再看下面的"为什么")
3. [00-current-state/architecture-overview](./00-current-state/architecture-overview.zh-CN.md) — 现状是什么样的
4. [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) §0 + §总览 + §Stage 0 — 整体路线
**准备动手做 Stage 0(半天)**:
1. [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) **全文** — 不可逆决策、PR 拆分
@@ -267,10 +297,12 @@ docs/multi-tenant-redesign/
4. [ADR-006 §2.1](./01-redesign/adr-006-runtime-channel-tenancy.zh-CN.md) + [adr-spike-langgraph-postgres](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) — 为什么 LangGraph 表不挂 RLS
**准备动手做 Stage 1(一天)**:
1. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
2. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
3. [ADR-003 §4.3-§4.4](./01-redesign/adr-003-llm-key-billing.zh-CN.md) — quota + 悲观预扣
4. [ADR-002 §3](./01-redesign/adr-002-sandbox-isolation.zh-CN.md) — Stage 1 用 §3 轻量版(**不**读 §5 K8s 完整版
1. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — Stage 0 收尾现状 + Stage 1 可启动方向
2. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
3. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
4. [stage-1-headless-api-…-design](./01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md) **全文** — Pattern A 鉴权地基设计稿(动手前必读,含 5 PR + 不可逆决策
5. [ADR-003 §4.3-§4.4](./01-redesign/adr-003-llm-key-billing.zh-CN.md) — quota + 悲观预扣(付费 SaaS 轨道)
6. [ADR-002 §3](./01-redesign/adr-002-sandbox-isolation.zh-CN.md) — Stage 1 用 §3 轻量版(**不**读 §5 K8s 完整版)
**做安全/合规评审**
1. ADR-001 / ADR-002 / ADR-003 §4.6BYO/ ADR-004 §5.4strict 装饰器)
@@ -196,7 +196,7 @@ async def postgres_url(postgres_container):
- [ ] **T1.7 doctor.py 加 PG 探测**:仅在 `database.backend == 'postgres'` 时调 `asyncpg.connect(url)` + 报 PG version;测试 `database.backend: sqlite` 时不查 PGregression);commit
- [ ] **T1.8 setup_wizard.py 加交互**:选数据库后端时新增 postgres 选项 + DATABASE_URL 引导;commit
- [ ] **T1.9 加 CI workflow**:新建 `.github/workflows/backend-postgres-tests.yml`(用 docker service 或让 testcontainers 在 GitHub runner 起 PG)跑 `pytest -m postgres -v`;本地推到 fork 验证 CI 绿;commit
- [ ] **T1.10 验收 + 文档**:跑全套 `cd backend && make test` 验证既有 277 测试不破;`docs/multi-tenant-redesign/03-impl/pr1-postgres-setup.md` 记录 PG 版本对齐结论 + fixture 用法;commit
- [ ] **T1.10 验收 + 文档**:跑全套 `cd backend && make test` 验证既有 277 测试不破;`docs/multi-tenant-redesign/03-impl/pr1-postgres-setup.zh-CN.md` 记录 PG 版本对齐结论 + fixture 用法;commit
---
@@ -217,7 +217,7 @@ async def postgres_url(postgres_container):
**新增**
- `scripts/migrate_sqlite_to_postgres.py` — SQLAlchemy reflection 把现有 4 张表数据搬过去
- `docs/multi-tenant-redesign/03-impl/pr2-postgres-default.md`implementation note,可选)
- `docs/multi-tenant-redesign/03-impl/pr2-postgres-default.zh-CN.md`implementation note,可选)
**修改**
- `config.example.yaml``database` 段默认 postgres
@@ -1028,7 +1028,7 @@ class ExternalUserRow(Base):
## Stage 0 退出 Go/No-Go(来自 phased-rollout-by-scale
工程层面:
- [x] PR1-PR8 全部合入 ✅(见 STATUS.md 8 PR 状态表,全 merged 进分支)
- [x] PR1-PR8 全部合入 ✅(见 STATUS.zh-CN.md 8 PR 状态表,全 merged 进分支)
- [x] 既有 277 + 新增 ~70 测试全 100% 通过 ✅(实际 3250 passed + 31 skipped,新增 ~163
- [x] CI(含 backend-postgres-tests)绿 ✅ 2026-05-12 用户确认
- [x] 手工 smoke:注册新用户 → workspace 自动建 → JWT 含 wid → 创建 thread → 跨 workspace 互调 404 ✅ 2026-06-27 `apps/examples/http-chat/multi_tenant.py` PASS(N 租户真并发 + 多轮链式上下文 + 双向隔离 search/404;注:JWT wid claim 未显式解码断言,由隔离端到端间接覆盖)
@@ -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 细分"即可,不与本设计冲突。
+16 -4
View File
@@ -30,26 +30,38 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
return proxyRequest(
request,
`/api/v1/memory/${(await params).path.join("/")}`,
);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
return proxyRequest(
request,
`/api/v1/memory/${(await params).path.join("/")}`,
);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
return proxyRequest(
request,
`/api/v1/memory/${(await params).path.join("/")}`,
);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
return proxyRequest(
request,
`/api/v1/memory/${(await params).path.join("/")}`,
);
}
+2 -2
View File
@@ -27,9 +27,9 @@ async function proxyRequest(request: NextRequest, pathname: string) {
}
export async function GET(request: NextRequest) {
return proxyRequest(request, "/api/memory");
return proxyRequest(request, "/api/v1/memory");
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request, "/api/memory");
return proxyRequest(request, "/api/v1/memory");
}
@@ -408,7 +408,7 @@ export function InputBox({
setFollowupsLoading(true);
setFollowups([]);
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
fetch(`${getBackendBaseURL()}/api/v1/threads/${threadId}/suggestions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+6 -6
View File
@@ -27,20 +27,20 @@ function isAgentsApiDisabledDetail(detail: string | undefined): boolean {
}
export async function listAgents(): Promise<Agent[]> {
const res = await fetch(`${getBackendBaseURL()}/api/agents`);
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents`);
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
const data = (await res.json()) as { agents: Agent[] };
return data.agents;
}
export async function getAgent(name: string): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`);
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`);
if (!res.ok) throw new Error(`Agent '${name}' not found`);
return res.json() as Promise<Agent>;
}
export async function createAgent(request: CreateAgentRequest): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents`, {
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
@@ -59,7 +59,7 @@ export async function updateAgent(
name: string,
request: UpdateAgentRequest,
): Promise<Agent> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
@@ -72,7 +72,7 @@ export async function updateAgent(
}
export async function deleteAgent(name: string): Promise<void> {
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
@@ -84,7 +84,7 @@ export async function checkAgentName(
let res: Response;
try {
res = await fetch(
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
`${getBackendBaseURL()}/api/v1/agents/check?name=${encodeURIComponent(name)}`,
);
} catch {
throw new AgentNameCheckError(
+2 -2
View File
@@ -15,7 +15,7 @@ export async function upsertFeedback(
comment?: string,
): Promise<FeedbackData> {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -33,7 +33,7 @@ export async function deleteFeedback(
runId: string,
): Promise<void> {
const res = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}/runs/${encodeURIComponent(runId)}/feedback`,
{ method: "DELETE" },
);
if (!res.ok && res.status !== 404) {
+2 -2
View File
@@ -15,7 +15,7 @@ export function urlOfArtifact({
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/api/v1/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
@@ -23,5 +23,5 @@ export function extractArtifactsFromThread(thread: AgentThread) {
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
return `${getBackendBaseURL()}/api/v1/threads/${threadId}/artifacts${absolutePath}`;
}
+2 -2
View File
@@ -4,12 +4,12 @@ import { getBackendBaseURL } from "@/core/config";
import type { MCPConfig } from "./types";
export async function loadMCPConfig() {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`);
const response = await fetch(`${getBackendBaseURL()}/api/v1/mcp/config`);
return response.json() as Promise<MCPConfig>;
}
export async function updateMCPConfig(config: MCPConfig) {
const response = await fetch(`${getBackendBaseURL()}/api/mcp/config`, {
const response = await fetch(`${getBackendBaseURL()}/api/v1/mcp/config`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
+7 -7
View File
@@ -81,12 +81,12 @@ async function readMemoryResponse(
}
export async function loadMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`);
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory`);
return readMemoryResponse(response, "Failed to fetch memory");
}
export async function clearMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory`, {
method: "DELETE",
});
return readMemoryResponse(response, "Failed to clear memory");
@@ -94,7 +94,7 @@ export async function clearMemory(): Promise<UserMemory> {
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
const response = await fetch(
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
{
method: "DELETE",
},
@@ -103,12 +103,12 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
}
export async function exportMemory(): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/export`);
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/export`);
return readMemoryResponse(response, "Failed to export memory");
}
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/import`, {
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/import`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -121,7 +121,7 @@ export async function importMemory(memory: UserMemory): Promise<UserMemory> {
export async function createMemoryFact(
input: MemoryFactInput,
): Promise<UserMemory> {
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/facts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -136,7 +136,7 @@ export async function updateMemoryFact(
input: MemoryFactPatchInput,
): Promise<UserMemory> {
const response = await fetch(
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
{
method: "PATCH",
headers: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
import type { ModelsResponse } from "./types";
export async function loadModels(): Promise<ModelsResponse> {
const res = await fetch(`${getBackendBaseURL()}/api/models`);
const res = await fetch(`${getBackendBaseURL()}/api/v1/models`);
const data = (await res.json()) as Partial<ModelsResponse>;
return {
models: data.models ?? [],
+3 -3
View File
@@ -4,14 +4,14 @@ import { getBackendBaseURL } from "@/core/config";
import type { Skill } from "./type";
export async function loadSkills() {
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
const skills = await fetch(`${getBackendBaseURL()}/api/v1/skills`);
const json = await skills.json();
return json.skills as Skill[];
}
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await fetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
`${getBackendBaseURL()}/api/v1/skills/${skillName}`,
{
method: "PUT",
headers: {
@@ -39,7 +39,7 @@ export interface InstallSkillResponse {
export async function installSkill(
request: InstallSkillRequest,
): Promise<InstallSkillResponse> {
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
const response = await fetch(`${getBackendBaseURL()}/api/v1/skills/install`, {
method: "POST",
headers: {
"Content-Type": "application/json",
+2 -2
View File
@@ -570,7 +570,7 @@ export function useThreadHistory(threadId: string) {
try {
setLoading(true);
const result: { data: RunMessage[]; hasMore: boolean } = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadIdRef.current)}/runs/${encodeURIComponent(run.run_id)}/messages`,
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadIdRef.current)}/runs/${encodeURIComponent(run.run_id)}/messages`,
{
method: "GET",
headers: {
@@ -721,7 +721,7 @@ export function useDeleteThread() {
await apiClient.threads.delete(threadId);
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}`,
{
method: "DELETE",
},
+3 -3
View File
@@ -52,7 +52,7 @@ export async function uploadFiles(
});
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads`,
{
method: "POST",
body: formData,
@@ -73,7 +73,7 @@ export async function listUploadedFiles(
threadId: string,
): Promise<ListFilesResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/list`,
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/list`,
);
if (!response.ok) {
@@ -93,7 +93,7 @@ export async function deleteUploadedFile(
filename: string,
): Promise<{ success: boolean; message: string }> {
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/${filename}`,
{
method: "DELETE",
},