diff --git a/.gitignore b/.gitignore index 0076848e..e32962ae 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ config.yaml.bak /frontend/playwright-report/ .gstack/ .worktrees +skills/gstack +skills/superpowers +CLAUDE.md diff --git a/docs/multi-tenant-redesign/00-current-state/architecture-overview.zh-CN.md b/docs/multi-tenant-redesign/00-current-state/architecture-overview.zh-CN.md new file mode 100644 index 00000000..7c0074c8 --- /dev/null +++ b/docs/multi-tenant-redesign/00-current-state/architecture-overview.zh-CN.md @@ -0,0 +1,188 @@ +# DeerFlow 整体架构鸟瞰 + +按"自外向内、自顶向下"分层讲,并指出每一层对应的代码位置,方便后续深入。 + +## 一、进程与部署拓扑 + +DeerFlow 表面是 4 个端口,本质是 **3 个进程 + 1 个反向代理**。 + +``` + ┌──────────────────────┐ +浏览器 / IM ─────────▶│ nginx :2026 │ 统一入口 + │ (含 CORS、SSE 透传) │ + └──────────┬───────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ │ + ▼ ▼ + ┌────────────────────┐ ┌──────────────────────────┐ + │ Frontend (Next.js) │ │ Gateway (uvicorn) │ + │ :3000 │ │ :8001 │ + │ pnpm dev / preview │ │ ┌──────────────────────┐ │ + └────────────────────┘ │ │ FastAPI 路由层 │ │ + │ │ /api/models, /skills │ │ + │ │ /threads, /runs ... │ │ + │ ├──────────────────────┤ │ + │ │ LangGraph Runtime │ │ + │ │ (RunManager, │ │ + │ │ StreamBridge, │ │ + │ │ Checkpointer) │ │ + │ ├──────────────────────┤ │ + │ │ lead_agent 图 │ │ + │ │ + 18 个中间件 │ │ + │ │ + Sandbox / Tools │ │ + │ └──────────────────────┘ │ + └────────────┬─────────────┘ + │ + ┌──────────────────────────┼─────────────────────────┐ + ▼ ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ Sandbox │ │ MCP Servers │ │ LLM 提供商 │ + │ Local / AIO Docker│ │ (stdio/sse/http) │ │ OpenAI/Anthropic │ + │ 提供 bash/fs │ │ │ │ /vLLM/Codex CLI │ + └──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +关键事实(容易踩坑): + +- **LangGraph 运行时不是独立进程**,而是嵌在 Gateway 这同一个 uvicorn 进程里。`scripts/serve.sh:225` 只起了一个后端进程:`uvicorn app.gateway.app:app`。 +- nginx 把 `/api/langgraph/*` 重写成 `/api/*` 再代理到 Gateway(`docker/nginx/nginx.local.conf:49-73`),所以前端用的"标准 LangGraph SDK 协议"和 DeerFlow 自己的 REST 是同一个 8001 端口。 +- 路由协议在 nginx 里特意为 SSE 关闭了缓冲(`proxy_buffering off; X-Accel-Buffering no`),否则流式响应会被吃掉。 + +## 二、后端代码二分:Harness vs App + +后端最重要的边界,**决定新写代码该放哪里**。 + +``` +backend/ +├── packages/harness/deerflow/ ← 可发布的智能体框架(import: deerflow.*) +│ ├── agents/ lead_agent + memory + middlewares + ThreadState +│ ├── runtime/ checkpointer / runs / stream_bridge / events / store +│ ├── sandbox/ Sandbox 抽象 + local 实现 + 文件/bash 工具 +│ ├── subagents/ 子代理注册表 + 后台执行池 +│ ├── tools/ 内建工具(present_files / ask_clarification / view_image) +│ ├── mcp/ MultiServerMCPClient + 缓存 + OAuth +│ ├── skills/ SKILL.md 加载、工具白名单 +│ ├── models/ 模型工厂、vLLM/Codex/Claude 自定义 provider +│ ├── community/ tavily / jina / firecrawl / aio_sandbox(可选实现) +│ ├── memory/ 长期记忆(事实抽取、debounce 队列) +│ ├── persistence/ SQLAlchemy 模型(用户、运行、事件、反馈) +│ ├── guardrails/ 工具调用前置鉴权(可插拔 provider) +│ ├── tracing/ LangSmith / Langfuse callback +│ ├── reflection/ "module:variable" 字符串 → 实例(配置驱动的关键) +│ ├── uploads/ 上传文件转换 markdown +│ └── client.py DeerFlowClient(嵌入式 Python 客户端) +│ +└── app/ ← 应用代码(import: app.*) + ├── gateway/ + │ ├── app.py FastAPI 入口 + lifespan + │ ├── auth_middleware.py 会话/Token 鉴权 + │ ├── csrf_middleware.py 双重 cookie CSRF + │ ├── langgraph_auth.py 注入到 langgraph.json 的鉴权钩子 + │ └── routers/ ↓ 下表 + └── channels/ IM(Slack/Telegram/Feishu/DingTalk/微信/企微) +``` + +**铁律:app 可以 import deerflow,deerflow 不能 import app**(CI 用 `tests/test_harness_boundary.py` 强制)。这意味着 harness 必须自给自足——任何"agent 运行时需要的能力"都要在 harness 里完成抽象,app 层只做 HTTP/IM 适配。 + +## 三、Gateway 路由总览 + +`backend/app/gateway/routers/` 14 个路由文件,分三类职责: + +| 类别 | 路由 | 干什么 | +|---|---|---| +| **配置/资源管理** | `models` `skills` `mcp` `memory` `agents` | 列出/启停 LLM 模型、技能、MCP、自定义 agent | +| **会话/数据** | `threads` `uploads` `artifacts` `suggestions` | 管理线程、上传文件、产物下载、追问建议 | +| **运行(核心)** | `thread_runs` `runs` `feedback` `assistants_compat` | 创建运行、SSE 流、消息分页、反馈打分、LangGraph 兼容协议 | +| **横切** | `auth` `channels` | 用户登录注册、IM 渠道状态 | + +`assistants_compat.py` 是关键:它把前端用的 LangGraph SDK 协议(`POST /threads/{id}/runs/stream`、`messages-tuple` 流模式等)翻译成 DeerFlow 内部的 `RunManager` 调用——这就是 nginx 那条 `/api/langgraph/*` 重写规则的接收端。 + +## 四、一次对话的完整生命周期 + +把上面所有零件串起来——用户在前端输入一句话,会发生这些事: + +``` +1. 前端 useThreadStream hook + └─▶ LangGraph SDK 调用 POST /api/langgraph/threads/{id}/runs/stream + (stream_mode=["values","messages-tuple","custom"]) + +2. nginx 重写 → /api/threads/{id}/runs/stream → Gateway + +3. Gateway thread_runs 路由 + ├─▶ AuthMiddleware 解析 session → user_id 注入到 user_context(contextvar) + ├─▶ CSRFMiddleware 校验 + └─▶ runtime.RunManager 创建 Run → 落库(runs / run_events 表) + +4. RunManager 调用 lead_agent 图(langgraph.json: deerflow.agents:make_lead_agent) + ├─▶ 解析 configurable: model_name / thinking_enabled / is_plan_mode / subagent_enabled + ├─▶ create_chat_model() 实例化 LLM(reflection 从 "module:Class" 字符串实例化) + └─▶ create_agent(model, tools, middlewares, state_schema=ThreadState) + +5. 18 个中间件按顺序拦截每一轮 model→tool→model: + ThreadDataMiddleware 创建 .deer-flow/users/{uid}/threads/{tid}/... + UploadsMiddleware 注入新上传文件 + SandboxMiddleware acquire 沙箱,state.sandbox_id 写入 + DanglingToolCall 修复中断的 tool_call 序列 + LLMErrorHandling LLM 报错降级 + Guardrail 工具调用前鉴权(可选) + SandboxAudit 记录 bash/fs 操作 + ToolErrorHandling tool 异常 → ToolMessage 不中断 + Summarization token 接近上限时压缩历史 + TodoList plan_mode 才挂 + TokenUsage 累计 token + Title 首轮后自动起标题 + Memory 队列异步抽取记忆 + ViewImage 视觉模型注入 base64 + DeferredToolFilter 需要时才暴露 tool schema + SubagentLimit 限制 task 并发到 3 + LoopDetection 检测重复工具循环 + Clarification ask_clarification 触发 interrupt(END) + +6. Tools 由 get_available_tools() 拼装: + ├─ Sandbox 工具:bash / ls / read_file / write_file / str_replace + ├─ 内建工具:present_files / ask_clarification / view_image / setup_agent + ├─ MCP 工具:从 extensions_config.json 启用的 server 拉取 + ├─ Community 工具:tavily / jina / firecrawl / image_search(按 config.yaml) + └─ task 工具(可选):派遣 subagent + +7. StreamBridge 把图执行的事件流转换成 SSE: + - "values" 完整状态快照 + - "messages-tuple" 增量 token / 工具调用 / 工具返回 + - "custom" StreamWriter 自定义事件 + - "end" 收尾,附 token usage + +8. 前端 LangGraph SDK 接 SSE,按 message id 累加 delta,更新 UI + +9. 运行结束后,MemoryMiddleware 后台 30s debounce 抽取记忆事实写入 + .deer-flow/users/{uid}/memory.json +``` + +## 五、状态与持久化的几条线 + +DeerFlow 的状态被有意拆成"快/慢/历史"三层,因为它要同时支持长会话、跨进程恢复、文件级产物: + +| 状态 | 位置 | 谁写 | +|---|---|---| +| **会话状态(messages, todos, artifacts)** | LangGraph checkpointer(内置 SQLite/PG,路径在 `runtime/checkpointer/async_provider.py`) | 每个 step 自动 | +| **运行元数据/事件流** | `persistence/` 下 SQLAlchemy 模型(`runs`、`run_events`、`feedback`、`threads_meta`) | RunManager + StreamBridge | +| **每用户每线程文件** | `.deer-flow/users/{uid}/threads/{tid}/user-data/{workspace,uploads,outputs}` | ThreadDataMiddleware + 沙箱工具 | +| **长期记忆** | `.deer-flow/users/{uid}/memory.json`(可叠加 per-agent) | MemoryMiddleware(异步) | +| **配置** | `config.yaml`(模型、工具、沙箱、记忆…) + `extensions_config.json`(MCP、技能开关) | `make setup` 或 Gateway PUT | + +agent 看到的永远是 **虚拟路径** `/mnt/user-data/...` 和 `/mnt/skills/...`,由 `sandbox/tools.py` 的 `replace_virtual_path()` 翻译成上面物理路径。这层抽象让"本地沙箱"和"Docker 沙箱"对 agent 完全透明。 + +## 六、前端架构(一行总结) + +`frontend/src/core/threads/hooks.ts` 里的 `useThreadStream` / `useSubmitThread` / `useThreads` 是整个前端的"主动脉"——它们包了 LangGraph SDK 单例(`core/api/`),所有 UI 组件订阅 thread 状态做渲染。Server Components 默认,需要交互的才 `"use client"`。`core/` 下其它子目录(artifacts/skills/mcp/memory/settings)都是为这条主动脉提供周边能力。 + +## 七、一图记住"它在做什么" + +DeerFlow 本质上是一个 **"LangGraph 智能体 + 18 段切面 + 沙箱 + 记忆"** 的组合: + +- **LangGraph** 提供图执行、checkpoint、stream 协议 +- **18 个中间件** 是 DeerFlow 自己加的"切面层",每个解决一个具体的健壮性/能力问题(错误恢复、上下文压缩、记忆、子代理限流……) +- **沙箱+技能+MCP+工具** 是 agent 的"手脚" +- **Gateway + IM Channels + 嵌入式 Client** 是同一个 agent 的三种暴露方式(HTTP/聊天/Python 直调) + +> 想继续往里钻的话,建议下一步选三个之一:(a)走读 lead_agent + 中间件链,理解 agent 一轮 think/act 的完整代码路径;(b)走读 sandbox + tools,理解虚拟路径和工具拼装;(c)走读 runtime + StreamBridge,理解 SSE 协议怎么映射回 LangGraph SDK。 diff --git a/docs/multi-tenant-redesign/01-redesign/adr-001-data-isolation.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-001-data-isolation.zh-CN.md new file mode 100644 index 00000000..f23c64ad --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-001-data-isolation.zh-CN.md @@ -0,0 +1,244 @@ +# ADR-001 · 数据隔离模型 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | CTO + 架构 + 后端 lead | +| 关联 ADR | ADR-004 租户层级、ADR-005 存储拓扑、ADR-006 运行时与渠道 | + +--- + +## 1. 背景 + +DeerFlow 当前是 **"多用户单租户"** 模型:所有用户的会话、运行、记忆、产物都共用同一套表,仓储层用 `user_id` WHERE 过滤做个人空间隔离(`runtime/user_context.py:138-167`)。这套机制设计良好——repository 用 ContextVar + `AUTO` 哨兵自动注入当前用户,下层不依赖上层(harness 不能 import app)。 + +多租户化需要在 `user_id` 之上再加一层 `tenant_id`。问题是:**用什么物理隔离强度?** + +三种主流方案: + +| 维度 | 行级(tenant_id WHERE) | per-tenant schema | per-tenant DB | +|---|---|---|---| +| 实现成本 | 低 | 中 | 高 | +| 跨租户 bug 爆炸半径 | 高 | 中 | 极低 | +| 备份/恢复粒度 | 全量 | 按 schema | 按 DB | +| 合规友好度(SOC2/HIPAA) | 一般 | 好 | 最好 | +| 跨租户分析查询 | 容易 | 中 | 难 | +| 升级 schema | 一次完成 | 要遍历所有 schema | 要遍历所有 DB | +| 适用客户规模 | <10k 租户 | 10k–100 大客户 | <100 大客户 | +| 运维复杂度 | 低 | 中 | 高 | + +--- + +## 2. 决策 + +**采用 行级 `tenant_id` + Postgres Row-Level Security(RLS)** 作为双保险。 + +理由: + +1. **DeerFlow 仓储层现状几乎平行扩展**——已经有 `resolve_user_id()` 哨兵模式,把 `tenant_id` 按同样模式补一遍,改造面集中、风险可控。 +2. **Postgres RLS 是 DB 层兜底**——即使应用层有 bug 漏写 `WHERE tenant_id = ...`,DB 也会强制过滤,第二道防线。 +3. **覆盖目标客户规模**:B2B 中小客户为主、租户数 1k–10k,行级方案足够。 +4. **不放弃跨租户分析能力**:平台需要做用量统计、监控、健康检查,单库行级最方便。 + +--- + +## 3. 备选方案与拒绝理由 + +### A. per-tenant schema(同库不同 schema) + +**拒绝。** 看似比行级更隔离,实际坑很多: + +- **schema 数量爆炸**:1000 个租户 = 1000 个 schema × 每张表,pg_class 体积膨胀,连接池里 search_path 切换有性能抖动 +- **schema migration 痛苦**:每发布一次 schema 改动要遍历所有 schema 跑 migration,失败回滚极复杂 +- **跨租户查询难**:要写 `UNION ALL` 跨所有 schema,运营仪表盘几乎无法实现 +- **依然需要应用层过滤**:连接进哪个 schema 仍由应用层决定,没真正消除"应用层 bug 跨租户" + +### B. per-tenant database(独立物理库) + +**拒绝(默认场景)。** 隔离最强但成本极高: + +- **运维负担**:1000 个 DB = 1000 套备份/恢复/监控/连接池 +- **冷启动延迟**:每个租户新建 DB 时间从秒级飙到分钟级 +- **跨租户操作不可能**:平台级查询、聚合、迁移全部失效 +- **连接池复杂度爆炸**:每租户独立连接池或者共用动态切库,都是噩梦 + +**仅在两种情况切换到此方案:** ① 拿到强合规客户(金融/医疗/政府),合同里写明物理数据隔离;② 客户付费足够覆盖每租户独立 DB 的运维成本(典型企业级订阅)。 + +--- + +## 4. 落地影响 + +### 4.1 表结构改造 + +所有业务表加 `tenant_id` 列 + 复合索引(`tenant_id` 作为前导列): + +```sql +ALTER TABLE threads_meta ADD COLUMN tenant_id UUID NOT NULL; +CREATE INDEX idx_threads_meta_tenant_user ON threads_meta (tenant_id, user_id, updated_at DESC); + +ALTER TABLE runs ADD COLUMN tenant_id UUID NOT NULL; +CREATE INDEX idx_runs_tenant_created ON runs (tenant_id, created_at DESC); + +ALTER TABLE run_events ADD COLUMN tenant_id UUID NOT NULL; +CREATE INDEX idx_run_events_tenant_run ON run_events (tenant_id, run_id, seq); + +ALTER TABLE feedback ADD COLUMN tenant_id UUID NOT NULL; +CREATE INDEX idx_feedback_tenant_run ON feedback (tenant_id, run_id); + +-- ADR-005 引入的新表也要带 tenant_id(建表时就有) +-- agent_configs, memory_facts, memory_context, +-- tenant_skill_state, tenant_mcp_configs, tenant_secrets, tenant_quotas +``` + +**关键索引原则**:每个 `tenant_id` 都必须是复合索引的**第一列**——RLS policy 走的就是这条路径,前导列错了 RLS 会全表扫。 + +#### 4.1.1 LangGraph 自有表(checkpoints / checkpoint_writes / checkpoint_blobs) + +`runtime/checkpointer/async_provider.py` 用的是 LangGraph 内置 `AsyncPostgresSaver`,**表结构不在 DeerFlow 控制下**——直接 ALTER 添加 `tenant_id` 列在 LangGraph 升级时会被它自己的 migration 覆盖。处理路径分两档(详见 ADR-006 §2.1): + +**默认(subquery 形式 RLS,零侵入)**: + +```sql +-- LangGraph 自动建表后,DeerFlow 的 init migration 跑: +ALTER TABLE checkpoints ENABLE ROW LEVEL SECURITY; +ALTER TABLE checkpoints FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON checkpoints + USING ( + thread_id IN ( + SELECT thread_id::text FROM threads_meta + WHERE tenant_id = current_setting('app.tenant_id', true)::uuid + ) + ); +-- checkpoint_writes / checkpoint_blobs 同形态,按 thread_id 反查 +``` + +**升级路径(侵入式列)**:当 subquery RLS 在大表上 EXPLAIN 出现问题时,改用: + +```sql +ALTER TABLE checkpoints ADD COLUMN tenant_id UUID; +CREATE INDEX idx_checkpoints_tenant_thread ON checkpoints (tenant_id, thread_id); +-- trigger 在 INSERT 时从 threads_meta 反查 tenant_id 写入 +-- 升级 LangGraph 前必须验证它的 migration 不会丢这列 +``` + +#### 4.1.2 第一道防线:thread_id ↔ tenant_id 校验 + +不论用哪种 RLS 形态,`AssistantsCompat` 路由(`app/gateway/routers/assistants_compat.py`)在调用 LangGraph 之前**必须先用 `threads_meta` 校验 `(tenant_id, thread_id)` 归属**。RLS 是兜底,应用层校验是第一道防线——RLS 只能"过滤掉看不到的",不能阻止"创建到错误租户名下"。 + +### 4.2 RLS policy 模板 + +所有带 `tenant_id` 的表都加同样形态的 policy: + +```sql +ALTER TABLE threads_meta ENABLE ROW LEVEL SECURITY; +ALTER TABLE threads_meta FORCE ROW LEVEL SECURITY; -- 即使表所有者也走 policy + +CREATE POLICY tenant_isolation ON threads_meta + USING (tenant_id = current_setting('app.tenant_id', true)::uuid) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid); +``` + +应用层在每次拿到连接时,先 `SET LOCAL app.tenant_id = ''`: + +```python +# packages/harness/deerflow/persistence/engine.py +async def _set_session_tenant(session: AsyncSession, tenant_id: str) -> None: + """Bind session to tenant; RLS policy enforces filter.""" + await session.execute(text("SET LOCAL app.tenant_id = :tid"), {"tid": tenant_id}) +``` + +每个仓储方法的开头自动调用,从 ContextVar 取 tenant_id(仿照现有 `resolve_user_id` 模式)。 + +**LangGraph 自有连接池的注入**:DeerFlow 仓储和 LangGraph checkpointer 是**两套连接池**——前者是 SQLAlchemy `AsyncSession`,后者是 LangGraph 自己持有的 asyncpg 池。后者的注入路径不能复用上面的 helper: + +```python +# 1. 优选:自定义 connection factory 注入到 saver 构造(langgraph-checkpoint-postgres>=2.0) +async def tenant_aware_acquire(pool): + async with pool.acquire() as conn: + tid = get_current_tenant_id() + await conn.execute("SET LOCAL app.tenant_id = $1", tid) + yield conn + +saver = AsyncPostgresSaver(connection_factory=tenant_aware_acquire) + +# 2. 兜底:在 RunManager 进入 thread 前主动 issue 一条 SET(要求 LangGraph 复用同 conn) +``` + +具体路径选择与失败模式见 ADR-006 §2.1。 + +**关键约束**:所有"会查 LangGraph 表"的代码路径——`AssistantsCompat` 路由、`RunManager`、checkpointer 直读——都必须保证调用栈上已注入 `app.tenant_id`,否则 RLS 会把整个会话过滤成空集。CI 加冒烟测试确认这点。 + +### 4.3 ContextVar 扩展 + +在 `runtime/user_context.py` 旁边加 `tenant_context.py`: + +```python +_current_tenant: Final[ContextVar[CurrentTenant | None]] = ContextVar("deerflow_current_tenant", default=None) + +class _AutoSentinel: ... # 同 user_id 模式 +AUTO: Final[_AutoSentinel] = _AutoSentinel() + +def resolve_tenant_id(value, *, method_name) -> str: + """与 resolve_user_id 同款三态:AUTO / 显式 str / 显式 None。 + SaaS 模式下 None 是 forbidden(除非显式 admin override)。""" +``` + +`AuthMiddleware` 在解析完 JWT 后两个 ContextVar 同时注入。 + +### 4.4 SQLite → Postgres 迁移 + +**SQLite 不支持 RLS**,多租户上线必须切 Postgres。迁移路径: + +1. 第 0 阶段:在 dev 环境同时跑 SQLite 和 Postgres,用 `aiosqlite` / `asyncpg` 双驱动 +2. 第 1 阶段:生产切 Postgres,老数据用 `pg_loader` 导入;DeerFlow 现有的 `Base.metadata.create_all()` 直接接 Postgres +3. 老用户的 `user_id` 在没有 tenant_id 时归到一个"legacy_tenant",迁移脚本同步把 `tenant_id` 回填 + +### 4.5 跨租户操作(平台后台) + +平台 admin / 运维需要跨租户查询时,不能简单"绕过 RLS"——而是用一个**专用 role** 配 `BYPASSRLS`,只给受限运维账号使用,操作审计入库: + +```sql +CREATE ROLE deerflow_admin BYPASSRLS; +-- 应用层 admin 路由用这个 role 的连接池,并强制审计日志 +``` + +**绝不允许应用主连接池有 `BYPASSRLS`。** + +--- + +## 5. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 应用层漏写 tenant_id WHERE | RLS 是兜底;CI 加静态检查(detect SQL 不带 tenant_id) | +| 索引前导列错了走全表扫 | DBA 评审所有 EXPLAIN;上线前压测 | +| `current_setting('app.tenant_id')` 没设导致 RLS 全过滤掉 | 应用层 fail-closed;监控空集查询率 | +| 跨租户分析需求多 | 提供受控的 admin role + 审计日志 | +| SQLite 开发 vs Postgres 生产差异 | 测试集成层用 testcontainers 跑 Postgres;不允许用 SQLite 跑 RLS 相关测试 | +| 单 DB 容量上限(>1TB 后维护困难) | 监控 DB 体积;超过阈值切 per-tenant DB(推翻方案) | + +--- + +## 6. 推翻条件 + +切换到 **per-tenant DB** 当且仅当: + +1. 拿到强合规客户(金融/医疗/政府),合同要求物理数据隔离 +2. 单 DB 容量 / 写 TPS 触顶,垂直扩展不经济 +3. 出现一次跨租户数据泄露事故,董事会要求最强隔离 + +--- + +## 7. 默认假设 + +| 项 | 默认 | +|---|---| +| 数据库 | PostgreSQL 16+ | +| RLS 启用 | 所有带 `tenant_id` 的业务表 | +| 主键 | UUID v7(时间排序) | +| 索引前导列 | `tenant_id` | +| Connection pool | 每应用进程 20–50 conns,PgBouncer transaction mode | +| 备份 | 每日全量 + WAL streaming,保留 30 天 | +| 跨租户查询 | 仅通过 `deerflow_admin` role + 审计 | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-002-sandbox-isolation.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-002-sandbox-isolation.zh-CN.md new file mode 100644 index 00000000..5a98ac4b --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-002-sandbox-isolation.zh-CN.md @@ -0,0 +1,363 @@ +# ADR-002 · 沙箱隔离模型 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 安全 + 架构 + SRE | +| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑 | + +--- + +## 0. 概念前提 + +本 ADR 反复出现 **K8s Namespace + gVisor/Kata 运行时 + NetworkPolicy** 三件套——它们在不同层把租户的代码运行环境关起来,缺一不可。先用一段话讲清楚是什么、防什么、不防什么,再读后面的决策细节会顺很多。 + +### 0.1 K8s Namespace —— 资源/视图隔离 + +Kubernetes 的逻辑分区。一个集群里跑多租户,每租户分一个 namespace(如 `tenant-acme`、`tenant-bigco`),其中的 Pod / Service / Secret / ConfigMap 互相看不见。配套: + +- **ResourceQuota**:限制 namespace 总用量(CPU、内存、Pod 数、存储) +- **LimitRange**:单 Pod 兜底(默认 request/limit、单 Pod 上限) +- **RBAC**:把租户管理员权限只绑到自己的 namespace + +⚠️ **不是安全边界**。namespace 只让你"看不到",不是"碰不到"。两租户的 Pod 若都跑在默认 `runc` 上、共享同一个 Linux 内核,**任何一个内核 0day 都能让 A 容器逃逸到宿主,进而看到 B 容器**。所以需要下一层。 + +> 类比:办公楼里不同公司的门禁卡。同事进不了别人的工位,但墙不会自己变厚。 + +### 0.2 gVisor / Kata —— 内核级隔离 + +把容器从宿主内核上"再隔一层"。DeerFlow 沙箱要跑租户上传的任意 Python 代码,必须比 runc 更硬。 + +**gVisor(Google)** +- 用户态实现一个沙箱内核(Sentry),拦截容器所有 syscall 自己模拟,再用极少几个 syscall 跟真内核打交道 +- 攻击面:先攻破 Sentry,再攻破真内核——多一道 +- 代价:每个 syscall 中转,IO 密集型 workload 慢 ~10-30% +- 集成:Pod spec 写 `runtimeClassName: gvisor` + +**Kata Containers** +- 给每个 Pod 起一个轻量虚拟机(QEMU 或 Firecracker 后端),容器跑在 VM 内独立内核里 +- 隔离强度 ≈ 真 VM,启动几百毫秒 +- 代价:每 Pod 多占 ~50-150 MB 内存、冷启动比 gVisor 慢一点 +- 集成:`runtimeClassName: kata-qemu` / `kata-fc` + +> 本 ADR 取舍:默认 **gVisor**(性价比平衡),监管/付费档位切 **Kata-Firecracker**(接近 VM 强度,单独定价)。配套 Cosign 镜像签名 + 只读根文件系统 + drop ALL caps 是纵深防御。 + +### 0.3 NetworkPolicy —— 出/入流量白名单 + +K8s 原生防火墙,按 namespace / Pod label 控制谁能跟谁通信。**默认拒绝 + 显式放行**是标准姿势: + +```yaml +spec: + podSelector: {} # 命中 namespace 内所有 Pod + policyTypes: [Egress] + egress: [] # 空白名单 = 全部禁止 +``` + +为什么对 DeerFlow 是关键——租户代码可能尝试访问: + +| 目标 | 风险 | +|---|---| +| `169.254.169.254`(云元数据) | 偷 IAM 凭据、节点 token,直接拿下集群 | +| 平台内网 DB / Redis | 横向打到其他租户的数据 | +| 其他租户的 namespace IP | 跨租户监听/嗅探 | +| 互联网 C2 服务器 | 数据外泄、挖矿、僵尸网络 | + +默认全部拒绝后,仅放行:DNS(CoreDNS)+ 出口走 **Egress Gateway**(Envoy/Squid 做域名白名单,允许 `api.openai.com`、`pypi.org` 等,拒绝其余)。 + +> 执行靠 CNI 插件(Calico / Cilium)。Cilium 还支持 L7 策略(如"允许 GET /v1/chat/completions、禁 POST /admin"),是更强的备选。 + +### 0.4 三件套合起来看 + +``` +租户的 Python 代码 + │ + ▼ +┌────────────────────────────────────────┐ +│ Pod (tenant-acme namespace) │ ← K8s Namespace:逻辑隔离 + 配额 +│ ├─ runtimeClassName: gvisor │ ← gVisor:内核级攻击面隔离 +│ ├─ readOnlyRootFilesystem │ +│ └─ capabilities.drop: ["ALL"] │ +└────────────────────────────────────────┘ + │ egress + ▼ +┌────────────────────────────────────────┐ +│ NetworkPolicy: default-deny │ ← NetworkPolicy:网络层白名单 +│ → 仅允许 Egress Gateway / DNS │ +└────────────────────────────────────────┘ + │ + ▼ + Egress Gateway(域名白名单) +``` + +- 没有 namespace:租户互相能看见对方的资源对象 +- 没有 gVisor:一个内核 0day 全集群陪葬 +- 没有 NetworkPolicy:租户代码 `curl 169.254.169.254` 就能拿走节点凭据 + +三层都套上,才是本 ADR 想要的"对抗任意租户代码"的最低防御姿势。 + +--- + +## 1. 背景 + +沙箱是多租户里**爆炸半径最大**的组件:客户的 agent 可以跑任意 bash 命令、读写文件、调用 MCP 工具。如果隔离不够强,一个客户能: + +- **读到其他客户的数据**(容器逃逸 / 共享卷误用) +- **薅云元数据**(`curl http://169.254.169.254/...` 偷 IAM 凭证) +- **横向移动**(同 namespace 的其他容器、同节点的 hostNetwork) +- **耗尽资源**(fork bomb、无限循环、磁盘填满) + +DeerFlow 现状有两个 sandbox provider: + +| Provider | 强度 | 多租户可用 | +|---|---|---| +| `LocalSandboxProvider` | bash/fs **直接落主机**,零隔离 | ❌ 绝对不能用 | +| `AioSandboxProvider` | Docker 容器(社区实现,`packages/harness/deerflow/community/aio_sandbox/`) | ⚠️ 当前配置不够 | + +`AioSandboxProvider` 起点不错(每 thread 一个容器、虚拟路径翻译已有),但默认配置缺少多租户必需的几条隔离:默认出网未禁、CPU/内存 limits 未强制、根文件系统未只读、镜像未签名校验。 + +--- + +## 2. 决策 + +**采用 K8s + 强隔离运行时(gVisor 或 Kata Containers)+ NetworkPolicy 默认禁出网 + per-tenant Namespace。** + +| 层 | 作用 | +|---|---| +| **K8s Namespace per tenant** | 资源逻辑隔离;NetworkPolicy 起效边界 | +| **gVisor (runsc) 运行时** | 用户态系统调用拦截,容器逃逸到宿主难度大幅提升;性能损失 ~5-15%(多数 agent 任务可接受) | +| **NetworkPolicy 默认 DENY** | 出网白名单:只允许到 LLM endpoint、配置的 MCP servers、搜索 API | +| **ResourceQuota + LimitRange** | per-namespace CPU/内存上限;单 pod CPU/内存上限 | +| **Pod Security Standard: restricted** | 禁 root、禁 privileged、只读根文件系统、drop ALL caps | +| **emptyDir 临时卷** | 数据靠 ADR-005 同步对象存储,pod 销毁即清 | +| **镜像签名校验**(Cosign) | 启动 pod 前验证 sandbox 镜像签名,防供应链攻击 | + +**Premium 客户档位**:在此之上再加 per-tenant **物理节点池** + **Firecracker microVM**(kata-fc),把"租户 X 的沙箱永远不和别人共享物理节点"做到 SLA 里。 + +--- + +## 3. 威胁模型 + +| 攻击场景 | 共享 Docker(现状) | per-tenant K8s NS + gVisor(决策) | per-tenant Firecracker | +|---|---|---|---| +| 容器逃逸到宿主 | 全员沦陷 | 单租户沦陷(gVisor 大幅降低成功率) | 单租户沦陷(VM 边界,逃逸难度极高) | +| 容器间横向移动(同节点) | 可行 | NetworkPolicy 禁止 + namespace 隔离 | 不可能(独立 VM) | +| 出网到云元数据 169.254.169.254 | 默认可行 | NetworkPolicy 禁 + IMDSv2 强制 token | 同左 | +| 出网到内部服务(DB / 内网) | 可行 | NetworkPolicy 禁 + egress gateway 白名单 | 同左 | +| 侧信道(CPU 缓存 / Spectre) | 可行 | 减弱(gVisor 隔离系统调用,但 CPU 共享仍有风险) | 显著减弱(独立 VM、独立 vCPU) | +| 资源耗尽(fork bomb / OOM) | 影响同节点全部容器 | LimitRange 强制 cgroup;超限 OOMKill | VM 内独立调度 | +| 持久化攻击(写定时任务) | 可写主机 cron | 只读根文件系统 + ephemeral pod 重建即清 | 同左 | +| 提权 | 看 Docker 配置(默认 root) | restricted PSS 禁 root、禁 capabilities | 同左 | +| 镜像被替换(供应链) | 不校验 | Cosign 验证签名 + admission controller 拦截 | 同左 | + +--- + +## 4. 备选方案与拒绝理由 + +### A. 共享 Docker(保留 AioSandboxProvider 当前模式) + +**拒绝。** 即使加固到极致,根本问题仍在: + +- 所有租户共享 dockerd / containerd,一次容器逃逸 = 全员沦陷 +- Docker 的 NetworkPolicy 等价物(user-defined network)粒度粗 +- 资源限制依赖 cgroup v1/v2 配置一致,运维难统一 + +### B. per-tenant Firecracker microVM(默认) + +**拒绝(作为默认)。** 隔离最强但成本最高: + +- 冷启动比 K8s pod 慢 2-5×(500ms vs 几秒) +- 运维需要专门团队(kata-fc / cloud-hypervisor 都不是开箱即用) +- AWS / 阿里云的部分托管 K8s 不直接支持 Firecracker,需要自建 nodepool + +**保留作为 premium 档位**:把 Firecracker 当付费 SLA 卖给监管类客户。 + +### C. 共享 K8s Namespace + 仅靠 NetworkPolicy + cgroup + +**拒绝。** namespace 不分隔意味着: + +- pod-to-pod 通信默认开放(NetworkPolicy 是白名单制,漏一条就全开) +- ServiceAccount 共享,越权读 secret 风险大 +- ResourceQuota 是 namespace 级别,没法精细分配到租户 + +--- + +## 5. 落地影响 + +### 5.1 新建 `K8sSandboxProvider` + +```python +# packages/harness/deerflow/sandbox/k8s/provider.py +class K8sSandboxProvider(SandboxProvider): + """每 thread 一个 Pod,按 tenant 落到对应 Namespace。 + + 生命周期: + acquire(thread_id) → 创建 Pod(gVisor runtime, restricted PSS, NetworkPolicy 已挂) + get(sandbox_id) → 返回与运行中 Pod 通信的客户端(kubectl exec / WebSocket) + release(sandbox_id) → delete Pod(emptyDir 自动回收) + """ +``` + +替换 `LocalSandboxProvider`(开发/测试用)和 `AioSandboxProvider`(保留作为单机部署 fallback)。 + +### 5.2 K8s 资源(每租户 Namespace 一份) + +```yaml +# tenant onboarding 时自动渲染、apply +apiVersion: v1 +kind: Namespace +metadata: + name: tenant-{tenant_id} + labels: + pod-security.kubernetes.io/enforce: restricted + deerflow.io/tenant-id: {tenant_id} +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + namespace: tenant-{tenant_id} +spec: + hard: + cpu: "16" + memory: 32Gi + pods: "20" + requests.storage: 100Gi +--- +apiVersion: v1 +kind: LimitRange +metadata: + namespace: tenant-{tenant_id} +spec: + limits: + - type: Container + default: { cpu: "500m", memory: 1Gi } + defaultRequest: { cpu: "100m", memory: 256Mi } + max: { cpu: "2", memory: 4Gi } +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: deny-all-egress + namespace: tenant-{tenant_id} +spec: + podSelector: {} + policyTypes: [Egress] + egress: # 仅允许下面这些 + - to: + - namespaceSelector: { matchLabels: { name: kube-system } } + podSelector: { matchLabels: { k8s-app: kube-dns } } + ports: [{ protocol: UDP, port: 53 }] + - to: # 通过 egress gateway 出外网 + - podSelector: { matchLabels: { app: deerflow-egress-gateway } } +``` + +### 5.3 Egress gateway + +放一个集中的出口代理(envoy / squid)做: + +- LLM endpoint 白名单(OpenAI / Anthropic / vLLM 内网) +- MCP server 白名单(per-tenant 启用列表) +- 搜索 API 白名单(Tavily / Jina / Brave / DuckDuckGo) +- **黑名单**:169.254.169.254(cloud metadata)、10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16(内部网络,除非白名单) +- 全量审计(按租户记录每次出网) + +### 5.4 Pod Spec 关键字段 + +```yaml +spec: + runtimeClassName: gvisor # 或 kata-fc(premium) + automountServiceAccountToken: false # 沙箱不该有 SA token + securityContext: + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: sandbox + image: registry.deerflow.io/sandbox:v2.3.4@sha256:... # 强制 digest pin + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumeMounts: + - { name: workspace, mountPath: /mnt/user-data } + - { name: skills, mountPath: /mnt/skills, readOnly: true } + volumes: + - { name: workspace, emptyDir: { sizeLimit: 10Gi } } + - { name: skills, emptyDir: { sizeLimit: 5Gi } } # 启动时从 S3 拉 +``` + +### 5.5 镜像签名 + +- CI 用 cosign sign 给 sandbox 镜像签名 +- K8s admission controller(policy-controller / Kyverno)启动 pod 前验证 cosign signature +- 拒绝任何未签名 / 签名不匹配的镜像 + +### 5.6 SandboxAuditMiddleware 增强 + +现有 `SandboxAuditMiddleware` 记录了工具调用,多租户必须: + +- 每条审计带 `(tenant_id, user_id, thread_id, tool_name, args_hash)` +- 异步推到独立审计存储(不与业务库共用) +- 保留至少 90 天(合规要求常见值) + +--- + +## 6. 性能影响 + +| 指标 | 估计 | +|---|---| +| Pod 冷启动(gVisor + 镜像 pre-warm) | 1.5-3s | +| Pod 冷启动(Firecracker) | 3-8s | +| Bash 命令延迟(vs 宿主) | gVisor +5-15%;Firecracker +10-30% | +| 文件 I/O(小文件) | gVisor 显著慢(系统调用拦截);用 emptyDir tmpfs 缓解 | +| 网络(egress gateway) | +1-3ms 单跳 | + +**冷启动是最敏感的指标**——靠两条路径优化: + +1. **Pod prewarm**:每个 namespace 维护 N 个空闲 pod 池(`PodReadinessProbe` 通过即可服用,按需绑定 thread) +2. **镜像层缓存**:每节点预拉镜像(DaemonSet image-puller) + +--- + +## 7. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| gVisor 与某些 syscall 不兼容(agent bash 跑不动某些工具) | sandbox 镜像里预装常用工具;CI 跑兼容性测试集 | +| 出网白名单维护负担 | Per-tenant MCP/搜索配置自动生成 NetworkPolicy;运维 admin UI 一键加 | +| Firecracker 运维复杂 | 仅作为 premium 档位,不强制全量上 | +| 节点 noisy neighbor(CPU 共享导致侧信道) | 高敏租户走专属 nodepool(taints/tolerations) | +| Pod prewarm 池资源浪费 | 按租户活跃度动态调节池大小;闲置超过阈值缩到 0 | +| 镜像供应链攻击 | Cosign 强制 + SBOM + 漏洞扫描 | + +--- + +## 8. 推翻条件 + +切换到 **per-tenant Firecracker(默认)** 当且仅当: + +1. 实测 gVisor 在某条关键 syscall 上有不可绕过的兼容性问题(且 sandbox 镜像无法预装替代品) +2. 拿到合同要求"强物理隔离"的监管客户,付费档位要求覆盖运维成本 +3. 出现一次容器逃逸 PoC 影响多租户 + +切换到 **共享 Docker(极端简化)** 当且仅当: + +- 公司决定退回单租户产品形态——多租户上线后基本不应回退 + +--- + +## 9. 默认假设 + +| 项 | 默认 | +|---|---| +| 集群 | EKS / ACK / GKE,K8s 1.29+ | +| 沙箱 runtime | gVisor (runsc) | +| Premium runtime | Kata Containers + Firecracker | +| Pod 隔离粒度 | per-thread(不复用) | +| 冷启动 SLO | P50 < 2s, P99 < 5s | +| Pod CPU/Mem 上限 | 2 CPU / 4 GiB(单 pod) | +| Namespace 配额 | 16 CPU / 32 GiB / 20 pods(按 plan 调) | +| Egress 白名单数量 | <30 个域名 / 租户 | +| 审计保留期 | 90 天热 + 1 年冷 | +| 镜像签名 | cosign + Kyverno 强制 | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-003-llm-key-billing.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-003-llm-key-billing.zh-CN.md new file mode 100644 index 00000000..b1611a3a --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-003-llm-key-billing.zh-CN.md @@ -0,0 +1,285 @@ +# ADR-003 · LLM Key 与计费模型 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 产品 + CTO + 财务 | +| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑、ADR-006 运行时与渠道 | + +--- + +## 1. 背景 + +DeerFlow 当前的 LLM 配置是**进程级全局**:`config.yaml` 写 `api_key: $OPENAI_API_KEY`,环境变量在容器启动时注入,所有用户共用同一个 key。`models/factory.py` 在 `create_chat_model()` 时做反射 + 环境变量替换。 + +多租户场景下,这套模式有四个问题: + +1. **成本归因不清**:所有租户的 token 消费混在同一个 key 上,月底无法精确按租户计费 +2. **滥用风险大**:一个租户写循环 prompt 把 key 刷爆,所有租户一起被拉黑 +3. **客户合规问题**:部分企业要求"我的数据只能走我的 LLM 账户"(数据驻留 / 审计闭环) +4. **MCP / 第三方 API key 同样问题**:Tavily / Firecrawl / Jina 的 key 也是全局的 + +需要决定:**LLM Key 由谁出?怎么算账?** + +--- + +## 2. 决策 + +**采用混合模式(Hybrid BYO)**: + +- **Free / Pro 套餐**:默认走 **平台 key**,按月配额限制(token / 并发 / 沙箱 CPU 秒) +- **Enterprise / BYO 套餐**:客户上传自己的 LLM key(OpenAI / Anthropic / Azure / Bedrock),不限平台 quota,按"管理费 + 沙箱用量"收费 + +理由: + +1. **降低小客户上手摩擦**——他们不想注册 OpenAI 账号、不想填信用卡,平台 key 体验最顺 +2. **大客户控制权要求**——他们已经有 OpenAI / Anthropic 企业合同(折扣 / 数据条款),希望复用 +3. **平台风险可控**——平台 key 有 quota 兜底;BYO key 客户自己滥用自己的额度 +4. **现有 `models/factory.py` 改造可控**——加一层 tenant 上下文 + secret vault 即可 + +--- + +## 3. 备选方案与拒绝理由 + +### A. 纯平台 key(所有租户共用,按 token 加价转售) + +**拒绝。** 看似简单实则雷区遍地: + +- 大客户必拒——数据合规审查过不去(数据流过你的 key 等于过你的账户) +- 一旦 OpenAI 临时封号 / rate limit,全员宕机 +- 加价定价天花板低(客户自己注册也能买,溢价空间小) +- 你要替每个客户做信用评估和反滥用,运维成本飙升 + +### B. 纯 BYO key(强制客户自带) + +**拒绝。** 与早期增长冲突: + +- Onboarding 多一步"去注册 OpenAI"——转化率显著下降 +- 试用客户体验差("还没用就要填卡") +- 小客户会嫌烦直接放弃 + +### C. 平台 key + 严苛 quota(不开 BYO) + +**部分采纳,作为 Free/Pro 默认。** 但不开 BYO 会卡企业客户,不能作为唯一选项。 + +--- + +## 4. 落地影响 + +### 4.1 Tenant Secrets 表(ADR-005 已含) + +```sql +tenant_secrets ( + tenant_id UUID FK, + key VARCHAR(64), -- 'OPENAI_API_KEY' / 'ANTHROPIC_API_KEY' / 'TAVILY_API_KEY' / ... + encrypted_value BYTEA, -- KMS 加密 + rotated_at TIMESTAMP, + created_at TIMESTAMP, + PRIMARY KEY (tenant_id, key) +) +``` + +加密策略:每个租户一个 KMS data key(DEK),KEK 在 AWS KMS / 阿里云 KMS 集中管。 + +### 4.2 改造 `create_chat_model()` + +当前签名(伪): + +```python +def create_chat_model(name: str = None, *, thinking_enabled: bool, app_config: AppConfig = None) -> BaseChatModel: + model_config = app_config.get_model_config(name) + api_key = resolve_env_var(model_config.api_key) # 从 process env 取 + return reflect(model_config.use)(api_key=api_key, ...) +``` + +改造后: + +```python +async def create_chat_model( + name: str = None, + *, + thinking_enabled: bool, + tenant_id: str = AUTO, # 从 ContextVar 取 + app_config: AppConfig = None, +) -> BaseChatModel: + tenant = resolve_tenant_id(tenant_id, method_name="create_chat_model") + model_config = app_config.get_model_config(name) + + # 优先级:tenant 自带 key > 平台 key(带 quota) + api_key = await secret_vault.get(tenant, model_config.api_key_secret_name) + if api_key is None: + api_key = await platform_keys.get(model_config.api_key_secret_name) + # 走平台 key 的 LLM 调用必须挂 QuotaMiddleware(见 4.4) + + return reflect(model_config.use)(api_key=api_key, ...) +``` + +### 4.3 Quota 表与 Usage 表(ADR-005 已含) + +```sql +tenant_quotas ( + tenant_id UUID, + metric VARCHAR(32), -- tokens_monthly / runs_concurrent / sandbox_cpu_seconds_daily + hard_limit BIGINT, -- 超过即拒绝 + soft_limit BIGINT, -- 超过即告警 / 降速 + PRIMARY KEY (tenant_id, metric) +) + +tenant_usage_daily ( + tenant_id UUID, + date DATE, + metric VARCHAR(32), + model_name VARCHAR(64), -- 区分 OpenAI / Anthropic / 本地 vLLM + value BIGINT, + PRIMARY KEY (tenant_id, date, metric, model_name) +) +``` + +写入时机: + +- token:现有 `TokenUsageMiddleware` 改造,写入按 (tenant_id, model, date) 累加 +- 沙箱 CPU 秒:K8s metrics-server / Prometheus 抓取,每 5 min 聚合写入 +- 并发 runs:`RunManager` 启动/结束时增减计数器 + +### 4.4 新增 `QuotaMiddleware` + +放在 lead_agent 中间件链最前(在 ThreadDataMiddleware 之后、SandboxMiddleware 之前),LLM 调用前检查: + +```python +class QuotaMiddleware(AgentMiddleware): + async def before_model(self, state, runtime): + tenant = get_current_tenant() + usage = await usage_repo.current_month(tenant.id, "tokens_monthly") + quota = await quota_repo.get(tenant.id, "tokens_monthly") + + if quota.hard_limit and usage >= quota.hard_limit: + raise QuotaExceeded( + "Monthly token quota exhausted. Upgrade plan or wait for reset.", + next_reset=first_day_of_next_month(), + ) + if quota.soft_limit and usage >= quota.soft_limit: + # 软限:仍允许调用,但触发告警 + 在 UI 显示警告 + await alert_soft_limit(tenant.id) +``` + +`QuotaExceeded` 通过 `LLMErrorHandlingMiddleware` 转成 user-facing 错误(不是 500),保持一致性。 + +#### 4.4.1 悲观预扣 vs 事后结算("幽灵 token"问题) + +`before_model` 只能看到"调用前累计用量",但 token 消耗是 `after_model` 才知道的——硬限到达时**最后一次调用一定超额**(典型可超 32k–200k token,单次可达 quota 的 1-5%)。处理: + +| 阶段 | 动作 | +|---|---| +| `before_model` | **悲观预扣**:按 `model_max_input_tokens` 估上限(按 model 配置查表),累加到 "reserved" 列。reserved + actual ≥ hard_limit 时拒绝 | +| `after_model`(非流式) | 拿到真实 token,把对应 reservation 从 reserved 移到 actual,差额返还 | +| `after_model`(流式) | 流尾 `usage` event 到达时同上;流被 cancel 时按已收到的增量扣,剩余 reservation 释放 | + +```sql +tenant_usage_daily ( + ..., + metric VARCHAR(32), -- tokens_input / tokens_output / tokens_reserved + value BIGINT, + ... +) +``` + +**reserved 不进账单**,只用于 quota gate。月底对账只看 `tokens_input` + `tokens_output`。 + +#### 4.4.2 流式 token 的提交时机 + +LangGraph SDK 的 `messages-tuple` 流模式按 chunk 推 delta。当前 `TokenUsageMiddleware` 在 `after_model` 一次性提交——多租户后这有两个隐患: + +1. **客户端 abort 流时漏记**:用户关浏览器、SSE 断开 → middleware 没收到 `after_model` → token 漏算 +2. **provider 本身的 usage 帧晚到**:OpenAI/Anthropic 把 usage 放在最后一个 chunk;StreamBridge 必须在 finalizing 时强制等这一帧 + +强约束: + +- StreamBridge 收到 abort/disconnect 信号时,**仍需等 LLM provider 流自然结束** + 把 usage 提交后再断 client(限超时 5s 兜底) +- 提交时机:`after_model` 终态(成功/失败/abort)三选一时立刻 commit;不允许"等会话结束再批量" +- 流式调用的 `usage_category`(见 ADR-006 §2.5)按触发中间件分类 +- 测试覆盖:`test_billing_stream_abort.py` 模拟客户端断连,断言 token 仍被记录 + +#### 4.4.3 内部 LLM 调用的归属 + +详见 ADR-006 §2.5。简表如下: + +| 触发 | 计费归属 | usage_category | +|---|---|---| +| 主对话 | tenant | `main` | +| MemoryMiddleware 抽取 | tenant | `memory` | +| TitleMiddleware 起标题 | tenant | `title` | +| SummarizationMiddleware 历史压缩 | tenant | `summarization` | +| 平台 admin 主动 LLM 工具(健康检查等) | platform | `platform` | + +`tenant_usage_daily` schema 在 §4.3 基础上补 `usage_category` 列。报表 UI 展示这五类分项,避免"为什么我没说话也产生 token"这类客户投诉。 + +### 4.5 计费对账 + +平台 key 模式下,"实际成本"和"客户账单"要分清: + +| 维度 | 数据来源 | 用途 | +|---|---|---| +| **OpenAI 实际账单** | OpenAI API usage report(每日拉) | 与平台财务对账 | +| **客户应付** | `tenant_usage_daily`(你自己记的) | 月底生成账单 | +| **差额** | OpenAI 实际 - sum(客户应付) | 监控异常(>5% 触发审计) | + +差额监控很重要——如果你少记了 token(比如 streaming 异常时漏记),平台会替客户埋单。每月对账。 + +### 4.6 BYO key 验证流程 + +客户填 key 时: + +1. 加密前先做一次 test call(小 prompt,验证 key 有效) +2. 通过后加密入库 +3. UI 显示"已配置"但永远不回显原始 key(防泄露) +4. 提供轮换流程(rotate)和撤销流程(revoke) + +--- + +## 5. 套餐建议(产品决策,仅参考) + +| 套餐 | LLM Key | 月度 token quota | 并发 runs | 沙箱 CPU 秒/月 | 价格 | +|---|---|---|---|---|---| +| **Free** | 平台 key | 100k | 1 | 1k | $0 | +| **Pro** | 平台 key | 5M | 5 | 50k | $X | +| **Team** | 平台 key(按 token 转售) | 50M | 20 | 500k | $XX | +| **Enterprise BYO** | 客户自带 | 不限 | 协商 | 协商 | $XXX 管理费 + 沙箱用量 | + +具体数字由 PMM 和财务定,不在本 ADR 范围。 + +--- + +## 6. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 平台 key 被某租户刷爆 | QuotaMiddleware 硬限 + 异常用量告警(>3σ)+ 单 run token 上限 | +| 平台 key 被 OpenAI 临时封禁 | 多备份 key 轮询(多 tier API key)+ 多 provider 兜底(OpenAI 挂了切 Anthropic) | +| BYO key 在 DB 泄露 | KMS 加密 + 审计每次 decrypt + 仅在沙箱 pod 启动时 inject 进环境,不出 pod | +| 客户 BYO key 滥用导致他自己被 OpenAI 封 | 不归我们管(合同里写明) | +| 计费错算(少记 token) | 月度对账 + 5% 阈值告警 + 流式调用结束时强制 commit | +| 客户跨币种 / 退款 | 接 Stripe 完整闭环,不要自己手搓 | + +--- + +## 7. 推翻条件 + +- **退回纯平台 key**:如果 BYO 流量 < 5%,可考虑下线 BYO 简化运维(但企业客户已签合同的不能强制迁回) +- **强制 BYO**:如果平台 key 滥用/欺诈损失年化 > $X,关掉免费档 +- **per-tenant 物理 LLM 资源**:如果监管客户要求"专属推理实例",需要专用 vLLM / Bedrock provisioned throughput + +--- + +## 8. 默认假设 + +| 项 | 默认 | +|---|---| +| Secret 加密 | 信封加密:DEK(per-tenant)+ KEK(KMS) | +| Quota 维度 | tokens_monthly + runs_concurrent + sandbox_cpu_seconds_daily | +| Quota 重置 | 月度 token 按 UTC 月初;并发是实时;沙箱秒按 UTC 日初 | +| 软限:硬限比例 | soft = 0.8 × hard | +| BYO 支持的 provider | OpenAI / Anthropic / Azure / AWS Bedrock / vLLM-compatible | +| 计费货币 | USD(多币种由 Stripe 处理) | +| 对账周期 | 每日抓取 OpenAI usage,月度核对 | +| 单 run token 上限 | 平台 key:100k tokens;BYO:不限 | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-004-tenant-rbac.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-004-tenant-rbac.zh-CN.md new file mode 100644 index 00000000..aaeb36cb --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-004-tenant-rbac.zh-CN.md @@ -0,0 +1,372 @@ +# ADR-004 · 租户内层级与 RBAC + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 产品 + 后端 lead | +| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费 | + +--- + +## 1. 背景 + +DeerFlow 当前的角色模型在 `users.system_role` 字段(`backend/packages/harness/deerflow/persistence/user/model.py:33`),只有两级: + +- `admin`:首启账户、平台运维 +- `user`:普通注册用户 + +并且 admin 是**全平台级别**的——它能管所有用户。多租户化后,"管理员"概念要拆成两层: + +- **平台 admin**:你(运营 DeerFlow SaaS 的人)的运维账号 +- **租户内角色**:客户公司内部的角色(owner / admin / member) + +需要决定:**租户内要不要分角色?分多细?** + +--- + +## 2. 决策 + +**采用二级 RBAC**:每个租户内有 `owner` / `admin` / `member` 三种角色。最小化但够用。 + +| 角色 | 数量 | 核心权限 | +|---|---|---| +| **owner** | 1 个/租户(可转让) | 删除租户、修改计费、转让所有权、管理 admin | +| **admin** | 多个 | 邀请/移除 member、安装/启用 skill、配置 MCP、查看用量 | +| **member** | 多个 | 跑 agent、上传文件、看自己的 thread、看本租户共享 skill | + +--- + +## 3. 备选方案与拒绝理由 + +### A. 扁平(租户内不分角色) + +**拒绝。** 简单但企业客户会立刻不满: + +- 没法满足"我让员工用,但不让他们改 skill 配置"的需求 +- 没法做 SSO / SCIM 集成(IT 管理员要能批量管成员) +- 客户拉新时(员工增多)会出现"谁负责清理"的问题 + +### B. 三级以上 RBAC(自定义角色 / 项目级权限) + +**拒绝(起步阶段)。** 复杂度爆炸: + +- 自定义角色 = 完整的 RBAC engine(角色继承、权限矩阵、UI 编辑器) +- 项目/工作区级权限 = 还要再加一层组织 +- 这是 Enterprise 后期需求,第一年用不到 + +**保留作为后续扩展点**:等"自定义角色"成为销售卡点再做。 + +### C. 单一 admin(owner = admin = 同一个) + +**拒绝。** owner 和 admin 必须分开: + +- owner 是"账户所有权"——负责计费和租户存亡 +- admin 是"日常管理"——可以授权多个 +- 不分开会导致"所有 admin 都能删租户",运营失控 + +--- + +## 4. 权限矩阵 + +按"资源 × 动作"展开,下表是 MVP 矩阵(可扩展): + +| 资源 | 动作 | owner | admin | member | +|---|---|---|---|---| +| **tenant** | view | ✓ | ✓ | ✓ | +| | update_settings | ✓ | ✓ | | +| | delete | ✓ | | | +| | transfer_ownership | ✓ | | | +| **billing** | view | ✓ | ✓ | | +| | update_payment | ✓ | | | +| | manage_byo_key | ✓ | ✓ | | +| **members** | invite | ✓ | ✓ | | +| | remove | ✓ | ✓ | | +| | change_role | ✓ | | | +| **threads** | create | ✓ | ✓ | ✓ | +| | read_own | ✓ | ✓ | ✓ | +| | read_others | ✓ | ✓ | | +| | delete_own | ✓ | ✓ | ✓ | +| | delete_others | ✓ | ✓ | | +| **skills** | install | ✓ | ✓ | | +| | enable_disable | ✓ | ✓ | | +| | use | ✓ | ✓ | ✓ | +| **mcp_servers** | configure | ✓ | ✓ | | +| | use | ✓ | ✓ | ✓ | +| **custom_agents** | create_for_self | ✓ | ✓ | ✓ | +| | create_shared | ✓ | ✓ | | +| | edit_others | ✓ | ✓ | | +| **usage_reports** | view | ✓ | ✓ | | +| **audit_log** | view | ✓ | ✓ | | + +注意几个设计选择: + +- **`threads:read_others`**:默认 admin 能看(合规审计需要),但建议加配置项让租户 owner 可关掉 +- **`custom_agents:create_for_self` 给 member**:每个成员可以建私人 agent,但要 admin 才能"共享给整租户" +- **`skills:use` 给 member**:使用 admin 启用的 skill;不能自己装 / 关 + +--- + +## 5. 落地影响 + +### 5.1 数据模型(ADR-005 phase 0 plan 已含) + +```sql +-- 1 个用户可属于多个租户 +tenant_memberships ( + tenant_id UUID FK, + user_id UUID FK, + role VARCHAR(16), -- 'owner' | 'admin' | 'member' + invited_by UUID, + joined_at TIMESTAMP, + PRIMARY KEY (tenant_id, user_id) +) + +-- 1 个租户必须有且仅有 1 个 owner +CREATE UNIQUE INDEX idx_one_owner_per_tenant + ON tenant_memberships (tenant_id) WHERE role = 'owner'; + +-- 当前用户的"默认租户"(用户登录时落进哪个 tenant context) +ALTER TABLE users ADD COLUMN default_tenant_id UUID; +``` + +### 5.2 JWT Payload 改造 + +当前 JWT 只有 `sub: user_id`。改造后: + +```json +{ + "sub": "", + "tid": "", // 当前激活的租户 + "role": "admin", // 当前租户内的角色 + "tv": 5, // token_version(保留现有撤销机制) + "iat": ..., + "exp": ... +} +``` + +**为什么把 role 放 JWT**:避免每次请求都查 `tenant_memberships` 表;权限变化时通过 `token_version++` 强制踢人重登。 + +**租户切换**:用户在 UI 切换租户时,调 `POST /api/v1/auth/switch-tenant` → 服务端校验 membership → 重发 JWT(新 tid + role)+ 新 cookie。 + +#### 5.2.1 token_version 的存储与失效路径 + +`token_version` 不是 JWT 自带字段——需要在 `users` 表加一列: + +```sql +ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0; +``` + +签发 JWT 时把当前 `token_version` 写进 `tv` claim;验证 JWT 时**只读一次**(不每请求查 DB): + +- 命中**短 cache**(30s LRU,key=user_id)→ 比对 cache 里的 token_version +- cache miss → 查 DB 一次,写入 cache +- DB 里的 `token_version` 与 JWT 内 `tv` 不一致 → 401,cookie 强制失效,重登 + +谁会 bump `token_version`: + +| 触发 | 谁写 | +|---|---| +| Membership 撤销 | `DELETE /tenants/{tid}/members/{uid}` 时 `users.token_version += 1` | +| 角色变更(admin → member 等) | 同上 | +| 主动注销所有设备("踢出所有会话") | `POST /auth/sign-out-all` | +| 密码修改 | `POST /auth/change-password` | +| owner 转让冷静期满 | 双方都 bump | + +#### 5.2.2 JWT 内 role 与每请求查 DB 的取舍 + +这两条**只能选一条**,本 ADR 选 **A:JWT 内 role + 30s cache + 敏感操作必查 DB**: + +- **常规请求**(read thread、list skills 等):信 JWT,30s 内可能拿到过期权限——可接受 +- **写敏感资源**(删除 thread、修改 billing、邀请成员、安装 skill):装饰器 `@require_permission(strict=True)` 强制查 `tenant_memberships`,不走 cache +- **撤销/降权后**:bump `token_version` 让 cache miss → 下一次请求 401 → 用户重登拿新 JWT + +这等于"读路径乐观、写路径悲观",与 ADR-001 RLS 是双层兜底(JWT role 错了 RLS 还在 tenant 维度过滤,跨租户绝不会泄露)。 + +#### 5.2.3 cache 实现要点 + +```python +# packages/harness/deerflow/persistence/membership/cache.py +class MembershipCache: + """Per-process LRU, 30s TTL, key = (user_id,) → token_version.""" + _cache: TTLCache = TTLCache(maxsize=10_000, ttl=30) + + async def get_token_version(self, user_id: str) -> int: + cached = self._cache.get(user_id) + if cached is not None: + return cached + version = await user_repo.get_token_version(user_id) + self._cache[user_id] = version + return version + + def invalidate(self, user_id: str) -> None: + """主动失效——bump token_version 时调用。""" + self._cache.pop(user_id, None) +``` + +进程间不共享(K8s 多 pod 时 30s 内可能不一致——可接受,超过 30s 全部 pod 自动同步)。 + +**禁忌**:把 membership cache 做到 Redis 之类共享层。理由:失效广播复杂、租户隔离混乱、收益小。30s 不一致窗口比这个复杂度划算。 + +### 5.3 AuthMiddleware 注入两层 ContextVar + +```python +async def dispatch(request, call_next): + if _is_public(request.url.path): + return await call_next(request) + + user = await get_current_user_from_request(request) + jwt_payload = await verify_jwt(request) + tenant_id = jwt_payload["tid"] + role = jwt_payload["role"] + jwt_tv = jwt_payload["tv"] + + # 走 cache 比对 token_version:通常不查 DB + current_tv = await membership_cache.get_token_version(user.id) + if jwt_tv != current_tv: + raise HTTPException(401, "Token revoked, please sign in again") + + # ContextVar + request.state 注入 + set_current_user(user) + set_current_tenant(tenant_id, role) + request.state.user = user + request.state.tenant_id = tenant_id + request.state.role = role + + return await call_next(request) +``` + +**这里**只比对 `token_version`(30s cache),**不**每请求查 `tenant_memberships`。membership 状态变化通过 §5.2.1 的 bump 机制传导。 + +敏感写操作另走一层(`@require_permission(strict=True)`)—— 在路由 handler 里查 DB,详见 §5.2.2。 + +### 5.4 权限装饰器升级 + +现有 `@require_permission("threads", "read", owner_check=True)` 沿用,但语义升级: + +```python +@router.get("/{thread_id}") +@require_auth +@require_permission("threads", "read", owner_check="self_or_admin") +# self_or_admin: 自己拥有的 thread 自由读;他人的需要 admin/owner +async def get_thread(thread_id: str, request: Request): + ... + +@router.delete("/tenants/{tid}/members/{uid}") +@require_auth +@require_permission("members", "remove", owner_check="admin_only", strict=True) +# strict=True: 不走 cache,强制查最新 membership +async def remove_member(...): + ... +``` + +`owner_check` 的几种模式: + +- `"self"`:必须是该资源的 user_id 创建者 +- `"self_or_admin"`:自己 / 当前租户的 admin/owner 都行 +- `"admin_only"`:仅 admin/owner +- `"owner_only"`:仅 owner + +`strict=True` 何时必加(不走 30s cache,强制查 DB): + +- 任何修改 RBAC 的操作(邀请/移除/改角色/转让) +- 任何修改计费 / billing 的操作 +- 删除 thread / 删除 skill / 删除 MCP server +- owner 才能做的危险操作(删租户) + +读操作和普通写操作(创建 thread、改自己的 memory)不需要 strict——cache 不一致最多窗口 30s,足够。 + +### 5.5 邀请流程 + +``` +1. admin 在 UI 输入 email + role → POST /api/v1/tenants/{tid}/invitations +2. 后端写 invitations 表 → 发邮件(链接含 invitation_token) +3. 受邀用户点击: + a. 已注册 → 直接 attach membership + b. 未注册 → 跳注册流程,注册成功后 attach +4. 邀请 7 天过期,admin 可重发或撤销 +``` + +`invitations` 表已在 phase 0 plan 设计中。 + +### 5.6 Owner 转让 + +owner 是单一的,转让流程要谨慎: + +``` +1. owner 在 UI 选定新 owner(必须是当前 admin) +2. 系统给原 owner 发确认邮件 + 二次密码确认 +3. 24h 冷静期内可撤销 +4. 冷静期满 → membership 表事务交换: + 原 owner.role := 'admin' + 新 owner.role := 'owner' + (单事务 + 唯一索引保证不会出现 2 个 owner) +``` + +### 5.7 SSO / SCIM 预留 + +**SSO(SAML / OIDC)**: + +- 复用现有 `oauth_provider` / `oauth_id` 字段(`UserRow`) +- 新增 `tenant_sso_configs(tenant_id, provider, idp_url, cert, ...)` +- 用户首次 SSO 登录时,自动 attach 到 IdP 配置的默认 tenant + 默认角色(通常 member) + +**SCIM**(自动用户配置 / 撤销): + +- 实现 `/api/scim/v2/Users` + `/api/scim/v2/Groups` 标准接口 +- IdP(Okta / Azure AD)push 用户增删 → 自动同步 `tenant_memberships` +- **第一年可不实现**——SSO 已能覆盖大多数企业需求 + +--- + +## 6. 与现有 system_role 的关系 + +| 字段 | 含义 | 是否保留 | +|---|---|---| +| `users.system_role` | **平台级**角色:`platform_admin` / `user` | 保留 | +| `tenant_memberships.role` | **租户内**角色:`owner` / `admin` / `member` | 新增 | + +`platform_admin`(DeerFlow 运营人员)可以跨租户操作(结合 ADR-001 的 `BYPASSRLS` role),但操作必须审计。普通用户的 `system_role = 'user'`。 + +注意 **首次启动**逻辑改动: + +- 旧:`/setup` 创建第一个 admin 用户 +- 新:`/setup` 创建第一个 `platform_admin` + 同时建一个名为 `default` 的租户,把这个用户设为 owner(兼容老部署) + +--- + +## 7. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 最后一个 owner 离职导致租户失控 | 不允许 owner 直接退出,必须先转让;提供平台 admin 强制转让的运维接口(带审计) | +| 权限矩阵越改越复杂 | MVP 矩阵冻结半年;新需求先走"是否扁平角色能解决"评审 | +| JWT 里 role 缓存与 DB 不一致 | membership 撤销时 bump `token_version`,cookie 失效 | +| 跨租户用户切换场景容易误操作 | UI 显著标识当前激活租户(顶栏色块 + 租户名);危险操作再校验 | +| SSO 接入工作量被低估 | SSO 列入 v2 路线图,明确不阻塞 v1 上线 | + +--- + +## 8. 推翻条件 + +- **走向自定义角色**:销售反馈明确"我们大客户必须自定义 admin/operator/auditor"——升级到完整 RBAC engine +- **走向项目/工作区**:客户内部需要"多个团队互相不可见"——加 workspace 层(tenant > workspace > user) +- **退回扁平**:极简产品形态变化,弃用企业销售路线(不太可能) + +--- + +## 9. 默认假设 + +| 项 | 默认 | +|---|---| +| 角色数 | 3 (owner/admin/member) | +| Owner 数 | 严格 1 个/租户 | +| Admin 数 | 不限(按 plan 可设上限) | +| 跨租户成员 | 同一 user 可属于多个租户 | +| JWT role claim | 缓存到 token 里,membership 变更走 token_version 失效 | +| 默认新成员角色 | `member` | +| Invitation TTL | 7 天 | +| Owner 转让冷静期 | 24h | +| SSO | v2 路线图(非 v1 阻塞) | +| SCIM | 暂不实现 | +| 自定义角色 | 暂不实现 | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-005-storage-topology.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-005-storage-topology.zh-CN.md new file mode 100644 index 00000000..79f1eeb2 --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-005-storage-topology.zh-CN.md @@ -0,0 +1,402 @@ +# ADR-005 · 存储拓扑与持久化策略 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 架构 + 后端 lead + SRE | +| 关联 ADR | ADR-001 数据隔离、ADR-002 沙箱隔离、ADR-006 运行时与渠道 | + +--- + +## 1. 背景 + +DeerFlow 当前所有持久化数据都在容器/主机的本地文件系统下(`{base_dir}/...`,默认 `./.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖)。这套布局是为"单机/单副本本地开发"设计的——多租户上线后会出现三类问题: + +1. **多副本不一致**:K8s 横向扩展后,副本 A 写的 `memory.json` 副本 B 看不到。 +2. **容器重建丢数据**:沙箱 pod 销毁、Gateway pod 重启即丢失自定义 skill / agent / memory / 上传 / 产物。 +3. **没有备份/灾备**:磁盘损坏即客户数据全失。 + +现状盘点(详见 `paths.py` 与 `skills/storage/`): + +| 数据 | 现位置 | 容器重建会丢 | +|---|---|---| +| 公共 skills | repo 内 `skills/public/`(镜像里) | 否 | +| 自定义 skills(用户安装) | `skills/custom/`(**全局共享**,非 per-user) | **是** | +| 自定义 agent(SOUL.md + config.yaml) | `{base_dir}/users/{uid}/agents/{name}/` | **是** | +| 长期记忆 | `{base_dir}/users/{uid}/memory.json` | **是** | +| 用户上传 | `{base_dir}/users/{uid}/threads/{tid}/user-data/uploads/` | **是** | +| Agent 工作区(草稿) | `.../user-data/workspace/` | 是(可接受) | +| Agent 产物 | `.../user-data/outputs/` | **是** | +| 配置开关 | `extensions_config.json`(仓库根,全局) | **是** | +| 会话/运行/反馈 | SQLAlchemy 表(DB) | 否 | +| 用户/认证 | `users` 表(DB) | 否 | + +--- + +## 2. 决策 + +**采用三层存储拓扑**:结构化数据进 Postgres,二进制大对象进对象存储,沙箱本地只保留运行期临时区。**沙箱 pod 因此是真正无状态的**——可被任意调度、滚动升级、销毁重建。 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Postgres (RLS) │ +│ ┌────────────────┐ ┌──────────────────┐ │ +│ │ users │ │ agent_configs │ ← SOUL.md 入库 │ +│ │ tenants │ │ memory_facts │ ← memory.json 入库│ +│ │ memberships │ │ memory_context │ │ +│ │ threads_meta │ │ tenant_secrets │ ← API key 加密入库│ +│ │ runs │ │ tenant_skill_state│ │ +│ │ run_events │ │ tenant_mcp_configs│ │ +│ │ feedback │ │ tenant_quotas │ │ +│ └────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 对象存储(S3 / OSS / MinIO) │ +│ deerflow-data/{tenant_id}/... │ +│ uploads/{thread_id}/... ← 客户上传 │ +│ threads/{thread_id}/outputs/...← agent 产物 │ +│ deerflow-skills/{tenant_id}/ │ +│ {skill_name}-{version}.skill ← 技能包源 │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 沙箱 Pod 本地(容器 emptyDir / 临时卷) │ +│ /mnt/user-data/workspace/ ← 中间草稿,run 结束清掉 │ +│ /mnt/user-data/uploads/ ← 启动时从 S3 拉,按需 │ +│ /mnt/user-data/outputs/ ← 写完后同步到 S3 │ +│ /mnt/skills/ ← 启动时按租户 enabled 列表拉│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.1 各类数据的归属 + +**A. 进 Postgres(结构化、要查询、要并发更新、不大)** + +| 数据 | 表 | 备注 | +|---|---|---| +| 自定义 agent 的 `SOUL.md` + `config.yaml` | `agent_configs(tenant_id, user_id, agent_name, soul_md TEXT, config_yaml TEXT, version, updated_at)` | 文本不大;要并发改、版本回滚 | +| memory.json 中的事实 | `memory_facts(tenant_id, user_id, fact_id, content, category, confidence, created_at, source)` | 要按 confidence/category 查询、去重、流式追加 | +| memory.json 中的上下文 | `memory_context(tenant_id, user_id, work_context, personal_context, top_of_mind, ...)` | 1 行 / (tenant, user) | +| skill 启用状态 | `tenant_skill_state(tenant_id, skill_name, enabled, source)` | 替代全局 `extensions_config.json` | +| MCP 配置 | `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config)` | 替代全局 `extensions_config.json` | +| Tenant secrets(LLM key 等) | `tenant_secrets(tenant_id, key, encrypted_value)` | KMS 加密 | + +**B. 进对象存储(大、二进制、写一次读多次、版本化天然)** + +| 数据 | Object key | 备注 | +|---|---|---| +| 用户上传文件 | `tenants/{tid}/uploads/{thread_id}/{filename}` | presigned PUT 直传,沙箱按需拉 | +| Agent 产物 | `tenants/{tid}/threads/{thread_id}/outputs/{path}` | `present_files` 触发上传 | +| 技能包 `.skill` | `tenants/{tid}/skills/{skill_name}/{version}.skill` | SHA256 校验,做版本控制 | +| 公共技能包(平台) | `platform/skills/{skill_name}/{version}.skill` | 跨租户共享 | + +**C. 不持久化(彻底丢弃)** + +| 数据 | 为什么 | +|---|---| +| 沙箱 `workspace/` 中间产物 | agent 的"草稿纸"——半成品脚本、临时调试输出。强行同步浪费带宽 + 增加爆炸半径。让 agent 主动 `present_files` 到 outputs 才同步 | +| 解压后的 skill 目录 | 当本地缓存(LRU)。启动时从 S3 拉 .skill 包解压;缓存命中则跳过 | +| Gateway 进程的本地缓存(mtime 失效那些) | 进程级,不需要持久化 | + +--- + +## 3. 备选方案 + +### A. 全部进 PVC(共享文件存储 RWX,例如 EFS / NFS) +**拒绝。** 看似最小改动,但: +- 读写竞态依然存在(DeerFlow 的 atomic rename 在 NFS 上行为不一致) +- 大量小文件读写性能差(memory.json 每次写需要 fsync) +- 备份策略复杂(NFS 快照 vs 增量备份) +- 退订清理很慢(递归删大量小文件) + +### B. 全部进对象存储(连 metadata 都用 S3) +**拒绝。** 用对象存储模拟文件系统: +- 强一致性差(多数对象存储是 read-after-write,没有 CAS) +- 对小文件高频写延迟太高(每次写 50–200ms) +- 没有事务,跨对象一致性靠应用层补 +- 列表操作慢(list-objects 是分页拉取) + +### C. 分层:DB(结构化)+ S3(二进制)+ 临时区(中间产物) +**采纳。** 各取所长,与 ADR-001(行级隔离 + RLS)天然契合,并让沙箱 pod 真正无状态。 + +--- + +## 4. ObjectStorage 接口草稿 + +抽象层放在 `backend/packages/harness/deerflow/storage/`。所有调用方写抽象接口,**不直接 import boto3**。 + +```python +# packages/harness/deerflow/storage/protocol.py + +from collections.abc import AsyncIterator +from datetime import datetime +from typing import Protocol, runtime_checkable + +from pydantic import BaseModel + + +class ObjectMetadata(BaseModel): + key: str + size: int + etag: str + content_type: str + last_modified: datetime + metadata: dict[str, str] = {} # 自定义元数据(x-amz-meta-*) + + +class ObjectNotFound(Exception): + """对象不存在(幂等删除/读取时由实现转换为此异常或返回 None)。""" + + +@runtime_checkable +class ObjectStorage(Protocol): + """租户感知的对象存储抽象。 + + 实现: + - LocalObjectStorage 开发/单元测试用,落本地目录 + - S3ObjectStorage AWS S3 / 兼容接口(OSS / OBS / R2) + - MinIOObjectStorage 自部署 MinIO(S3 协议) + + Key 命名规范(强约束,便于按 prefix 退订清理): + tenants/{tenant_id}/... + platform/... ← 跨租户共享资源(公共技能包) + """ + + # ── 同步 / 异步统一为 async(实现里用 aioboto3 / 自托管 thread pool) ── + + async def put( + self, + key: str, + data: bytes, + *, + content_type: str = "application/octet-stream", + metadata: dict[str, str] | None = None, + ) -> ObjectMetadata: ... + + async def put_stream( + self, + key: str, + stream: AsyncIterator[bytes], + *, + content_type: str = "application/octet-stream", + content_length: int | None = None, + metadata: dict[str, str] | None = None, + ) -> ObjectMetadata: ... + + async def get(self, key: str) -> bytes: ... + + async def get_stream(self, key: str) -> AsyncIterator[bytes]: ... + + async def stat(self, key: str) -> ObjectMetadata | None: + """读取元数据;不存在返回 None(不抛异常)。""" + + async def exists(self, key: str) -> bool: ... + + async def delete(self, key: str) -> None: + """幂等删除——不存在视为成功。""" + + async def delete_prefix(self, prefix: str) -> int: + """按 prefix 批量删除,返回删除数量。 + + 重要:仅供 tenant 退订 / GC 任务使用。生产实现应: + - 校验 prefix 必须以 ``tenants/{tenant_id}/`` 开头 + - 限制最大并发与最大删除数(避免一次误删全库) + - 异步分批,超时可继续 + """ + + async def list( + self, + prefix: str, + *, + limit: int = 1000, + continuation_token: str | None = None, + ) -> tuple[list[ObjectMetadata], str | None]: + """分页列出。第二个返回值是下一页 token,None 表示结束。""" + + async def copy(self, src_key: str, dst_key: str) -> ObjectMetadata: ... + + async def presigned_put_url( + self, + key: str, + *, + expires_in: int = 3600, + content_type: str = "application/octet-stream", + max_size_bytes: int | None = None, + ) -> str: + """给客户端生成预签名上传 URL。max_size_bytes 用于 S3 POST policy。""" + + async def presigned_get_url( + self, + key: str, + *, + expires_in: int = 3600, + content_disposition: str | None = None, + ) -> str: + """给客户端生成预签名下载 URL。 + + 重要:对 HTML/SVG 等活动内容,调用方必须传入 + content_disposition='attachment; filename="..."' + 以保留当前 Gateway artifacts 路由的 XSS 防护策略。 + """ +``` + +### 4.1 调用边界 + +```python +# 应用层只 import 抽象,不知道底下是 S3 还是 local +from deerflow.storage import ObjectStorage, get_storage + +storage: ObjectStorage = get_storage() # 单例工厂,按 config.yaml 选实现 + +# 上传:从 sandbox outputs 同步到 S3 +await storage.put_stream( + f"tenants/{tid}/threads/{thread_id}/outputs/{filename}", + stream=open_async(local_path), + content_type=guessed_mime, +) + +# 退订:清理整个租户(GC 任务) +await storage.delete_prefix(f"tenants/{tid}/") + +# 给前端 presigned upload +url = await storage.presigned_put_url( + f"tenants/{tid}/uploads/{thread_id}/{filename}", + expires_in=900, + content_type=mime, + max_size_bytes=100 * 1024 * 1024, +) +``` + +### 4.2 配置 + +```yaml +# config.yaml +storage: + use: deerflow.storage.s3:S3ObjectStorage # 反射加载,沿用现有模式 + bucket: deerflow-data + region: ap-northeast-1 + endpoint_url: null # MinIO 时填自部署地址 + access_key_id: $S3_ACCESS_KEY_ID + secret_access_key: $S3_SECRET_ACCESS_KEY + encryption: SSE-KMS # SSE-S3 / SSE-KMS / null + kms_key_id: $S3_KMS_KEY_ID # 用 KMS 时填 +``` + +--- + +## 5. 落地改造点(实施清单) + +按优先级排序,每条对应第 1 阶段或第 2 阶段的一个 PR: + +### 第 1 阶段(必须) + +1. **抽象 `ObjectStorage` 接口 + `LocalObjectStorage` 实现** — 走通端到端,开发/测试用本地目录跑,不阻塞迁移 +2. **memory.json 迁库** + - 新增 `memory_facts` / `memory_context` 表 + - 改写 `agents/memory/storage.py` 用 repository + - 移除 30s debounce 队列的"合并写"逻辑(DB 层不需要了,保留外部 LLM 抽取的 debounce) + - 数据迁移脚本:扫现有 `memory.json` 文件 → 入库 → 校验 → 删源文件 +3. **agent SOUL/config 迁库** + - 新增 `agent_configs` + `agent_config_history` 表 + - 改写 `setup_agent` / `update_agent` 工具用 repository + - 数据迁移脚本:扫 `users/{uid}/agents/` → 入库 +4. **`extensions_config.json` 拆库**——这条比看起来大,连锁影响展开如下: + + **a. 新表与仓储** + - `tenant_skill_state(tenant_id, skill_name, enabled, source, version, updated_at)` + - `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config, updated_at)` + - 各自配 repository(仿 `persistence/user/` 的 ContextVar AUTO 模式) + + **b. 替换文件 mtime 热重载机制** + - 现状:`get_app_config()` + 多处 `_is_cache_stale()`(如 `mcp/cache.py:31`)靠文件 mtime 判失效 + - 改造: + - `extensions_config.py` 保留为"平台默认开关"的载体(公司 ship 的默认值) + - 运行时配置全部走 DB;mtime 失效信号改为 `tenant_*_configs.updated_at`(DB 单调递增) + - 缓存层从模块级单例 → per-tenant LRU(详见 ADR-006 §2.2 / §2.3) + + **c. Gateway 路由改写** + - `app/gateway/routers/skills.py`:`PUT /skills/{name}/enable`、`POST /skills/install` → 全部改为按当前 tenant_id 写 `tenant_skill_state` / 上传 S3 + - `app/gateway/routers/mcp.py`:`PUT /mcp/servers/{name}` → 改为按当前 tenant_id 写 `tenant_mcp_configs`,写完调 `TenantMCPCache.invalidate(tenant_id)` + - 鉴权:admin/owner 才能改(参照 ADR-004 §5.4 `strict=True`) + + **d. AppConfig 反射链不动** + - `config.yaml` 里的 model / sandbox / channels 等仍是平台级配置,不动 + - 只有"per-tenant 可定制的开关"挪到 DB(skill enabled、MCP server、tenant secrets) + - 旧的 `extensions_config.json` 在迁移完成后**删文件 + 删读取代码**,CI 加禁字典阻止再被引用 + + **e. 数据迁移** + - 写一次性脚本:扫现有 `extensions_config.json` → 写入 "legacy_tenant" 的 `tenant_skill_state` / `tenant_mcp_configs` + - 校验所有租户 enabled list 与现状一致后,删除文件 + - 自部署单机用户:first-run upgrade 时自动跑此脚本 + + **f. 老用户/单机模式兼容** + - 单机部署 = 1 租户("default");体验上不应感知"DB 化"——配置改完仍立即生效 + - 解决路径:tenant_*_configs 写入后,立即 invalidate 进程内 cache(同一个进程,没问题) + - 多 pod 部署:靠 `updated_at` 版本号在请求路径上自动同步,无需广播 + + **估工**:原 ADR-005 列了 1 条 bullet 偏乐观;这块包含 c/d/e/f 四子项,**整体约 M 偏 L**(一周量级),不是 S。 + +### 第 1 阶段 / 第 2 阶段交界 + +5. **`S3ObjectStorage` 实现** — 用 aioboto3,覆盖 protocol 全部方法 +6. **上传文件改 presigned 直传** + - 前端拿 presigned PUT URL → 直传 S3 + - 后端收 "upload complete" 通知 → 异步触发 markitdown 转换 worker + - 沙箱启动时按需 lazy 拉取(不预拉所有上传) +7. **agent 产物 outputs 同步** + - `present_files` 工具:上传 outputs 到 S3 + 返回 presigned GET URL(带 `Content-Disposition: attachment` 给 HTML/SVG) + - sandbox 销毁时本地清理(emptyDir 自然回收) +8. **技能包二进制迁 S3** + - `POST /api/skills/install`:把 .skill 上传到 S3(带 SHA256 metadata),不再解压到本地全局目录 + - 启动时按 tenant skill list 从 S3 拉 + 校验 + 解压到 LRU 缓存 + +### 第 2 阶段(建议) + +9. **退订 GC**:tenant 标记 deleted 后,30 天定时任务跑 `delete_prefix(f"tenants/{tid}/")` + DB cascade delete +10. **跨区域复制 / CDN**:按客户分布加 region replica 或 CloudFront / OSS 加速域名 +11. **审计接入**:每次 storage 操作(put/delete/presigned)记审计日志,带 (tenant_id, user_id, key, op, request_id) + +--- + +## 6. 一致性与失败模式 + +| 场景 | 行为 | 备注 | +|---|---|---| +| Run 中途崩溃,outputs 没上传完 | DB 里 run 标记为 failed;下次重试 / 用户重新发起 | outputs 是"声明产物",丢失中间状态可接受 | +| S3 上传成功但 DB 写失败 | 后台 GC 扫"无 DB 引用的 S3 对象"清理 | 标准的 sweep 模式 | +| DB 写成功但 S3 上传失败 | 应用层重试;持续失败则把 run 标 failed 并告警 | 不允许 DB 引用一个不存在的 S3 对象 | +| 客户上传到 presigned URL 但没通知后端 | 后台 sweeper 扫"上传超时未关联 thread 的对象"清理 | TTL 1h | +| Memory 抽取异步任务卡住 | DB 层不会有半成品(事务 commit 才落库) | 保留之前的可观测性 | + +--- + +## 7. 风险与推翻条件 + +**风险:** +- S3 调用延迟(上传/下载几十 MB 文件耗时,影响 sandbox 冷启动)→ 用 region 同区 + 内网 endpoint 缓解;首启延迟拿 LRU 命中率监控 +- presigned URL 泄露(被截获后任何人可上传/下载)→ TTL 短(≤1h)+ `max_size_bytes` 限制 + content-type 限制 +- 退订时 `delete_prefix` 误伤 → 强制 prefix 校验 + dry-run 模式 + 二次确认 + +**推翻条件:** +1. 实测 sandbox 冷启动 P99 > 30s 且无法靠 LRU 缓存解决 → 改用 PVC 存技能包 + 共享只读卷 +2. 客户合规要求物理隔离(监管类客户) → 切 ADR-001 的 per-tenant DB 方案,对象存储也切 per-tenant bucket(vs prefix) +3. 团队规模不足以维护 S3 + Postgres 两套基础设施 → 退回 PVC + 单一存储(接受单副本部署) + +--- + +## 8. 默认假设 + +如无相反证据,按此推进: + +| 决策项 | 默认值 | +|---|---| +| 对象存储实现 | S3 兼容接口(生产用 AWS S3 / 阿里 OSS,自部署用 MinIO) | +| 加密 | SSE-KMS,每个租户一个 KMS key alias(可选,起步用 SSE-S3) | +| Bucket 布局 | 单 bucket + tenant prefix(`tenants/{tid}/...`) | +| Presigned URL TTL | 上传 15 min / 下载 1 h | +| LRU 缓存大小 | 沙箱 pod 本地 5 GB(按 image size 调整) | +| 退订保留期 | 软删除 30 天后 GC | +| 技能包大小上限 | 50 MB | +| 单上传文件上限 | 100 MB(presigned policy 强制) | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-006-runtime-channel-tenancy.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-006-runtime-channel-tenancy.zh-CN.md new file mode 100644 index 00000000..897dc21e --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-006-runtime-channel-tenancy.zh-CN.md @@ -0,0 +1,294 @@ +# ADR-006 · 运行时与渠道层的租户化 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 后端 lead + 架构 + 渠道 owner | +| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费、ADR-005 存储拓扑 | + +--- + +## 1. 背景 + +ADR-001 ~ 005 解决了"数据/沙箱/Key/RBAC/存储"五个面,但 DeerFlow 还有几块**进程级、单例化、跨请求共享**的运行时组件,它们既不在仓储层也不在沙箱层,是 ADR-001 ~ 005 的"夹层",独立讲一遍才不漏: + +| 组件 | 现状 | 现在的 key | 多租户后的问题 | +|---|---|---|---| +| LangGraph Checkpointer | 内置 `AsyncSqliteSaver` / `AsyncPostgresSaver`(`runtime/checkpointer/async_provider.py`),表结构由 LangGraph 自己定 | `thread_id` | 表里只有 `thread_id`,没有 `tenant_id`;ADR-001 的 RLS policy 怎么挂、`SET LOCAL` 怎么注入 | +| MCP 工具缓存 | `mcp/cache.py:11` 一个进程级 `_mcp_tools_cache: list[BaseTool]`,按 `extensions_config.json` 的 mtime 失效 | 无(全局) | 每个租户启用的 MCP server 不同;当前缓存命中第一个加载的租户配置 | +| Skills loader | `skills/loader.py` 扫 `skills/public/` + `skills/custom/`,结果走 LRU;MCP 工具拼装在内 | 文件系统路径 | 多租户后 `skills/custom/` 不再是全局共享,要按租户维度拉/解压 | +| Sandbox provider 单例 | `LocalSandboxProvider` / `AioSandboxProvider` 在 lifespan 创建一次,所有 thread 共享 | thread_id | ADR-002 切到 K8s 后是 per-tenant Namespace,provider 必须知道当前租户 | +| Memory 抽取 LLM 调用 | `MemoryMiddleware` 30s debounce 后发 LLM 抽取事实 | 当前 thread 的 user_id | 这次调用的 token 算平台还是租户?BYO 时用谁的 key? | +| Title / Summarization 内部 LLM 调用 | `TitleMiddleware` / `SummarizationMiddleware` 在 thread 上下文里复用主对话 LLM | 同上 | 同上 | +| IM 渠道 ↔ 用户绑定 | `app/channels/store.py` 把 IM 用户映射到平台 user_id;落 `~/.deer-flow/channels.yaml` | platform user_id | Slack workspace / 飞书租户 / 企微 corp 怎么映射到平台 tenant?webhook 来流量时怎么决定 tenant 上下文? | + +这一组不解决,ADR-001 的 RLS、ADR-003 的计费都是空中楼阁。 + +--- + +## 2. 决策(按组件分别给) + +### 2.1 LangGraph Checkpointer + +**决策**:保留 LangGraph 原生 checkpointer 表结构(不 fork),但要做四件事: + +1. **逻辑租户隔离靠 thread_id 命名空间**:每个 `thread_id` 在 ADR-001 的 `threads_meta(tenant_id, thread_id)` 表里有租户归属。`AssistantsCompat` 路由收到 thread 操作时,**先用 `threads_meta` 校验 thread_id ↔ tenant_id**,再放行 LangGraph 调用。这是第一道防线。 +2. **物理隔离靠 RLS + 注入**:在 LangGraph 自己的 `checkpoints` / `checkpoint_writes` / `checkpoint_blobs` 三张表上加 RLS policy,policy 通过 `app.tenant_id` session var 过滤。需要写一段 SQL 脚本在 init migration 里跑: + ```sql + -- LangGraph 自己创建表后跑(不动它的 schema) + ALTER TABLE checkpoints ENABLE ROW LEVEL SECURITY; + ALTER TABLE checkpoints FORCE ROW LEVEL SECURITY; + + -- 通过 thread_id 反查 tenant_id(subquery 形式,性能要测) + CREATE POLICY tenant_isolation ON checkpoints + USING ( + thread_id IN ( + SELECT thread_id::text FROM threads_meta + WHERE tenant_id = current_setting('app.tenant_id', true)::uuid + ) + ); + -- checkpoint_writes / checkpoint_blobs 同样形态 + ``` + 缺点:subquery 形式 RLS 在大表(>千万行)上有性能风险。**备选**:在 LangGraph 表上**直接加一列 `tenant_id`**(用 ALTER TABLE,不 fork),靠每次 `put` 之前 trigger 从 thread_id 反查并写入。这是侵入更深但性能更好的路。 +3. **`SET LOCAL` 注入路径**:LangGraph 的 `AsyncPostgresSaver` 自己持有连接池,DeerFlow 不能直接控制每次取连接。两条路: + - **改造点 A**:包装一个 `TenantAwareConnectionFactory`,注入到 saver 构造参数;每次 acquire 时 `SET LOCAL app.tenant_id = ''`。需要看 LangGraph 是否支持自定义 connection factory(`langgraph-checkpoint-postgres>=2.0` 支持)。 + - **改造点 B**:在 `RunManager` 创建/恢复 thread 前,**主动 issue 一条 `SET app.tenant_id`** 到 LangGraph 用的连接池上。需要 LangGraph 复用同一个连接(pool size = 1 模式不可行,必须能 binding)。 + - **首选 A**;如果 LangGraph 版本不支持,用 B 作为兜底,并在升级版本后切回 A。 +4. **测试**:`backend/tests/test_harness_boundary.py` 之外,新增 `test_checkpointer_tenant_isolation.py` —— 建两个租户、各创建一个 thread,相互查询必须查不到。这是 RLS 是否生效的活体检测。 + +**推翻条件**:LangGraph 升级后表结构变化导致 RLS 不可挂 → 切换到 fork checkpointer 实现,自己控制表结构。 + +### 2.2 MCP 工具缓存 + +**决策**:模块级单例改为 **per-tenant 多级缓存**。 + +```python +# packages/harness/deerflow/mcp/cache.py + +class TenantMCPCache: + """Per-tenant MCP 工具缓存。 + + 第一级 key: tenant_id + 第二级 key: tenant_mcp_configs.updated_at(DB 版本,替代 mtime) + """ + _caches: dict[str, _CachedTools] = {} + _lock: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + + async def get(self, tenant_id: str) -> list[BaseTool]: + async with self._lock[tenant_id]: + cached = self._caches.get(tenant_id) + current_version = await mcp_config_repo.version(tenant_id) + if cached and cached.version == current_version: + return cached.tools + # 失效或未初始化:拉 tenant_mcp_configs → 启 MultiServerMCPClient + client = await build_mcp_client_for_tenant(tenant_id) + tools = await client.get_tools() + self._caches[tenant_id] = _CachedTools(tools=tools, version=current_version) + return tools + + async def invalidate(self, tenant_id: str) -> None: + async with self._lock[tenant_id]: + self._caches.pop(tenant_id, None) +``` + +要点: + +- 现在 `mcp/cache.py:31` 靠 `extensions_config.json` 的 mtime 判 stale;DB 化后用 `tenant_mcp_configs.updated_at`(或 `version` 列单调递增),失效信号走仓储层而不是文件系统。 +- `MultiServerMCPClient` 实例**也要 per-tenant 持有**,因为它内部缓存了到各 MCP server 的连接 + OAuth token。租户切换不能复用别的租户的连接。 +- **OAuth token 存储**:当前 `mcp/oauth.py` 的 token 落本地文件,多租户后必须挪到 `tenant_secrets`(`(tenant_id, key='mcp_oauth:')`),KMS 加密。 +- 进程内存上限:`TenantMCPCache` 加 LRU 上限(默认 1000 租户),超过淘汰最久未访问的;淘汰时关闭它的 MCP client 释放连接。 +- Gateway PUT mcp 路由(`app/gateway/routers/mcp.py`)改完写 DB 后,调 `TenantMCPCache.invalidate(tenant_id)` 主动失效。 + +### 2.3 Skills loader + +**决策**:拆成 **平台 skills(共享只读)+ 租户 skills(隔离可写)** 两套。 + +``` +本地缓存目录 (LRU, 5GB): + ${DEER_FLOW_SKILLS_CACHE}/platform/{skill_name}-{version}/ + ${DEER_FLOW_SKILLS_CACHE}/tenants/{tenant_id}/{skill_name}-{version}/ +``` + +启动/调用流程: + +1. Sandbox pod 启动时,从 ADR-005 的对象存储拉两类技能包: + - 平台启用列表:`platform/skills/{name}/{version}.skill`(公司维护,所有租户可见) + - 租户启用列表:`tenants/{tid}/skills/{name}/{version}.skill`(租户私有) +2. 解压到本地 LRU 缓存目录,挂到沙箱内的 `/mnt/skills/`(虚拟路径不变,对 agent 透明) +3. 工具拼装时 `get_available_tools()` 按 `tenant_skill_state(tenant_id, skill_name, enabled)` 过滤 +4. 卸载/禁用:直接刷 `tenant_skill_state.enabled = false`,下一次 thread 启动时不挂载(在线 thread 不强制热卸,避免 in-flight 调用失败) + +**LRU 淘汰策略**:按 `(tenant_id, skill_name)` 键淘汰;缓存满时优先淘汰非活跃租户的私有 skill,**永不淘汰 platform skills**(频繁命中)。 + +### 2.4 Sandbox provider 单例 + +**决策**:`SandboxProvider` 实例本身保持全局单例(每进程一个 K8s client),但 `acquire(thread_id)` 内部按 `tenant_id` 路由到对应 namespace: + +```python +class K8sSandboxProvider: + async def acquire(self, thread_id: str) -> SandboxHandle: + tenant_id = resolve_tenant_id(AUTO, method_name="acquire") + namespace = f"tenant-{tenant_id}" + # 从 per-tenant prewarm 池借 pod,没有则按 tenant 配额创建 + pod = await self._pool.borrow(namespace, thread_id) + return K8sSandboxHandle(pod=pod, tenant_id=tenant_id, thread_id=thread_id) +``` + +prewarm 池策略: + +| 租户活跃度 | 池大小 | +|---|---| +| 7 天内有 thread 创建 | 按 plan 维度(free=0, pro=1, team=3, enterprise=可配)| +| 闲置 > 24h | 缩到 0;下次冷启动接受 P95 < 5s | +| 7 天内无活动 | 删除 namespace 的 prewarm 资源(保留 NS) | + +`SandboxAuditMiddleware` 已经在 ADR-002 §5.6 加了 tenant_id;这里强调一句:**audit 写入不能复用业务 DB session**,要走独立 audit DB(避免业务回滚把审计也滚掉)。 + +### 2.5 Memory / Title / Summarization 的内部 LLM 计费 + +ADR-003 只覆盖了"主对话"的 LLM 调用计费,但 DeerFlow 的中间件链里有 3 处会**额外**触发 LLM: + +| 中间件 | 触发时机 | 现状 token 归属 | 决策 | +|---|---|---|---| +| `MemoryMiddleware` | thread 闲置 30s 后异步抽取 | 主 LLM 配置 | **算入 tenant 用量**:用 tenant 当前 thread 的 model + key | +| `TitleMiddleware` | 首轮回复后给 thread 起标题 | 主 LLM 配置 | **算入 tenant 用量**:成本可见,不要藏 | +| `SummarizationMiddleware` | token 接近上限时压缩历史 | 主 LLM 配置 | **算入 tenant 用量**:和主对话不可分割 | + +**统一原则**:凡是 tenant 触发的 thread 内部产生的 LLM 调用,**都计入该 tenant 的 quota 和账单**——理由: + +1. 透明:客户能在 usage 报表里看到"主对话 vs 内部任务"分项,不奇怪 +2. 安全:不会有"租户用 quota 跑完后还能让平台贴钱抽 memory"的 bug +3. BYO 一致:BYO 租户用自己的 key,平台不替他付任何 token + +实现侧改造: + +- 这 3 个中间件目前都通过 `create_chat_model()` 拿 LLM;ADR-003 §4.2 已经把这个函数改成 tenant-aware,自动会带上 tenant key +- `TokenUsageMiddleware` 现在按 message id 累加 token;多加一个 `usage_category` 字段(`main` / `memory` / `title` / `summarization`),写入 `tenant_usage_daily.usage_category` +- usage 报表 UI 区分这四类,让客户对账 + +**例外**:平台主动触发的 LLM 调用(比如平台 admin 跑健康检查时调用 LLM)算平台账,不算租户。 + +### 2.6 IM 渠道 ↔ 租户映射 + +这是产品形态决定的,方案分两档: + +#### 形态 A:单租户独占一个 IM 集成(小客户/SaaS) + +每个 Slack workspace / 飞书企业 / 钉钉 corp 绑到**一个** tenant。 +现有 `app/channels/store.py` 的"channel binding"扩成: + +```sql +channel_bindings ( + id UUID PK, + tenant_id UUID FK, -- 新增:每个绑定属于哪个租户 + platform VARCHAR(32), -- slack / feishu / dingtalk / wecom / telegram / discord / wechat + external_workspace_id VARCHAR(128), -- Slack team_id / 飞书 tenant_key / 钉钉 corpId + config_encrypted BYTEA, -- bot token / app secret 等 + status VARCHAR(16), + created_by UUID, + created_at, updated_at, + UNIQUE (platform, external_workspace_id) -- 一个外部 workspace 只能绑一个 tenant +) +``` + +Webhook 路由(`app/channels/manager.py:740` 附近): + +```python +async def on_webhook(platform: str, payload: dict): + external_id = extract_workspace_id(platform, payload) + binding = await binding_repo.get(platform, external_id) + if binding is None: + return reject_unbound() + # 把 tenant 上下文塞进去,再走 lead_agent + set_current_tenant(binding.tenant_id, role=None) # IM 渠道无 RBAC,按 tenant 默认权限 + user = await resolve_or_create_im_user(binding.tenant_id, payload.user) + set_current_user(user) + ... +``` + +**关键**:IM 来的请求**不走 JWT**,所以 ContextVar 注入要靠 webhook handler 自己写,不能漏。`app/channels/auth_filter.py` 加一道中间层强制每次 webhook 必经 `set_current_tenant`。 + +#### 形态 B:多租户共享一个 IM 集成(企业平台) + +平台只装一个 Slack App,多个客户公司装在自己 workspace;通过 Slack `team_id` 自动路由到对应 tenant。逻辑同 A,但 onboarding 流程不一样:客户跳 Slack OAuth → 回调时根据登录态的 `tenant_id` 写 binding。 + +**取舍**:起步先做 A(每租户独立 IM 集成)。形态 B 是 enterprise marketplace 上架后才需要,不在 v1 范围。 + +#### IM user ↔ platform user 的映射 + +```sql +channel_user_links ( + tenant_id UUID FK, + platform VARCHAR(32), + external_user_id VARCHAR(128), + user_id UUID FK, -- platform user + linked_at, + PRIMARY KEY (tenant_id, platform, external_user_id) +) +``` + +未链接的 IM 用户 → 自动建 ghost user(`tenant_memberships.role = 'member'`),首次发消息时让用户在 IM 里点链接确认 → 落库。 + +--- + +## 3. 落地改造清单(与 ADR-001/005 不重叠) + +| 模块 | 改动 | 估工 | +|---|---|---| +| `runtime/checkpointer/async_provider.py` | 接 LangGraph PG saver 的 connection factory,注入 `SET LOCAL` | M | +| 新建 `tests/test_checkpointer_tenant_isolation.py` | RLS 活体测试 | S | +| `mcp/cache.py` | 模块级 → `TenantMCPCache` 类 | M | +| `mcp/oauth.py` | OAuth token 存储从文件系统 → `tenant_secrets` | M | +| `app/gateway/routers/mcp.py` | PUT 接口改写 DB + 调 invalidate | S | +| `skills/loader.py` | 拆 platform / tenant 两路加载 | M | +| `app/gateway/routers/skills.py` | install 路径改为按 tenant 写 DB + S3 | M | +| `sandbox/k8s/provider.py`(ADR-002 新建) | `acquire` 注入 namespace = tenant | 已计入 ADR-002 | +| Sandbox prewarm pool | 新增 controller,按 tenant 维度管理 | L | +| `agents/memory/middleware.py` | usage_category=memory | S | +| `agents/title.py` / `agents/summarization.py` | usage_category=title/summarization | S | +| `agents/middleware/token_usage.py` | 增加 usage_category 维度 | S | +| `app/channels/store.py` | binding 表加 tenant_id | M | +| `app/channels/manager.py` | webhook handler 注入 tenant ContextVar | M | +| 新建 `app/channels/auth_filter.py` | 强制 tenant 上下文 | S | +| 新建 `channel_user_links` 仓储 | IM user ↔ platform user | M | + +合计:约 12 人周(2 人 6 周 / 3 人 4 周),不含 ADR-001 ~ 005 各自的改造。 + +--- + +## 4. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| RLS subquery 反查 thread_id → tenant_id 全表扫 | 测试 EXPLAIN,必要时 LangGraph 表加 `tenant_id` 列(侵入但可控) | +| LangGraph 升级换 schema | CI 锁版本;升级前先跑 tenant isolation 测试集 | +| `TenantMCPCache` 内存爆 | LRU 上限 + 闲置淘汰;监控租户数 / 进程 | +| MCP server 自身泄露租户上下文 | 出网走 ADR-002 egress gateway 白名单;MCP server 在沙箱内 stdio 启动时 env 不带平台 secret | +| Memory 抽取算入租户用量被客户抗议"我没让它跑" | UI 显式开关 + 用量分项展示;默认开启可关 | +| IM webhook 没注入 tenant 上下文 → 调用 RLS 全过滤掉空集 | 中间层 fail-closed;监控空集查询率 | +| 多 IM 平台 token 在 `channel_bindings.config_encrypted` 泄露 | KMS 加密 + 审计每次 decrypt(参照 ADR-003 §4.6) | +| ghost IM user 没绑回 platform user → 数据归到 ghost | 强制首次 IM 交互弹链接卡片 + N 天未确认自动停 | + +--- + +## 5. 推翻条件 + +- LangGraph 官方推出原生多租户 checkpointer → 切换并废弃本 ADR §2.1 的注入方案 +- 平台决定走"完全集中式 IM"(所有租户共用一个 bot account) → §2.6 切到形态 B 为唯一形态 +- MCP server 全部跑在 K8s sidecar 而非进程内 → §2.2 缓存策略需要重写,client 实例从进程内挪到 service mesh + +--- + +## 6. 默认假设 + +| 项 | 默认 | +|---|---| +| Checkpointer | LangGraph PG saver + RLS(subquery 反查 thread_id) | +| MCP cache | per-tenant LRU,上限 1000 租户/进程 | +| Skills | platform 公共只读 + tenant 私有;本地 LRU 5GB | +| Sandbox prewarm | per-tenant 池,按 plan 配置大小 | +| 内部 LLM 调用计费 | 全部记入 tenant,分 4 类 usage_category | +| IM 集成形态 | 形态 A:每租户独立 binding,UNIQUE(platform, external_workspace_id) | +| IM ghost user TTL | 7 天未链接自动停 | +| 审计 DB | 与业务 DB 物理分离(独立连接池或独立实例) | diff --git a/docs/multi-tenant-redesign/01-redesign/adr-007-routing-frontend.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-007-routing-frontend.zh-CN.md new file mode 100644 index 00000000..63bfad88 --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-007-routing-frontend.zh-CN.md @@ -0,0 +1,354 @@ +# ADR-007 · URL 路由与前端租户化 + +| 项目 | 内容 | +|---|---| +| 状态 | 草稿(Draft) | +| 决策日期 | TBD | +| 决策者 | 前端 lead + 后端 lead + 产品 | +| 关联 ADR | ADR-001 数据隔离、ADR-004 RBAC、ADR-006 运行时与渠道 | + +--- + +## 1. 背景 + +ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最先看到的是**浏览器地址栏长什么样**。多租户产品形态决定了 URL 形态、cookie scope、登录跳转、Better Auth 接入方式。 + +当前 DeerFlow(`frontend/src/`): +- nginx 把 `/api/*` → Gateway 8001、`/api/langgraph/*` → 同 Gateway(重写) +- 前端用 Better Auth 走 cookie session(路径作用域 `/`) +- 没有租户概念,单 host 单工作区 +- LangGraph SDK client 在 `core/api/` 单例,所有 thread 操作共用一个 SDK 实例 + +多租户后必须回答: + +1. URL 怎么标记 tenant? +2. 多 tenant 切换时 SDK 单例怎么处理? +3. cookie 怎么 scope(避免跨 tenant session 串)? +4. Better Auth 怎么知道当前 tenant? + +--- + +## 2. 决策 + +**采用 path-based slug + 顶层 `TenantProvider` + 切换时强制刷新**。 + +| 维度 | 决策 | +|---|---| +| URL 形态 | `/{tenant_slug}/...`(如 `/acme/threads/abc-123`) | +| 子域名(如 `acme.deerflow.app`) | 推迟到 v2,通过 `tenants.custom_domain` 列预留 | +| Tenant 解析点 | nginx 不解析;后端 `AuthMiddleware` 从 path + JWT 双向交叉校验 | +| Cookie scope | `Path=/`、不绑 tenant;通过 JWT 内 `tid` 区分 | +| SDK 单例 | 全局单例,但切租户时 `await invalidate()` + 强制 reload | +| Better Auth | 单一 `auth.deerflow.app` 登录域,登录后跳到 `/{slug}/`;多租户用户走 tenant picker | + +--- + +## 3. 备选方案与拒绝理由 + +### A. 子域名(`acme.deerflow.app`)作为默认 + +**拒绝(默认)。** 几个硬伤: + +- **本地开发劝退**:每个开发者要起 `*.localtest.me` 之类的通配 DNS,Docker compose 的 nginx 也要改 +- **TLS 证书**:通配证书或 ACME 动态签证;自部署客户卡这一步 +- **Better Auth cookie 跨子域**:要走 `Domain=.deerflow.app`,scope 太宽,租户隔离反而变弱 +- **CSRF 双重 cookie**:当前 csrf_middleware 假定同源;跨子域要改写 + +**保留**作为 enterprise plan 的"自定义域名"功能(vanity domain),通过 `tenants.custom_domain` 解析回平台 tenant,但不作为默认。 + +### B. Header `X-Tenant-Id`(无 URL 标记) + +**拒绝。** 浏览器分享一个 thread URL 别人打不开(缺 header),UX 灾难;爬虫/SEO 也无法索引租户公开内容。 + +### C. URL 不带 tenant,全靠 session + +**拒绝。** 用户多 tenant 切换后,浏览器 history 不可区分;同一个 URL 在不同会话里显示不同内容,BUG 报告噩梦。 + +--- + +## 4. URL 形态规范 + +``` +公开(不带租户): + / → 营销页 + /login → Better Auth 登录页 + /signup + /accept-invite/{token} + /pricing + +租户内: + /{slug}/ → 租户首页(threads 列表) + /{slug}/threads/{tid} + /{slug}/skills + /{slug}/mcp + /{slug}/memory + /{slug}/settings → 租户设置(owner/admin) + /{slug}/settings/billing + +平台 admin(system_role=platform_admin): + /admin/tenants + /admin/usage + /admin/audit +``` + +**slug 约束**: + +- `^[a-z0-9](-?[a-z0-9])*$`,3-32 字符 +- 保留 slug 黑名单:`admin`、`api`、`auth`、`login`、`signup`、`pricing`、`docs`、`status`、`accept-invite`、`platform` +- 大小写归一化:DB 存小写 +- 切换 slug:`tenants` 加 `slug_history` 表,30 天内老 slug 重定向到新 slug,过后 410 + +API 路径**不带 slug**: + +``` +/api/... # 业务 API(tenant 由 JWT 决定) +/api/langgraph/threads/{tid}/runs/stream # LangGraph 兼容 +``` + +理由:API 是 SDK 调用的,不需要人类可读 URL;slug 只在浏览器导航/分享时有意义。 + +--- + +## 5. 后端:Path slug 与 JWT 的交叉校验 + +`AuthMiddleware` 当前从 cookie 读 JWT,注入 `user_id` 到 ContextVar。多租户后改: + +```python +async def dispatch(request, call_next): + if _is_public(request.url.path): + return await call_next(request) + + payload = await verify_jwt_from_cookie(request) + jwt_tid: str = payload["tid"] + role: str = payload["role"] + + # 1. API 调用:tenant 完全靠 JWT + if request.url.path.startswith("/api/"): + active_tid = jwt_tid + + # 2. 页面导航:从 path 解析 slug → 反查 tenant_id + else: + slug = _extract_slug(request.url.path) + if slug is None: + active_tid = jwt_tid + else: + tenant = await tenant_repo.get_by_slug(slug) + if tenant is None: + raise HTTPException(404, "Tenant not found") + # JWT tid 与 URL slug 不一致 → 强制重定向到正确 slug 或拒绝 + if tenant.id != jwt_tid: + # 校验 user 是否是该 tenant 的成员 + membership = await membership_repo.get(tenant.id, payload["sub"]) + if membership is None: + raise HTTPException(403, "Not a member of this tenant") + # 是成员但 JWT 没切过来 → 重定向到 /switch-tenant + return RedirectResponse(f"/auth/switch-tenant?to={slug}&next={request.url.path}") + active_tid = tenant.id + + set_current_user(...) + set_current_tenant(active_tid, role) + return await call_next(request) +``` + +**关键**:page 路由用 path slug 校验,API 路由用 JWT tid——两条路只在登录时由 `/auth/switch-tenant` 触发同步。 + +--- + +## 6. 前端:TenantProvider + SDK 重建 + +### 6.1 顶层 Provider + +```tsx +// frontend/src/core/tenant/provider.tsx +"use client"; + +export function TenantProvider({ children, tenantId, slug, role }: Props) { + const value = useMemo(() => ({ tenantId, slug, role }), [tenantId, slug, role]); + return {children}; +} + +export function useTenant() { + const ctx = useContext(TenantContext); + if (!ctx) throw new Error("useTenant() outside TenantProvider"); + return ctx; +} +``` + +挂载点:`app/(tenant)/[slug]/layout.tsx`: + +```tsx +export default async function TenantLayout({ params, children }) { + const { slug } = await params; + const session = await getSession(); + const tenant = await fetchTenantBySlug(slug); + + if (!tenant) notFound(); + if (session.tid !== tenant.id) { + // 同上:要么是路径错了,要么是切租户没同步 + redirect(`/auth/switch-tenant?to=${slug}`); + } + + return ( + + {children} + + ); +} +``` + +### 6.2 LangGraph SDK 实例与 tenant 绑定 + +当前 `core/api/langgraph-client.ts` 是模块级单例。改造:**SDK 实例**保持单例(HTTP client 不需要重建),但**所有调用 wrapper**强制读 `useTenant()`,把 `tenantId` 作为对话 metadata 传递(实际 tenant 鉴权在后端 JWT,前端传只是为了请求溯源): + +```ts +// useThreadStream.ts +export function useThreadStream(threadId: string) { + const { tenantId } = useTenant(); + return useStream(threadId, { + apiUrl: "/api/langgraph", + metadata: { tenant_id: tenantId }, // 仅用于日志/追踪,不替代鉴权 + }); +} +``` + +**租户切换时的清理**: +- 切换前用 `cancelAllStreams()` 关掉所有打开的 SSE +- 调用 `POST /api/auth/switch-tenant`(后端重发 JWT 新 cookie) +- 拿到 200 后 `window.location.assign(/{newSlug}/)` 强制硬刷新 + +**为什么硬刷新**: +- React state 里有大量缓存的 thread / skill / mcp 配置,按租户全洗一遍代码量大 +- LangGraph SDK 内部维护 EventSource 连接池,强制重建最干净 +- 一次 nav 1-2 秒可接受,远比 in-memory 切租户的边界 bug 划算 + +### 6.3 租户切换 UI + +顶栏组件 ``: + +- 列出 user 的所有 membership(来自 `/api/auth/me` 返回的 `tenants[]`) +- 当前激活租户高亮 + 显著色块(避免误操作) +- 点击切换 → 上面 6.2 的硬刷新流程 + +### 6.4 路由组与 Server Components + +``` +frontend/src/app/ +├── (marketing)/ # 公开:/, /pricing +├── (auth)/ # 登录注册:/login, /signup, /accept-invite +├── (admin)/ # 平台 admin:/admin/... +└── (tenant)/[slug]/ # 租户内:/{slug}/... + ├── layout.tsx # TenantProvider 注入 + ├── page.tsx # threads 列表 + ├── threads/[tid]/page.tsx + ├── skills/page.tsx + ├── settings/page.tsx + └── ... +``` + +`layout.tsx` 内的 `fetchTenantBySlug` 走 Server Component → 直连后端,缓存 60s(用 React `cache()`)。 + +--- + +## 7. Cookie 与会话 + +| 维度 | 决策 | +|---|---| +| Session cookie name | `deerflow_session`(不变) | +| Path scope | `/`(不绑 tenant slug) | +| Domain | 平台主域(不跨子域) | +| SameSite | `Lax`(默认) | +| HttpOnly | 是 | +| Secure | 是(生产) | +| 切换 tenant | 后端**重签**新 JWT,覆写同一 cookie;不清旧 cookie | +| 跨设备登录 | session 多设备 OK;切 tenant 不强制其他设备退出 | + +**为什么 cookie 不绑 slug**:用户从 `/acme/...` 切到 `/bigco/...` 时如果 cookie path 不同,会出现"两个 cookie 同时存在浏览器但前端选错一个"的边界 case。统一 path=/ + JWT 内 `tid` 单一来源最干净。 + +**CSRF**:现有双重 cookie CSRF(`csrf_middleware.py`)保持,CSRF token 不需要按 tenant 区分。 + +--- + +## 8. Better Auth 接入 + +Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。多租户化改: + +1. **登录后落到 picker**:用户登录成功 → 检查 `tenant_memberships` 数量 + - 0 个:跳到 `/onboarding/create-tenant`(新用户首次登录) + - 1 个:直接跳到 `/{slug}/`,JWT 带该 tenant + - 多个:跳到 `/select-tenant`,让用户选;选后落 `users.default_tenant_id` +2. **JWT 签发**:Better Auth 的默认 session token 不够——需要在 `session.fresh()` 后注入 `{ tid, role, tv }` claim。建议自定义 session cookie 或在 Better Auth 之上叠一层 `deerflow_session`(与 ADR-004 §5.2 一致) +3. **SSO(v2)**:Better Auth 的 SAML/OIDC provider 已经支持组织化(`organization` plugin),后续接入时把 organization 等价映射到 tenant +4. **Invitation 流程**:`/accept-invite/{token}` 路径下点击 → 校验 invitation → 自动 attach membership → 跳到 `/{new_slug}/` + +--- + +## 9. 自定义域名(v2 预留) + +`tenants` 表加 `custom_domain VARCHAR(253) UNIQUE NULL`。客户配置 CNAME 后: + +1. 客户在 settings 里填域名 +2. 平台调 ACME 签证(per-domain)+ Caddy/nginx 动态 vhost +3. 请求来时 nginx 看 Host 头: + - 是平台主域 → 走 path slug 解析 + - 是 custom_domain → 反查 tenant_id 直接注入 + +不在 v1 范围。 + +--- + +## 10. 落地改造清单 + +| 模块 | 改动 | 估工 | +|---|---|---| +| `tenants.slug` + `slug_history` 表 | DB 迁移 | S | +| `AuthMiddleware` path slug 解析 + 交叉校验 | 后端 | M | +| `/api/auth/switch-tenant` 路由 | 后端 | S | +| `/api/auth/me` 返回 tenant 列表 | 后端 | S | +| Better Auth session 改造,注入 `tid/role/tv` | 后端 + 前端 | M | +| 前端 `app/(tenant)/[slug]/layout.tsx` + Provider | 前端 | M | +| 前端路由全部按 `(tenant)/[slug]/` 重组 | 前端 | L | +| `useTenant()` hook + 所有 API 调用接入 | 前端 | M | +| `` 组件 | 前端 | S | +| 租户首登 onboarding `/onboarding/create-tenant` | 前端 + 后端 | M | +| Tenant picker 页面 `/select-tenant` | 前端 | S | +| 平台 admin `/admin/...` 路由 + 鉴权 | 前端 + 后端 | M | +| 硬刷新切换流 + cancelAllStreams | 前端 | S | + +合计:约 8 人周(前端 5 + 后端 3)。 + +--- + +## 11. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| slug 冲突(保留字 / 已注册) | 注册流程强制校验黑名单;冲突时返显建议 slug | +| 浏览器分享 URL 给非成员看 | 后端 403,前端展示"申请加入"按钮 | +| 切换 tenant 时 streams 没断干净导致看到上租户的 events | hard reload 兜底;E2E 测试 stream cancellation | +| Better Auth 升级破坏 session 字段 | 锁版本;session 改造前先 fork 一份测 | +| 自定义域名灰区(DNS / TLS) | v2 才做,v1 不实现 | +| `default_tenant_id` 被删除(成员被踢) | 登录时 fallback 到 memberships 第一个;都没了引导建租户 | +| SEO 收录租户页 | 默认 `noindex`,租户开关启用公开页 | + +--- + +## 12. 推翻条件 + +- v2 决定走子域名优先 → §2 决策切到子域名 + 兼容老 path 形态 6 个月 +- 单页应用改成多页 / SSR 完整迁移 → 前端层重写,路由组结构会变 +- Better Auth 弃用 → 切换到自有 auth;JWT 部分不变 + +--- + +## 13. 默认假设 + +| 项 | 默认 | +|---|---| +| URL 形态 | `/{slug}/...`,slug 3-32 字符小写 | +| 自定义域名 | v2 才支持,v1 不开 | +| Cookie path | `/` | +| Cookie domain | 平台主域,不跨子域 | +| 切换 tenant | 硬刷新(`window.location.assign`) | +| Tenant picker | 1 个 membership 时跳过 | +| API 路径 | 不带 slug(tenant 由 JWT 决定) | +| SEO | 默认 `noindex`,可按租户开 | diff --git a/docs/multi-tenant-redesign/01-redesign/multi-tenant-phase-0-plan.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/multi-tenant-phase-0-plan.zh-CN.md new file mode 100644 index 00000000..36351bc9 --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/multi-tenant-phase-0-plan.zh-CN.md @@ -0,0 +1,262 @@ +# 多租户改造 · 第 0 阶段计划 + +> 目标:在写第一行代码前,对齐设计、产出可评审的文档。这一阶段不动代码,时间盒 **两周封顶**。 + +--- + +## 产出物清单(8 份文档 + 1 份代码盘点) + +| 产出物 | 形式 | 谁批 | 作用 | +|---|---|---|---| +| [ADR-001 数据隔离模型](./adr-001-data-isolation.zh-CN.md) | ADR(决策记录) | CTO / 架构 | 锁定行级 / schema / 库级;含 LangGraph checkpoint 表的注入路径 | +| [ADR-002 沙箱隔离模型](./adr-002-sandbox-isolation.zh-CN.md) | ADR + 威胁模型 | 安全 + 架构 | 锁定 K8s / Firecracker / VM | +| [ADR-003 LLM Key 与计费模型](./adr-003-llm-key-billing.zh-CN.md) | ADR | 产品 + CTO | 锁定 BYO / 平台付费 / 混合;含悲观预扣 + 内部 LLM 计费 | +| [ADR-004 租户 ↔ 用户层级](./adr-004-tenant-rbac.zh-CN.md) | ADR | 产品 | 锁定二级 RBAC + JWT/cache 一致性策略 | +| [ADR-005 存储拓扑](./adr-005-storage-topology.zh-CN.md) | ADR + ObjectStorage 接口签名 | 架构 + SRE | 锁定 DB / 对象存储 / 临时区分层 | +| [ADR-006 运行时与渠道租户化](./adr-006-runtime-channel-tenancy.zh-CN.md) | ADR | 后端 lead + 渠道 owner | 锁定 checkpointer / MCP cache / skills loader / 内部 LLM 计费 / IM 渠道 ↔ 租户 | +| [ADR-007 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md) | ADR | 前端 lead + 后端 lead | 锁定 URL 形态 / cookie / Better Auth / SDK 切换 | +| Tenant 数据模型设计 | DB schema 草稿 + ER 图 | 后端 lead | 第 1 阶段直接落地用 | +| 多租户改造代码盘点 | 表格 / spreadsheet | 后端 lead | 估工 + 拆 PR 用 | + +每份 ADR 用统一结构:**目标客户画像 → 评估维度 → 选项对比 → 选 X 的理由 → 推翻条件**。 + +--- + +## 1. 四个决策怎么定(决策框架) + +> 下面只是决策框架的 1 页概览。每份 ADR 的完整正文(背景 / 备选方案 / 落地影响 / 风险 / 推翻条件 / 默认假设)已分别成独立文档: +> - [ADR-001 数据隔离模型](./adr-001-data-isolation.zh-CN.md) +> - [ADR-002 沙箱隔离模型](./adr-002-sandbox-isolation.zh-CN.md) +> - [ADR-003 LLM Key 与计费模型](./adr-003-llm-key-billing.zh-CN.md) +> - [ADR-004 租户 ↔ 用户层级](./adr-004-tenant-rbac.zh-CN.md) +> - [ADR-005 存储拓扑与持久化策略](./adr-005-storage-topology.zh-CN.md) +> - [ADR-006 运行时与渠道租户化](./adr-006-runtime-channel-tenancy.zh-CN.md) +> - [ADR-007 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md) + +### ADR-001 数据隔离 + +| 维度 | 行级 (tenant_id WHERE) | per-tenant schema | per-tenant DB | +|---|---|---|---| +| 实现成本 | 低 | 中 | 高 | +| 跨租户 bug 爆炸半径 | 高 | 中 | 极低 | +| 备份/恢复粒度 | 全量 | 按 schema | 按 DB | +| 合规友好度(SOC2/HIPAA) | 一般 | 好 | 最好 | +| 跨租户分析查询 | 容易 | 中 | 难 | +| 适用客户规模 | <10k 租户 | 10k–100 大客户 | <100 大客户 | + +**90% 团队选 行级 + Postgres RLS(双保险)**。理由:DeerFlow 现在的仓储层已经是 `user_id` 过滤模式,RLS 加上去几乎是平行扩展。 + +**推翻条件**:拿到金融/医疗类客户、客户合同里写明"物理数据隔离"——直接跳到 per-tenant DB。 + +### ADR-002 沙箱隔离 + +威胁模型表(每行一个攻击场景): + +| 攻击 | 共享 Docker | per-tenant Namespace | per-tenant VM | +|---|---|---|---| +| 容器逃逸 | 全员沦陷 | 单租户沦陷 | 单租户沦陷 | +| 侧信道(CPU 缓存等) | 可行 | 可行 | 难 | +| 出网到云 metadata | 可行(必须默认禁) | 可行(必须默认禁) | 可行(必须默认禁) | +| 资源耗尽(fork bomb) | 影响他人 | 仅影响自己 | 仅影响自己 | +| 提权 | 看 K8s 配置 | 看 K8s 配置 | VM 边界更强 | + +**推荐:K8s namespace + gVisor/Kata runtime + NetworkPolicy 默认禁出网**。Firecracker 是更强的方案但运维成本翻倍,留给"premium 租户专属"档位。 + +### ADR-003 Key & 计费 + +三种模式选一种主线: + +- **BYO key**:客户自己带 OpenAI/Anthropic key。优点:你不背模型成本、不背滥用;缺点:客户体验差,需要 secret vault。 +- **平台付费**:你统一付,按 token 加价转售给客户。优点:体验顺;缺点:你要做精细的 quota+成本归账,否则会被刷爆。 +- **混合**(推荐起步):默认平台 key + 限额;高级套餐切 BYO key 不限额。 + +**写 ADR 时要把 "成本归因路径" 画清楚**:哪个表记 `tenant_id × model × token`,谁算月度账单,怎么和 Stripe 对账。 + +### ADR-004 租户内层级 + +最常见的两种: + +- **扁平**:tenant 直接装 user,所有 user 等价。简单,适合自助型 SaaS。 +- **二级 RBAC**:tenant 有 owner/admin/member,admin 能管 member 的 skill 安装权限和 quota 分配。适合企业销售。 + +如果要 SSO(SAML/OIDC),那默认要二级 RBAC——因为客户 IT 部门要能管理"哪些员工进哪些 workspace"。 + +### ADR-005 存储拓扑 + +详见独立文档:[ADR-005 · 存储拓扑与持久化策略](./adr-005-storage-topology.zh-CN.md)。 + +### ADR-006 运行时与渠道租户化 + +详见独立文档:[ADR-006 · 运行时与渠道层的租户化](./adr-006-runtime-channel-tenancy.zh-CN.md)。 + +要点: + +- **LangGraph checkpointer**:保留原表结构,靠 `thread_id ∈ threads_meta(tenant_id=...)` 子查询 RLS 兜底;连接注入 `SET LOCAL app.tenant_id` 走自定义 connection factory。 +- **MCP 工具缓存**:模块级单例 → per-tenant LRU;OAuth token 从文件挪到 `tenant_secrets`。 +- **Skills loader**:拆 platform 共享只读 + tenant 私有;按 `tenant_skill_state.enabled` 过滤工具。 +- **Sandbox provider**:实例单例,`acquire(thread_id)` 内按 tenant 路由到 K8s namespace;prewarm 池按 plan 大小。 +- **内部 LLM 计费**:Memory / Title / Summarization 调用都算 tenant 用量,分 `usage_category` 报表展示。 +- **IM 渠道**:每个 binding 加 `tenant_id`,webhook handler 强制注入 tenant ContextVar;ghost user 7 天未链接自动停。 + +### ADR-007 路由与前端租户化 + +详见独立文档:[ADR-007 · URL 路由与前端租户化](./adr-007-routing-frontend.zh-CN.md)。 + +要点: + +- **URL 形态**:`/{slug}/...`,子域名留给 v2 自定义域名。 +- **Cookie**:`Path=/` + JWT 内 `tid`;切换 tenant 重签 JWT + 硬刷新。 +- **前端**:`app/(tenant)/[slug]/layout.tsx` 注入 `TenantProvider`;SDK 实例单例但调用读 `useTenant()`;切换时 `cancelAllStreams + window.location.assign`。 +- **Better Auth**:登录后跳 picker / 直进 / onboarding;session 注入 `{tid, role, tv}`。 + +要点: + +- **现状问题**:自定义 skill / agent / memory / 上传 / 产物全部在容器本地文件系统。多副本不一致、容器重建丢数据、无备份。 +- **决策**:三层拓扑——**结构化进 Postgres**(agent SOUL/config、memory facts、skill enabled、tenant secrets)+ **大对象进对象存储**(上传、产物、技能包)+ **临时区**(沙箱 workspace,不持久化)。 +- **接口**:`ObjectStorage` Protocol,实现 `LocalObjectStorage`(dev)/ `S3ObjectStorage`(prod)/ `MinIOObjectStorage`(自部署)。 +- **关键约束**:单 bucket + `tenants/{tid}/` prefix;presigned URL 短 TTL;HTML/SVG 强制 `Content-Disposition: attachment`(保留当前 XSS 防护)。 +- **拒绝方案**:① 全部进 PVC(小文件读写差、备份难);② 全部进 S3(强一致差、列表慢、无事务)。 + +--- + +## 2. Tenant 数据模型草稿 + +第 0 阶段就把这张表画出来,第 1 阶段直接落地: + +```sql +-- 新表 +tenants ( + id UUID PK, + slug VARCHAR(64) UNIQUE, -- URL 用,/t/{slug}/... + display_name VARCHAR(128), + plan VARCHAR(32), -- free / pro / enterprise + status VARCHAR(16), -- active / suspended / deleted + created_at, updated_at +) + +tenant_memberships ( + tenant_id UUID FK, + user_id UUID FK, + role VARCHAR(16), -- owner / admin / member + invited_by UUID, + joined_at, + PRIMARY KEY (tenant_id, user_id) +) + +tenant_secrets ( + tenant_id UUID FK, + key VARCHAR(64), -- e.g. OPENAI_API_KEY + encrypted_value BYTEA, -- KMS 加密 + rotated_at, + PRIMARY KEY (tenant_id, key) +) + +tenant_quotas ( + tenant_id UUID FK, + metric VARCHAR(32), -- tokens_monthly / runs_concurrent / sandbox_cpu_seconds + hard_limit BIGINT, + soft_limit BIGINT, + PRIMARY KEY (tenant_id, metric) +) + +tenant_usage_daily ( + tenant_id UUID, + date DATE, + metric VARCHAR(32), + value BIGINT, + PRIMARY KEY (tenant_id, date, metric) +) + +invitations ( + id UUID PK, + tenant_id UUID FK, + email VARCHAR(320), + role VARCHAR(16), + token VARCHAR(64) UNIQUE, + expires_at, + used_at NULL +) + +-- 已有表加列 +users + default_tenant_id UUID +threads_meta + tenant_id UUID + INDEX (tenant_id, user_id, updated_at) +runs + tenant_id UUID + INDEX (tenant_id, created_at) +run_events + tenant_id UUID +feedback + tenant_id UUID + +-- 未来 Tier 2 还要加: +skills_state (tenant_id, skill_name, enabled, source) +agent_configs (tenant_id, user_id, agent_name, soul_md, config_yaml) +mcp_configs (tenant_id, server_name, transport, url, encrypted_config) +``` + +画完后让 DBA / 后端 lead 评审两件事:**索引覆盖**(每个查询 path 是否走索引)和 **RLS policy 草稿**(每张带 tenant_id 的表写一条 policy)。 + +--- + +## 3. 多租户改造代码盘点 + +第 0 阶段最容易被忽略的是**先量一下工作量**。在 spreadsheet 里把"目前涉及 user_id / 全局状态"的代码点全部列出来: + +```bash +grep -rn "user_id\|get_effective_user_id\|DEFAULT_USER_ID" backend/packages/harness/deerflow/ backend/app/ +grep -rn "users/\|/.deer-flow/" backend/ scripts/ +grep -rn "extensions_config\|skills/public\|skills/custom" backend/ +``` + +把命中点分成五类: + +| 类别 | 改造动作 | 估计点位 | +|---|---|---| +| **DB 仓储**(`persistence/*/sql.py`) | 增加 tenant_id 解析与 WHERE | ~10–15 处 | +| **文件系统路径**(`ThreadDataMiddleware`、memory storage、agents 存储) | 路径加 tenant 维度 | ~5–8 处 | +| **配置/Secret 读取**(`models/factory.py`、MCP client、community tools) | 改成 tenant 上下文取 key | ~8–12 处 | +| **路由 handler**(`app/gateway/routers/*.py`) | 加 `@require_permission` + tenant 上下文 | ~14 个 router 文件 | +| **全局单例**(沙箱 provider、MCP cache、skills loader) | 缓存 key 加 tenant 维度 | ~5 处 | + +每条点位估一个 S/M/L 工作量。这张表是后面拆 PR、估工期、估钱的依据。 + +--- + +## 4. 第 0 阶段的"完成定义" (DoD) + +走完这阶段,团队应该能回答: + +- [ ] 数据存哪、用什么数据库、怎么隔离 → ADR-001 给出 +- [ ] 客户的 bash/工具跑在哪、能访问什么、爆炸半径多大 → ADR-002 给出 +- [ ] 客户的 LLM 调用钱谁出、怎么算 → ADR-003 给出 +- [ ] 客户内部能不能自己加员工、怎么加 → ADR-004 给出 +- [ ] Skill / agent / 上传 / 产物 / memory 各自存哪、丢失怎么办 → ADR-005 给出 +- [ ] LangGraph / MCP / 内部 LLM / IM 渠道这些"夹层"怎么按租户隔离 → ADR-006 给出 +- [ ] 浏览器地址栏长什么样、Cookie 怎么 scope、租户切换怎么走 → ADR-007 给出 +- [ ] 第一阶段 PR 怎么拆、估几人周 → 代码盘点给出 +- [ ] 第一个内测客户长什么样、什么时候能上 → 项目经理排期 + +--- + +## 5. 时间盒与节奏 + +第 0 阶段 **两周封顶**,再长就是过度设计。 + +- **第 1 周**:写 ADR-001/002/003/004 草稿,团队读、challenge、收敛 +- **第 2 周**:定 schema、做代码盘点、估工、定第一阶段范围与 design partner 客户 + +如果两周后还有 ADR 定不下来,**绝大多数情况是因为缺一个真实客户做参照**——这时候应该先去签一个 design partner(哪怕免费),用他们的合同和合规要求来反推决策。 + +--- + +## 6. 默认假设(如无特殊情况按此推进) + +为避免决策瘫痪,先写下一个"默认值",所有 ADR 在没有相反证据前按这个走: + +| 决策 | 默认值 | 选它的理由 | +|---|---|---| +| 数据隔离 | 行级 + Postgres RLS(含 LangGraph 表 subquery RLS) | 改造成本低,DeerFlow 现状几乎平行扩展 | +| 沙箱隔离 | K8s namespace + gVisor + NetworkPolicy 默认禁出网 | 强度足够 + 运维可控 | +| LLM Key | 混合:默认平台 key + 限额,premium 切 BYO;悲观预扣防超额 | 体验与成本兼顾 | +| 租户层级 | 二级 RBAC(owner/admin/member)+ JWT/cache 双层 | 为 SSO 和企业销售留口 | +| 存储拓扑 | Postgres(结构化)+ S3 兼容对象存储(大对象)+ emptyDir(临时) | 沙箱 pod 真正无状态;备份/灾备/横向扩展直接通 | +| 运行时夹层 | per-tenant MCP cache + skills 拆双路 + 内部 LLM 计费分类 + IM binding 加 tenant | 关上"非仓储非沙箱"那一组进程级单例的隔离漏洞 | +| 前端路由 | `/{slug}/...` 路径 + JWT 内 tid + 硬刷新切换 | UX 简单,与 ADR-001/004 cookie 模型契合 | + +> 这是"中等强度方案",覆盖 90% B2B SaaS。如果客户画像偏极端(大企业 / 强合规 / 自助小客户),再调整。