Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b866a0bee | |||
| 428d4e9eb5 | |||
| b26c5e4dd0 | |||
| 02628d4e08 | |||
| 2d982c3f0b | |||
| 1b7d8217dd | |||
| a173d4f93c | |||
| cdd9d6701f | |||
| 4cd2acc192 | |||
| 37c3417bfb | |||
| 938c2eb0be | |||
| dee1eb2374 | |||
| 190c1dc3c8 | |||
| e6a12b9a7c | |||
| 2461a923be | |||
| 3f0d5c8c96 | |||
| 9d6decd91b | |||
| c3c4e57416 | |||
| 950964e0ef | |||
| 84e06ca396 | |||
| d16e294185 | |||
| e92fe0d7fb | |||
| 9eb6103a4d | |||
| 78359c3fd8 | |||
| 3ec4fb8537 | |||
| ac8b37bd27 | |||
| d9a86878f4 | |||
| 4f9116e3fe | |||
| ec4769a33f | |||
| 978b0cf24d | |||
| 5093d3d123 | |||
| ac2ab26a7e | |||
| ab40a4a17e | |||
| 04170205dd | |||
| 9166ab205d | |||
| 427709e0a8 | |||
| e5ff6e74f9 | |||
| ef32a6de0f | |||
| 240c6bd0e2 | |||
| 8ea5507d27 | |||
| 45b019dae8 | |||
| 3a16da6449 | |||
| 30fa16ba7a | |||
| c77ee0dc06 | |||
| 45efba50e4 | |||
| dd1d40368d |
@@ -31,6 +31,7 @@ extensions_config.json
|
|||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.qoder/
|
||||||
|
|
||||||
# Coverage report
|
# Coverage report
|
||||||
coverage.xml
|
coverage.xml
|
||||||
@@ -63,3 +64,4 @@ config.yaml.bak
|
|||||||
skills/gstack
|
skills/gstack
|
||||||
skills/superpowers
|
skills/superpowers
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
+186
-74
@@ -1,20 +1,59 @@
|
|||||||
# 🦌 DeerFlow - 2.0
|
# 🦌 DeerFlow - 2.0 · 多租户改造
|
||||||
|
|
||||||
[English](./README.md) | 中文 | [日本語](./README_ja.md) | [Français](./README_fr.md) | [Русский](./README_ru.md)
|
[English](./README.md) | 中文 | [日本語](./README_ja.md) | [Français](./README_fr.md) | [Русский](./README_ru.md)
|
||||||
|
|
||||||
[](./backend/pyproject.toml)
|
[](./backend/pyproject.toml)
|
||||||
[](./Makefile)
|
[](./Makefile)
|
||||||
|
[](./config.example.yaml)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/14699" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14699" alt="bytedance%2Fdeer-flow | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
DeerFlow(**D**eep **E**xploration and **E**fficient **R**esearch **Flow**)是一个开源的 **super agent harness**:它把 **sub-agents**、**memory**、**sandbox** 组织在一起,再配合可扩展的 **skills**,让 agent 可以完成几乎任何事情。
|
||||||
> 2026 年 2 月 28 日,DeerFlow 2 发布后登上 GitHub Trending 第 1 名。非常感谢社区的支持,这是大家一起做到的。
|
|
||||||
|
|
||||||
DeerFlow(**D**eep **E**xploration and **E**fficient **R**esearch **Flow**)是一个开源的 **super agent harness**。它把 **sub-agents**、**memory** 和 **sandbox** 组织在一起,再配合可扩展的 **skills**,让 agent 可以完成几乎任何事情。
|
> [!IMPORTANT]
|
||||||
|
> **本分支(`docs/multi-tenant-redesign`)是 DeerFlow 的多租户改造主线。** 在保留原有 super agent harness 全部能力的基础上,引入了 **workspace 租户模型、Postgres 为默认后端、带 `workspace_id` 的行级数据隔离、扩展后的 JWT(带 `wid`/`role`)、per-workspace 的文件系统布局、Headless API schema 底座,以及 `apps/` 上层应用脚手架**。下面的 [多租户改造](#多租户改造本分支主线) 一节是阅读本仓库的入口。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **DeerFlow 2.0 是一次彻底重写。** 它和 v1 没有共用代码。如果你要找的是最初的 Deep Research 框架,可以前往 [`1.x` 分支](https://github.com/bytedance/deer-flow/tree/main-1.x)。
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
||||||
|
|
||||||
> [!NOTE]
|
## 多租户改造(本分支主线)
|
||||||
> **DeerFlow 2.0 是一次彻底重写。** 它和 v1 没有共用代码。如果你要找的是最初的 Deep Research 框架,可以前往 [`1.x` 分支](https://github.com/bytedance/deer-flow/tree/main-1.x)。那里仍然欢迎贡献;当前的主要开发已经转向 2.0。
|
|
||||||
|
DeerFlow 原本面向"单机可信环境、单用户"。本分支按"以个人用户为主、少量小团队,统一只有 **workspace** 概念(个人 = 1 人 workspace),中心化 SaaS 为主线"的目标,把租户能力分 **Stage 0–4** 渐进落地。**目前 Stage 0(底座)工程层面已全部合入。**
|
||||||
|
|
||||||
|
### Stage 0 已落地的能力
|
||||||
|
|
||||||
|
| 能力 | 说明 | 落点 |
|
||||||
|
|---|---|---|
|
||||||
|
| **workspace 租户模型** | 新增 `workspaces` + `workspace_memberships` 两张表与仓储;每个用户注册时自动建 1 人 workspace(owner=自己);slug 唯一、黑名单校验。 | `persistence/workspace*` |
|
||||||
|
| **Postgres 成为默认后端** | `config.example.yaml` / `.env.example` / `make dev` / `make doctor` 默认走 Postgres,与生产对齐;SQLite 保留为离线开发兜底。 | 见 [数据库后端](#数据库后端) |
|
||||||
|
| **行级数据隔离** | 4 张业务表(`threads_meta` / `runs` / `run_events` / `feedback`)加 `workspace_id` 列(`NOT NULL` + `UNIQUE(workspace_id, thread_id)` 兜底),入口路由按 `(workspace_id, thread_id)` 强校验,跨 workspace 访问必 404。 | `persistence/*`、Gateway routers |
|
||||||
|
| **扩展后的 JWT** | TokenPayload 一次到位为 `{sub, wid, role, exp, iat, ver}`;登录 / 改密 / `/auth/me` 全部带上 workspace 与角色;旧版 4 字段 JWT 被识别为 `WORKSPACE_MISSING` 并要求重登。 | `app/gateway/auth/` |
|
||||||
|
| **per-workspace 文件系统** | 运行期状态从 `users/{uid}/...` 迁移到 `workspaces/{wid}/threads/{tid}/...`,提供 `make migrate-paths` 迁移脚本(支持 `DRY_RUN=1` 预览)。 | `config/paths.py`、`thread_data_middleware.py` |
|
||||||
|
| **边界扫描围栏** | CI 静态扫描禁止任何路径绕过入口直连 LangGraph checkpoint/store,确保隔离不被旁路。 | `tests/boundary_allowlist.toml`、`test_workspace_boundary*.py` |
|
||||||
|
| **Headless API schema 底座** | 预建 `service_accounts` / `api_keys`(`dfk_live_*` / `dfk_test_*`)/ `external_users` 三张表(schema-only),为 Stage 1 的无人值守接入做准备。 | `persistence/{service_account,api_key,external_user}` |
|
||||||
|
| **Alembic 迁移 + 回填** | `0001`→`0003` 迁移链 + `backfill_workspace_id.py` 回填脚本,dev 用 `create_all()` 自愈、生产用迁移。 | `persistence/migrations/` |
|
||||||
|
|
||||||
|
> 数据库的事实参考(10 张表全字段 / 外键 / 索引)见 [`database-schema-as-built.zh-CN.md`](docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md)。
|
||||||
|
|
||||||
|
### 路线图:Stage 0–4
|
||||||
|
|
||||||
|
| Stage | 目标 | 关键内容 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **0** | workspace 模型立起来 + Postgres 切换 + auth 收紧 | 上表全部 | ✅ 工程层面已合入(业务门 / live 验证待跟进)|
|
||||||
|
| **1** | 第一批付费客户 + 业务系统集成(双轨并行)| quota + 计费 + AioSandbox 轻量加固;Headless API(Pattern A/B)接通 PR8 三张表 | 🔜 已具备底座 |
|
||||||
|
| **2** | 增长期,安全与隔离深化 | DeerFlow 自有表启用 RLS、KMS、ObjectStorage、完整 RBAC + invitation | 📋 规划中 |
|
||||||
|
| **3** | 成熟期,K8s 隔离 + BYO | K8s namespace + NetworkPolicy、BYO LLM key、audit DB 拆分 | 📋 规划中 |
|
||||||
|
| **4** | 企业化,按需开启 | SSO、自定义域名、per-tenant DB、gVisor/Kata、合规审计 | 📋 按合同 |
|
||||||
|
|
||||||
|
### 多租户文档入口
|
||||||
|
|
||||||
|
- **汇总索引(先读这个)**:[`docs/multi-tenant-redesign/README.zh-CN.md`](docs/multi-tenant-redesign/README.zh-CN.md)
|
||||||
|
- **现状架构鸟瞰**:[`00-current-state/architecture-overview.zh-CN.md`](docs/multi-tenant-redesign/00-current-state/architecture-overview.zh-CN.md)
|
||||||
|
- **决策(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.zh-CN.md`](docs/multi-tenant-redesign/03-impl/STATUS.zh-CN.md)
|
||||||
|
|
||||||
## 官网
|
## 官网
|
||||||
|
|
||||||
@@ -32,13 +71,13 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- [🦌 DeerFlow - 2.0](#-deerflow---20)
|
- [🦌 DeerFlow - 2.0 · 多租户改造](#-deerflow---20--多租户改造)
|
||||||
|
- [多租户改造(本分支主线)](#多租户改造本分支主线)
|
||||||
- [官网](#官网)
|
- [官网](#官网)
|
||||||
- [InfoQuest](#infoquest)
|
- [字节跳动火山引擎方舟 Coding Plan](#字节跳动火山引擎方舟-coding-plan)
|
||||||
- [目录](#目录)
|
|
||||||
- [一句话交给 Coding Agent 安装](#一句话交给-coding-agent-安装)
|
|
||||||
- [快速开始](#快速开始)
|
- [快速开始](#快速开始)
|
||||||
- [配置](#配置)
|
- [配置](#配置)
|
||||||
|
- [数据库后端](#数据库后端)
|
||||||
- [运行应用](#运行应用)
|
- [运行应用](#运行应用)
|
||||||
- [部署建议与资源规划](#部署建议与资源规划)
|
- [部署建议与资源规划](#部署建议与资源规划)
|
||||||
- [方式一:Docker(推荐)](#方式一docker推荐)
|
- [方式一:Docker(推荐)](#方式一docker推荐)
|
||||||
@@ -48,7 +87,7 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
- [MCP Server](#mcp-server)
|
- [MCP Server](#mcp-server)
|
||||||
- [IM 渠道](#im-渠道)
|
- [IM 渠道](#im-渠道)
|
||||||
- [LangSmith 链路追踪](#langsmith-链路追踪)
|
- [LangSmith 链路追踪](#langsmith-链路追踪)
|
||||||
- [从 Deep Research 到 Super Agent Harness](#从-deep-research-到-super-agent-harness)
|
- [多租户架构详解](#多租户架构详解)
|
||||||
- [核心特性](#核心特性)
|
- [核心特性](#核心特性)
|
||||||
- [Skills 与 Tools](#skills-与-tools)
|
- [Skills 与 Tools](#skills-与-tools)
|
||||||
- [Claude Code 集成](#claude-code-集成)
|
- [Claude Code 集成](#claude-code-集成)
|
||||||
@@ -56,25 +95,14 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
- [Sandbox 与文件系统](#sandbox-与文件系统)
|
- [Sandbox 与文件系统](#sandbox-与文件系统)
|
||||||
- [Context Engineering](#context-engineering)
|
- [Context Engineering](#context-engineering)
|
||||||
- [长期记忆](#长期记忆)
|
- [长期记忆](#长期记忆)
|
||||||
- [推荐模型](#推荐模型)
|
- [在 DeerFlow 之上构建应用(apps/)](#在-deerflow-之上构建应用apps)
|
||||||
- [内嵌 Python Client](#内嵌-python-client)
|
- [内嵌 Python Client](#内嵌-python-client)
|
||||||
|
- [推荐模型](#推荐模型)
|
||||||
- [文档](#文档)
|
- [文档](#文档)
|
||||||
- [⚠️ 安全使用](#️-安全使用)
|
- [⚠️ 安全使用](#️-安全使用)
|
||||||
- [参与贡献](#参与贡献)
|
- [参与贡献](#参与贡献)
|
||||||
- [许可证](#许可证)
|
- [许可证](#许可证)
|
||||||
- [致谢](#致谢)
|
- [致谢](#致谢)
|
||||||
- [核心贡献者](#核心贡献者)
|
|
||||||
- [Star History](#star-history)
|
|
||||||
|
|
||||||
## 一句话交给 Coding Agent 安装
|
|
||||||
|
|
||||||
如果你在用 Claude Code、Codex、Cursor、Windsurf 或其他 coding agent,可以直接把下面这句话发给它:
|
|
||||||
|
|
||||||
```text
|
|
||||||
如果还没 clone DeerFlow,就先 clone,然后按照 https://raw.githubusercontent.com/bytedance/deer-flow/main/Install.md 把它的本地开发环境初始化好
|
|
||||||
```
|
|
||||||
|
|
||||||
这条提示词是给 coding agent 用的。它会在需要时先 clone 仓库,优先选择 Docker,完成初始化,并在结束时告诉你下一条启动命令,以及还缺哪些配置需要你补充。
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -149,6 +177,48 @@ https://github.com/user-attachments/assets/a8bcadc4-e040-4cf2-8fda-dd768b999c18
|
|||||||
api_key: your-actual-api-key-here # 替换为真实 key
|
api_key: your-actual-api-key-here # 替换为真实 key
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 数据库后端
|
||||||
|
|
||||||
|
多租户改造后,**Stage 0+ 默认后端是 Postgres**(与生产对齐,并为后续 RLS 留好空间)。`config.example.yaml` 默认带:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
backend: postgres
|
||||||
|
postgres_url: $DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `.env` 中设置 `DATABASE_URL`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql+asyncpg://deerflow:deerflow_dev@localhost:5432/deerflow
|
||||||
|
# 远程 RDS / Cloud SQL 示例:
|
||||||
|
# DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME
|
||||||
|
```
|
||||||
|
|
||||||
|
启动本地 Postgres 开发容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose-dev.yaml up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
- `make doctor` 会报告当前配置的后端、尝试 asyncpg 连接,并给出可执行的修复建议。
|
||||||
|
- `make dev` 在启动各服务前会先 preflight Postgres 可达性;`DATABASE_URL` 不可达时直接中止。
|
||||||
|
- **dev** 启动时用 `Base.metadata.create_all()` 自动建缺失的表(不改已存在的表);**生产**用 Alembic 迁移(`backend/packages/harness/deerflow/persistence/migrations/`)。目标库不存在时会自动 `CREATE DATABASE` 后重试。
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>离线开发(SQLite 兜底)</summary>
|
||||||
|
|
||||||
|
如果你不想起 Postgres,把 `config.yaml` 改成:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
database:
|
||||||
|
backend: sqlite
|
||||||
|
sqlite_dir: .deer-flow/data
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite 仍是合法的离线开发后端;但 RLS / 多节点等 Stage 2+ 能力需要 Postgres。
|
||||||
|
</details>
|
||||||
|
|
||||||
### 运行应用
|
### 运行应用
|
||||||
|
|
||||||
#### 部署建议与资源规划
|
#### 部署建议与资源规划
|
||||||
@@ -194,7 +264,7 @@ make down # 停止并移除容器
|
|||||||
|
|
||||||
如果你更希望直接在本地启动各个服务:
|
如果你更希望直接在本地启动各个服务:
|
||||||
|
|
||||||
前提:先完成上面的“配置”步骤(`make config` 和模型 API key 配置)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖;skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。
|
前提:先完成上面的"配置"步骤(`make config`、模型 API key、`DATABASE_URL`)。`make dev` 需要有效配置文件,默认读取项目根目录下的 `config.yaml`。可以用 `DEER_FLOW_PROJECT_ROOT` 显式指定项目根目录,也可以用 `DEER_FLOW_CONFIG_PATH` 指向某个具体配置文件。运行期状态默认写到项目根目录下的 `.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖;skills 默认读取项目根目录下的 `skills/`,可用 `DEER_FLOW_SKILLS_PATH` 覆盖。
|
||||||
在 Windows 上,请使用 Git Bash 运行本地开发流程。基于 bash 的服务脚本不支持直接在原生 `cmd.exe` 或 PowerShell 中执行,且 WSL 也不保证可用,因为部分脚本依赖 Git for Windows 的 `cygpath` 等工具。
|
在 Windows 上,请使用 Git Bash 运行本地开发流程。基于 bash 的服务脚本不支持直接在原生 `cmd.exe` 或 PowerShell 中执行,且 WSL 也不保证可用,因为部分脚本依赖 Git for Windows 的 `cygpath` 等工具。
|
||||||
|
|
||||||
1. **检查依赖环境**:
|
1. **检查依赖环境**:
|
||||||
@@ -220,6 +290,16 @@ make down # 停止并移除容器
|
|||||||
|
|
||||||
5. **访问地址**:http://localhost:2026
|
5. **访问地址**:http://localhost:2026
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> `make dev` 是前台阻塞运行。日常调试更顺手的是仓库根 `scripts/` 下两个生命周期脚本(子命令统一为 `start / stop / restart / status / logs / run`):
|
||||||
|
> - `scripts/dev-gateway.sh` — 只起 Gateway(`http://localhost:8001`),起得快,适合调后端 API / 接入示例。
|
||||||
|
> - `scripts/dev-full.sh` — Gateway + 前端 + nginx(`http://localhost:2026`),连前端一起调。
|
||||||
|
>
|
||||||
|
> 例如 `./scripts/dev-gateway.sh start`、`./scripts/dev-gateway.sh logs`、`SKIP_INSTALL=1 ./scripts/dev-full.sh start`。详见 [apps/README.md](apps/README.md)。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 把历史的 `users/` 目录树迁移到新的 per-workspace 布局:`make migrate-paths`(加 `DRY_RUN=1` 仅预览,`DEFAULT_WORKSPACE=<wid>` 指定未分配用户的归属 workspace)。
|
||||||
|
|
||||||
### 进阶配置
|
### 进阶配置
|
||||||
#### Sandbox 模式
|
#### Sandbox 模式
|
||||||
|
|
||||||
@@ -405,23 +485,48 @@ LANGSMITH_PROJECT=xxx
|
|||||||
|
|
||||||
Docker 部署时,追踪默认关闭。在 `.env` 中设置 `LANGSMITH_TRACING=true` 和 `LANGSMITH_API_KEY` 即可启用。
|
Docker 部署时,追踪默认关闭。在 `.env` 中设置 `LANGSMITH_TRACING=true` 和 `LANGSMITH_API_KEY` 即可启用。
|
||||||
|
|
||||||
## 从 Deep Research 到 Super Agent Harness
|
## 多租户架构详解
|
||||||
|
|
||||||
DeerFlow 最初是一个 Deep Research 框架,后来社区把它一路推到了更远的地方。上线之后,开发者拿它去做的事情早就不止研究:搭数据流水线、生成演示文稿、快速起 dashboard、自动化内容流程,很多方向一开始连我们自己都没想到。
|
> 这一节展开 [多租户改造](#多租户改造本分支主线) 里 Stage 0 已落地的实现细节。完整决策与路线见 [`docs/multi-tenant-redesign/`](docs/multi-tenant-redesign/)。
|
||||||
|
|
||||||
这让我们意识到一件事:DeerFlow 不只是一个研究工具。它更像一个 **harness**,一个真正让 agents 把事情做完的运行时基础设施。
|
**workspace 是唯一的隔离粒度。** 个人用户 = 1 人 workspace,小团队 = 多人 workspace。骨架是两条主线:
|
||||||
|
|
||||||
所以我们把它从头重做了一遍。
|
1. **租户骨架**:`users` ↔ `workspaces`(多对多经 `workspace_memberships`)。每个用户注册时自动建 1 人 workspace(owner=自己),slug 唯一且过黑名单校验。
|
||||||
|
2. **业务数据**:`threads_meta` → `runs` → `run_events` / `feedback`,全部挂 `workspace_id`(行级隔离),workspace 删除时级联清空。
|
||||||
|
|
||||||
DeerFlow 2.0 不再是一个需要你自己拼装的 framework。它是一个开箱即用、同时又足够可扩展的 super agent harness。基于 LangGraph 和 LangChain 构建,默认就带上了 agent 真正会用到的关键能力:文件系统、memory、skills、sandbox 执行环境,以及为复杂多步骤任务做规划、拉起 sub-agents 的能力。
|
**数据隔离怎么做的。** DeerFlow 自有表走行级 `workspace_id` + `UNIQUE(workspace_id, thread_id)` 兜底;入口路由(`threads.py` / `thread_runs.py`)按 `(workspace_id, thread_id)` 强校验,跨 workspace 访问必返 404。LangGraph 自己的 checkpointer / store 表(`langgraph-checkpoint-postgres==3.0.5` 无 `connection_factory`,无法注入 RLS)则走**应用层强校验**,并由 CI 静态扫描(`tests/boundary_allowlist.toml`)禁止任何路径绕过入口直连这些表。
|
||||||
|
|
||||||
你可以直接拿来用,也可以拆开重组,改成你自己的样子。
|
**身份与会话。** JWT TokenPayload 一次到位为 `{sub, wid, role, exp, iat, ver}`:
|
||||||
|
|
||||||
|
- `wid` — 当前 workspace;`role` — workspace 内角色(Stage 0 简化为 owner-only,Stage 2 扩到 owner/admin/member)。
|
||||||
|
- `ver` — `token_version`,bump 后旧 token 全失效。
|
||||||
|
- 旧版 4 字段 JWT 会被识别为 `WORKSPACE_MISSING` 并要求重登;登录 / 改密 / `/auth/me` 都会带上 workspace 与角色(`/auth/me` 返回 `workspaces[]`,含 id/name/slug/role)。
|
||||||
|
|
||||||
|
**文件系统布局。** 运行期状态按 workspace 分目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${DEER_FLOW_HOME:-./.deer-flow}/
|
||||||
|
└── workspaces/
|
||||||
|
└── {workspace_id}/
|
||||||
|
├── threads/{thread_id}/... ← 每个 thread 的 sandbox / 产物
|
||||||
|
└── users/{user_id}/... ← 用户级状态
|
||||||
|
```
|
||||||
|
|
||||||
|
历史的 `users/{uid}/...` 布局用 `make migrate-paths`(`DRY_RUN=1` 预览)迁移过来。
|
||||||
|
|
||||||
|
**Headless API schema 底座(Stage 0 末预建,schema-only)。** 为 Stage 1 的无人值守 / 业务系统接入准备:
|
||||||
|
|
||||||
|
- `service_accounts` — workspace 内的非人身份,带 `identity_mode` 三态(`collapsed` / `external_passthrough` / `both`)。
|
||||||
|
- `api_keys` — service account 的凭证,格式 `dfk_live_*` / `dfk_test_*`,`key_prefix` 全局唯一 + 部分索引 `WHERE revoked_at IS NULL`。
|
||||||
|
- `external_users` — passthrough 终端身份,`(service_account_id, external_id)` 复合唯一。
|
||||||
|
|
||||||
|
> ⚠️ Stage 0 只建表,**API Key 鉴权中间件尚未接入**。外部系统当前只能走会话 cookie(见 [apps/README.md](apps/README.md) 的鉴权说明);等 `Authorization: Bearer dfk_live_...` 在 Stage 1 落地后再补无人值守接入。
|
||||||
|
|
||||||
## 核心特性
|
## 核心特性
|
||||||
|
|
||||||
### Skills 与 Tools
|
### Skills 与 Tools
|
||||||
|
|
||||||
Skills 是 DeerFlow 能做“几乎任何事”的关键。
|
Skills 是 DeerFlow 能做"几乎任何事"的关键。
|
||||||
|
|
||||||
标准的 Agent Skill 是一种结构化能力模块,通常就是一个 Markdown 文件,里面定义了工作流、最佳实践,以及相关的参考资源。DeerFlow 自带一批内置 skills,覆盖研究、报告生成、演示文稿制作、网页生成、图像和视频生成等场景。真正有意思的地方在于它的扩展性:你可以加自己的 skills,替换内置 skills,或者把多个 skills 组合成复合工作流。
|
标准的 Agent Skill 是一种结构化能力模块,通常就是一个 Markdown 文件,里面定义了工作流、最佳实践,以及相关的参考资源。DeerFlow 自带一批内置 skills,覆盖研究、报告生成、演示文稿制作、网页生成、图像和视频生成等场景。真正有意思的地方在于它的扩展性:你可以加自己的 skills,替换内置 skills,或者把多个 skills 组合成复合工作流。
|
||||||
|
|
||||||
@@ -431,8 +536,6 @@ Skills 采用按需渐进加载,不会一次性把所有内容都塞进上下
|
|||||||
|
|
||||||
Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索、网页抓取、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
|
Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索、网页抓取、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
|
||||||
|
|
||||||
Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
# sandbox 容器内的路径
|
# sandbox 容器内的路径
|
||||||
/mnt/skills/public
|
/mnt/skills/public
|
||||||
@@ -456,24 +559,7 @@ Gateway 生成后续建议时,现在会先把普通字符串输出和 block/li
|
|||||||
npx skills add https://github.com/bytedance/deer-flow --skill claude-to-deerflow
|
npx skills add https://github.com/bytedance/deer-flow --skill claude-to-deerflow
|
||||||
```
|
```
|
||||||
|
|
||||||
然后确认 DeerFlow 已经启动(默认地址是 `http://localhost:2026`),在 Claude Code 里使用 `/claude-to-deerflow` 命令即可。
|
然后确认 DeerFlow 已经启动(默认地址是 `http://localhost:2026`),在 Claude Code 里使用 `/claude-to-deerflow` 命令即可。完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。
|
||||||
|
|
||||||
**你可以做的事情包括:**
|
|
||||||
- 给 DeerFlow 发送消息,并接收流式响应
|
|
||||||
- 选择执行模式:flash(更快)、standard、pro(规划模式)、ultra(sub-agents 模式)
|
|
||||||
- 检查 DeerFlow 健康状态,列出 models / skills / agents
|
|
||||||
- 管理 threads 和会话历史
|
|
||||||
- 上传文件做分析
|
|
||||||
|
|
||||||
**环境变量**(可选,用于自定义端点):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DEERFLOW_URL=http://localhost:2026 # 统一代理基地址
|
|
||||||
DEERFLOW_GATEWAY_URL=http://localhost:2026 # Gateway API
|
|
||||||
DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
|
|
||||||
```
|
|
||||||
|
|
||||||
完整 API 说明见 [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md)。
|
|
||||||
|
|
||||||
### Sub-Agents
|
### Sub-Agents
|
||||||
|
|
||||||
@@ -485,12 +571,10 @@ lead agent 可以按需动态拉起 sub-agents。每个 sub-agent 都有自己
|
|||||||
|
|
||||||
### Sandbox 与文件系统
|
### Sandbox 与文件系统
|
||||||
|
|
||||||
DeerFlow 不只是“会说它能做”,它是真的有一台自己的“电脑”。
|
DeerFlow 不只是"会说它能做",它是真的有一台自己的"电脑"。
|
||||||
|
|
||||||
每个任务都运行在隔离的 Docker 容器里,里面有完整的文件系统,包括 skills、workspace、uploads、outputs。agent 可以读写和编辑文件,可以执行 bash 命令和代码,也可以查看图片。整个过程都在 sandbox 内完成,可审计、会隔离,不会在不同 session 之间互相污染。
|
每个任务都运行在隔离的 Docker 容器里,里面有完整的文件系统,包括 skills、workspace、uploads、outputs。agent 可以读写和编辑文件,可以执行 bash 命令和代码,也可以查看图片。整个过程都在 sandbox 内完成,可审计、会隔离,不会在不同 session 之间互相污染。
|
||||||
|
|
||||||
这就是“带工具的聊天机器人”和“真正有执行环境的 agent”之间的差别。
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
# sandbox 容器内的路径
|
# sandbox 容器内的路径
|
||||||
/mnt/user-data/
|
/mnt/user-data/
|
||||||
@@ -509,16 +593,30 @@ DeerFlow 不只是“会说它能做”,它是真的有一台自己的“电
|
|||||||
|
|
||||||
大多数 agents 会在对话结束后把一切都忘掉,DeerFlow 不一样。
|
大多数 agents 会在对话结束后把一切都忘掉,DeerFlow 不一样。
|
||||||
|
|
||||||
跨 session 使用时,DeerFlow 会逐步积累关于你的持久 memory,包括你的个人偏好、知识背景,以及长期沉淀下来的工作习惯。你用得越多,它越了解你的写作风格、技术栈和重复出现的工作流。memory 保存在本地,控制权也始终在你手里。
|
跨 session 使用时,DeerFlow 会逐步积累关于你的持久 memory,包括你的个人偏好、知识背景,以及长期沉淀下来的工作习惯。你用得越多,它越了解你的写作风格、技术栈和重复出现的工作流。
|
||||||
|
|
||||||
## 推荐模型
|
## 在 DeerFlow 之上构建应用(apps/)
|
||||||
|
|
||||||
DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM,理论上都可以接入。不过在下面这些能力上表现更强的模型,通常会更适合 DeerFlow:
|
`apps/`(仓库根目录、与 `backend/` / `frontend/` 平级)用于存放**消费 DeerFlow 能力的上层应用**,遵循严格的依赖方向:**app 可以依赖 deerflow,deerflow 不能依赖 app / apps**。
|
||||||
|
|
||||||
- **长上下文窗口**(100k+ tokens),适合深度研究和多步骤任务
|
两种集成模式:
|
||||||
- **推理能力**,适合自适应规划和复杂拆解
|
|
||||||
- **多模态输入**,适合理解图片和视频
|
| 模式 | 适用场景 | 怎么连 | 示例 |
|
||||||
- **稳定的 tool use 能力**,适合可靠的函数调用和结构化输出
|
|---|---|---|---|
|
||||||
|
| **HTTP Gateway**(REST+SSE) | 上层是别的服务 / 多语言 | 调 `http://localhost:2026/api/*` | [`apps/examples/http-chat/`](apps/examples/http-chat/) |
|
||||||
|
| **内嵌 DeerFlowClient** | 上层本身是 Python,进程内直接当 SDK 调 | `from deerflow.client import DeerFlowClient` | [`apps/examples/embedded-chat/`](apps/examples/embedded-chat/) |
|
||||||
|
|
||||||
|
每个示例自带 `run.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① HTTP 模式:需要先起 Gateway(dev-gateway 或 dev-full 都行)
|
||||||
|
./apps/examples/http-chat/run.sh
|
||||||
|
|
||||||
|
# ② 内嵌模式:不需要起任何服务,run.sh 自动进 backend uv 环境运行
|
||||||
|
./apps/examples/embedded-chat/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
完整说明、鉴权流程与新建应用约定见 [apps/README.md](apps/README.md)。
|
||||||
|
|
||||||
## 内嵌 Python Client
|
## 内嵌 Python Client
|
||||||
|
|
||||||
@@ -546,12 +644,25 @@ client.upload_files("thread-1", ["./report.pdf"]) # {"success": True, "files":
|
|||||||
|
|
||||||
所有返回 dict 的方法都会在 CI 中通过 Gateway 的 Pydantic 响应模型校验(`TestGatewayConformance`),以确保内嵌 client 始终和 HTTP API schema 保持同步。完整 API 说明见 `backend/packages/harness/deerflow/client.py`。
|
所有返回 dict 的方法都会在 CI 中通过 Gateway 的 Pydantic 响应模型校验(`TestGatewayConformance`),以确保内嵌 client 始终和 HTTP API schema 保持同步。完整 API 说明见 `backend/packages/harness/deerflow/client.py`。
|
||||||
|
|
||||||
|
## 推荐模型
|
||||||
|
|
||||||
|
DeerFlow 对模型没有强绑定,只要实现了 OpenAI 兼容 API 的 LLM,理论上都可以接入。不过在下面这些能力上表现更强的模型,通常会更适合 DeerFlow:
|
||||||
|
|
||||||
|
- **长上下文窗口**(100k+ tokens),适合深度研究和多步骤任务
|
||||||
|
- **推理能力**,适合自适应规划和复杂拆解
|
||||||
|
- **多模态输入**,适合理解图片和视频
|
||||||
|
- **稳定的 tool use 能力**,适合可靠的函数调用和结构化输出
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
- [多租户改造汇总索引](docs/multi-tenant-redesign/README.zh-CN.md) - workspace / Postgres / RLS / Headless API 的决策与路线
|
||||||
|
- [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) - 开发环境搭建与协作流程
|
- [贡献指南](CONTRIBUTING.md) - 开发环境搭建与协作流程
|
||||||
- [配置指南](backend/docs/CONFIGURATION.md) - 安装与配置说明
|
- [配置指南](backend/docs/CONFIGURATION.md) - 安装与配置说明
|
||||||
- [架构概览](backend/CLAUDE.md) - 技术架构说明
|
- [架构概览](backend/CLAUDE.md) - 技术架构说明
|
||||||
- [后端架构](backend/README.md) - 后端架构与 API 参考
|
- [后端架构](backend/README.md) - 后端架构与 API 参考
|
||||||
|
- [apps/ 上层应用](apps/README.md) - 在 DeerFlow 之上构建应用
|
||||||
|
|
||||||
## ⚠️ 安全使用
|
## ⚠️ 安全使用
|
||||||
|
|
||||||
@@ -562,6 +673,9 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
|||||||
- **未授权的非法调用**:agent 功能被未授权的第三方、公网恶意扫描程序探测到,进而发起批量非法调用请求,执行系统命令、文件读写等高危操作,可能导致安全后果。
|
- **未授权的非法调用**:agent 功能被未授权的第三方、公网恶意扫描程序探测到,进而发起批量非法调用请求,执行系统命令、文件读写等高危操作,可能导致安全后果。
|
||||||
- **合规与法律风险**:若 agent 被非法调用用于实施网络攻击、信息窃取等违法违规行为,可能产生法律责任与合规风险。
|
- **合规与法律风险**:若 agent 被非法调用用于实施网络攻击、信息窃取等违法违规行为,可能产生法律责任与合规风险。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 多租户改造引入的 workspace 行级隔离 / 入口强校验 / 边界扫描,目标是**应用内**的租户隔离;它不替代上面的网络层 / 部署层防护。把 DeerFlow 曝光到不可信网络仍需配合下面的安全措施。多租户更强的 DB 层兜底(RLS / KMS / K8s)规划在 Stage 2–3。
|
||||||
|
|
||||||
### 安全使用建议
|
### 安全使用建议
|
||||||
|
|
||||||
**注意:建议您将 DeerFlow 部署在本地可信的网络环境下。** 若您有跨设备、跨网络的部署需求,必须加入严格的安全措施。例如,采取如下手段:
|
**注意:建议您将 DeerFlow 部署在本地可信的网络环境下。** 若您有跨设备、跨网络的部署需求,必须加入严格的安全措施。例如,采取如下手段:
|
||||||
@@ -575,7 +689,12 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
|||||||
|
|
||||||
欢迎参与贡献。开发环境、工作流和相关规范见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
欢迎参与贡献。开发环境、工作流和相关规范见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|
||||||
目前回归测试已经覆盖 Docker sandbox 模式识别,以及 `backend/tests/` 中 provisioner kubeconfig-path 处理相关测试。
|
提 PR 前请先在本地跑通校验(CI 会在每个 PR 上执行 backend lint + 测试,含 Postgres matrix):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && make lint && make test # ruff + pytest
|
||||||
|
cd frontend && pnpm lint && pnpm typecheck
|
||||||
|
```
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
@@ -583,22 +702,15 @@ DeerFlow 具备**系统指令执行、资源操作、业务逻辑调用**等关
|
|||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
DeerFlow 建立在开源社区大量优秀工作的基础上。所有让 DeerFlow 成为可能的项目和贡献者,我们都心怀感谢。毫不夸张地说,我们是站在巨人的肩膀上继续往前走。
|
DeerFlow 建立在开源社区大量优秀工作的基础上。所有让 DeerFlow 成为可能的项目和贡献者,我们都心怀感谢。
|
||||||
|
|
||||||
特别感谢以下项目带来的关键支持:
|
特别感谢以下项目带来的关键支持:
|
||||||
|
|
||||||
- **[LangChain](https://github.com/langchain-ai/langchain)**:它们提供的优秀框架支撑了我们的 LLM 交互与 chains,让整体集成和能力编排顺畅可用。
|
- **[LangChain](https://github.com/langchain-ai/langchain)**:它们提供的优秀框架支撑了我们的 LLM 交互与 chains。
|
||||||
- **[LangGraph](https://github.com/langchain-ai/langgraph)**:它们在多 agent 编排上的创新方式,是 DeerFlow 复杂工作流得以成立的重要基础。
|
- **[LangGraph](https://github.com/langchain-ai/langgraph)**:它们在多 agent 编排上的创新方式,是 DeerFlow 复杂工作流得以成立的重要基础。
|
||||||
|
|
||||||
这些项目体现了开源协作真正的力量,我们也很高兴能继续建立在这些基础之上。
|
|
||||||
|
|
||||||
### 核心贡献者
|
|
||||||
|
|
||||||
感谢 `DeerFlow` 的核心作者,是他们的判断、投入和持续推进,才让这个项目真正落地:
|
|
||||||
|
|
||||||
- **[Daniel Walnut](https://github.com/hetaoBackend/)**
|
|
||||||
- **[Henry Li](https://github.com/magiccube/)**
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#bytedance/deer-flow&Date)
|
[](https://star-history.com/#bytedance/deer-flow&Date)
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
|
|||||||
+62
-1
@@ -23,6 +23,44 @@ apps/ ← 你的应用(消费 deerflow,不反
|
|||||||
|
|
||||||
> 还有第三种:LangGraph SDK(`langgraph_sdk.get_client(url=".../api")`,graph id `lead_agent`),用于接入 LangGraph 生态工具链。需要的话照 HTTP 示例的鉴权流程拿 cookie 即可。
|
> 还有第三种:LangGraph SDK(`langgraph_sdk.get_client(url=".../api")`,graph id `lead_agent`),用于接入 LangGraph 生态工具链。需要的话照 HTTP 示例的鉴权流程拿 cookie 即可。
|
||||||
|
|
||||||
|
### 运行示例(每个示例自带 run.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① HTTP 模式:需要先起 Gateway(dev-gateway 或 dev-full 都行)
|
||||||
|
# run.sh 会自动探测网关地址:优先 :2026,回退 :8001
|
||||||
|
./apps/examples/http-chat/run.sh
|
||||||
|
DF_BASE=http://localhost:8001 ./apps/examples/http-chat/run.sh # 也可手动指定
|
||||||
|
|
||||||
|
# ② 内嵌模式:不需要起任何服务,run.sh 自动进 backend uv 环境运行
|
||||||
|
./apps/examples/embedded-chat/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
http-chat 的 `run.sh` 优先用 `uv run --no-project --with requests`(临时环境,不污染系统),没有 uv 才回退到本地 `.venv` + pip;可用 `DF_BASE` / `DF_EMAIL` / `DF_PASSWORD` 覆盖。embedded-chat 的 `run.sh` 自动定位 `backend/`、加载 `.env` 后用 `uv run` 启动,依赖 `config.yaml` 里有可用模型。
|
||||||
|
|
||||||
|
### 多租户并发验证(HTTP 模式)
|
||||||
|
|
||||||
|
[`examples/http-chat/multi_tenant.py`](examples/http-chat/multi_tenant.py) 是 `app.py` 的并发 / 多租户版:用 `/api/v1/auth/register` 并发创建多个租户(每个注册用户自带独立 workspace),各自一个 `requests.Session`(独立 cookie)同时跑 N 轮链式对话,并校验:① 真并发(对话时间窗重叠);② 多轮上下文按 `thread_id` 各自保持(第 2 轮起每轮都依赖上一轮结果);③ 租户隔离(`POST /api/threads/search` 仅见己有线程,跨租户 `GET /api/threads/{id}` 返回 404)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前提:已起 Gateway(dev-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.py
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量:`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
|
||||||
|
# 前提:已起 Gateway(dev-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 跑起来
|
## 前置:先把 DeerFlow 跑起来
|
||||||
|
|
||||||
在**仓库根目录**:
|
在**仓库根目录**:
|
||||||
@@ -33,6 +71,29 @@ make dev # 起 Gateway(8001) + 前端(3000) + nginx(2026),统一入
|
|||||||
|
|
||||||
确保 `config.yaml` 里至少配了一个可用模型 + API key。
|
确保 `config.yaml` 里至少配了一个可用模型 + API key。
|
||||||
|
|
||||||
|
### 本地调试脚本(推荐)
|
||||||
|
|
||||||
|
`make dev` 是前台阻塞运行。日常调试更顺手的是仓库根 `scripts/` 下两个生命周期脚本,子命令统一为 `start / stop / restart / status / logs / run`:
|
||||||
|
|
||||||
|
| 脚本 | 起什么 | 入口 | 适合 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `scripts/dev-gateway.sh` | 只起 Gateway | `http://localhost:8001` | 调后端 API / 接入示例,起得快 |
|
||||||
|
| `scripts/dev-full.sh` | Gateway + 前端 + nginx | `http://localhost:2026` | 连前端一起调,完整体验 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-gateway.sh start # 后台启动,等就绪后返回
|
||||||
|
./scripts/dev-gateway.sh status # PID / 端口 / HTTP 健康检查
|
||||||
|
./scripts/dev-gateway.sh logs # tail -f 跟随日志(不影响服务)
|
||||||
|
./scripts/dev-gateway.sh stop
|
||||||
|
|
||||||
|
./scripts/dev-full.sh start # 全量栈后台启动(首次装依赖)
|
||||||
|
SKIP_INSTALL=1 ./scripts/dev-full.sh start # 跳过依赖安装,重启更快
|
||||||
|
./scripts/dev-full.sh status # 三服务一览
|
||||||
|
./scripts/dev-full.sh run # 前台运行(= make dev,gateway 带热重载)
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量:`PORT=`(换端口)、`NO_RELOAD=1`(关热重载,断点更稳)、`SKIP_INSTALL=1`(全量栈跳过装依赖)。
|
||||||
|
|
||||||
## 鉴权(HTTP 模式必读)
|
## 鉴权(HTTP 模式必读)
|
||||||
|
|
||||||
Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要带会话 cookie:
|
Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要带会话 cookie:
|
||||||
@@ -42,7 +103,7 @@ Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要
|
|||||||
3. 成功后 Session 里有 `access_token`(HttpOnly) + `csrf_token` 两个 cookie
|
3. 成功后 Session 里有 `access_token`(HttpOnly) + `csrf_token` 两个 cookie
|
||||||
4. **所有写请求**(POST/PUT/DELETE/PATCH)必须带 `X-CSRF-Token` 头 = `csrf_token` 值
|
4. **所有写请求**(POST/PUT/DELETE/PATCH)必须带 `X-CSRF-Token` 头 = `csrf_token` 值
|
||||||
|
|
||||||
> 多租户:当前 `docs/multi-tenant-redesign` 分支的 API Key 鉴权中间件尚未接入,外部系统暂时只能走会话 cookie。等 `Authorization: Bearer dfk_live_...` 落地后再补无人值守接入。
|
> 多租户:浏览器式接入走会话 cookie(每个注册用户即一个独立租户,自带 workspace),并发与隔离可用 [`multi_tenant.py`](examples/http-chat/multi_tenant.py) 验证。**无人值守 / 业务后端**接入已支持 API Key(Stage 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)。
|
||||||
|
|
||||||
## 新建一个应用
|
## 新建一个应用
|
||||||
|
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# embedded-chat 示例启动脚本
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 内嵌 SDK 模式:进程内直接 import deerflow.*,不需要起任何服务。
|
||||||
|
# 必须在 backend 的 uv 虚拟环境里跑(才能解析 deerflow-harness / app 包),
|
||||||
|
# 本脚本自动 cd 到 backend 并用 uv run 启动。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./run.sh
|
||||||
|
#
|
||||||
|
# 前提:
|
||||||
|
# - 已 `cd backend && uv sync`(或跑过任意一个 dev 脚本,venv 已建好)
|
||||||
|
# - config.yaml 里配好至少一个可用模型 + API key
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
APP="$SCRIPT_DIR/app.py"
|
||||||
|
BACKEND="$REPO_ROOT/backend"
|
||||||
|
|
||||||
|
# ── 前置检查 ──────────────────────────────────────────────────────
|
||||||
|
command -v uv >/dev/null 2>&1 || { echo "✗ 未找到 uv。安装:curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
|
||||||
|
[ -f "$REPO_ROOT/config.yaml" ] || echo "⚠ 未找到 $REPO_ROOT/config.yaml —— 没有可用模型会启动失败" >&2
|
||||||
|
|
||||||
|
if [ ! -d "$BACKEND/.venv" ]; then
|
||||||
|
echo "→ 未发现 backend/.venv,执行 uv sync"
|
||||||
|
(cd "$BACKEND" && uv sync)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 加载 .env(模型 key / 数据库等)──────────────────────────────
|
||||||
|
if [ -f "$REPO_ROOT/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$REPO_ROOT/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 在 backend uv 环境里运行(config.yaml 解析依赖运行目录为 backend/)──
|
||||||
|
echo "→ 在 backend uv 环境中运行 embedded-chat"
|
||||||
|
cd "$BACKEND"
|
||||||
|
exec env PYTHONPATH=. uv run python "$APP"
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
多租户并发测试:以 http-chat 的方式(Gateway REST + SSE),多个租户同时对话,
|
||||||
|
并验证租户隔离。app.py 的并发 / 多租户版。
|
||||||
|
|
||||||
|
运行(前提:仓库根已起 Gateway,如 `./scripts/dev-gateway.sh start`):
|
||||||
|
DF_BASE=http://localhost:8001 DF_TENANTS=4 \
|
||||||
|
uv run --no-project --with requests python multi_tenant.py
|
||||||
|
# 没有 uv 时:pip install -r requirements.txt && python multi_tenant.py
|
||||||
|
环境变量:DF_BASE(网关地址,默认 :8001)、DF_TENANTS(并发租户数,默认 3)。
|
||||||
|
|
||||||
|
每个注册用户 = 一个独立租户(自带独立 workspace)。并发 = 每租户一个
|
||||||
|
requests.Session(独立 cookie),放进线程池同时跑。
|
||||||
|
|
||||||
|
字段/事件名沿用 apps/examples/http-chat/app.py(已对照后端源码):
|
||||||
|
鉴权 /api/v1/auth/{setup-status,register,me}
|
||||||
|
线程 POST /api/threads ;列举 POST /api/threads/search ;单查 GET /api/threads/{id}
|
||||||
|
SSE POST /api/threads/{id}/runs/stream
|
||||||
|
|
||||||
|
约束(来自后端源码):
|
||||||
|
- GET /auth/setup-status 限流 1 次/60s/IP → 整个测试只调用一次
|
||||||
|
- POST /auth/login/local 限流 5 次/5min/IP → 本测试用 /register 建新租户,不走 login
|
||||||
|
- 跨租户访问线程返回 404(不是 403)→ 即隔离信号
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
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")) # 每租户的对话轮数
|
||||||
|
# 同一批次唯一后缀,避免重复运行时 email 冲突
|
||||||
|
RUN_ID = uuid.uuid4().hex[:8]
|
||||||
|
PASSWORD = "DeerTenant-7x9q!" # ≥8 位且不在弱口令黑名单
|
||||||
|
|
||||||
|
_print_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str) -> None:
|
||||||
|
with _print_lock:
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""注册并自动登录(register 会同时下发 access_token + csrf_token cookie)。"""
|
||||||
|
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_thread(s: requests.Session) -> str:
|
||||||
|
r = s.post(f"{BASE}/api/threads", json={}, headers=_csrf(s))
|
||||||
|
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 消息生成线程标题,它和正文答复
|
||||||
|
是不同的 message-id。必须按 id 分组,否则正文数字会和标题数字粘连
|
||||||
|
(如 180 + "12乘15..." → "18012"),导致校验误判。
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
"assistant_id": "lead_agent",
|
||||||
|
"input": {"messages": [{"role": "user", "content": message}]},
|
||||||
|
"stream_mode": ["messages-tuple", "values"],
|
||||||
|
}
|
||||||
|
headers = {**_csrf(s), "Accept": "text/event-stream"}
|
||||||
|
by_id: dict[str, str] = {}
|
||||||
|
with s.post(f"{BASE}/api/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/threads/search", json={"limit": 100, "offset": 0}, headers=_csrf(s))
|
||||||
|
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/threads/{thread_id}").status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _contains(by_id: dict, n: int) -> bool:
|
||||||
|
"""某条 AI 消息里是否独立出现数字 n(按 message-id 分组比对,避免与标题数字粘连)。"""
|
||||||
|
return any(str(n) in re.findall(r"\d+", text.replace(",", "")) for text in by_id.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _main_text(by_id: dict) -> str:
|
||||||
|
"""取最长的一条 AI 消息当正文(标题通常更短)。"""
|
||||||
|
return (max(by_id.values(), key=len) if by_id else "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 一个租户的完整链路(在独立线程里跑)────────────────────────────────
|
||||||
|
def run_tenant(idx: int) -> dict:
|
||||||
|
"""N_TURNS 轮链式对话,复用同一 thread:
|
||||||
|
T1 = a×b;之后每轮「把上一条数字再加 d」(d 每租户不同)。
|
||||||
|
每轮都必须记得上一轮结果,逐轮校验,验证多轮上下文在并发下各自保持。
|
||||||
|
"""
|
||||||
|
email = f"tenant-{RUN_ID}-{idx}@example.com"
|
||||||
|
a, b = 11 + idx, 13 + idx * 2 # 每租户不同算式
|
||||||
|
d = 2 + idx # 每租户不同步长,进一步坐实无串扰
|
||||||
|
|
||||||
|
# 预先算出每轮期望值
|
||||||
|
expected = [a * b]
|
||||||
|
for _ in range(1, N_TURNS):
|
||||||
|
expected.append(expected[-1] + d)
|
||||||
|
|
||||||
|
s = requests.Session()
|
||||||
|
rec = {"idx": idx, "email": email, "d": d, "expected": expected, "turns": [], "ok": False}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
user = register(s, email)
|
||||||
|
me = whoami(s)
|
||||||
|
rec["user_id"] = user["id"]
|
||||||
|
rec["workspace_id"] = me.get("default_workspace_id")
|
||||||
|
rec["t_start"] = t0
|
||||||
|
log(f"[租户{idx}] 注册完成 user={user['id'][:8]} ws={str(rec['workspace_id'])[:8]} d={d} email={email}")
|
||||||
|
|
||||||
|
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:]) # 第 2 轮起依赖上下文
|
||||||
|
rec["t_end"] = time.time()
|
||||||
|
rec["session"] = s
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
log(f"=== 多租户并发测试 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("⚠ 系统尚未初始化管理员。请先创建管理员(apps/examples/http-chat/app.py 首启会建),再跑本测试。")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
log(f"setup-status: HTTP {st.status_code}(限流则忽略,按已初始化处理)")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log(f"setup-status 请求失败:{e}")
|
||||||
|
|
||||||
|
# 并发跑所有租户
|
||||||
|
log(f"\n── 并发启动 {N_TENANTS} 个租户 ──")
|
||||||
|
results = []
|
||||||
|
with ThreadPoolExecutor(max_workers=N_TENANTS) as ex:
|
||||||
|
futs = [ex.submit(run_tenant, i) for i in range(N_TENANTS)]
|
||||||
|
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 = r["t_start"] - base_t
|
||||||
|
e_off = 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}")
|
||||||
|
|
||||||
|
# 隔离校验
|
||||||
|
log("\n── 隔离校验 ──")
|
||||||
|
iso_pass = True
|
||||||
|
own_thread = {r["idx"]: r["thread_id"] for r in ok}
|
||||||
|
for r in ok:
|
||||||
|
s = r["session"]
|
||||||
|
mine = {t["thread_id"] for t in search_threads(s)}
|
||||||
|
# 1) search 只含自己的线程
|
||||||
|
only_own = mine == {r["thread_id"]} if mine else False
|
||||||
|
leaked = {own_thread[j] for j in own_thread if j != r["idx"]} & mine
|
||||||
|
# 2) 直接 GET 别人的线程 → 期望 404
|
||||||
|
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']} 访问 租户{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")
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
log(f"\n── 汇总(每租户 {N_TURNS} 轮链式对话:T1=a×b,之后每轮 +d)──")
|
||||||
|
log(f"{'租户':<6}{'user_id':<12}{'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['user_id'][:8]:<12}{r['thread_id'][:10]:<14}{r['d']:<8}{passed:<12}{seq:<14}OK")
|
||||||
|
else:
|
||||||
|
log(f"{r['idx']:<6}{'-':<12}{'-':<14}{'-':<8}{'-':<12}{'-':<14}FAIL: {r.get('error')}")
|
||||||
|
|
||||||
|
all_ok = len(ok) == N_TENANTS
|
||||||
|
turn1_ok = all(r["turns"][0]["ok"] for r in ok)
|
||||||
|
context_ok = all(r.get("context_ok") for r in ok) # 第 2 轮起全对
|
||||||
|
all_turns_ok = all(r.get("all_turns_ok") for r in ok) # N 轮全对
|
||||||
|
log("\n=== 结果 ===")
|
||||||
|
log(f" 租户全部成功: {all_ok} ({len(ok)}/{N_TENANTS})")
|
||||||
|
log(f" 首轮答复无串扰: {turn1_ok}")
|
||||||
|
log(f" 多轮上下文保持: {context_ok} ← 第 2~{N_TURNS} 轮每轮都依赖上一轮结果")
|
||||||
|
log(f" 全程 {N_TURNS} 轮全对: {all_turns_ok}")
|
||||||
|
log(f" 租户隔离: {iso_pass}")
|
||||||
|
verdict = all_ok and turn1_ok and context_ok and iso_pass
|
||||||
|
log(f" >>> {'PASS ✅' if verdict else 'FAIL ❌'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Headless 多租户测试:以 **API Key(Authorization: Bearer dfk_...)** 的 server-to-server
|
||||||
|
方式跑多租户并发对话并验证隔离 —— 即 [multi_tenant.py] 的「无人值守 / 业务后端」版。
|
||||||
|
|
||||||
|
与 multi_tenant.py(浏览器式 cookie + CSRF)的区别:
|
||||||
|
- 每个租户先由一个 **人类 owner**(cookie 会话)创建 service account 并 mint 一把
|
||||||
|
workspace-scoped API key(plaintext 仅返回一次);
|
||||||
|
- 之后所有对话只用 **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/stream(Bearer,无 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()
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# http-chat 示例启动脚本
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# - 自动探测网关地址:优先 :2026(nginx 全量栈),回退 :8001(只起 Gateway)
|
||||||
|
# - 优先用 uv 临时虚拟环境带上 requests(--no-project,不污染系统/项目)
|
||||||
|
# 没有 uv 时回退到本地 .venv + pip
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./run.sh # 自动探测网关并运行
|
||||||
|
# DF_BASE=http://localhost:8001 ./run.sh # 手动指定网关
|
||||||
|
# DF_EMAIL=a@b.com DF_PASSWORD=xxxx ./run.sh
|
||||||
|
#
|
||||||
|
# 前提:先起好 Gateway
|
||||||
|
# ../../../scripts/dev-gateway.sh start # → :8001
|
||||||
|
# ../../../scripts/dev-full.sh start # → :2026
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ── 探测可用网关 ──────────────────────────────────────────────────
|
||||||
|
_alive() { curl -s -o /dev/null -w "%{http_code}" "$1/api/v1/auth/setup-status" 2>/dev/null | grep -qE "200|429"; }
|
||||||
|
|
||||||
|
if [ -z "${DF_BASE:-}" ]; then
|
||||||
|
if _alive "http://localhost:2026"; then DF_BASE="http://localhost:2026"
|
||||||
|
elif _alive "http://localhost:8001"; then DF_BASE="http://localhost:8001"
|
||||||
|
else
|
||||||
|
echo "✗ 没探测到运行中的网关(:2026 / :8001 都不通)。" >&2
|
||||||
|
echo " 先启动:scripts/dev-gateway.sh start 或 scripts/dev-full.sh start" >&2
|
||||||
|
echo " 或手动指定:DF_BASE=http://your-host:port ./run.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
export DF_BASE
|
||||||
|
echo "→ 使用网关: $DF_BASE"
|
||||||
|
|
||||||
|
# ── 运行:优先 uv,回退 venv+pip ─────────────────────────────────
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "→ uv 临时环境运行(--with requests)"
|
||||||
|
exec uv run --no-project --with "requests>=2.31" python app.py
|
||||||
|
else
|
||||||
|
echo "→ 未找到 uv,使用本地 .venv + pip"
|
||||||
|
if [ ! -d .venv ]; then
|
||||||
|
python3 -m venv .venv
|
||||||
|
./.venv/bin/pip install -q -r requirements.txt
|
||||||
|
fi
|
||||||
|
exec ./.venv/bin/python app.py
|
||||||
|
fi
|
||||||
+56
-28
@@ -10,9 +10,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.gateway.auth_middleware import AuthMiddleware
|
from app.gateway.auth_middleware import AuthMiddleware
|
||||||
from app.gateway.config import get_gateway_config
|
from app.gateway.config import get_gateway_config
|
||||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||||
|
from app.gateway.deprecation_middleware import ApiDeprecationMiddleware
|
||||||
from app.gateway.deps import langgraph_runtime
|
from app.gateway.deps import langgraph_runtime
|
||||||
from app.gateway.routers import (
|
from app.gateway.routers import (
|
||||||
agents,
|
agents,
|
||||||
|
api_keys,
|
||||||
artifacts,
|
artifacts,
|
||||||
assistants_compat,
|
assistants_compat,
|
||||||
auth,
|
auth,
|
||||||
@@ -22,6 +24,7 @@ from app.gateway.routers import (
|
|||||||
memory,
|
memory,
|
||||||
models,
|
models,
|
||||||
runs,
|
runs,
|
||||||
|
service_accounts,
|
||||||
skills,
|
skills,
|
||||||
suggestions,
|
suggestions,
|
||||||
thread_runs,
|
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
|
# CSRF: Double Submit Cookie pattern for state-changing requests
|
||||||
app.add_middleware(CSRFMiddleware)
|
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.
|
# CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware.
|
||||||
# In production, nginx handles CORS and no middleware is needed.
|
# In production, nginx handles CORS and no middleware is needed.
|
||||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "")
|
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
|
# Include routers
|
||||||
# Models API is mounted at /api/models
|
# Legacy routers are dual-mounted on /api (backward compat) and /api/v1 (versioned).
|
||||||
app.include_router(models.router)
|
# The deprecation middleware (Task 5.1) stamps X-API-Deprecated on /api responses.
|
||||||
|
|
||||||
# MCP API is mounted at /api/mcp
|
# Models API — /api/models and /api/v1/models
|
||||||
app.include_router(mcp.router)
|
app.include_router(models.router, prefix="/api")
|
||||||
|
app.include_router(models.router, prefix="/api/v1")
|
||||||
|
|
||||||
# Memory API is mounted at /api/memory
|
# MCP API — /api/mcp and /api/v1/mcp
|
||||||
app.include_router(memory.router)
|
app.include_router(mcp.router, prefix="/api")
|
||||||
|
app.include_router(mcp.router, prefix="/api/v1")
|
||||||
|
|
||||||
# Skills API is mounted at /api/skills
|
# Memory API — /api/memory and /api/v1/memory
|
||||||
app.include_router(skills.router)
|
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
|
# Skills API — /api/skills and /api/v1/skills
|
||||||
app.include_router(artifacts.router)
|
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
|
# Artifacts API — /api/threads/{thread_id}/artifacts and /api/v1/threads/{thread_id}/artifacts
|
||||||
app.include_router(uploads.router)
|
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}
|
# Uploads API — /api/threads/{thread_id}/uploads and /api/v1/threads/{thread_id}/uploads
|
||||||
app.include_router(threads.router)
|
app.include_router(uploads.router, prefix="/api")
|
||||||
|
app.include_router(uploads.router, prefix="/api/v1")
|
||||||
|
|
||||||
# Agents API is mounted at /api/agents
|
# Threads API — /api/threads/{thread_id} and /api/v1/threads/{thread_id}
|
||||||
app.include_router(agents.router)
|
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
|
# Agents API — /api/agents and /api/v1/agents
|
||||||
app.include_router(suggestions.router)
|
app.include_router(agents.router, prefix="/api")
|
||||||
|
app.include_router(agents.router, prefix="/api/v1")
|
||||||
|
|
||||||
# Channels API is mounted at /api/channels
|
# Suggestions API — /api/threads/{thread_id}/suggestions and /api/v1/threads/{thread_id}/suggestions
|
||||||
app.include_router(channels.router)
|
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)
|
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)
|
app.include_router(auth.router)
|
||||||
|
|
||||||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
# Service Accounts API — /api/v1/service-accounts only (already versioned)
|
||||||
app.include_router(feedback.router)
|
app.include_router(service_accounts.router)
|
||||||
|
|
||||||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
# API Keys API — /api/v1/api-keys only (already versioned)
|
||||||
app.include_router(thread_runs.router)
|
app.include_router(api_keys.router)
|
||||||
|
|
||||||
# Stateless Runs API (stream/wait without a pre-existing thread)
|
# Feedback API — /api/threads/{thread_id}/runs/{run_id}/feedback and /api/v1/... twin
|
||||||
app.include_router(runs.router)
|
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"])
|
@app.get("/health", tags=["health"])
|
||||||
async def health_check() -> dict:
|
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))
|
||||||
@@ -22,6 +22,7 @@ class AuthErrorCode(StrEnum):
|
|||||||
NOT_AUTHENTICATED = "not_authenticated"
|
NOT_AUTHENTICATED = "not_authenticated"
|
||||||
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
||||||
WORKSPACE_REQUIRED = "workspace_required"
|
WORKSPACE_REQUIRED = "workspace_required"
|
||||||
|
INSUFFICIENT_SCOPE = "insufficient_scope"
|
||||||
|
|
||||||
|
|
||||||
class TokenError(StrEnum):
|
class TokenError(StrEnum):
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ class User(BaseModel):
|
|||||||
needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes")
|
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")
|
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)
|
# Workspace linkage (Stage 0 PR4)
|
||||||
default_workspace_id: str | None = Field(
|
default_workspace_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ owner filtering works automatically via the sentinel pattern.
|
|||||||
Fine-grained permission checks remain in authz.py decorators.
|
Fine-grained permission checks remain in authz.py decorators.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, Response
|
from fastapi import HTTPException, Request, Response
|
||||||
@@ -16,6 +17,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
from starlette.types import ASGIApp
|
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.errors import AuthErrorCode, AuthErrorResponse
|
||||||
from app.gateway.auth.models import ActiveWorkspace
|
from app.gateway.auth.models import ActiveWorkspace
|
||||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
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.user_context import reset_current_user, set_current_user
|
||||||
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
from deerflow.runtime.workspace_context import reset_current_workspace, set_current_workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Paths that never require authentication.
|
# Paths that never require authentication.
|
||||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||||
"/health",
|
"/health",
|
||||||
@@ -51,6 +55,34 @@ def _is_public(path: str) -> bool:
|
|||||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
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):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
"""Strict auth gate: reject requests without a valid session.
|
"""Strict auth gate: reject requests without a valid session.
|
||||||
|
|
||||||
@@ -78,6 +110,45 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
if _is_public(request.url.path):
|
if _is_public(request.url.path):
|
||||||
return await call_next(request)
|
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
|
internal_user = None
|
||||||
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
|
if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)):
|
||||||
internal_user = get_internal_user()
|
internal_user = get_internal_user()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
|||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.gateway.auth.api_key_backend import ServicePrincipal
|
||||||
from app.gateway.auth.models import User
|
from app.gateway.auth.models import User
|
||||||
|
|
||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
@@ -65,13 +66,14 @@ class AuthContext:
|
|||||||
Stored in request.state.auth after require_auth decoration.
|
Stored in request.state.auth after require_auth decoration.
|
||||||
|
|
||||||
Attributes:
|
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")
|
permissions: List of permission strings (e.g., "threads:read")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("user", "permissions")
|
__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.user = user
|
||||||
self.permissions = permissions or []
|
self.permissions = permissions or []
|
||||||
|
|
||||||
@@ -93,8 +95,11 @@ class AuthContext:
|
|||||||
permission = f"{resource}:{action}"
|
permission = f"{resource}:{action}"
|
||||||
return permission in self.permissions
|
return permission in self.permissions
|
||||||
|
|
||||||
def require_user(self) -> User:
|
def require_user(self) -> User | ServicePrincipal:
|
||||||
"""Get user or raise 401.
|
"""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:
|
Raises:
|
||||||
HTTPException 401 if not authenticated
|
HTTPException 401 if not authenticated
|
||||||
@@ -302,3 +307,18 @@ def require_permission(
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
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")
|
||||||
|
|||||||
@@ -29,15 +29,28 @@ def generate_csrf_token() -> str:
|
|||||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
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:
|
def should_check_csrf(request: Request) -> bool:
|
||||||
"""Determine if a request needs CSRF validation.
|
"""Determine if a request needs CSRF validation.
|
||||||
|
|
||||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
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"):
|
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if has_bearer_header(request):
|
||||||
|
return False
|
||||||
|
|
||||||
path = request.url.path.rstrip("/")
|
path = request.url.path.rstrip("/")
|
||||||
# Exempt /api/v1/auth/me endpoint
|
# Exempt /api/v1/auth/me endpoint
|
||||||
if path == "/api/v1/auth/me":
|
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
|
||||||
@@ -14,7 +14,7 @@ from deerflow.config.paths import get_paths
|
|||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(tags=["agents"])
|
||||||
|
|
||||||
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -12,7 +12,7 @@ from app.gateway.path_utils import resolve_thread_virtual_path
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["artifacts"])
|
router = APIRouter(tags=["artifacts"])
|
||||||
|
|
||||||
ACTIVE_CONTENT_MIME_TYPES = {
|
ACTIVE_CONTENT_MIME_TYPES = {
|
||||||
"text/html",
|
"text/html",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||||
|
|
||||||
|
|
||||||
class ChannelStatusResponse(BaseModel):
|
class ChannelStatusResponse(BaseModel):
|
||||||
|
|||||||
@@ -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
|
from app.gateway.deps import get_current_user, get_feedback_repo, get_run_store
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["feedback"])
|
router = APIRouter(prefix="/threads", tags=["feedback"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
|
|||||||
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["mcp"])
|
router = APIRouter(tags=["mcp"])
|
||||||
|
|
||||||
|
|
||||||
class McpOAuthConfigResponse(BaseModel):
|
class McpOAuthConfigResponse(BaseModel):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from deerflow.agents.memory.updater import (
|
|||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["memory"])
|
router = APIRouter(tags=["memory"])
|
||||||
|
|
||||||
|
|
||||||
class ContextSection(BaseModel):
|
class ContextSection(BaseModel):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
|
|||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["models"])
|
router = APIRouter(tags=["models"])
|
||||||
|
|
||||||
|
|
||||||
class ModelResponse(BaseModel):
|
class ModelResponse(BaseModel):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.gateway.services import sse_consumer, start_run
|
|||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||||
|
|
||||||
|
|
||||||
def _resolve_thread_id(body: RunCreateRequest) -> str:
|
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)
|
||||||
@@ -18,7 +18,7 @@ from deerflow.skills.types import SKILL_MD_FILE, SkillCategory
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["skills"])
|
router = APIRouter(tags=["skills"])
|
||||||
|
|
||||||
|
|
||||||
class SkillResponse(BaseModel):
|
class SkillResponse(BaseModel):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from deerflow.models import create_chat_model
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["suggestions"])
|
router = APIRouter(tags=["suggestions"])
|
||||||
|
|
||||||
|
|
||||||
class SuggestionMessage(BaseModel):
|
class SuggestionMessage(BaseModel):
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from app.gateway.services import sse_consumer, start_run
|
|||||||
from deerflow.runtime import RunRecord, serialize_channel_values
|
from deerflow.runtime import RunRecord, serialize_channel_values
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["runs"])
|
router = APIRouter(prefix="/threads", tags=["runs"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from deerflow.runtime.user_context import get_effective_user_id
|
|||||||
from deerflow.utils.time import coerce_iso, now_iso
|
from deerflow.utils.time import coerce_iso, now_iso
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Metadata keys that the server controls; clients are not allowed to set
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
UPLOAD_CHUNK_SIZE = 8192
|
||||||
DEFAULT_MAX_FILES = 10
|
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 __future__ import annotations
|
||||||
|
|
||||||
from deerflow.persistence.api_key.model import ApiKeyRow
|
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 __future__ import annotations
|
||||||
|
|
||||||
from deerflow.persistence.external_user.model import ExternalUserRow
|
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 __future__ import annotations
|
||||||
|
|
||||||
from deerflow.persistence.service_account.model import ServiceAccountRow
|
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):
|
class CurrentUser(Protocol):
|
||||||
"""Structural type for the current authenticated user.
|
"""Structural type for the current authenticated user.
|
||||||
|
|
||||||
Any object with an ``.id: str`` attribute satisfies this protocol.
|
Requires only ``.id: str`` — the persistence layer reads nothing else,
|
||||||
Concrete implementations live in ``app.gateway.auth.models.User``.
|
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
|
id: str
|
||||||
|
|||||||
@@ -277,19 +277,35 @@ phase_e2e() {
|
|||||||
|
|
||||||
local tag
|
local tag
|
||||||
tag=$(date +%s)
|
tag=$(date +%s)
|
||||||
local alice="alice-${tag}@verify.local"
|
local alice="alice-${tag}@verify-stage0.com"
|
||||||
local bob="bob-${tag}@verify.local"
|
local bob="bob-${tag}@verify-stage0.com"
|
||||||
local pw="VerifyStage0_${tag}"
|
local pw="VerifyStage0_${tag}!"
|
||||||
local jar_a=/tmp/verify_alice_${tag}.cookies
|
local jar_a=/tmp/verify_alice_${tag}.cookies
|
||||||
local jar_b=/tmp/verify_bob_${tag}.cookies
|
local jar_b=/tmp/verify_bob_${tag}.cookies
|
||||||
rm -f "$jar_a" "$jar_b"
|
rm -f "$jar_a" "$jar_b"
|
||||||
|
|
||||||
|
# Ensure system is initialized (admin account exists) before registering users.
|
||||||
|
info "ensuring admin account exists (POST /api/v1/auth/initialize)"
|
||||||
|
local init_code
|
||||||
|
init_code=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"admin-${tag}@verify-stage0.com\",\"password\":\"$pw\"}" \
|
||||||
|
"$GATEWAY_URL/api/v1/auth/initialize")
|
||||||
|
if [ "$init_code" = "201" ]; then
|
||||||
|
ok "admin initialized (first boot)"
|
||||||
|
elif [ "$init_code" = "409" ]; then
|
||||||
|
ok "admin already exists (system previously initialized)"
|
||||||
|
else
|
||||||
|
fail "admin initialization returned $init_code"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
register_user() {
|
register_user() {
|
||||||
local jar="$1"; local email="$2"
|
local jar="$1"; local email="$2"
|
||||||
curl -sS -c "$jar" -o /tmp/verify_register_$$.json -w '%{http_code}' \
|
curl -sS -c "$jar" -o /tmp/verify_register_$$.json -w '%{http_code}' \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "{\"email\":\"$email\",\"password\":\"$pw\"}" \
|
-d "{\"email\":\"$email\",\"password\":\"$pw\"}" \
|
||||||
"$GATEWAY_URL/api/auth/register"
|
"$GATEWAY_URL/api/v1/auth/register"
|
||||||
}
|
}
|
||||||
|
|
||||||
info "registering Alice + Bob"
|
info "registering Alice + Bob"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: artifact_path)
|
||||||
|
|
||||||
app = make_authed_test_app()
|
app = make_authed_test_app()
|
||||||
app.include_router(artifacts_router.router)
|
app.include_router(artifacts_router.router, prefix="/api")
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt?download=false")
|
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)
|
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path: skill_path)
|
||||||
|
|
||||||
app = make_authed_test_app()
|
app = make_authed_test_app()
|
||||||
app.include_router(artifacts_router.router)
|
app.include_router(artifacts_router.router, prefix="/api")
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/notes.txt?download=true")
|
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()
|
||||||
@@ -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
|
||||||
@@ -382,7 +382,7 @@ def _make_test_app(tmp_path: Path):
|
|||||||
from app.gateway.routers.agents import router
|
from app.gateway.routers.agents import router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(router)
|
app.include_router(router, prefix="/api")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -26,7 +26,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict:
|
|||||||
|
|
||||||
def test_export_memory_route_returns_current_memory() -> None:
|
def test_export_memory_route_returns_current_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
exported_memory = _sample_memory(
|
exported_memory = _sample_memory(
|
||||||
facts=[
|
facts=[
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ def test_export_memory_route_returns_current_memory() -> None:
|
|||||||
|
|
||||||
def test_import_memory_route_returns_imported_memory() -> None:
|
def test_import_memory_route_returns_imported_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
imported_memory = _sample_memory(
|
imported_memory = _sample_memory(
|
||||||
facts=[
|
facts=[
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,7 @@ def test_import_memory_route_returns_imported_memory() -> None:
|
|||||||
|
|
||||||
def test_export_memory_route_preserves_source_error() -> None:
|
def test_export_memory_route_preserves_source_error() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
exported_memory = _sample_memory(
|
exported_memory = _sample_memory(
|
||||||
facts=[
|
facts=[
|
||||||
{
|
{
|
||||||
@@ -99,7 +99,7 @@ def test_export_memory_route_preserves_source_error() -> None:
|
|||||||
|
|
||||||
def test_import_memory_route_preserves_source_error() -> None:
|
def test_import_memory_route_preserves_source_error() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
imported_memory = _sample_memory(
|
imported_memory = _sample_memory(
|
||||||
facts=[
|
facts=[
|
||||||
{
|
{
|
||||||
@@ -124,7 +124,7 @@ def test_import_memory_route_preserves_source_error() -> None:
|
|||||||
|
|
||||||
def test_clear_memory_route_returns_cleared_memory() -> None:
|
def test_clear_memory_route_returns_cleared_memory() -> None:
|
||||||
app = FastAPI()
|
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 patch("app.gateway.routers.memory.clear_memory_data", return_value=_sample_memory()):
|
||||||
with TestClient(app) as client:
|
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:
|
def test_create_memory_fact_route_returns_updated_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
updated_memory = _sample_memory(
|
updated_memory = _sample_memory(
|
||||||
facts=[
|
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:
|
def test_delete_memory_fact_route_returns_updated_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
updated_memory = _sample_memory(
|
updated_memory = _sample_memory(
|
||||||
facts=[
|
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:
|
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||||
app = FastAPI()
|
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 patch("app.gateway.routers.memory.delete_memory_fact", side_effect=KeyError("fact_missing")):
|
||||||
with TestClient(app) as client:
|
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:
|
def test_update_memory_fact_route_returns_updated_memory() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
updated_memory = _sample_memory(
|
updated_memory = _sample_memory(
|
||||||
facts=[
|
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:
|
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router, prefix="/api")
|
||||||
updated_memory = _sample_memory(
|
updated_memory = _sample_memory(
|
||||||
facts=[
|
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:
|
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
|
||||||
app = FastAPI()
|
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 patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")):
|
||||||
with TestClient(app) as client:
|
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:
|
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
|
||||||
app = FastAPI()
|
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 patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")):
|
||||||
with TestClient(app) as client:
|
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
|
||||||
@@ -17,7 +17,7 @@ from app.gateway.routers import runs
|
|||||||
def _make_app(run_store=None, event_store=None, feedback_repo=None):
|
def _make_app(run_store=None, event_store=None, feedback_repo=None):
|
||||||
"""Build a test FastAPI app with stub auth and mocked state."""
|
"""Build a test FastAPI app with stub auth and mocked state."""
|
||||||
app = make_authed_test_app()
|
app = make_authed_test_app()
|
||||||
app.include_router(runs.router)
|
app.include_router(runs.router, prefix="/api")
|
||||||
|
|
||||||
if run_store is not None:
|
if run_store is not None:
|
||||||
app.state.run_store = run_store
|
app.state.run_store = run_store
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -39,7 +39,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
|
|||||||
def _make_test_app(config) -> FastAPI:
|
def _make_test_app(config) -> FastAPI:
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.state.config = config
|
app.state.config = config
|
||||||
app.include_router(skills_router.router)
|
app.include_router(skills_router.router, prefix="/api")
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.gateway.routers import thread_runs
|
|||||||
def _make_app(event_store=None):
|
def _make_app(event_store=None):
|
||||||
"""Build a test FastAPI app with stub auth and mocked state."""
|
"""Build a test FastAPI app with stub auth and mocked state."""
|
||||||
app = make_authed_test_app()
|
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:
|
if event_store is not None:
|
||||||
app.state.run_event_store = event_store
|
app.state.run_event_store = event_store
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def _build_app(workspace_id: str):
|
|||||||
app.state.store = store
|
app.state.store = store
|
||||||
app.state.checkpointer = checkpointer
|
app.state.checkpointer = checkpointer
|
||||||
app.state.thread_store = MemoryThreadMetaStore(store)
|
app.state.thread_store = MemoryThreadMetaStore(store)
|
||||||
app.include_router(threads.router)
|
app.include_router(threads.router, prefix="/api")
|
||||||
return app, store
|
return app, store
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _build_thread_app() -> tuple[FastAPI, InMemoryStore, InMemorySaver]:
|
|||||||
app.state.store = store
|
app.state.store = store
|
||||||
app.state.checkpointer = checkpointer
|
app.state.checkpointer = checkpointer
|
||||||
app.state.thread_store = _PermissiveThreadMetaStore(store)
|
app.state.thread_store = _PermissiveThreadMetaStore(store)
|
||||||
app.include_router(threads.router)
|
app.include_router(threads.router, prefix="/api")
|
||||||
return app, store, checkpointer
|
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")
|
(paths.sandbox_work_dir("thread-route", user_id=user_id) / "notes.txt").write_text("hello", encoding="utf-8")
|
||||||
|
|
||||||
app = make_authed_test_app()
|
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 patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@@ -128,7 +128,7 @@ def test_delete_thread_route_rejects_invalid_thread_id(tmp_path):
|
|||||||
paths = Paths(tmp_path)
|
paths = Paths(tmp_path)
|
||||||
|
|
||||||
app = make_authed_test_app()
|
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 patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||||
with TestClient(app) as client:
|
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)
|
paths = Paths(tmp_path)
|
||||||
|
|
||||||
app = make_authed_test_app()
|
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 patch("app.gateway.routers.threads.get_paths", return_value=paths):
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -598,7 +598,7 @@ def test_upload_limits_endpoint_requires_thread_access():
|
|||||||
cfg.uploads = {}
|
cfg.uploads = {}
|
||||||
app = make_authed_test_app(owner_check_passes=False)
|
app = make_authed_test_app(owner_check_passes=False)
|
||||||
app.state.config = cfg
|
app.state.config = cfg
|
||||||
app.include_router(uploads.router)
|
app.include_router(uploads.router, prefix="/api")
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/threads/thread-local/uploads/limits")
|
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.store = store
|
||||||
app.state.checkpointer = InMemorySaver()
|
app.state.checkpointer = InMemorySaver()
|
||||||
app.state.thread_store = MemoryThreadMetaStore(store)
|
app.state.thread_store = MemoryThreadMetaStore(store)
|
||||||
app.include_router(threads.router)
|
app.include_router(threads.router, prefix="/api")
|
||||||
return app, store
|
return app, store
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
# 数据库设计 · 落地版(as-built)
|
||||||
|
|
||||||
|
> 写于 2026-06-27。**对照实现代码生成**,反映 Stage 0 PR1–PR8 合入后的真实 schema。
|
||||||
|
>
|
||||||
|
> 与 [workspace-schema-design.zh-CN.md](./workspace-schema-design.zh-CN.md) 的关系:那份是 **Stage 0 动手前的锁定版**(决策 + 不可逆点),本文是 **落地后的事实参考**。两者冲突时**以代码与本文为准**(锁定版里标 "待确认 / schema only" 的项,这里给出最终结果,例如 `run_events` 已确认为 DB 表并带 `workspace_id`)。
|
||||||
|
>
|
||||||
|
> **真源**:`backend/packages/harness/deerflow/persistence/`
|
||||||
|
> - 表定义:各子目录 `*/model.py`(如 `user/model.py`、`api_key/model.py`)+ `models/run_event.py`
|
||||||
|
> - 基类 / 引擎:`base.py` / `engine.py`
|
||||||
|
> - 迁移:`migrations/versions/0001..0003`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 持久化层总览
|
||||||
|
|
||||||
|
### 1.1 后端与建表
|
||||||
|
|
||||||
|
| 维度 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 引擎 | 异步 SQLAlchemy(`create_async_engine`),见 `engine.py` |
|
||||||
|
| 后端三选一 | `memory`(不建引擎,仓储回退内存实现)/ `sqlite`(aiosqlite)/ `postgres`(asyncpg)。Stage 0 生产默认 **postgres** |
|
||||||
|
| 建表方式 | **dev**:启动时 `Base.metadata.create_all()` 自动建表(缺表即补,不改已存在的表)。**生产**:用 Alembic 迁移(`migrations/versions/`) |
|
||||||
|
| Postgres 自愈 | 目标库不存在时(报 `does not exist`),自动连到 `postgres` 维护库 `CREATE DATABASE` 后重建引擎重试(`_auto_create_postgres_db`) |
|
||||||
|
| SQLite 加固 | 每条连接启用 `PRAGMA journal_mode=WAL` + `synchronous=NORMAL` + `foreign_keys=ON` |
|
||||||
|
| JSON 序列化 | 自定义 `json.dumps(..., ensure_ascii=False)`,中文不转义 |
|
||||||
|
| 连接池 | postgres:`pool_size`(默认 5)+ `pool_pre_ping=True` |
|
||||||
|
|
||||||
|
> ⚠️ `create_all` 只**新建缺失的表**,**不会 ALTER 已存在的表**。给已有表加列/改约束必须走 Alembic 迁移;dev 下想偷懒可删库重建。
|
||||||
|
|
||||||
|
### 1.2 不归 ORM 管的表
|
||||||
|
|
||||||
|
LangGraph 的 **checkpointer**(`checkpoints*`)与 **store**(`store` / `store_migrations`)由 LangGraph 自己 `setup()` 建表,**不在** `Base.metadata` 里(见 `runtime/checkpointer/` 与 `runtime/store/`)。它们与本文的业务表**共用同一个 Postgres 库**,但生命周期、迁移各自独立。多租户隔离对这些表走"应用层强校验 + `UNIQUE(workspace_id, thread_id)` 兜底"(见 [ADR-001](./adr-001-data-isolation.zh-CN.md) / [spike-langgraph-postgres](./adr-spike-langgraph-postgres.zh-CN.md))。
|
||||||
|
|
||||||
|
### 1.3 通用约定
|
||||||
|
|
||||||
|
- **主键 id**:业务实体用 `String(36)`(UUID v4 字符串),跨 SQLite/Postgres 可移植(Postgres 落 `CHAR(36)`,性能差异可忽略)。
|
||||||
|
- **时间**:一律 `DateTime(timezone=True)`,应用层写 `datetime.now(UTC)`;`updated_at` 在写入时自动更新。
|
||||||
|
- **枚举**:状态/角色用 `String(16)` + 应用层校验,**不用 DB enum**(Postgres enum 加值要 `ALTER TYPE`、不可删,扩展成本高)。
|
||||||
|
- **JSON 列**:用 SQLAlchemy 可移植 `JSON` 类型(Postgres 落 `json`),默认 `{}`。
|
||||||
|
- **partial unique / partial index**:同时声明 `sqlite_where` + `postgresql_where` 两套等价条件,双后端兼容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 实体关系总览
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
users ||--o{ workspace_memberships : "成员"
|
||||||
|
workspaces ||--o{ workspace_memberships : "包含"
|
||||||
|
users ||--o| workspaces : "owner_id (RESTRICT)"
|
||||||
|
workspaces ||--o| users : "default_workspace_id (SET NULL)"
|
||||||
|
|
||||||
|
workspaces ||--o{ threads_meta : "wid (CASCADE)"
|
||||||
|
workspaces ||--o{ runs : "wid (CASCADE)"
|
||||||
|
workspaces ||--o{ run_events : "wid (CASCADE)"
|
||||||
|
workspaces ||--o{ feedback : "wid (CASCADE)"
|
||||||
|
|
||||||
|
workspaces ||--o{ service_accounts : "wid (CASCADE)"
|
||||||
|
service_accounts ||--o{ api_keys : "CASCADE"
|
||||||
|
service_accounts ||--o{ external_users : "CASCADE"
|
||||||
|
workspaces ||--o{ external_users : "wid (CASCADE)"
|
||||||
|
|
||||||
|
threads_meta ||--o{ runs : "thread_id (逻辑)"
|
||||||
|
runs ||--o{ run_events : "run_id (逻辑)"
|
||||||
|
runs ||--o{ feedback : "run_id (逻辑)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**两条主线**:
|
||||||
|
1. **租户骨架**:`users` ↔ `workspaces`(多对多经 `workspace_memberships`)。workspace 是隔离粒度单位,每个用户注册自动建 1 人 workspace。
|
||||||
|
2. **业务数据**:`threads_meta` → `runs` → `run_events` / `feedback`,全部挂 `workspace_id`(行级隔离),workspace 删除时级联清空。
|
||||||
|
3. **Headless 接入**(Stage 0 末预建 schema):`service_accounts` → `api_keys`(鉴权凭证)+ `external_users`(passthrough 终端身份)。
|
||||||
|
|
||||||
|
> `threads_meta.thread_id` / `runs.run_id` 与下游是**逻辑关联**(无 DB 外键,因 thread/run id 也被 LangGraph 表使用);workspace 外键才是物理约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 表参考
|
||||||
|
|
||||||
|
> 列约定:所有 `created_at`/`updated_at` 为 `DateTime(tz)` NOT NULL,下表不再逐行重复说明。
|
||||||
|
|
||||||
|
### 3.1 `users` — 用户账户(本地密码 + OAuth)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | String(36) | PK | UUID |
|
||||||
|
| `email` | String(320) | UNIQUE NOT NULL,索引 | 登录邮箱 |
|
||||||
|
| `password_hash` | String(128) | NULL | OAuth-only 用户为 NULL |
|
||||||
|
| `system_role` | String(16) | NOT NULL default `"user"` | 平台级角色 `admin`/`user`(与 workspace role 正交) |
|
||||||
|
| `oauth_provider` | String(32) | NULL | google/github… |
|
||||||
|
| `oauth_id` | String(128) | NULL | 提供商内用户 ID |
|
||||||
|
| `needs_setup` | Boolean | NOT NULL default `False` | 首次设置标记 |
|
||||||
|
| `token_version` | Integer | NOT NULL default `0` | 自增即吊销该用户所有旧 JWT |
|
||||||
|
| `default_workspace_id` | String(36) | NULL,FK `workspaces.id` **SET NULL** | 登录默认进入的 workspace;NULL 走 picker |
|
||||||
|
| `created_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:`idx_users_oauth_identity` UNIQUE `(oauth_provider, oauth_id)`,仅当两者均非 NULL(partial)。
|
||||||
|
|
||||||
|
### 3.2 `workspaces` — 工作空间(多租户隔离单位)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | String(36) | PK | UUID |
|
||||||
|
| `name` | String(64) | NOT NULL | 显示名 |
|
||||||
|
| `slug` | String(32) | UNIQUE NOT NULL | URL 标识 `^[a-z0-9](-?[a-z0-9])*$`,3–32 字符 |
|
||||||
|
| `status` | String(16) | NOT NULL default `"active"` | `active`/`suspended`/`deleted` |
|
||||||
|
| `owner_id` | String(36) | NOT NULL,FK `users.id` **RESTRICT** | 冗余 owner;删 owner 被阻拦 |
|
||||||
|
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
> slug 黑名单(`admin`/`api`/`auth`/`_next`/… 见锁定版 §2.1)走应用层校验,不入 DB 约束。
|
||||||
|
|
||||||
|
### 3.3 `workspace_memberships` — 成员(RBAC)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `workspace_id` | String(36) | **复合 PK**,FK `workspaces.id` **CASCADE** | |
|
||||||
|
| `user_id` | String(36) | **复合 PK**,FK `users.id` **CASCADE** | |
|
||||||
|
| `role` | String(16) | NOT NULL | Stage 0 仅 `owner`;Stage 2 起 `admin`/`member` |
|
||||||
|
| `invited_by` | String(36) | NULL,FK `users.id` **SET NULL** | Stage 2 invitation 才写 |
|
||||||
|
| `joined_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- 复合 PK `(workspace_id, user_id)`
|
||||||
|
- `idx_workspace_memberships_user` `(user_id, workspace_id)` — 倒查"某 user 的所有 workspace"
|
||||||
|
- `idx_one_owner_per_workspace` UNIQUE `(workspace_id)` WHERE `role='owner'`(partial)— 每 workspace 恰好 1 owner
|
||||||
|
|
||||||
|
### 3.4 `threads_meta` — 会话元数据
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `thread_id` | String(64) | PK | LangGraph thread_id |
|
||||||
|
| `assistant_id` | String(128) | NULL,索引 | 自定义 agent 名;NULL=默认 lead agent |
|
||||||
|
| `user_id` | String(64) | NULL,索引 | 所有者;NULL=历史无主 |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL(0003 后),FK `workspaces.id` **CASCADE** | |
|
||||||
|
| `display_name` | String(256) | NULL | 自动标题或用户改名 |
|
||||||
|
| `status` | String(20) | NOT NULL default `"idle"` | `idle`/`busy` |
|
||||||
|
| `metadata_json` | JSON | NOT NULL default `{}` | |
|
||||||
|
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `idx_threads_meta_workspace_user_updated` `(workspace_id, user_id, updated_at)` — 前端 thread list 默认查询
|
||||||
|
- `idx_threads_meta_workspace_thread` UNIQUE `(workspace_id, thread_id)`(0003 加)— 防跨 workspace 复用同 thread_id
|
||||||
|
|
||||||
|
### 3.5 `runs` — 单次 agent 运行(含 token 指标)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `run_id` | String(64) | PK | |
|
||||||
|
| `thread_id` | String(64) | NOT NULL,索引 | 所属会话 |
|
||||||
|
| `assistant_id` | String(128) | NULL | |
|
||||||
|
| `user_id` | String(64) | NULL,索引 | |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL(0003 后),FK `workspaces.id` **CASCADE** | |
|
||||||
|
| `status` | String(20) | NOT NULL default `"pending"` | `pending`/`running`/`success`/`error`/`timeout`/`interrupted` |
|
||||||
|
| `model_name` | String(128) | NULL | |
|
||||||
|
| `multitask_strategy` | String(20) | NOT NULL default `"reject"` | `reject`/`interrupt`/`rollback`/`enqueue` |
|
||||||
|
| `metadata_json` / `kwargs_json` | JSON | NOT NULL default `{}` | 运行级元数据 / 提交参数 |
|
||||||
|
| `error` | Text | NULL | 失败错误文本 |
|
||||||
|
| `message_count` | Integer | NOT NULL default `0` | |
|
||||||
|
| `first_human_message` / `last_ai_message` | Text | NULL | 文本预览 |
|
||||||
|
| `total_input_tokens` / `total_output_tokens` / `total_tokens` | Integer | NOT NULL default `0` | 累计 token |
|
||||||
|
| `llm_call_count` | Integer | NOT NULL default `0` | |
|
||||||
|
| `lead_agent_tokens` / `subagent_tokens` / `middleware_tokens` | Integer | NOT NULL default `0` | 分项 token(主 agent / 子 agent / 中间件) |
|
||||||
|
| `follow_up_to_run_id` | String(64) | NULL | 续接的上一次运行(重新生成/继续) |
|
||||||
|
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:`ix_runs_thread_status` `(thread_id, status)`。
|
||||||
|
|
||||||
|
### 3.6 `run_events` — 运行事件流(回放 + 审计真源)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | Integer | PK autoincrement | |
|
||||||
|
| `thread_id` | String(64) | NOT NULL | |
|
||||||
|
| `run_id` | String(64) | NOT NULL,索引 | |
|
||||||
|
| `user_id` | String(64) | NULL,索引 | |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL(0003 后),FK `workspaces.id` **CASCADE** | |
|
||||||
|
| `event_type` | String(32) | NOT NULL | 子类型(`ai_message_chunk`/`tool_call`…) |
|
||||||
|
| `category` | String(16) | NOT NULL | `message`/`trace`/`lifecycle` |
|
||||||
|
| `content` | Text | NOT NULL default `""` | 事件文本 |
|
||||||
|
| `event_metadata` | JSON | NOT NULL default `{}` | |
|
||||||
|
| `seq` | Integer | NOT NULL | thread 内全局递增序号 |
|
||||||
|
| `created_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `uq_events_thread_seq` UNIQUE `(thread_id, seq)`
|
||||||
|
- `ix_events_thread_cat_seq` `(thread_id, category, seq)`
|
||||||
|
- `ix_events_run` `(thread_id, run_id, seq)`
|
||||||
|
|
||||||
|
### 3.7 `feedback` — 运行反馈(赞/踩 + 评论)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `feedback_id` | String(64) | PK | |
|
||||||
|
| `run_id` | String(64) | NOT NULL,索引 | |
|
||||||
|
| `thread_id` | String(64) | NOT NULL,索引 | |
|
||||||
|
| `user_id` | String(64) | NULL,索引 | |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL(0003 后),FK `workspaces.id` **CASCADE** | |
|
||||||
|
| `message_id` | String(64) | NULL | NULL=针对整次运行而非单条消息 |
|
||||||
|
| `rating` | Integer | NOT NULL | +1 赞 / -1 踩 |
|
||||||
|
| `comment` | Text | NULL | |
|
||||||
|
| `created_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:`uq_feedback_thread_run_user` UNIQUE `(thread_id, run_id, user_id)` — 一人对一次运行只一条反馈。
|
||||||
|
|
||||||
|
### 3.8 `service_accounts` — 服务账号(headless 非人身份)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | String(36) | PK | |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL,FK `workspaces.id` **CASCADE**,索引 | |
|
||||||
|
| `name` | String(64) | NOT NULL | |
|
||||||
|
| `role` | String(16) | NOT NULL default `"member"` | |
|
||||||
|
| `identity_mode` | String(16) | NOT NULL default `"collapsed"` | `collapsed`/`external_passthrough`/`both` |
|
||||||
|
| `status` | String(16) | NOT NULL default `"active"` | `active`/`suspended`/`deleted` |
|
||||||
|
| `created_by` | String(36) | NOT NULL,FK `users.id` **RESTRICT** | 创建者(owner/admin) |
|
||||||
|
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:`idx_service_accounts_workspace` `(workspace_id, status)`。
|
||||||
|
|
||||||
|
### 3.9 `api_keys` — API Key(headless 凭证)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | String(36) | PK | |
|
||||||
|
| `service_account_id` | String(36) | NOT NULL,FK `service_accounts.id` **CASCADE**,索引 | |
|
||||||
|
| `key_prefix` | String(16) | UNIQUE NOT NULL | 公开 prefix(`dfk_live_…`),可打日志 |
|
||||||
|
| `key_hash` | String(128) | NOT NULL | 完整 token 的 sha-256 hex |
|
||||||
|
| `name` | String(64) | NOT NULL | 标签(同 SA 内不强制唯一) |
|
||||||
|
| `scopes` | String(1024) | NOT NULL default `""` | 逗号分隔(`threads:read,threads:write`) |
|
||||||
|
| `rate_limit_rpm` | Integer | NULL | NULL=走 SA 默认 |
|
||||||
|
| `expires_at` / `last_used_at` / `revoked_at` | DateTime(tz) | NULL | `revoked_at` 非空=软删,不删行 |
|
||||||
|
| `created_at` | DateTime(tz) | NOT NULL | |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `idx_api_keys_sa` `(service_account_id)`
|
||||||
|
- `idx_api_keys_active` `(key_prefix)` WHERE `revoked_at IS NULL`(partial)— 鉴权热路径只扫活跃 key
|
||||||
|
|
||||||
|
### 3.10 `external_users` — 终端用户身份(passthrough)
|
||||||
|
|
||||||
|
| 列 | 类型 | 约束 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | String(36) | PK | ghost user id |
|
||||||
|
| `workspace_id` | String(36) | NOT NULL,FK `workspaces.id` **CASCADE** | 冗余存,加速跨 SA 的 workspace 查询 |
|
||||||
|
| `service_account_id` | String(36) | NOT NULL,FK `service_accounts.id` **CASCADE** | |
|
||||||
|
| `external_id` | String(128) | NOT NULL | 调用方传入的 `X-External-User-Id`,原样存 |
|
||||||
|
| `display_name` | String(128) | NULL | 仅 admin UI 展示 |
|
||||||
|
| `metadata_json` | JSON | NOT NULL default `{}` | plan tier / region / tag |
|
||||||
|
| `created_at` | DateTime(tz) | NOT NULL | 首见时间 |
|
||||||
|
| `last_seen_at` | DateTime(tz) | NULL | |
|
||||||
|
|
||||||
|
**索引**:`uq_external_users_sa_external` UNIQUE `(service_account_id, external_id)` — 同一 SA 下 external_id 唯一(鉴权中间件按此 upsert)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 外键与删除策略一览
|
||||||
|
|
||||||
|
| 子表 | 外键列 | 指向 | ON DELETE | 含义 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| users | default_workspace_id | workspaces.id | **SET NULL** | 默认 workspace 没了就清空,用户仍在 |
|
||||||
|
| workspaces | owner_id | users.id | **RESTRICT** | 不能直接删 owner,需先转移 |
|
||||||
|
| workspace_memberships | workspace_id | workspaces.id | **CASCADE** | 删 workspace → 成员清空 |
|
||||||
|
| workspace_memberships | user_id | users.id | **CASCADE** | 删 user → 其成员关系清空 |
|
||||||
|
| workspace_memberships | invited_by | users.id | **SET NULL** | 删邀请人,保留成员关系 |
|
||||||
|
| threads_meta / runs / run_events / feedback | workspace_id | workspaces.id | **CASCADE** | 删 workspace → 业务数据全清 |
|
||||||
|
| service_accounts | workspace_id | workspaces.id | **CASCADE** | 删 workspace → SA 清空 |
|
||||||
|
| service_accounts | created_by | users.id | **RESTRICT** | 不能删 SA 创建者 |
|
||||||
|
| api_keys | service_account_id | service_accounts.id | **CASCADE** | 删 SA → key 清空 |
|
||||||
|
| external_users | workspace_id | workspaces.id | **CASCADE** | |
|
||||||
|
| external_users | service_account_id | service_accounts.id | **CASCADE** | |
|
||||||
|
|
||||||
|
**心智模型**:删 workspace = 整租户级联清空(业务数据 + SA + key + 外部身份);user 作为他人的 owner/creator 受 RESTRICT 保护,不能"误删带塌一片"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 迁移历史(Alembic)
|
||||||
|
|
||||||
|
> dev 用 `create_all` 直接建到最新;生产/已有库用迁移逐步推进。`migrations/versions/`:
|
||||||
|
|
||||||
|
| 版本 | 依赖 | 变更 | 要点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **0001** `users_default_workspace` | — | `users` 加 `default_workspace_id`(nullable)+ FK→`workspaces.id` SET NULL | 无需回填(nullable) |
|
||||||
|
| **0002** `business_tables_workspace` | 0001 | `threads_meta`/`runs`/`feedback`/`run_events` 各加 `workspace_id`(**nullable**)+ FK CASCADE;`threads_meta` 加 `(workspace_id,user_id,updated_at)` 索引 | 先 nullable,留给 `scripts/backfill_workspace_id.py` 回填 |
|
||||||
|
| **0003** `business_tables_workspace_not_null` | 0002 | 4 张表 `workspace_id` 改 **NOT NULL**;`threads_meta` 加 `(workspace_id,thread_id)` UNIQUE | **升级前校验**:任一表仍有 `workspace_id IS NULL` 则拒绝升级,逼先跑回填脚本 |
|
||||||
|
|
||||||
|
**两段式上线**(0002→0003)是为了零停机:先加可空列 → 后台回填 → 校验通过再锁 NOT NULL,避免大表 ALTER 长锁与脏数据静默写入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与设计锁定版的差异 / 落地补充
|
||||||
|
|
||||||
|
| 项 | 锁定版 | 落地实际 |
|
||||||
|
|---|---|---|
|
||||||
|
| `run_events.workspace_id` | "待确认是否 DB 表" | **已确认**为 DB 表,比照 runs 加 `workspace_id` + CASCADE,并入 0002/0003 |
|
||||||
|
| `runs` token 指标列 | 未在 schema 文档列出 | 实际有完整一组:`total_*_tokens` / `llm_call_count` / `lead_agent_tokens` / `subagent_tokens` / `middleware_tokens` |
|
||||||
|
| `service_accounts.status` | `active/suspended/revoked` | 落地为 `active/suspended/deleted`(与 workspace 状态机一致) |
|
||||||
|
| `api_keys.scopes` 默认 | `''` | 一致(`String(1024)`,逗号分隔) |
|
||||||
|
| 索引名 | 设计期未定名 | 见各表"索引"小节(如 `ix_runs_thread_status`、`uq_events_thread_seq`) |
|
||||||
|
|
||||||
|
> 不可逆决策(id 类型 / 命名 / slug / 复合 PK / TokenPayload / FK 删除策略)均按锁定版 §5 执行,未变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 配套阅读
|
||||||
|
|
||||||
|
- [workspace-schema-design.zh-CN.md](./workspace-schema-design.zh-CN.md) — Stage 0 schema 锁定版(决策依据 + 不可逆点 + JWT TokenPayload)
|
||||||
|
- [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.zh-CN.md](../03-impl/STATUS.zh-CN.md) + `03-impl/pr8-headless-api-schema.zh-CN.md` — PR 级实现进度
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
# Stage 1 · Headless API Pattern A 鉴权地基 — 设计
|
||||||
|
|
||||||
|
> 设计稿。日期 2026-06-28。承接 Stage 0(PR1-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 → 设 contextvar,L77-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_`),全局 UNIQUE(PR8 schema 已定)。DB 只存 `sha256(plaintext)` hex。 |
|
||||||
|
|
||||||
|
## 4. 架构与数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
业务系统 backend ──▶ AuthMiddleware ──┬── "Bearer dfk_..." ──▶ APIKeyAuthBackend
|
||||||
|
│ │
|
||||||
|
│ ├─ sha256(token) → ApiKeyRepository.get_active_by_hash
|
||||||
|
│ ├─ 校 key.expires_at 未过期;load SA 校 status=active;load 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 即 skip(PR3)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
路由 + @require_permission(scope 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 的建 key(app)都要用,按 harness boundary(app 可 import deerflow,反之不可)必须在 deerflow 侧。
|
||||||
|
|
||||||
|
**测试**(严格 TDD 红→绿):每仓储 CRUD round-trip;`get_active_by_hash` 对 revoked / expired key 返 None;token 生成格式(前缀、长度、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 的 key(PR1 仓储)构造。
|
||||||
|
|
||||||
|
### 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 · 管理 endpoint(mint 闭环)
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
- `app/gateway/routers/service_accounts.py` — `APIRouter(prefix="/api/v1/service-accounts")`:
|
||||||
|
- `POST /` 建 SA(body: name, role?, identity_mode?)→ 归当前 workspace,`created_by` = 当前 user。`@require_permission` gated 到 owner/admin。
|
||||||
|
- `GET /` 列当前 workspace 的 SA。
|
||||||
|
- `PATCH /{sa_id}` 改 status(suspend/active)。
|
||||||
|
- `app/gateway/routers/api_keys.py` — `APIRouter(prefix="/api/v1/api-keys")`:
|
||||||
|
- `POST /` 为指定 SA 建 key(body: 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 复用现有 authz;SA / 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` header(sunset 日期取 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` / 仓储测试风格。
|
||||||
|
- 仓储层用 SQLite(autouse 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 16,sha256 存储(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-deny:service principal 只能访问数据平面(`/api/threads*`、`/api/runs*`、`/api/assistants`),所有控制平面路由(含 read)一律 403 `insufficient_scope`。实现见 `AuthMiddleware._is_dataplane_path`;设计见 [api-key-control-plane-default-deny-design](../../superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md)。细粒度 scope 词汇升级仍按原计划推后。
|
||||||
|
|
||||||
|
## 9. 与后续 PR 的接口
|
||||||
|
|
||||||
|
本 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
-13
@@ -2,25 +2,40 @@
|
|||||||
|
|
||||||
> **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。
|
> **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。
|
||||||
>
|
>
|
||||||
> 上次更新:2026-05-14,PR8 merge 进 docs branch 后(Stage 0 工程全合)
|
> 上次更新:2026-06-28——`multi_tenant.py` live smoke 跑通(2026-06-27 用户运行,用户确认 PASS),退出门「手工 smoke」项关闭。分支已由 `docs/multi-tenant-redesign` 改名为 `feat/multi-tenant`(origin + kcim 均同步)。
|
||||||
|
|
||||||
## 一句话状态
|
## 一句话状态
|
||||||
|
|
||||||
**PR1-PR8 全部已 merge——Stage 0 工程层面收尾。** **PR8 (2026-05-14)** 落地:3 张新表 schema-only 为 Stage 1 headless API 准备底座——`service_accounts`(workspace-scoped 非人身份,`identity_mode` 三态:`collapsed` / `external_passthrough` / `both`,`created_by` FK RESTRICT)/ `api_keys`(service_account 凭证,`key_prefix` 全局 UNIQUE + 双驱动部分索引 `idx_api_keys_active` WHERE `revoked_at IS NULL`,`scopes` 用 String(1024) 不用 PG `text[]` 保 SQLite 兼容)/ `external_users`(passthrough 终端身份,复合 UNIQUE `(service_account_id, external_id)`,`workspace_id` 冗余存储加速聚合)。FK 行为:workspace/SA delete CASCADE、creator user delete RESTRICT。9 新单测(3 service_account + 3 api_key + 2 external_user + 1 反向 metadata registration)。**3250 passed + 31 skipped + 18 caplog flake**(PR7 末 3241 + 31 + 18;+9 passed,flake 数 0 增)。Stage 0 工程层面**仅剩用户跟进的 live smoke**(见下);业务层面看 "Stage 0 退出 Go/No-Go"。
|
**PR1-PR8 全部已 merge——Stage 0 工程层面收尾。** **PR8 (2026-05-14)** 落地:3 张新表 schema-only 为 Stage 1 headless API 准备底座——`service_accounts`(workspace-scoped 非人身份,`identity_mode` 三态:`collapsed` / `external_passthrough` / `both`,`created_by` FK RESTRICT)/ `api_keys`(service_account 凭证,`key_prefix` 全局 UNIQUE + 双驱动部分索引 `idx_api_keys_active` WHERE `revoked_at IS NULL`,`scopes` 用 String(1024) 不用 PG `text[]` 保 SQLite 兼容)/ `external_users`(passthrough 终端身份,复合 UNIQUE `(service_account_id, external_id)`,`workspace_id` 冗余存储加速聚合)。FK 行为:workspace/SA delete CASCADE、creator user delete RESTRICT。9 新单测(3 service_account + 3 api_key + 2 external_user + 1 反向 metadata registration)。**3250 passed + 31 skipped + 18 caplog flake**(PR7 末 3241 + 31 + 18;+9 passed,flake 数 0 增)。Stage 0 工程层面**仅剩用户跟进的 live smoke**(见下);业务层面看 "Stage 0 退出 Go/No-Go"。
|
||||||
|
|
||||||
|
## Live smoke 结果(2026-06-27 `multi_tenant.py`)
|
||||||
|
|
||||||
|
**用户运行 `apps/examples/http-chat/multi_tenant.py` 打到运行中的 Gateway,确认 verdict = PASS。** 这是退出门「手工 smoke」项的实跑验证,且比清单要求更强。脚本(代码层面核实)实际断言的不变量:
|
||||||
|
|
||||||
|
- **注册 → workspace 自动建**:每租户走 `POST /api/v1/auth/register` 新建用户,`GET /api/v1/auth/me` 返回 `default_workspace_id`(workspace 随注册自动创建)。
|
||||||
|
- **真并发**:N 个租户各自独立 `requests.Session`(独立 cookie)放进线程池同时跑,输出「对话时间窗重叠」证据证明是真并发而非串行。
|
||||||
|
- **多轮上下文保持**:每租户复用同一 thread 跑 N 轮链式对话(T1=a×b,之后每轮 +d,步长 d 每租户不同),逐轮校验上一轮结果,验证并发下各租户上下文互不串扰。
|
||||||
|
- **双向隔离**:① `POST /api/threads/search` 只返回自己的 thread(不泄漏他人);② 直接 `GET /api/threads/{他人 thread_id}` 一律返回 **404**(不是 403)。
|
||||||
|
|
||||||
|
> 未核实数字(用户选择不编造):本次运行的具体租户数 `DF_TENANTS`、轮数 `DF_TURNS`、逐轮通过数。如需精确记录,贴终端输出(含 `>>> PASS ✅` 行)后回填。
|
||||||
|
>
|
||||||
|
> **已知覆盖缺口**:退出门 smoke 文字里的「JWT 含 wid」脚本未显式解码 token 断言 wid claim——由隔离端到端工作间接覆盖,非字面级验证。
|
||||||
|
|
||||||
|
**本次关闭的项**:退出门「手工 smoke:注册 → workspace 自动建 → 创建 thread → 跨 workspace 互调 404」✅;PR4 T4.14 真机注册 smoke ✅;PR6 T6.15 的「双账户互访 404」隔离部分 ✅(该 task 的文件迁移部分 `make migrate-paths` 仍 ⏳,见跳过表)。
|
||||||
|
|
||||||
## 8 PR 状态表
|
## 8 PR 状态表
|
||||||
|
|
||||||
| PR | 状态 | Commits | 分支 / 落点 | impl note |
|
| PR | 状态 | Commits | 分支 / 落点 | impl note |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **PR0** | ✅ merged | 1 | `a74b88a4` on docs branch | — |
|
| **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) |
|
| **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.md](./pr2-postgres-default.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.md](./pr3-workspaces.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.md](./pr4-auth-workspace.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.md](./pr5-business-workspace-id.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.md](./pr6-routes-paths-workspace.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.md](./pr7-ci-boundary-scan.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.md](./pr8-headless-api-schema.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 passed,PR8 新增 3 + 3 + 2 + 1 = 9 个 schema 测试)。PR6 末 3214 + 30;PR5 末 3150 + 30;PR4 末 3136 + 26;PR3 末 3134 + 25;PR2 末 3087。**18 个 caplog 排序 flake 持续存在**(17 个 pre-existing + 1 PR6 引入,PR7/PR8 均未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。
|
**测试基线**:**PR8 末 3250 passed + 31 skipped**(PR7 末 3241 + 31;+9 passed,PR8 新增 3 + 3 + 2 + 1 = 9 个 schema 测试)。PR6 末 3214 + 30;PR5 末 3150 + 30;PR4 末 3136 + 26;PR3 末 3134 + 25;PR2 末 3087。**18 个 caplog 排序 flake 持续存在**(17 个 pre-existing + 1 PR6 引入,PR7/PR8 均未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。
|
||||||
|
|
||||||
@@ -47,13 +62,13 @@ PR1 起到 PR8 末,从既有 ~3087 增到 3250 passed(+163 测试,覆盖
|
|||||||
| PR2 T2.7 | 写 setup_wizard 推荐 PG 的代码 | 已在 PR1 T1.8 完整实现(empty commit `745a33e0` 仅做 task tracking) | 无需跟进 |
|
| PR2 T2.7 | 写 setup_wizard 推荐 PG 的代码 | 已在 PR1 T1.8 完整实现(empty commit `745a33e0` 仅做 task tracking) | 无需跟进 |
|
||||||
| PR2 T2.8 | sqlite→pg 数据迁移工具 (`scripts/migrate_sqlite_to_postgres.py`) | plan 标 optional + Stage 0 没生产数据 | 如果出现"dev 用 SQLite 跑过一段、想保留数据迁 PG"的需求再补 |
|
| PR2 T2.8 | sqlite→pg 数据迁移工具 (`scripts/migrate_sqlite_to_postgres.py`) | plan 标 optional + Stage 0 没生产数据 | 如果出现"dev 用 SQLite 跑过一段、想保留数据迁 PG"的需求再补 |
|
||||||
| PR2 T2.9 | `backend/CLAUDE.md` Database 段更新 | README 已覆盖 80% 价值 | 写 PR3 时顺手补一句(agent 自己能做,不阻塞) |
|
| PR2 T2.9 | `backend/CLAUDE.md` Database 段更新 | README 已覆盖 80% 价值 | 写 PR3 时顺手补一句(agent 自己能做,不阻塞) |
|
||||||
| PR4 T4.14 | 真机 `make dev` smoke 注册流程 | agent 无法实际起 gateway daemon | 用户跟进;命令清单见 [pr4-auth-workspace.md "Live smoke 命令"](./pr4-auth-workspace.md#live-smoke-命令用户跟进) |
|
| PR4 T4.14 | 真机 `make dev` smoke 注册流程 | agent 无法实际起 gateway daemon | ✅ done 2026-06-27——`multi_tenant.py` PASS 覆盖(注册 → workspace 自建 → me 返回 wid) |
|
||||||
| PR4 follow-up | Regular user pre-PR4 backfill 脚本 | login 路径已 lazy backfill 覆盖;如果生产有大量预存 regular user,可补 batch 脚本 | 等真出现这个场景再写 |
|
| 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 处理 |
|
| 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.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-命令用户跟进) |
|
| 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 服务 | 用户跟进;命令清单见 [pr6-routes-paths-workspace.md "Live smoke 命令"](./pr6-routes-paths-workspace.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.md "Live smoke 命令"](./pr8-headless-api-schema.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 处理)
|
## 即将遇到的开放问题(plan 末尾列的,下个 session 处理)
|
||||||
|
|
||||||
+1
-1
@@ -145,4 +145,4 @@ make stop && make dev
|
|||||||
- 跨 workspace 必 404 怎么实现的 → 看 `check_access` 改造(commit `05be7f9a`)+ `@require_permission` 装饰器
|
- 跨 workspace 必 404 怎么实现的 → 看 `check_access` 改造(commit `05be7f9a`)+ `@require_permission` 装饰器
|
||||||
- 路径新形态 → `Paths.thread_dir` 三档优先级(commit `f013fc1a`)
|
- 路径新形态 → `Paths.thread_dir` 三档优先级(commit `f013fc1a`)
|
||||||
- 仓储 workspace_id 哨兵模式 → 任一 `*/sql.py` 看 create/get/search 签名
|
- 仓储 workspace_id 哨兵模式 → 任一 `*/sql.py` 看 create/get/search 签名
|
||||||
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.md`
|
- 迁移脚本与 PR5 backfill 的边界 → 本文件 "迁移路径" 段,外加 `pr5-business-workspace-id.zh-CN.md`
|
||||||
+2
-2
@@ -37,7 +37,7 @@
|
|||||||
- [x] **scanner 红→绿循环**:empty allowlist → 14 violations across 4 files(threads / async_provider / provider / worker,TYPE_CHECKING-only 的 factory.py 正确不在内);填入 4 entry → PASS
|
- [x] **scanner 红→绿循环**:empty allowlist → 14 violations across 4 files(threads / async_provider / provider / worker,TYPE_CHECKING-only 的 factory.py 正确不在内);填入 4 entry → PASS
|
||||||
- [x] **scanner self-test 9 个全过**(防静默空跑)
|
- [x] **scanner self-test 9 个全过**(防静默空跑)
|
||||||
- [x] **T7.4 反注入实验**:往 `feedback.py:13` 加一行违规 import → `pytest tests/test_workspace_boundary.py` 单条 fail,error 精准指 `app/gateway/routers/feedback.py:13 imports langgraph.checkpoint.postgres`;revert 后立即返绿
|
- [x] **T7.4 反注入实验**:往 `feedback.py:13` 加一行违规 import → `pytest tests/test_workspace_boundary.py` 单条 fail,error 精准指 `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
|
- [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.py` — AST 扫描器(127 行)
|
||||||
- `backend/tests/test_workspace_boundary_self.py` — 扫描器 self-test(93 行)
|
- `backend/tests/test_workspace_boundary_self.py` — 扫描器 self-test(93 行)
|
||||||
- `backend/tests/boundary_allowlist.toml` — 4 个合法 importer + 每行注释(28 行)
|
- `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 行
|
- `backend/CLAUDE.md` — Boundary check 段 +2 行
|
||||||
+2
-2
@@ -47,7 +47,7 @@
|
|||||||
- `backend/tests/test_api_key_schema.py`(3 cases)
|
- `backend/tests/test_api_key_schema.py`(3 cases)
|
||||||
- `backend/tests/test_external_user_schema.py`(2 cases)
|
- `backend/tests/test_external_user_schema.py`(2 cases)
|
||||||
- `backend/tests/test_pr8_metadata_registration.py`(1 case)
|
- `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__` 注册
|
- `backend/packages/harness/deerflow/persistence/models/__init__.py` — 加 3 行 import + `__all__` 注册
|
||||||
@@ -89,7 +89,7 @@ PYTHONPATH=. uv run pytest -m postgres -v
|
|||||||
|
|
||||||
## Stage 0 退出门
|
## 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` 见
|
- [ ] RDS 上 `service_accounts` / `api_keys` / `external_users` 三张表 `\dt` 见
|
||||||
- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空
|
- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空
|
||||||
- [ ] testcontainers ephemeral PG smoke 跑过一次
|
- [ ] testcontainers ephemeral PG smoke 跑过一次
|
||||||
@@ -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 文档里。
|
> **范围**:仅汇总与导航,不引入新决策。具体决策正文在各自的 ADR / rollout 文档里。
|
||||||
>
|
>
|
||||||
@@ -26,13 +28,20 @@ docs/multi-tenant-redesign/
|
|||||||
│ ├── adr-spike-langgraph-postgres spike:LangGraph PG 注入能力
|
│ ├── adr-spike-langgraph-postgres spike:LangGraph PG 注入能力
|
||||||
│ ├── adr-vs-code-audit 审计:ADR vs 现状代码
|
│ ├── adr-vs-code-audit 审计:ADR vs 现状代码
|
||||||
│ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物
|
│ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物
|
||||||
│ └── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点)
|
│ ├── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点)
|
||||||
└── 02-rollout/ 落地路线 + 集成轨道
|
│ ├── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
|
||||||
├── phased-rollout-by-scale **Stage 0–4 主线** 路线图
|
│ └── stage-1-headless-api-pattern-a-auth-foundation-design **Stage 1 鉴权地基设计**(Pattern A)
|
||||||
├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
|
├── 02-rollout/ 落地路线 + 集成轨道
|
||||||
└── headless-api-track 业务系统集成轨道(Pattern A / B)
|
│ ├── phased-rollout-by-scale **Stage 0–4 主线** 路线图
|
||||||
|
│ ├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
|
||||||
|
│ └── headless-api-track 业务系统集成轨道(Pattern A / B)
|
||||||
|
└── 03-impl/ **执行记录层**(进度 + 各 PR 落地笔记)
|
||||||
|
├── STATUS ★ **唯一进度权威**("现在到哪了")
|
||||||
|
└── pr1..pr8 Stage 0 各 PR impl note(postgres / workspaces / auth / 业务表 / 路由 / boundary / 三表 schema)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 命名约定:全部 `.zh-CN.md` 后缀;`01-redesign` 用语义名(`adr-*` / `*-design`),`03-impl` 用 `prN-*` / `STATUS` 顺序名。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. ADR 与配套文档状态表
|
## 1. ADR 与配套文档状态表
|
||||||
@@ -49,6 +58,8 @@ docs/multi-tenant-redesign/
|
|||||||
| spike | [LangGraph PG 注入](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) | 已结论 | 2026-05-09 | `langgraph-checkpoint-postgres==3.0.5` **不存在 connection_factory**;改走应用层强校验 + 自有表 RLS 的两层模型 |
|
| spike | [LangGraph PG 注入](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) | 已结论 | 2026-05-09 | `langgraph-checkpoint-postgres==3.0.5` **不存在 connection_factory**;改走应用层强校验 + 自有表 RLS 的两层模型 |
|
||||||
| 审计 | [ADR vs 代码](./01-redesign/adr-vs-code-audit.zh-CN.md) | 已结论 | 2026-05-09 | 代码库 0 处 `tenant`;Better Auth 不存在;ObjectStorage / KMS / Postgres 测试夹具全缺;底座先行 §3.5 |
|
| 审计 | [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 动手前必读 |
|
| 锁定 | [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) |
|
| 计划 | [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 0–4 + 触发/退出/时间盒/Go-No-Go |
|
| 路线 | [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) | **当前主线路线图** | 2026-05-09 | Stage 0–4 + 触发/退出/时间盒/Go-No-Go |
|
||||||
| 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 |
|
| 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 |
|
||||||
@@ -56,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 only(Stage 1 地基) |
|
||||||
|
|
||||||
|
> Stage 1 的逐 task **实现计划**(writing-plans 产出)将落在 `docs/superpowers/plans/`,与 Stage 0 master plan 一致;其设计稿见 §1 的 stage-1 spec 行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Stage 0–4 速览矩阵
|
## 2. Stage 0–4 速览矩阵
|
||||||
|
|
||||||
| Stage | 触发 | 退出 | 时间盒 | 主要 ADR 章节 |
|
| Stage | 触发 | 退出 | 时间盒 | 主要 ADR 章节 |
|
||||||
@@ -254,9 +285,10 @@ docs/multi-tenant-redesign/
|
|||||||
## 7. 阅读路径建议
|
## 7. 阅读路径建议
|
||||||
|
|
||||||
**第一次进项目(30 min)**:
|
**第一次进项目(30 min)**:
|
||||||
1. 本 README
|
1. 本 README(设计 / 路线导航)
|
||||||
2. [00-current-state/architecture-overview](./00-current-state/architecture-overview.zh-CN.md) — 现状是什么样的
|
2. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — **现在到哪了**(先看这个,再看下面的"为什么")
|
||||||
3. [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) §0 + §总览 + §Stage 0 — 现在在哪、下一步做什么
|
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(半天)**:
|
**准备动手做 Stage 0(半天)**:
|
||||||
1. [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) **全文** — 不可逆决策、PR 拆分
|
1. [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) **全文** — 不可逆决策、PR 拆分
|
||||||
@@ -265,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
|
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(一天)**:
|
**准备动手做 Stage 1(一天)**:
|
||||||
1. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
|
1. [03-impl/STATUS](./03-impl/STATUS.zh-CN.md) — Stage 0 收尾现状 + Stage 1 可启动方向
|
||||||
2. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
|
2. [phased-rollout Stage 1](./02-rollout/phased-rollout-by-scale.zh-CN.md) — 双轨并行
|
||||||
3. [ADR-003 §4.3-§4.4](./01-redesign/adr-003-llm-key-billing.zh-CN.md) — quota + 悲观预扣
|
3. [headless-api-track](./02-rollout/headless-api-track.zh-CN.md) **全文** — Pattern A/B 完整设计
|
||||||
4. [ADR-002 §3](./01-redesign/adr-002-sandbox-isolation.zh-CN.md) — Stage 1 用 §3 轻量版(**不**读 §5 K8s 完整版)
|
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.6(BYO)/ ADR-004 §5.4(strict 装饰器)
|
1. ADR-001 / ADR-002 / ADR-003 §4.6(BYO)/ ADR-004 §5.4(strict 装饰器)
|
||||||
|
|||||||
@@ -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` 时不查 PG(regression);commit
|
- [ ] **T1.7 doctor.py 加 PG 探测**:仅在 `database.backend == 'postgres'` 时调 `asyncpg.connect(url)` + 报 PG version;测试 `database.backend: sqlite` 时不查 PG(regression);commit
|
||||||
- [ ] **T1.8 setup_wizard.py 加交互**:选数据库后端时新增 postgres 选项 + DATABASE_URL 引导;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.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 张表数据搬过去
|
- `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
|
- `config.example.yaml` — `database` 段默认 postgres
|
||||||
@@ -799,7 +799,7 @@ if not ok:
|
|||||||
- [ ] 17+ 新单测过 + 既有 277 + PR3/PR4/PR5 新测全过
|
- [ ] 17+ 新单测过 + 既有 277 + PR3/PR4/PR5 新测全过
|
||||||
- [ ] `python scripts/migrate_paths_to_workspace.py --dry-run` 输出可读
|
- [ ] `python scripts/migrate_paths_to_workspace.py --dry-run` 输出可读
|
||||||
- [ ] 实际跑迁移后 lifespan warning 消失
|
- [ ] 实际跑迁移后 lifespan warning 消失
|
||||||
- [ ] 手工 smoke:起 dev 服务,注册两个 user → 创各自 thread → 互访 404
|
- [x] 手工 smoke:起 dev 服务,注册两个 user → 创各自 thread → 互访 404 ✅ 2026-06-27 `multi_tenant.py` PASS(N 租户并发版,含 search 不泄漏 + 跨租户 GET 404)
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
@@ -1028,13 +1028,13 @@ class ExternalUserRow(Base):
|
|||||||
## Stage 0 退出 Go/No-Go(来自 phased-rollout-by-scale)
|
## Stage 0 退出 Go/No-Go(来自 phased-rollout-by-scale)
|
||||||
|
|
||||||
工程层面:
|
工程层面:
|
||||||
- [ ] PR1-PR8 全部合入
|
- [x] PR1-PR8 全部合入 ✅(见 STATUS.zh-CN.md 8 PR 状态表,全 merged 进分支)
|
||||||
- [ ] 既有 277 + 新增 ~70 测试全 100% 通过
|
- [x] 既有 277 + 新增 ~70 测试全 100% 通过 ✅(实际 3250 passed + 31 skipped,新增 ~163)
|
||||||
- [ ] CI(含 backend-postgres-tests)绿
|
- [x] CI(含 backend-postgres-tests)绿 ✅ 2026-05-12 用户确认
|
||||||
- [ ] 手工 smoke:注册新用户 → workspace 自动建 → JWT 含 wid → 创建 thread → 跨 workspace 互调 404
|
- [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 未显式解码断言,由隔离端到端间接覆盖)
|
||||||
- [ ] 部署到生产 ≥ 2 周,无 workspace 隔离 bug 报告
|
- [ ] 部署到生产 ≥ 2 周,无 workspace 隔离 bug 报告
|
||||||
- [ ] 生产稳定运行在 Postgres 上 ≥ 2 周,无 schema/性能 regression
|
- [ ] 生产稳定运行在 Postgres 上 ≥ 2 周,无 schema/性能 regression
|
||||||
- [ ] 7 项不可逆 LOCK 决策经团队 review 拍板
|
- [x] 7 项不可逆 LOCK 决策经团队 review 拍板 ✅ 2026-05-12 全 7 项 sign-off
|
||||||
|
|
||||||
业务层面:
|
业务层面:
|
||||||
- [ ] 第一个付费意向客户出现(业务条件,非工程)
|
- [ ] 第一个付费意向客户出现(业务条件,非工程)
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# API Key 控制平面 default-deny 收口 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 service principal(API key `Authorization: Bearer dfk_...` 请求)只能访问数据平面(`/api/threads*`、`/api/runs*`、`/api/assistants`),任何控制平面路由(models/mcp/memory/skills/channels/agents 与管理/auth 端点)读写一律返回 403;真人 cookie 请求完全不受影响。
|
||||||
|
|
||||||
|
**Architecture:** 在 `AuthMiddleware.dispatch` 的 bearer 分支内、token 校验通过之后、写 contextvar 之前,加一道 default-deny 路径白名单检查(`_is_dataplane_path`)。白名单是模块级前缀元组,新增控制平面路由自动被拦,不复现"忘了保护"的缺陷。检查只在 bearer 分支内,cookie 路径天然不进。
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 · FastAPI · Starlette `BaseHTTPMiddleware` · `starlette.testclient.TestClient` · pytest + `pytest.mark.anyio`。
|
||||||
|
|
||||||
|
**设计来源:** [2026-06-28-api-key-control-plane-default-deny-design.md](../specs/2026-06-28-api-key-control-plane-default-deny-design.md)(策略、白名单边界、错误口径、nginx rewrite 前提皆以该 spec 为准)。
|
||||||
|
|
||||||
|
**运行约定(每条命令都从 `backend/` 目录执行):**
|
||||||
|
- 单测:`PYTHONPATH=. uv run pytest tests/<file>.py -v`
|
||||||
|
- lint:`make lint`(ruff,行宽 240,双引号)
|
||||||
|
- 全量回归:`make test`
|
||||||
|
|
||||||
|
**关键前提(spec §3.1,已验证):** nginx 把 `/api/langgraph/(.*)` rewrite 成 `/api/$1` 后才转给 gateway,IM channels 也直连 `/api/*`,因此 `AuthMiddleware` 永远看不到 `/api/langgraph`;LangGraph-SDK 调用到达中间件时是 `/api/threads`、`/api/runs`、`/api/assistants`。白名单因此是这三个前缀,**不含** `/api/langgraph`(死代码)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
新增 / 修改文件一览(精确路径):
|
||||||
|
|
||||||
|
- Modify: `backend/app/gateway/auth/errors.py` — `AuthErrorCode` 加 `INSUFFICIENT_SCOPE`
|
||||||
|
- Modify: `backend/app/gateway/auth_middleware.py` — 加 `_DATAPLANE_PREFIXES` + `_is_dataplane_path`;bearer 分支加 403 检查
|
||||||
|
- Modify: `backend/tests/test_auth_middleware_api_key.py` — 探针路径 `/api/probe` → `/api/v1/threads/_probe`
|
||||||
|
- Modify: `backend/tests/test_headless_api_smoke.py` — 同上
|
||||||
|
- Create: `backend/tests/test_api_key_control_plane.py` — 单元表 + 集成 403 / 放行 / cookie 回归
|
||||||
|
- Modify: `docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md` — §8.1 从"已知限制"翻成"已解决"
|
||||||
|
|
||||||
|
**Task → commit 边界:** 4 个 Task,顺序实现,每个 Task 末尾 commit。Task 2(挪探针路径)必须早于 Task 3(加 deny),否则 deny 落地会打断既有探针测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 错误码 + 数据平面白名单辅助函数
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/gateway/auth/errors.py`
|
||||||
|
- Modify: `backend/app/gateway/auth_middleware.py`
|
||||||
|
- Test: `backend/tests/test_api_key_control_plane.py`
|
||||||
|
|
||||||
|
> 本 Task 只加错误码 + 纯函数 `_is_dataplane_path`(中间件尚未调用它,Task 3 才接线)。先用表驱动单测锁定边界。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
Create `backend/tests/test_api_key_control_plane.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""API key control-plane default-deny tests (Stage 1 收口).
|
||||||
|
|
||||||
|
service principal (API key) 只能访问数据平面 (threads/runs/assistants);
|
||||||
|
控制平面 (models/mcp/memory/skills/channels/agents 与管理/auth) 一律 403。
|
||||||
|
真人 cookie 路径不受影响。设计见 spec
|
||||||
|
docs/superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway.auth_middleware import _is_dataplane_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/api/threads",
|
||||||
|
"/api/threads/abc",
|
||||||
|
"/api/v1/threads",
|
||||||
|
"/api/v1/threads/abc/runs/xyz/feedback",
|
||||||
|
"/api/runs",
|
||||||
|
"/api/runs/stream",
|
||||||
|
"/api/v1/runs/stream",
|
||||||
|
"/api/assistants",
|
||||||
|
"/api/assistants/search",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_dataplane_paths_allowed(path):
|
||||||
|
assert _is_dataplane_path(path) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/api/models",
|
||||||
|
"/api/v1/models",
|
||||||
|
"/api/mcp/config",
|
||||||
|
"/api/v1/mcp/config",
|
||||||
|
"/api/v1/memory",
|
||||||
|
"/api/v1/skills/install",
|
||||||
|
"/api/v1/channels/restart",
|
||||||
|
"/api/v1/agents",
|
||||||
|
"/api/v1/service-accounts",
|
||||||
|
"/api/v1/api-keys",
|
||||||
|
"/api/v1/auth/me",
|
||||||
|
"/api/langgraph/threads", # nginx 死代码:中间件本看不到,真混进来也应 deny
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_control_plane_paths_denied(path):
|
||||||
|
assert _is_dataplane_path(path) is False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v`
|
||||||
|
Expected: FAIL — `ImportError: cannot import name '_is_dataplane_path' from 'app.gateway.auth_middleware'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写实现 — 错误码**
|
||||||
|
|
||||||
|
Modify `backend/app/gateway/auth/errors.py` — 在 `AuthErrorCode` 枚举末尾(`WORKSPACE_REQUIRED` 之后)加一个成员。将:
|
||||||
|
|
||||||
|
```python
|
||||||
|
NOT_AUTHENTICATED = "not_authenticated"
|
||||||
|
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
||||||
|
WORKSPACE_REQUIRED = "workspace_required"
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
NOT_AUTHENTICATED = "not_authenticated"
|
||||||
|
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
||||||
|
WORKSPACE_REQUIRED = "workspace_required"
|
||||||
|
INSUFFICIENT_SCOPE = "insufficient_scope"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 写实现 — 白名单辅助函数**
|
||||||
|
|
||||||
|
Modify `backend/app/gateway/auth_middleware.py` — 在 `_is_public` 函数定义之后(L55 后)追加数据平面前缀常量与辅助函数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Data-plane / SDK route prefixes a service principal (API key) may reach.
|
||||||
|
# Everything else (global control plane: models/mcp/memory/skills/channels/
|
||||||
|
# agents, plus management/auth endpoints) is denied by default for API keys.
|
||||||
|
# NOTE: nginx rewrites /api/langgraph/(.*) -> /api/$1 before the gateway, so
|
||||||
|
# AuthMiddleware never sees /api/langgraph; the SDK surface arrives as
|
||||||
|
# /api/threads, /api/runs, /api/assistants. assistants.search()/get() is
|
||||||
|
# required for langgraph-sdk client init, so /api/assistants is allowed.
|
||||||
|
_DATAPLANE_PREFIXES: tuple[str, ...] = (
|
||||||
|
"/api/threads",
|
||||||
|
"/api/v1/threads",
|
||||||
|
"/api/runs",
|
||||||
|
"/api/v1/runs",
|
||||||
|
"/api/assistants",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dataplane_path(path: str) -> bool:
|
||||||
|
"""True if an API key request may reach this path. Reusable by a future
|
||||||
|
Pattern B service-token branch."""
|
||||||
|
return any(path.startswith(prefix) for prefix in _DATAPLANE_PREFIXES)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 跑测试确认通过**
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v`
|
||||||
|
Expected: PASS(21 个 parametrize 用例)
|
||||||
|
|
||||||
|
- [ ] **Step 6: lint + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && make lint && git add app/gateway/auth/errors.py app/gateway/auth_middleware.py tests/test_api_key_control_plane.py
|
||||||
|
git commit -m "feat(authz): data-plane allowlist helper + INSUFFICIENT_SCOPE code (Stage 1 收口)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 既有探针测试路径挪到数据平面
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/tests/test_auth_middleware_api_key.py`
|
||||||
|
- Modify: `backend/tests/test_headless_api_smoke.py`
|
||||||
|
|
||||||
|
> 这两个文件的探针路由是 `/api/probe`(非数据平面)。Task 3 的 deny 落地后,valid-key 探针会被 403 打断。这些探针的本意是"SA 访问一个受保护的数据平面路由",故先把路径挪到 `/api/v1/threads/_probe`。本 Task 不改运行时行为(deny 尚未接线),改完探针仍应全绿。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 改 `test_auth_middleware_api_key.py` 的探针路由定义**
|
||||||
|
|
||||||
|
Modify `backend/tests/test_auth_middleware_api_key.py` — 把 `_make_app` 内的探针路由声明(约 L72)从:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/probe")
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/v1/threads/_probe")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改 `test_auth_middleware_api_key.py` 的全部 `client.get` 路径**
|
||||||
|
|
||||||
|
同文件,把所有 `client.get("/api/probe", ...)`(5 处:约 L87 / L98 / L108 / L120 / L131)的路径串 `"/api/probe"` 全部改为 `"/api/v1/threads/_probe"`。其余参数(headers)不动。
|
||||||
|
|
||||||
|
> 校验:`grep -n '/api/probe' tests/test_auth_middleware_api_key.py` 应无输出。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改 `test_headless_api_smoke.py` 的探针路由定义 + 调用**
|
||||||
|
|
||||||
|
Modify `backend/tests/test_headless_api_smoke.py`:
|
||||||
|
- 把 `_probe_app` 内的探针路由声明(约 L95)`@app.get("/api/probe")` 改为 `@app.get("/api/v1/threads/_probe")`
|
||||||
|
- 把两处调用(约 L111 / L116)`probe.get("/api/probe", ...)` 的路径串改为 `"/api/v1/threads/_probe"`
|
||||||
|
|
||||||
|
> 校验:`grep -n '/api/probe' tests/test_headless_api_smoke.py` 应无输出。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测试确认仍全绿(无行为变化)**
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py -v`
|
||||||
|
Expected: PASS(与改动前相同的用例数;deny 尚未接线,valid 探针走数据平面路径仍 200,401 用例仍 401)
|
||||||
|
|
||||||
|
- [ ] **Step 5: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && git add tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py
|
||||||
|
git commit -m "test(auth): move bearer probe routes under /api/v1/threads (Stage 1 收口)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: AuthMiddleware bearer 分支 default-deny
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/gateway/auth_middleware.py`
|
||||||
|
- Test: `backend/tests/test_api_key_control_plane.py`(追加集成测试)
|
||||||
|
|
||||||
|
> deny 检查放在 bearer 分支内 `if result is None: return 401` **之后**、写 contextvar 之前——无效 key 仍是 401(不是 403),只有 valid key 命中控制平面才 403。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**(追加到 `test_api_key_control_plane.py` 末尾)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from deerflow.auth.tokens import generate_api_key
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend() -> str:
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_key(tmp_path, *, scopes="threads:read"):
|
||||||
|
from deerflow.persistence.api_key import ApiKeyRepository
|
||||||
|
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||||
|
from deerflow.persistence.service_account.model import ServiceAccountRow
|
||||||
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
from deerflow.persistence.workspace.model import WorkspaceRow
|
||||||
|
|
||||||
|
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
|
||||||
|
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
|
||||||
|
sf = get_session_factory()
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(UserRow(id="u-alice", email="alice@example.com"))
|
||||||
|
await session.commit()
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(WorkspaceRow(id="w-1", name="WS", slug="ws", owner_id="u-alice"))
|
||||||
|
await session.commit()
|
||||||
|
async with sf() as session:
|
||||||
|
session.add(ServiceAccountRow(id="sa-1", workspace_id="w-1", name="bot", role="member", identity_mode="collapsed", status="active", created_by="u-alice"))
|
||||||
|
await session.commit()
|
||||||
|
repo = ApiKeyRepository(sf)
|
||||||
|
gen = generate_api_key("live")
|
||||||
|
await repo.create(service_account_id="sa-1", key_prefix=gen.prefix, key_hash=gen.key_hash, name="k", scopes=scopes)
|
||||||
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup():
|
||||||
|
from deerflow.persistence.engine import close_engine
|
||||||
|
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app():
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from app.gateway.auth_middleware import AuthMiddleware
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(AuthMiddleware)
|
||||||
|
|
||||||
|
@app.get("/api/v1/threads/_probe")
|
||||||
|
async def threads_probe(request: Request):
|
||||||
|
return {"user_id": get_effective_user_id()}
|
||||||
|
|
||||||
|
@app.get("/api/assistants/search")
|
||||||
|
async def assistants_probe():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sa_allowed_on_dataplane(tmp_path):
|
||||||
|
gen = await _seed_key(tmp_path)
|
||||||
|
try:
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
r = client.get("/api/v1/threads/_probe", headers={"Authorization": f"Bearer {gen.plaintext}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"user_id": "sa-1"}
|
||||||
|
finally:
|
||||||
|
await _cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sa_allowed_on_assistants_init(tmp_path):
|
||||||
|
gen = await _seed_key(tmp_path)
|
||||||
|
try:
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
r = client.get("/api/assistants/search", headers={"Authorization": f"Bearer {gen.plaintext}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
finally:
|
||||||
|
await _cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
[
|
||||||
|
"/api/v1/mcp/config",
|
||||||
|
"/api/mcp/config",
|
||||||
|
"/api/v1/models",
|
||||||
|
"/api/v1/skills/install",
|
||||||
|
"/api/v1/channels/restart",
|
||||||
|
"/api/v1/agents",
|
||||||
|
"/api/v1/memory",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_sa_denied_on_control_plane(tmp_path, path):
|
||||||
|
gen = await _seed_key(tmp_path)
|
||||||
|
try:
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
r = client.get(path, headers={"Authorization": f"Bearer {gen.plaintext}"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"]["code"] == "insufficient_scope"
|
||||||
|
finally:
|
||||||
|
await _cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_key_still_401_not_403(tmp_path):
|
||||||
|
# 无效 key 命中控制平面路径,应是 401 (TOKEN_INVALID),不是 403 ——
|
||||||
|
# deny 检查在 None 校验之后。
|
||||||
|
await _seed_key(tmp_path)
|
||||||
|
try:
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
r = client.get("/api/v1/mcp/config", headers={"Authorization": "Bearer dfk_live_bogus00000000000000000"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
finally:
|
||||||
|
await _cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cookie_path_unaffected_by_deny(tmp_path):
|
||||||
|
# 非 bearer-dfk 请求不进 bearer 分支:控制平面路径走 cookie 路径,
|
||||||
|
# 无 cookie → 401 not_authenticated,绝不会拿到 403 insufficient_scope。
|
||||||
|
await _seed_key(tmp_path)
|
||||||
|
try:
|
||||||
|
client = TestClient(_make_app())
|
||||||
|
r = client.get("/api/v1/mcp/config")
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"]["code"] != "insufficient_scope"
|
||||||
|
finally:
|
||||||
|
await _cleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑测试确认失败**
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v`
|
||||||
|
Expected: FAIL — `test_sa_denied_on_control_plane[...]` 返回 200/404 而非 403(deny 尚未接线)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写实现 — bearer 分支加 deny**
|
||||||
|
|
||||||
|
Modify `backend/app/gateway/auth_middleware.py` — 在 bearer 分支内,`if result is None: return 401` 之后、`request.state.user = result.principal` 之前插入路径检查。将:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()},
|
||||||
|
)
|
||||||
|
request.state.user = result.principal
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"detail": AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Invalid API key").model_dump()},
|
||||||
|
)
|
||||||
|
# Default-deny: a service principal may only reach the data plane
|
||||||
|
# (threads/runs/assistants). Control-plane routes (mcp/skills/
|
||||||
|
# channels/models/agents/memory + management/auth) are global,
|
||||||
|
# un-partitioned config — never reachable by an API key. New
|
||||||
|
# control-plane routes are denied automatically (allowlist, not
|
||||||
|
# blocklist). Humans (cookie path) never enter this branch.
|
||||||
|
if not _is_dataplane_path(request.url.path):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": AuthErrorResponse(code=AuthErrorCode.INSUFFICIENT_SCOPE, message="API keys cannot access this endpoint").model_dump()},
|
||||||
|
)
|
||||||
|
request.state.user = result.principal
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑测试确认通过**
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_api_key_control_plane.py -v`
|
||||||
|
Expected: PASS(Task 1 的 21 单元 + 本 Task 的集成/参数化用例全绿)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 既有 bearer 测试回归**
|
||||||
|
|
||||||
|
确认探针挪位 + deny 后既有用例不破:
|
||||||
|
|
||||||
|
Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth_middleware_api_key.py tests/test_headless_api_smoke.py tests/test_auth_middleware.py tests/test_auth_middleware_workspace.py -v`
|
||||||
|
Expected: PASS(valid 探针走 `/api/v1/threads/_probe` 数据平面 → 200;invalid/revoked/non-dfk → 401;cookie 路径不变)
|
||||||
|
|
||||||
|
- [ ] **Step 6: lint + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && make lint && git add app/gateway/auth_middleware.py tests/test_api_key_control_plane.py
|
||||||
|
git commit -m "harden(gateway): API keys default-deny on control-plane routes (Stage 1 收口)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: 文档收尾 + 全量回归
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 把 spec §8.1 从"已知限制"翻成"已解决"**
|
||||||
|
|
||||||
|
Modify `docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md` §8.1。在该节"后续 PR 决策项"那条 bullet 之后追加一行解决说明(保留原限制描述作为历史,追加 resolved 标注):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **【已解决 2026-06-28】** 改为 default-deny:service principal 只能访问数据平面(`/api/threads*`、`/api/runs*`、`/api/assistants`),所有控制平面路由(含 read)一律 403 `insufficient_scope`。实现见 `AuthMiddleware._is_dataplane_path`;设计见 [api-key-control-plane-default-deny-design](../../superpowers/specs/2026-06-28-api-key-control-plane-default-deny-design.md)。细粒度 scope 词汇升级仍按原计划推后。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量回归 + lint + 边界**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && make lint && make test && PYTHONPATH=. uv run pytest tests/test_harness_boundary.py -v
|
||||||
|
```
|
||||||
|
Expected: lint clean;test 全绿(含 stage-1 的 13 个文件 + 本次新增 `test_api_key_control_plane.py`);boundary PASS(本改动全在 app 层,未引入 deerflow→app import)
|
||||||
|
|
||||||
|
- [ ] **Step 3: commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/wangguixuan/work/github/deer-flow
|
||||||
|
git add docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md
|
||||||
|
git commit -m "docs(stage-1): mark control-plane scope limitation resolved (Stage 1 收口)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage(逐条对 spec)**
|
||||||
|
- §2 目标:service principal 仅数据平面、真人不受影响、default-deny、读写一律拒 → Task 3 deny + Task 1 白名单 + 参数化用例(含 GET `/api/v1/models` 读也拒)✅
|
||||||
|
- §3.1 白名单 = threads/runs/assistants 三前缀、不含 langgraph → Task 1 `_DATAPLANE_PREFIXES` + 单元表(含 `/api/langgraph` 应 False)✅
|
||||||
|
- §3.2 enforce 位置(bearer 分支 None 校验之后、contextvar 之前)→ Task 3 Step 3 精确锚点 ✅
|
||||||
|
- §3.3 `_is_dataplane_path` 辅助 + 复用接缝 → Task 1 Step 4 ✅
|
||||||
|
- §3.4 错误码 `INSUFFICIENT_SCOPE` + 403 同构响应 → Task 1 Step 3 + Task 3 Step 3 ✅
|
||||||
|
- §4.1 探针挪到 `/api/v1/threads/_probe` → Task 2 ✅
|
||||||
|
- §4.2 新增 `test_api_key_control_plane.py`(403 / 放行 / cookie 回归 / 单元表)→ Task 1 + Task 3 ✅
|
||||||
|
- §4.3 回归 make test + lint + boundary → Task 4 Step 2 ✅
|
||||||
|
- §5 文件清单 → 全覆盖(errors.py / auth_middleware.py / 两测试文件 / 新测试 / spec 文档)✅
|
||||||
|
- §5 文档:§8.1 翻成已解决 → Task 4 Step 1 ✅
|
||||||
|
|
||||||
|
**2. Placeholder scan:** 所有 code step 含可运行实际代码;改测试路径处给了精确行号锚点 + grep 校验命令;无 TBD/TODO/“类似上文”。✅
|
||||||
|
|
||||||
|
**3. Type consistency:**
|
||||||
|
- `_is_dataplane_path(path) -> bool` / `_DATAPLANE_PREFIXES: tuple[str, ...]` — Task 1 定义,Task 1 单元测试 + Task 3 中间件调用一致 ✅
|
||||||
|
- `AuthErrorCode.INSUFFICIENT_SCOPE = "insufficient_scope"` — Task 1 定义,Task 3 响应 + 测试断言 `code == "insufficient_scope"` 一致 ✅
|
||||||
|
- `AuthErrorResponse(code=..., message=...).model_dump()` — 与 bearer 分支既有 401 用法一致(spec §3.4)✅
|
||||||
|
- 探针路径 `/api/v1/threads/_probe` — Task 2 改既有两文件 + Task 3 新测试 app 一致 ✅
|
||||||
|
- `_seed_key` 返回 `GeneratedKey`(`.plaintext`/`.prefix`/`.key_hash`),`ApiKeyRepository.create(*, service_account_id, key_prefix, key_hash, name, scopes)` — 与 stage-1 PR1/PR2 既有签名一致 ✅
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
# API Key 控制平面 default-deny 收口 · 设计
|
||||||
|
|
||||||
|
> 写于 2026-06-28。收口 Stage 1 final review 记录的最小权限缺口([stage-1 design §8.1](../../multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md))。
|
||||||
|
>
|
||||||
|
> **承接:** [Stage 1 · Headless API Pattern A 鉴权地基](../plans/2026-06-28-stage-1-headless-api-pattern-a-auth-foundation.md)(已完成)。本设计是轨道二的安全补丁,不是新功能。
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
Stage 1 让业务系统用 workspace-scoped API key(`Authorization: Bearer dfk_...`)server-to-server 直调 Gateway。鉴权热路径在 `AuthMiddleware` 的 bearer 分支解析 token → `ServicePrincipal`,并把 `(user_id=SA.id, workspace_id)` 写进 contextvar,使下游隔离与真人同构。
|
||||||
|
|
||||||
|
落地后的整体安全复核(2026-06-28)发现一处最小权限缺口(stage-1 design §8.1):
|
||||||
|
|
||||||
|
- **scope 只在 `@require_permission` 装饰的路由上生效。** `AuthContext.permissions`(由 key 的 scopes 填充)只被 `@require_permission` 读取,而该装饰器目前只挂在 threads/runs/uploads/artifacts/feedback/suggestions 上。
|
||||||
|
- `mcp`(`PUT /api/v1/mcp/config`)、`skills`(`POST /api/v1/skills/install`)、`channels`(`restart`)、`models`、`agents`、`memory` 等路由**只校验"已认证",不校验 scope/role**。
|
||||||
|
- 后果:一把 `scopes="threads:read"` 的 key 仍能改全局 MCP 配置、装技能、重启 channel。
|
||||||
|
- 更严重:这些目标是**进程级全局资源**(`extensions_config.json`、磁盘上的 skills、channel 进程),不是 workspace 分区的。对它们而言 workspace 隔离也不成立——一个租户的 key 改的是所有租户共享的配置。
|
||||||
|
|
||||||
|
### 根因再定位
|
||||||
|
|
||||||
|
这不是"几条路由忘了加 scope 检查"。真正的原因是:`_ALL_PERMISSIONS` 里**只有 6 个 `threads:*` / `runs:*` 权限**——控制平面路由从来就不在权限模型里。真人能访问它们,仅仅是因为它们未被装饰。API key 出现后,这些"对所有已认证者开放"的全局控制路由,意外地也对 service principal 开放了。
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
**目标**
|
||||||
|
- service principal(API key 请求)只能访问**数据平面**;任何控制平面路由 → 403。
|
||||||
|
- 真人 / cookie 请求行为**完全不变**。
|
||||||
|
- 默认拒绝(default-deny):将来新增控制平面路由,自动被拦,不复现本次"忘了保护"的缺陷。
|
||||||
|
- 读、写一律拒(不区分 method)。
|
||||||
|
|
||||||
|
**非目标(明确推后)**
|
||||||
|
- 不引入 per-scope 细粒度授权(`scopes=["mcp:write"]` 之类的词汇升级)——留到 external_user 透传 / scope 升级 PR(stage-1 design D4 已推后)。
|
||||||
|
- 不把控制平面资源改成 workspace 分区(那是更大的多租户改造)。
|
||||||
|
- 不动 `@require_permission` / `_ALL_PERMISSIONS` 现有语义。
|
||||||
|
- 不处理 Pattern B(短期 JWT / service-token 分支)——但要为其留好复用接缝。
|
||||||
|
|
||||||
|
## 3. 设计
|
||||||
|
|
||||||
|
### 3.1 数据平面边界
|
||||||
|
|
||||||
|
> **关键前提(已验证):** nginx 把 `/api/langgraph/(.*)` **rewrite 成 `/api/$1`** 后才转给 gateway(见 `docker/nginx/nginx.local.conf` L48-51);IM channels 也直连 gateway 的 `/api/*`(`langgraph_url` 默认 `http://localhost:8001/api`)。因此 `AuthMiddleware` **永远看不到 `/api/langgraph` 前缀**——LangGraph-SDK 的调用到达中间件时就是 `/api/threads`、`/api/runs`、`/api/assistants` 这些真实 router 路径。gateway 本身没有挂任何 `/api/langgraph` 路由,该前缀是纯 nginx 别名,放进白名单是死代码。
|
||||||
|
|
||||||
|
对照 Gateway 路由表(`app/gateway/app.py` include_router + 各 router prefix),**数据平面 / SDK 接口只落在三个前缀下**:
|
||||||
|
|
||||||
|
| 前缀 | 覆盖的 router | 性质 |
|
||||||
|
|---|---|---|
|
||||||
|
| `threads*` | threads、thread-runs、uploads(`/threads/{id}/uploads`)、artifacts、suggestions、feedback(均 `/threads/...`) | 数据平面 |
|
||||||
|
| `runs*` | 无状态 runs(`/runs`) | 数据平面 |
|
||||||
|
| `/api/assistants` | `assistants_compat`——**langgraph-sdk 客户端 init 必需**(`assistants.search()`/`get()`),只读元数据,单挂(无 `/api/v1` twin) | SDK init |
|
||||||
|
|
||||||
|
因此 service principal 的白名单很小且稳定:
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/threads /api/v1/threads
|
||||||
|
/api/runs /api/v1/runs
|
||||||
|
/api/assistants
|
||||||
|
```
|
||||||
|
|
||||||
|
> 不放 `/api/langgraph`(死代码,见上)。`assistants` 单挂故只有一条 `/api/assistants`(无版本)。
|
||||||
|
|
||||||
|
**其余一律拒绝**(对 SA):`models`、`mcp`、`memory`、`skills`、`channels`、`agents`,以及 `service-accounts`、`api-keys`、`auth`。
|
||||||
|
|
||||||
|
- 管理类 endpoint(`service-accounts`/`api-keys`)本来就因 `require_workspace_admin`(SA role=member)对 SA 返回 403。default-deny 让它**更早、更统一**地 403,不改变可达性结论。
|
||||||
|
- `auth`(`/api/v1/auth/*`)当前对 SA 无意义,拒掉无副作用。
|
||||||
|
|
||||||
|
### 3.2 enforce 位置
|
||||||
|
|
||||||
|
在 `app/gateway/auth_middleware.py` 的 `AuthMiddleware.dispatch` bearer 分支内,`result` 校验通过之后、写 contextvar / `call_next` 之前插入路径检查:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if auth_header.startswith("Bearer dfk_"):
|
||||||
|
...
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=401, ...) # 现有
|
||||||
|
# ↓ 新增:service principal 只能走数据平面
|
||||||
|
if not _is_dataplane_path(request.url.path):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"detail": AuthErrorResponse(
|
||||||
|
code=AuthErrorCode.INSUFFICIENT_SCOPE,
|
||||||
|
message="API keys cannot access this endpoint",
|
||||||
|
).model_dump()},
|
||||||
|
)
|
||||||
|
request.state.user = result.principal
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
放在 bearer 分支内的理由:
|
||||||
|
- 真人 cookie 路径根本不进这段,天然不受影响。
|
||||||
|
- 检查发生在 `_is_public` 早退之后——公共路径(`/health` 等)即便带 bearer 头也先被 `_is_public` 放行,不进此分支,符合预期。
|
||||||
|
|
||||||
|
### 3.3 辅助函数
|
||||||
|
|
||||||
|
仿照现有 `_is_public(path)` / `_PUBLIC_PATH_PREFIXES`,在同文件新增模块级常量与函数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_DATAPLANE_PREFIXES = (
|
||||||
|
"/api/threads",
|
||||||
|
"/api/v1/threads",
|
||||||
|
"/api/runs",
|
||||||
|
"/api/v1/runs",
|
||||||
|
"/api/assistants", # langgraph-sdk client init (assistants.search/get), 只读
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dataplane_path(path: str) -> bool:
|
||||||
|
"""service principal 允许访问的数据平面 / SDK 路由前缀。
|
||||||
|
|
||||||
|
数据平面挂在 threads / runs 下;langgraph-sdk init 需要 assistants。
|
||||||
|
其余(全局控制平面:models/mcp/memory/skills/channels/agents 与
|
||||||
|
管理/auth 端点)对 API key 一律拒绝。注意 `/api/langgraph` 被 nginx
|
||||||
|
rewrite 掉,中间件看不到,故不在表内。将来 Pattern B 的 service-token
|
||||||
|
分支可复用本函数。匹配只在路径段边界命中(精确相等或后接 `/`),
|
||||||
|
避免同名前缀路由(如未来的 `/api/threads-export`)被悄悄放进白名单。
|
||||||
|
"""
|
||||||
|
return any(path == p or path.startswith(p + "/") for p in _DATAPLANE_PREFIXES)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **前缀匹配的精度(边界对齐)**:用"精确相等或 `prefix + "/"`"而非裸 `startswith`。这是 default-deny **白名单**——over-match 的方向是"误放行"(把控制平面误判成数据平面),与本设计"新增控制平面路由自动被拦"的承诺直接冲突。故即便当前路由表无同名前缀冲突,也用边界匹配把这条安全不变量钉死,让将来出现 `/api/threads-export` 之类路由时不会被悄悄纳入数据平面。(注:与 `_is_public` 的裸 `startswith` 有意不同——`_is_public` 是 Stage 1 既有代码,本次不动它。)
|
||||||
|
|
||||||
|
### 3.4 错误口径
|
||||||
|
|
||||||
|
新增 `AuthErrorCode.INSUFFICIENT_SCOPE = "insufficient_scope"`(`app/gateway/auth/errors.py`)。该枚举定位是"穷举所有 auth 失败条件",新增一个符合既有模式。
|
||||||
|
|
||||||
|
- HTTP 403(已认证但无权),区别于无效 key 的 401。
|
||||||
|
- 响应体 `{"detail": {"code": "insufficient_scope", "message": ...}}`,与现有 401 `AuthErrorResponse` 完全同构。
|
||||||
|
|
||||||
|
## 4. 测试影响
|
||||||
|
|
||||||
|
### 4.1 现有探针测试需调整(有意,非绕过)
|
||||||
|
|
||||||
|
`tests/test_auth_middleware_api_key.py` 与 `tests/test_headless_api_smoke.py` 用 `/api/probe` 作探针路由。该路径不属数据平面,default-deny 会把它 403——但这些探针的**本意**就是"SA 访问一个受保护的数据平面路由"。
|
||||||
|
|
||||||
|
处理:把探针路径挪到数据平面前缀下(如 `/api/v1/threads/_probe`)。这是让测试反映真实约束的正确修正。涉及:
|
||||||
|
- `test_valid_bearer_sets_sa_contextvars`(探针仍应 200,验证 contextvar)
|
||||||
|
- `test_headless_api_smoke` 的 `_probe_app`(mint→use 链路仍应 200)
|
||||||
|
- 其余 401/revoked 用例不受影响(它们本就期望非 200)
|
||||||
|
|
||||||
|
### 4.2 新增 `tests/test_api_key_control_plane.py`
|
||||||
|
|
||||||
|
| 用例 | 期望 |
|
||||||
|
|---|---|
|
||||||
|
| SA bearer → `PUT /api/v1/mcp/config` | 403 `insufficient_scope` |
|
||||||
|
| SA bearer → `GET /api/v1/models` | 403(**读也拒**) |
|
||||||
|
| SA bearer → `POST /api/v1/skills/install` | 403 |
|
||||||
|
| SA bearer → `/api/mcp`(无版本旧路径) | 403 |
|
||||||
|
| SA bearer → `/api/v1/channels/...`、`/api/v1/agents`、`/api/v1/memory` | 403 |
|
||||||
|
| SA bearer → `/api/v1/threads/_probe` | 放行(数据平面) |
|
||||||
|
| SA bearer → `/api/assistants/search`(SDK init) | 放行 |
|
||||||
|
| cookie/真人 → `/api/v1/mcp`(或任一控制平面) | 不受影响(回归守护——不进 bearer 分支) |
|
||||||
|
| `_is_dataplane_path` 表驱动单元 | threads/runs(含 `/api` 与 `/api/v1` 双形态)、`/api/assistants` → True;控制平面前缀(含 `/api/langgraph`,死代码也应 False)→ False |
|
||||||
|
|
||||||
|
### 4.3 回归
|
||||||
|
|
||||||
|
- `make test` 全绿(含 stage-1 的 13 个文件)。
|
||||||
|
- `make lint` clean、`test_harness_boundary` PASS(本改动全在 app 层)。
|
||||||
|
|
||||||
|
## 5. 文件清单
|
||||||
|
|
||||||
|
**修改**
|
||||||
|
- `backend/app/gateway/auth/errors.py` — 加 `AuthErrorCode.INSUFFICIENT_SCOPE`
|
||||||
|
- `backend/app/gateway/auth_middleware.py` — 加 `_DATAPLANE_PREFIXES` + `_is_dataplane_path` + bearer 分支 403 检查
|
||||||
|
- `backend/tests/test_auth_middleware_api_key.py` — 探针路径挪到 `/api/v1/threads/_probe`
|
||||||
|
- `backend/tests/test_headless_api_smoke.py` — 同上
|
||||||
|
|
||||||
|
**新增**
|
||||||
|
- `backend/tests/test_api_key_control_plane.py`
|
||||||
|
|
||||||
|
**文档**
|
||||||
|
- `docs/multi-tenant-redesign/01-redesign/stage-1-headless-api-pattern-a-auth-foundation-design.zh-CN.md` — §8.1 从"已知限制"翻成"已解决",指向本 PR
|
||||||
|
|
||||||
|
## 6. 不可逆 / 需想清楚的点
|
||||||
|
|
||||||
|
| 决策 | 取舍 |
|
||||||
|
|---|---|
|
||||||
|
| 数据平面边界 = threads/runs/assistants 三前缀 | 业务系统已接入后收窄白名单 = 破坏调用方;故白名单只增不减。本次定的是最小集,后续按需 **加**(如 Pattern B 的 `exchange-token`)。 |
|
||||||
|
| 读也拒(`GET /api/v1/models` 对 SA 403) | 若将来业务方需要列模型选型,再单独 allowlist 该 GET;default-deny 下"放开"比"收紧"安全。 |
|
||||||
|
| 错误码 `insufficient_scope` | 接入方可能据此分支处理;改名要联调。早定。 |
|
||||||
|
|
||||||
|
## 7. 与后续 PR 的接缝
|
||||||
|
|
||||||
|
- **Pattern B(service-token 分支)**:复用 `_is_dataplane_path`。新增的 `exchange-token` endpoint 若需 SA 调用,记得把其路径加入 `_DATAPLANE_PREFIXES`(或单独放行)。
|
||||||
|
- **scope 词汇升级**(`scopes=["mcp:write"]`):若将来要让特定 SA 受控访问某控制平面路由,在本 default-deny 之上叠加"白名单内再按 scope 细分"即可,不与本设计冲突。
|
||||||
@@ -30,26 +30,38 @@ export async function GET(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ 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(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ 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(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ 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(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ path: string[] }> },
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
) {
|
) {
|
||||||
return proxyRequest(request, `/api/memory/${(await params).path.join("/")}`);
|
return proxyRequest(
|
||||||
|
request,
|
||||||
|
`/api/v1/memory/${(await params).path.join("/")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ async function proxyRequest(request: NextRequest, pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
return proxyRequest(request, "/api/memory");
|
return proxyRequest(request, "/api/v1/memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
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);
|
setFollowupsLoading(true);
|
||||||
setFollowups([]);
|
setFollowups([]);
|
||||||
|
|
||||||
fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, {
|
fetch(`${getBackendBaseURL()}/api/v1/threads/${threadId}/suggestions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -27,20 +27,20 @@ function isAgentsApiDisabledDetail(detail: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listAgents(): Promise<Agent[]> {
|
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}`);
|
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||||
const data = (await res.json()) as { agents: Agent[] };
|
const data = (await res.json()) as { agents: Agent[] };
|
||||||
return data.agents;
|
return data.agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAgent(name: string): Promise<Agent> {
|
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`);
|
if (!res.ok) throw new Error(`Agent '${name}' not found`);
|
||||||
return res.json() as Promise<Agent>;
|
return res.json() as Promise<Agent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAgent(request: CreateAgentRequest): 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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
@@ -59,7 +59,7 @@ export async function updateAgent(
|
|||||||
name: string,
|
name: string,
|
||||||
request: UpdateAgentRequest,
|
request: UpdateAgentRequest,
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, {
|
const res = await fetch(`${getBackendBaseURL()}/api/v1/agents/${name}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
@@ -72,7 +72,7 @@ export async function updateAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAgent(name: string): Promise<void> {
|
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",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||||
@@ -84,7 +84,7 @@ export async function checkAgentName(
|
|||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(
|
res = await fetch(
|
||||||
`${getBackendBaseURL()}/api/agents/check?name=${encodeURIComponent(name)}`,
|
`${getBackendBaseURL()}/api/v1/agents/check?name=${encodeURIComponent(name)}`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AgentNameCheckError(
|
throw new AgentNameCheckError(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function upsertFeedback(
|
|||||||
comment?: string,
|
comment?: string,
|
||||||
): Promise<FeedbackData> {
|
): Promise<FeedbackData> {
|
||||||
const res = await fetch(
|
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",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -33,7 +33,7 @@ export async function deleteFeedback(
|
|||||||
runId: string,
|
runId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(
|
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" },
|
{ method: "DELETE" },
|
||||||
);
|
);
|
||||||
if (!res.ok && res.status !== 404) {
|
if (!res.ok && res.status !== 404) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function urlOfArtifact({
|
|||||||
if (isMock) {
|
if (isMock) {
|
||||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
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) {
|
export function extractArtifactsFromThread(thread: AgentThread) {
|
||||||
@@ -23,5 +23,5 @@ export function extractArtifactsFromThread(thread: AgentThread) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||||
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
|
return `${getBackendBaseURL()}/api/v1/threads/${threadId}/artifacts${absolutePath}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
import type { MCPConfig } from "./types";
|
import type { MCPConfig } from "./types";
|
||||||
|
|
||||||
export async function loadMCPConfig() {
|
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>;
|
return response.json() as Promise<MCPConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMCPConfig(config: 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",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -81,12 +81,12 @@ async function readMemoryResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadMemory(): Promise<UserMemory> {
|
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");
|
return readMemoryResponse(response, "Failed to fetch memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearMemory(): Promise<UserMemory> {
|
export async function clearMemory(): Promise<UserMemory> {
|
||||||
const response = await fetch(`${getBackendBaseURL()}/api/memory`, {
|
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
return readMemoryResponse(response, "Failed to clear memory");
|
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> {
|
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
},
|
||||||
@@ -103,12 +103,12 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exportMemory(): 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");
|
return readMemoryResponse(response, "Failed to export memory");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -121,7 +121,7 @@ export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
|||||||
export async function createMemoryFact(
|
export async function createMemoryFact(
|
||||||
input: MemoryFactInput,
|
input: MemoryFactInput,
|
||||||
): Promise<UserMemory> {
|
): Promise<UserMemory> {
|
||||||
const response = await fetch(`${getBackendBaseURL()}/api/memory/facts`, {
|
const response = await fetch(`${getBackendBaseURL()}/api/v1/memory/facts`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -136,7 +136,7 @@ export async function updateMemoryFact(
|
|||||||
input: MemoryFactPatchInput,
|
input: MemoryFactPatchInput,
|
||||||
): Promise<UserMemory> {
|
): Promise<UserMemory> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
`${getBackendBaseURL()}/api/v1/memory/facts/${encodeURIComponent(factId)}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getBackendBaseURL } from "../config";
|
|||||||
import type { ModelsResponse } from "./types";
|
import type { ModelsResponse } from "./types";
|
||||||
|
|
||||||
export async function loadModels(): Promise<ModelsResponse> {
|
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>;
|
const data = (await res.json()) as Partial<ModelsResponse>;
|
||||||
return {
|
return {
|
||||||
models: data.models ?? [],
|
models: data.models ?? [],
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { getBackendBaseURL } from "@/core/config";
|
|||||||
import type { Skill } from "./type";
|
import type { Skill } from "./type";
|
||||||
|
|
||||||
export async function loadSkills() {
|
export async function loadSkills() {
|
||||||
const skills = await fetch(`${getBackendBaseURL()}/api/skills`);
|
const skills = await fetch(`${getBackendBaseURL()}/api/v1/skills`);
|
||||||
const json = await skills.json();
|
const json = await skills.json();
|
||||||
return json.skills as Skill[];
|
return json.skills as Skill[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
`${getBackendBaseURL()}/api/v1/skills/${skillName}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -39,7 +39,7 @@ export interface InstallSkillResponse {
|
|||||||
export async function installSkill(
|
export async function installSkill(
|
||||||
request: InstallSkillRequest,
|
request: InstallSkillRequest,
|
||||||
): Promise<InstallSkillResponse> {
|
): Promise<InstallSkillResponse> {
|
||||||
const response = await fetch(`${getBackendBaseURL()}/api/skills/install`, {
|
const response = await fetch(`${getBackendBaseURL()}/api/v1/skills/install`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ export function useThreadHistory(threadId: string) {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result: { data: RunMessage[]; hasMore: boolean } = await fetch(
|
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",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -721,7 +721,7 @@ export function useDeleteThread() {
|
|||||||
await apiClient.threads.delete(threadId);
|
await apiClient.threads.delete(threadId);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
`${getBackendBaseURL()}/api/v1/threads/${encodeURIComponent(threadId)}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function uploadFiles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads`,
|
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -73,7 +73,7 @@ export async function listUploadedFiles(
|
|||||||
threadId: string,
|
threadId: string,
|
||||||
): Promise<ListFilesResponse> {
|
): Promise<ListFilesResponse> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/list`,
|
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/list`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -93,7 +93,7 @@ export async function deleteUploadedFile(
|
|||||||
filename: string,
|
filename: string,
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`,
|
`${getBackendBaseURL()}/api/v1/threads/${threadId}/uploads/${filename}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+151
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 本地调试全量栈管理脚本(方式 A:Gateway + Frontend + Nginx)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 复用仓库已有的 scripts/serve.sh(处理 config-upgrade / postgres extras /
|
||||||
|
# nginx 临时目录 / 依赖同步 / 端口等待),在其守护进程模式之上补齐
|
||||||
|
# status 和 logs,子命令风格与 scripts/dev-gateway.sh 保持一致。
|
||||||
|
#
|
||||||
|
# 服务与端口:
|
||||||
|
# Gateway localhost:8001 (REST API + agent runtime)
|
||||||
|
# Frontend localhost:3000 (Next.js)
|
||||||
|
# Nginx localhost:2026 (统一入口 / 反向代理) ← 浏览器访问这个
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/dev-full.sh start # 后台启动整套(首次会装依赖)
|
||||||
|
# ./scripts/dev-full.sh stop # 关闭整套
|
||||||
|
# ./scripts/dev-full.sh restart # 重启整套
|
||||||
|
# ./scripts/dev-full.sh status # 三个服务的端口 / 健康检查
|
||||||
|
# ./scripts/dev-full.sh logs [服务] # 跟随日志,默认三个一起;可指定 gateway|frontend|nginx
|
||||||
|
# ./scripts/dev-full.sh run # 前台运行(= make dev,Ctrl-C 全停,gateway 带热重载)
|
||||||
|
#
|
||||||
|
# 环境变量:
|
||||||
|
# SKIP_INSTALL=1 跳过依赖安装,重启更快
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
SERVE="$REPO_ROOT/scripts/serve.sh"
|
||||||
|
|
||||||
|
# 服务清单:名称:端口:日志文件:健康检查路径(空=只查端口)
|
||||||
|
SERVICES=(
|
||||||
|
"Gateway:8001:gateway.log:/api/v1/auth/setup-status"
|
||||||
|
"Frontend:3000:frontend.log:/"
|
||||||
|
"Nginx:2026:nginx.log:/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 工具函数 ──────────────────────────────────────────────────────
|
||||||
|
_port_pid() { { lsof -ti tcp:"$1" 2>/dev/null || true; } | head -1; }
|
||||||
|
|
||||||
|
_any_running() {
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
local port="${svc#*:}"; port="${port%%:*}"
|
||||||
|
[ -n "$(_port_pid "$port")" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_serve_flags() {
|
||||||
|
# serve.sh 守护模式启动;可选跳过依赖安装
|
||||||
|
local flags="--dev --daemon"
|
||||||
|
[ "${SKIP_INSTALL:-0}" = "1" ] && flags="$flags --skip-install"
|
||||||
|
echo "$flags"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 子命令 ────────────────────────────────────────────────────────
|
||||||
|
cmd_start() {
|
||||||
|
if _any_running; then
|
||||||
|
echo "检测到已有服务在运行 —— 如需重启用:$0 restart"
|
||||||
|
cmd_status || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "→ 后台启动全量栈(serve.sh $(_serve_flags))"
|
||||||
|
[ "${SKIP_INSTALL:-0}" = "1" ] || echo " 首次启动会执行 uv sync + pnpm install,可能较慢;重启可加 SKIP_INSTALL=1"
|
||||||
|
# shellcheck disable=SC2046
|
||||||
|
bash "$SERVE" $(_serve_flags)
|
||||||
|
echo
|
||||||
|
cmd_status || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
if ! _any_running; then
|
||||||
|
echo "未在运行"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "→ 关闭全量栈(serve.sh --stop)"
|
||||||
|
bash "$SERVE" --stop
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local all_up=0
|
||||||
|
printf "%-10s %-7s %-9s %s\n" "服务" "端口" "状态" "健康"
|
||||||
|
printf "%-10s %-7s %-9s %s\n" "----" "----" "----" "----"
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
local name port log path rest
|
||||||
|
name="${svc%%:*}"; rest="${svc#*:}"
|
||||||
|
port="${rest%%:*}"; rest="${rest#*:}"
|
||||||
|
log="${rest%%:*}"; path="${rest#*:}"
|
||||||
|
local pid; pid="$(_port_pid "$port")"
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
printf "%-10s %-7s %-9s %s\n" "$name" "$port" "✗ 停止" "-"
|
||||||
|
all_up=1
|
||||||
|
else
|
||||||
|
local code="-"
|
||||||
|
if [ -n "$path" ]; then
|
||||||
|
code="$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port$path" 2>/dev/null || echo 000)"
|
||||||
|
case "$code" in 200|429|301|302|307) code="✓ HTTP $code";; 000) code="⚠ 无响应";; *) code="⚠ HTTP $code";; esac
|
||||||
|
fi
|
||||||
|
printf "%-10s %-7s %-9s %s\n" "$name" "$port" "● 运行 ($pid)" "$code"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$all_up" = "0" ]; then
|
||||||
|
echo
|
||||||
|
echo " 🌐 统一入口: http://localhost:2026"
|
||||||
|
fi
|
||||||
|
return "$all_up"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
local target="${1:-}"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
local files=()
|
||||||
|
if [ -n "$target" ]; then
|
||||||
|
local f="logs/${target}.log"
|
||||||
|
[ -f "$f" ] || { echo "暂无日志:$f(可选 gateway|frontend|nginx)" >&2; return 1; }
|
||||||
|
files=("$f")
|
||||||
|
else
|
||||||
|
for svc in "${SERVICES[@]}"; do
|
||||||
|
local log; log="${svc#*:}"; log="${log#*:}"; log="${log%%:*}"
|
||||||
|
[ -f "logs/$log" ] && files+=("logs/$log")
|
||||||
|
done
|
||||||
|
[ ${#files[@]} -gt 0 ] || { echo "暂无日志文件(logs/ 为空)" >&2; return 1; }
|
||||||
|
fi
|
||||||
|
echo "→ 跟随日志(Ctrl-C 退出,不影响服务):${files[*]}"
|
||||||
|
tail -n 30 -f "${files[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
if _any_running; then
|
||||||
|
echo "已有后台实例在运行,先 $0 stop" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "→ 前台运行全量栈(= make dev,Ctrl-C 全停)"
|
||||||
|
exec bash "$SERVE" --dev
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 分发 ──────────────────────────────────────────────────────────
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) cmd_start ;;
|
||||||
|
stop) cmd_stop ;;
|
||||||
|
restart) cmd_stop; echo; SKIP_INSTALL="${SKIP_INSTALL:-1}" cmd_start ;;
|
||||||
|
status|"") cmd_status ;;
|
||||||
|
logs) shift || true; cmd_logs "${1:-}" ;;
|
||||||
|
run) cmd_run ;;
|
||||||
|
-h|--help|help)
|
||||||
|
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
|
*)
|
||||||
|
echo "未知命令: $1" >&2
|
||||||
|
echo "可用: start | stop | restart | status | logs [服务] | run" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
Executable
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 本地调试 Gateway 管理脚本(方式 B:只起 Gateway,端口 8001)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# - 用 backend/.venv 虚拟环境运行(由 uv 管理)
|
||||||
|
# - 自动加载仓库根 .env(数据库、模型 key 等)
|
||||||
|
# - 支持 start / stop / restart / status / logs / run 子命令
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/dev-gateway.sh start # 后台启动(写 PID + 日志)
|
||||||
|
# ./scripts/dev-gateway.sh stop # 关闭
|
||||||
|
# ./scripts/dev-gateway.sh restart # 重启
|
||||||
|
# ./scripts/dev-gateway.sh status # 查看状态(PID / 端口 / 健康检查)
|
||||||
|
# ./scripts/dev-gateway.sh logs # 实时跟随日志(Ctrl-C 退出,不影响服务)
|
||||||
|
# ./scripts/dev-gateway.sh run # 前台运行(断点调试,Ctrl-C 退出)
|
||||||
|
#
|
||||||
|
# 环境变量:
|
||||||
|
# PORT=8002 换端口(默认 8001)
|
||||||
|
# NO_RELOAD=1 关掉热重载(断点调试更稳)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 定位仓库根 ────────────────────────────────────────────────────
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
PORT="${PORT:-8001}"
|
||||||
|
PID_FILE="$REPO_ROOT/logs/gateway-dev.pid"
|
||||||
|
LOG_FILE="$REPO_ROOT/logs/gateway-dev.log"
|
||||||
|
|
||||||
|
# ── 工具函数 ──────────────────────────────────────────────────────
|
||||||
|
_running_pid() {
|
||||||
|
# 打印存活的服务 PID(优先 PID 文件,回退到端口探测),否则空
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
|
||||||
|
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$pid"; return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# lsof 在无监听时返回 1,配合 pipefail+set -e 会误终止脚本 → 用 || true 兜底
|
||||||
|
{ lsof -ti tcp:"$PORT" 2>/dev/null || true; } | head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
_load_env() {
|
||||||
|
if [ -f "$REPO_ROOT/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$REPO_ROOT/.env"
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
echo "⚠ 未找到 $REPO_ROOT/.env(数据库/模型 key 可能缺失)" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_uvicorn_flags() {
|
||||||
|
if [ "${NO_RELOAD:-0}" != "1" ]; then
|
||||||
|
echo "--reload --reload-include=*.yaml --reload-include=.env --reload-exclude=*.pyc --reload-exclude=__pycache__/* --reload-exclude=sandbox/* --reload-exclude=.deer-flow/*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_preflight() {
|
||||||
|
command -v uv >/dev/null 2>&1 || { echo "✗ 未找到 uv。安装:curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
|
||||||
|
mkdir -p "$REPO_ROOT/logs"
|
||||||
|
if [ ! -d "$REPO_ROOT/backend/.venv" ]; then
|
||||||
|
echo "→ 未发现 backend/.venv,执行 uv sync 创建虚拟环境"
|
||||||
|
(cd "$REPO_ROOT/backend" && uv sync)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_wait_ready() {
|
||||||
|
# 探测 setup-status,最多 30s;就绪返回 0
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/v1/auth/setup-status" 2>/dev/null | grep -qE "200|429"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 子命令 ────────────────────────────────────────────────────────
|
||||||
|
cmd_start() {
|
||||||
|
local pid; pid="$(_running_pid)"
|
||||||
|
if [ -n "$pid" ]; then
|
||||||
|
echo "已在运行 (PID $pid, 端口 $PORT) —— 如需重启用:$0 restart"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
_preflight
|
||||||
|
_load_env
|
||||||
|
echo "→ 后台启动 Gateway @localhost:${PORT}(venv: backend/.venv, 热重载: $([ "${NO_RELOAD:-0}" = "1" ] && echo off || echo on))"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
( cd "$REPO_ROOT/backend" && exec env PYTHONPATH=. uv run uvicorn app.gateway.app:app \
|
||||||
|
--host 0.0.0.0 --port "$PORT" $(_uvicorn_flags) ) > "$LOG_FILE" 2>&1 &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
if _wait_ready; then
|
||||||
|
echo "✓ 启动成功 (PID $(cat "$PID_FILE"))"
|
||||||
|
echo " 日志: $0 logs 状态: $0 status 关闭: $0 stop"
|
||||||
|
else
|
||||||
|
echo "✗ 30s 内未就绪,最后 20 行日志:" >&2
|
||||||
|
tail -n 20 "$LOG_FILE" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
local pid; pid="$(_running_pid)"
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
echo "未在运行"
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "→ 关闭 Gateway (PID $pid)"
|
||||||
|
# 优雅终止整组进程(uv → uvicorn → reloader 子进程)
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
|
||||||
|
# 兜底:按端口清残留(reload worker 偶尔不随父进程退出)
|
||||||
|
lsof -ti tcp:"$PORT" 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
echo "✓ 已停止,端口 $PORT 释放"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_status() {
|
||||||
|
local pid; pid="$(_running_pid)"
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
echo "● Gateway: 已停止 (端口 $PORT 空闲)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "● Gateway: 运行中"
|
||||||
|
echo " PID: $pid"
|
||||||
|
echo " 端口: $PORT"
|
||||||
|
local code
|
||||||
|
code="$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/v1/auth/setup-status" 2>/dev/null || echo "000")"
|
||||||
|
case "$code" in
|
||||||
|
200|429) echo " 健康: ✓ HTTP $code (REST API 响应中)";;
|
||||||
|
000) echo " 健康: ⚠ 端口占用但 HTTP 无响应(可能仍在启动)";;
|
||||||
|
*) echo " 健康: ⚠ HTTP $code";;
|
||||||
|
esac
|
||||||
|
echo " 日志: $LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_logs() {
|
||||||
|
[ -f "$LOG_FILE" ] || { echo "暂无日志文件:$LOG_FILE"; return 1; }
|
||||||
|
echo "→ 跟随日志(Ctrl-C 退出,不影响服务):$LOG_FILE"
|
||||||
|
tail -n 50 -f "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
# 前台运行:日志直出终端,适合 IDE 断点 / 看实时堆栈
|
||||||
|
local pid; pid="$(_running_pid)"
|
||||||
|
[ -n "$pid" ] && { echo "已有后台实例在运行 (PID $pid),先 $0 stop" >&2; exit 1; }
|
||||||
|
_preflight
|
||||||
|
_load_env
|
||||||
|
echo "→ 前台运行 @localhost:${PORT}(Ctrl-C 退出)"
|
||||||
|
cd "$REPO_ROOT/backend"
|
||||||
|
# shellcheck disable=SC2046,SC2086
|
||||||
|
exec env PYTHONPATH=. uv run uvicorn app.gateway.app:app \
|
||||||
|
--host 0.0.0.0 --port "$PORT" $(_uvicorn_flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 分发 ──────────────────────────────────────────────────────────
|
||||||
|
case "${1:-status}" in
|
||||||
|
start) cmd_start ;;
|
||||||
|
stop) cmd_stop ;;
|
||||||
|
restart) cmd_stop; echo; cmd_start ;;
|
||||||
|
status|"") cmd_status ;;
|
||||||
|
logs) cmd_logs ;;
|
||||||
|
run) cmd_run ;;
|
||||||
|
-h|--help|help)
|
||||||
|
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
|
*)
|
||||||
|
echo "未知命令: $1" >&2
|
||||||
|
echo "可用: start | stop | restart | status | logs | run" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user