From 27c4f14233fc6984b65d23147440398a3b7c7ae1 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Sat, 9 May 2026 21:03:16 +0800 Subject: [PATCH] =?UTF-8?q?docs(multi-tenant):=20=E5=8A=A0=E5=85=A5=20ADR?= =?UTF-8?q?=20=E5=AE=A1=E8=AE=A1=20+=20spike=EF=BC=8C=E5=B9=B6=E6=8D=AE?= =?UTF-8?q?=E5=85=B6=E4=BF=AE=E8=AE=A2=20ADR-001/006/007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增两份评审产出物: - adr-vs-code-audit:7 份 ADR 与现状代码的差异核对,标注每条假设是否成立 - adr-spike-langgraph-postgres:实测 langgraph-checkpoint-postgres==3.0.5 注入能力,确认不存在 connection_factory 参数,且 psycopg_pool 自带的 configure callback 不是 per-acquire hook 据 spike 与审计修订三份 ADR: - ADR-001 数据隔离:LangGraph 表改为应用层强校验 + threads_meta unique 约束兜底(不再挂 RLS、不 ALTER 表);hook 点从 AssistantsCompat 修正 为 threads.py + thread_runs.py - ADR-006 运行时与渠道:§2.1 完全重写为应用层强校验;MCP OAuth token 从"无持久化进程内存"直接做加密 DB;channel store binding 改为新建 channel_bindings 表 - ADR-007 路由与前端:删除 Better Auth 假设(前端实际无此依赖),改为 扩展现有 auth/jwt.py TokenPayload 加 tid/role 字段 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adr-001-data-isolation.zh-CN.md | 76 ++++--- .../adr-006-runtime-channel-tenancy.zh-CN.md | 88 ++++---- .../adr-007-routing-frontend.zh-CN.md | 61 ++++-- .../adr-spike-langgraph-postgres.zh-CN.md | 170 +++++++++++++++ .../01-redesign/adr-vs-code-audit.zh-CN.md | 193 ++++++++++++++++++ 5 files changed, 482 insertions(+), 106 deletions(-) create mode 100644 docs/multi-tenant-redesign/01-redesign/adr-spike-langgraph-postgres.zh-CN.md create mode 100644 docs/multi-tenant-redesign/01-redesign/adr-vs-code-audit.zh-CN.md 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 index f23c64ad..0def38f8 100644 --- 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 @@ -2,10 +2,11 @@ | 项目 | 内容 | |---|---| -| 状态 | 草稿(Draft) | +| 状态 | 草稿(Draft) · 2026-05-09 据 spike 结果修订 §4.1.1 / §4.1.2 / §4.2 | | 决策日期 | TBD | | 决策者 | CTO + 架构 + 后端 lead | | 关联 ADR | ADR-004 租户层级、ADR-005 存储拓扑、ADR-006 运行时与渠道 | +| 关联 spike / 审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) · [langgraph-postgres spike](./adr-spike-langgraph-postgres.zh-CN.md) | --- @@ -93,39 +94,40 @@ CREATE INDEX idx_feedback_tenant_run ON feedback (tenant_id, run_id); **关键索引原则**:每个 `tenant_id` 都必须是复合索引的**第一列**——RLS policy 走的就是这条路径,前导列错了 RLS 会全表扫。 -#### 4.1.1 LangGraph 自有表(checkpoints / checkpoint_writes / checkpoint_blobs) +#### 4.1.1 LangGraph 自有表(checkpoints / checkpoint_writes / checkpoint_blobs / checkpoint_migrations) -`runtime/checkpointer/async_provider.py` 用的是 LangGraph 内置 `AsyncPostgresSaver`,**表结构不在 DeerFlow 控制下**——直接 ALTER 添加 `tenant_id` 列在 LangGraph 升级时会被它自己的 migration 覆盖。处理路径分两档(详见 ADR-006 §2.1): +`runtime/checkpointer/async_provider.py` 用的是 LangGraph 内置 `AsyncPostgresSaver`,**表结构不在 DeerFlow 控制下**。原稿讨论过两条路(subquery RLS / 列升级),spike([adr-spike-langgraph-postgres](./adr-spike-langgraph-postgres.zh-CN.md))验证后我们改用 **两层隔离模型**: -**默认(subquery 形式 RLS,零侵入)**: +| 表归属 | 隔离机制 | 防线性质 | +|---|---|---| +| **DeerFlow 自有表**(threads_meta、runs、run_events、feedback、users、tenant_*) | RLS + `SET LOCAL app.tenant_id` via SQLAlchemy session(DeerFlow 完全控制 conn pool) | DB 强约束 | +| **LangGraph checkpoint 表** | **应用层强校验**——入口路由在调 LangGraph 前必查 `threads_meta` 上的 `(tenant_id, thread_id)` 归属 | 应用层强约束 + 表 unique constraint 兜底 | -```sql --- LangGraph 自动建表后,DeerFlow 的 init migration 跑: -ALTER TABLE checkpoints ENABLE ROW LEVEL SECURITY; -ALTER TABLE checkpoints FORCE ROW LEVEL SECURITY; +**为何对 LangGraph 表放弃 RLS**: -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 反查 -``` +- `langgraph-checkpoint-postgres==3.0.5` **不存在 `connection_factory` 参数**(spike §2 实测);其连接池注入路径只有 `__init__(conn=AsyncConnectionPool)` 这一个口子,且 `psycopg_pool` 自带的 `configure` callback 只在物理连接首次创建时跑——拿不到运行期 ContextVar 里的 tenant_id +- 子类化 `AsyncConnectionPool` 重写 `getconn` 注入 `SET app.tenant_id`/`RESET` 是可行的 hack,但侵入 psycopg-pool 内部,库升级风险高(spike §3.1) +- 给 LangGraph 表 ALTER 加 `tenant_id` 列同样不可取——LangGraph 用 `MIGRATIONS` 数组管理 schema,每次升级都要 diff 防漏(spike §3.4) -**升级路径(侵入式列)**:当 subquery RLS 在大表上 EXPLAIN 出现问题时,改用: +**LangGraph 表的安全模型**(接受的 trade-off): -```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 不会丢这列 -``` +- 安全等级从"DB 强约束"降级为"应用层强约束 + 表 unique constraint" +- 强约束点是 **`threads_meta` 表上的 `UNIQUE (tenant_id, thread_id)` 复合索引** + 入口路由的强校验:任何代码路径要写 LangGraph 表前必须先在 `threads_meta` 找到对应行,且行的 `tenant_id` 与当前 ContextVar 一致 +- CI 加 boundary 测试,禁止任何路径绕过 `threads.py` / `thread_runs.py` 直连 LangGraph saver(包括 LangGraph Studio 必须走相同入口或显式审批) +- 平台 admin 路径走 `BYPASSRLS` role 时同样必须经过应用层 audit,不直接跳过 thread 归属检查 + +**未来可升级路径**(不阻塞 phase-0):若上游接受 PR 加入 `connection_factory`,可平滑切回"DeerFlow 表 + LangGraph 表统一 RLS"模型。 #### 4.1.2 第一道防线:thread_id ↔ tenant_id 校验 -不论用哪种 RLS 形态,`AssistantsCompat` 路由(`app/gateway/routers/assistants_compat.py`)在调用 LangGraph 之前**必须先用 `threads_meta` 校验 `(tenant_id, thread_id)` 归属**。RLS 是兜底,应用层校验是第一道防线——RLS 只能"过滤掉看不到的",不能阻止"创建到错误租户名下"。 +LangGraph 调用入口在 **`app/gateway/routers/threads.py`**(thread CRUD)和 **`app/gateway/routers/thread_runs.py`**(run 创建/恢复/事件流)。这两个路由在调用 LangGraph 之前**必须先用 `threads_meta` 校验 `(tenant_id, thread_id)` 归属**: + +- **创建路径**:先在 `threads_meta` 写入 `(tenant_id=current, thread_id, user_id=current)`,依赖 `UNIQUE (tenant_id, thread_id)` 防重;再调 LangGraph 创建对应 thread +- **读/写路径**:先用 `(current_tenant_id, requested_thread_id)` SELECT `threads_meta`,未命中即 404;命中后才允许调 LangGraph + +> 注:原稿写"在 `AssistantsCompat` 路由强制校验"是错的——`assistants_compat.py:1-50` 只服务 `assistants.search/get` 静态 stub,**不**触达 thread 入口(审计报告 §ADR-001 已修正)。 + +应用层校验是第一道防线、`UNIQUE` 约束是 DB 层兜底——任何对 LangGraph 表的访问都经过这一关。 ### 4.2 RLS policy 模板 @@ -151,24 +153,14 @@ async def _set_session_tenant(session: AsyncSession, tenant_id: str) -> None: 每个仓储方法的开头自动调用,从 ContextVar 取 tenant_id(仿照现有 `resolve_user_id` 模式)。 -**LangGraph 自有连接池的注入**:DeerFlow 仓储和 LangGraph checkpointer 是**两套连接池**——前者是 SQLAlchemy `AsyncSession`,后者是 LangGraph 自己持有的 asyncpg 池。后者的注入路径不能复用上面的 helper: +**LangGraph 自有连接池不参与 SET LOCAL**:DeerFlow 仓储和 LangGraph checkpointer 是**两套连接池**——前者是 SQLAlchemy `AsyncSession`(DeerFlow 控制),后者是 LangGraph 自己持有的 psycopg 池(DeerFlow 不可控)。按 §4.1.1 的两层模型: -```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 +- **DeerFlow 自有表**:上面 `_set_session_tenant` helper 在每次仓储调用前注入 `SET LOCAL`,RLS 兜底 +- **LangGraph 表**:**不注入 `SET LOCAL`**——LangGraph 表上不启用 RLS,租户隔离靠应用层强校验(§4.1.2)实现。`AsyncPostgresSaver` 仍按现状用 `from_conn_string`,无侵入 -saver = AsyncPostgresSaver(connection_factory=tenant_aware_acquire) +> 原稿设想的"自定义 `connection_factory` 注入到 saver 构造"在 `langgraph-checkpoint-postgres==3.0.5` 不可行——库不存在该参数([spike](./adr-spike-langgraph-postgres.zh-CN.md) §2.2/2.4)。详细备选方案与拒绝理由见 spike §3。 -# 2. 兜底:在 RunManager 进入 thread 前主动 issue 一条 SET(要求 LangGraph 复用同 conn) -``` - -具体路径选择与失败模式见 ADR-006 §2.1。 - -**关键约束**:所有"会查 LangGraph 表"的代码路径——`AssistantsCompat` 路由、`RunManager`、checkpointer 直读——都必须保证调用栈上已注入 `app.tenant_id`,否则 RLS 会把整个会话过滤成空集。CI 加冒烟测试确认这点。 +**关键约束**:所有"会查 DeerFlow 自有表"的代码路径都必须保证调用栈上已注入 `app.tenant_id`,否则 RLS 会把整个会话过滤成空集。CI 加冒烟测试确认这点。LangGraph 表上的访问则必须经过 §4.1.2 的入口校验。 ### 4.3 ContextVar 扩展 @@ -212,11 +204,13 @@ CREATE ROLE deerflow_admin BYPASSRLS; | 风险 | 缓解 | |---|---| -| 应用层漏写 tenant_id WHERE | RLS 是兜底;CI 加静态检查(detect SQL 不带 tenant_id) | +| 应用层漏写 tenant_id WHERE | RLS 是兜底(DeerFlow 表);CI 加静态检查(detect SQL 不带 tenant_id) | +| **LangGraph 表无 RLS,仅应用层强约束**(§4.1.1 trade-off) | `threads_meta` `UNIQUE (tenant_id, thread_id)` 兜底;CI boundary 测试禁止绕过 `threads.py` / `thread_runs.py` 直连 saver;定期审计任何新增的 LangGraph 直连路径 | | 索引前导列错了走全表扫 | DBA 评审所有 EXPLAIN;上线前压测 | | `current_setting('app.tenant_id')` 没设导致 RLS 全过滤掉 | 应用层 fail-closed;监控空集查询率 | | 跨租户分析需求多 | 提供受控的 admin role + 审计日志 | | SQLite 开发 vs Postgres 生产差异 | 测试集成层用 testcontainers 跑 Postgres;不允许用 SQLite 跑 RLS 相关测试 | +| **当前不存在 Postgres 测试夹具基础设施**(审计报告 §ADR-001 highest-risk gap) | phase-0 必须先落 testcontainers + RLS 冒烟测试,再做仓储改造;否则 RLS bug 进生产 | | 单 DB 容量上限(>1TB 后维护困难) | 监控 DB 体积;超过阈值切 per-tenant DB(推翻方案) | --- 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 index 897dc21e..58bc0e02 100644 --- 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 @@ -2,10 +2,11 @@ | 项目 | 内容 | |---|---| -| 状态 | 草稿(Draft) | +| 状态 | 草稿(Draft) · 2026-05-09 据 spike + 审计修订 §1 / §2.1 / §2.2 / §2.5 / §2.6 / §3 / §4 / §6 | | 决策日期 | TBD | | 决策者 | 后端 lead + 架构 + 渠道 owner | | 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费、ADR-005 存储拓扑 | +| 关联 spike / 审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) · [langgraph-postgres spike](./adr-spike-langgraph-postgres.zh-CN.md) | --- @@ -15,13 +16,14 @@ ADR-001 ~ 005 解决了"数据/沙箱/Key/RBAC/存储"五个面,但 DeerFlow | 组件 | 现状 | 现在的 key | 多租户后的问题 | |---|---|---|---| -| LangGraph Checkpointer | 内置 `AsyncSqliteSaver` / `AsyncPostgresSaver`(`runtime/checkpointer/async_provider.py`),表结构由 LangGraph 自己定 | `thread_id` | 表里只有 `thread_id`,没有 `tenant_id`;ADR-001 的 RLS policy 怎么挂、`SET LOCAL` 怎么注入 | +| LangGraph Checkpointer | 内置 `AsyncSqliteSaver` / `AsyncPostgresSaver`(`runtime/checkpointer/async_provider.py:73,117`,走 `from_conn_string`),表结构由 LangGraph 自己定 | `thread_id` | 表里只有 `thread_id`,没有 `tenant_id`;`langgraph-checkpoint-postgres==3.0.5` 不存在 `connection_factory`([spike](./adr-spike-langgraph-postgres.zh-CN.md) §2),`SET LOCAL` 注入路径不通;只能走应用层强校验 | | MCP 工具缓存 | `mcp/cache.py:11` 一个进程级 `_mcp_tools_cache: list[BaseTool]`,按 `extensions_config.json` 的 mtime 失效 | 无(全局) | 每个租户启用的 MCP server 不同;当前缓存命中第一个加载的租户配置 | +| MCP OAuth token | `mcp/oauth.py:25-31` `OAuthTokenManager` token 缓存为**进程内存 `dict[str, _OAuthToken]`**,**无任何持久化**——进程重启后重新刷 token | 无(全局) | 多租户后必须按 tenant 隔离 + 持久化,从"无持久化"直接到"KMS 加密 DB"——比"文件挪到 DB"成本高 | | 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 上下文? | +| IM 渠道 ↔ 用户绑定 | `app/channels/store.py:36-42` 把 IM `channel:chat[:topic]` 映射到 `{thread_id, user_id}`;落 `${DEER_FLOW_HOME}/channels/store.json`(不是 `channels.yaml`),**当前没有 binding / workspace 概念** | platform user_id | Slack workspace / 飞书租户 / 企微 corp 怎么映射到平台 tenant?webhook 来流量时怎么决定 tenant 上下文?需要新建 `channel_bindings` 表 | 这一组不解决,ADR-001 的 RLS、ADR-003 的计费都是空中楼阁。 @@ -31,33 +33,22 @@ ADR-001 ~ 005 解决了"数据/沙箱/Key/RBAC/存储"五个面,但 DeerFlow ### 2.1 LangGraph Checkpointer -**决策**:保留 LangGraph 原生 checkpointer 表结构(不 fork),但要做四件事: +**决策**:保留 LangGraph 原生 checkpointer(不 fork、不 ALTER 它的表),租户隔离走**应用层强校验 + thread_id 唯一约束兜底**。详见 ADR-001 §4.1.1 两层模型。 -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; +> 历史背景:原稿设想用 `connection_factory` 注入 `SET LOCAL app.tenant_id` + 在 LangGraph 表上挂 RLS。spike 验证([adr-spike-langgraph-postgres](./adr-spike-langgraph-postgres.zh-CN.md))发现 `langgraph-checkpoint-postgres==3.0.5` 不存在 `connection_factory` 参数;备选方案(子类化 `psycopg_pool` / 包装 saver / ALTER 加列)都有不可接受的维护成本。改用应用层强校验。 - -- 通过 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 实现,自己控制表结构。 +1. **入口路径强校验**:所有调 LangGraph saver 的入口——**`app/gateway/routers/threads.py`** 和 **`app/gateway/routers/thread_runs.py`**——在调用前必须先在 `threads_meta` 上做 `(tenant_id=current, thread_id=requested)` 查询;未命中即 404,命中后才放行。 + - 创建 thread:先在 `threads_meta` 写入 `(tenant_id, thread_id, user_id)`,依赖 `UNIQUE (tenant_id, thread_id)` 复合索引兜底防重;再调 LangGraph 创建对应 thread + - 读/写 thread:先 SELECT `threads_meta`,命中后再放行 + - **不在 LangGraph 自有表上挂 RLS**——`SET LOCAL app.tenant_id` 只对 DeerFlow 自有表生效(ADR-001 §4.2) +2. **CI boundary 测试**:`backend/tests/` 下新增 `test_langgraph_access_boundary.py`——静态扫描禁止任何路径 import LangGraph saver/client 而绕过 `threads.py` / `thread_runs.py`。LangGraph Studio 直连必须走相同入口或显式审批 +3. **活体测试**:`tests/test_checkpointer_tenant_isolation.py`——建两个租户、各创建一个 thread,互相通过对方 thread_id 调 `/api/threads/{tid}` / `/api/threads/{tid}/runs` 必须 404;同 tid 走自己路径必须正常。这是入口校验是否生效的回归网 + +**推翻条件**: +- LangGraph 上游加入 `connection_factory` 或等价 hook → 切回"DeerFlow 表 + LangGraph 表统一 RLS"模型 +- 应用层校验在压测中暴露 perf 瓶颈 → 评估 fork checkpointer 自控 schema ### 2.2 MCP 工具缓存 @@ -96,7 +87,7 @@ class TenantMCPCache: - 现在 `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 加密。 +- **OAuth token 存储**:当前 `mcp/oauth.py:25-31` 的 token 是**进程内存 `dict[str, _OAuthToken]`,无任何持久化**——进程重启后重新刷 token。多租户化要直接做"租户隔离 + 持久化 + 加密"三步并发:落 `tenant_secrets`(`(tenant_id, key='mcp_oauth:')`)+ KMS 加密 + 失败回退到刷新流程。工作量比"文件挪到 DB"高一档,估工 M+。 - 进程内存上限:`TenantMCPCache` 加 LRU 上限(默认 1000 租户),超过淘汰最久未访问的;淘汰时关闭它的 MCP client 释放连接。 - Gateway PUT mcp 路由(`app/gateway/routers/mcp.py`)改完写 DB 后,调 `TenantMCPCache.invalidate(tenant_id)` 主动失效。 @@ -163,8 +154,8 @@ ADR-003 只覆盖了"主对话"的 LLM 调用计费,但 DeerFlow 的中间件 实现侧改造: -- 这 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` +- 这 3 个中间件目前都通过 `create_chat_model()` 拿 LLM;ADR-003 §4.2 把这个函数改成 tenant-aware,自动会带上 tenant key +- **`TokenUsageMiddleware` 当前只 log,不持久化**(`agents/middlewares/token_usage_middleware.py:268-275`);新增 `usage_category` 字段(`main` / `memory` / `title` / `summarization`)+ 持久化路径都要从空白起,写入 `tenant_usage_daily(tenant_id, date, usage_category, tokens_in, tokens_out)`。详见 ADR-003 §4.4 - usage 报表 UI 区分这四类,让客户对账 **例外**:平台主动触发的 LLM 调用(比如平台 admin 跑健康检查时调用 LLM)算平台账,不算租户。 @@ -176,7 +167,10 @@ ADR-003 只覆盖了"主对话"的 LLM 调用计费,但 DeerFlow 的中间件 #### 形态 A:单租户独占一个 IM 集成(小客户/SaaS) 每个 Slack workspace / 飞书企业 / 钉钉 corp 绑到**一个** tenant。 -现有 `app/channels/store.py` 的"channel binding"扩成: + +> 现状澄清:当前 `app/channels/store.py:36-42` 只存 `channel:chat[:topic] → {thread_id, user_id}` 的 JSON 字典(路径 `${DEER_FLOW_HOME}/channels/store.json`),**没有 binding / workspace 概念**。下面的 `channel_bindings` 表是新建,不是扩展现有结构。 + +新建 `channel_bindings` 表: ```sql channel_bindings ( @@ -236,24 +230,26 @@ channel_user_links ( | 模块 | 改动 | 估工 | |---|---|---| -| `runtime/checkpointer/async_provider.py` | 接 LangGraph PG saver 的 connection factory,注入 `SET LOCAL` | M | -| 新建 `tests/test_checkpointer_tenant_isolation.py` | RLS 活体测试 | S | +| `app/gateway/routers/threads.py` + `thread_runs.py` | 入口注入 `(tenant_id, thread_id)` 校验;写路径先入 `threads_meta` | M | +| 新建 `tests/test_checkpointer_tenant_isolation.py` | LangGraph 入口校验活体测试(不再是 RLS 测试) | S | +| 新建 `tests/test_langgraph_access_boundary.py` | 静态扫描禁止绕过入口直连 saver | S | | `mcp/cache.py` | 模块级 → `TenantMCPCache` 类 | M | -| `mcp/oauth.py` | OAuth token 存储从文件系统 → `tenant_secrets` | M | -| `app/gateway/routers/mcp.py` | PUT 接口改写 DB + 调 invalidate | S | +| `mcp/oauth.py` | OAuth token 从**进程内存** → `tenant_secrets`(持久化 + KMS 加密 + 失败回退到刷新) | M+ | +| `app/gateway/routers/mcp.py` | PUT 接口改写 DB + 调 invalidate;不再依赖 `extensions_config.json` mtime | 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 | +| `agents/middlewares/memory_middleware.py` | usage_category=memory | S | +| `agents/middlewares/title_middleware.py` / `summarization_middleware.py` | usage_category=title/summarization | S | +| `agents/middlewares/token_usage_middleware.py` | **从只 log 升级为持久化**(参 ADR-003 §4.4) + 增加 usage_category 维度 | M | +| 新建 `channel_bindings` 表 + 仓储 | IM workspace ↔ tenant 映射(不复用 store.json) | M | +| `app/channels/store.py` | 现有 `channel:chat → {thread_id, user_id}` 字典升级为 SQL 表,并按 tenant 分区 | M | | `app/channels/manager.py` | webhook handler 注入 tenant ContextVar | M | -| 新建 `app/channels/auth_filter.py` | 强制 tenant 上下文 | S | +| 新建 `app/channels/auth_filter.py` | 强制 tenant 上下文 fail-closed | S | | 新建 `channel_user_links` 仓储 | IM user ↔ platform user | M | -合计:约 12 人周(2 人 6 周 / 3 人 4 周),不含 ADR-001 ~ 005 各自的改造。 +合计:约 14 人周(2 人 7 周 / 3 人 5 周),不含 ADR-001 ~ 005 各自的改造。 --- @@ -261,10 +257,11 @@ channel_user_links ( | 风险 | 缓解 | |---|---| -| RLS subquery 反查 thread_id → tenant_id 全表扫 | 测试 EXPLAIN,必要时 LangGraph 表加 `tenant_id` 列(侵入但可控) | +| **LangGraph 表无 RLS,应用层校验失效则跨租户**(§2.1 trade-off) | `threads_meta` `UNIQUE (tenant_id, thread_id)` 兜底;CI boundary 测试禁止绕过入口路由直连 saver;任何新增 LangGraph 直连路径必须 PR review | | LangGraph 升级换 schema | CI 锁版本;升级前先跑 tenant isolation 测试集 | | `TenantMCPCache` 内存爆 | LRU 上限 + 闲置淘汰;监控租户数 / 进程 | | MCP server 自身泄露租户上下文 | 出网走 ADR-002 egress gateway 白名单;MCP server 在沙箱内 stdio 启动时 env 不带平台 secret | +| **MCP OAuth token 持久化是从无到有**,错误处理面更大 | 持久化失败时回退到"无持久化进程内存"模式(不阻塞业务)+ 报警;KMS 不可用时 fail-closed | | Memory 抽取算入租户用量被客户抗议"我没让它跑" | UI 显式开关 + 用量分项展示;默认开启可关 | | IM webhook 没注入 tenant 上下文 → 调用 RLS 全过滤掉空集 | 中间层 fail-closed;监控空集查询率 | | 多 IM 平台 token 在 `channel_bindings.config_encrypted` 泄露 | KMS 加密 + 审计每次 decrypt(参照 ADR-003 §4.6) | @@ -274,7 +271,7 @@ channel_user_links ( ## 5. 推翻条件 -- LangGraph 官方推出原生多租户 checkpointer → 切换并废弃本 ADR §2.1 的注入方案 +- LangGraph 官方推出原生多租户 checkpointer 或上游加入 `connection_factory` 等价 hook → 切回 ADR-001 §4.1.1 "DeerFlow 表 + LangGraph 表统一 RLS" 模型,废弃本 ADR §2.1 的应用层强校验作为唯一防线 - 平台决定走"完全集中式 IM"(所有租户共用一个 bot account) → §2.6 切到形态 B 为唯一形态 - MCP server 全部跑在 K8s sidecar 而非进程内 → §2.2 缓存策略需要重写,client 实例从进程内挪到 service mesh @@ -284,11 +281,12 @@ channel_user_links ( | 项 | 默认 | |---|---| -| Checkpointer | LangGraph PG saver + RLS(subquery 反查 thread_id) | +| Checkpointer | LangGraph PG saver 原状(不挂 RLS、不 ALTER 表)+ 入口路由强校验 + `threads_meta` `UNIQUE(tenant_id, thread_id)` 兜底 | | MCP cache | per-tenant LRU,上限 1000 租户/进程 | +| MCP OAuth token | `tenant_secrets` 持久化 + KMS 加密;持久化失败回退到进程内存 + 报警 | | Skills | platform 公共只读 + tenant 私有;本地 LRU 5GB | | Sandbox prewarm | per-tenant 池,按 plan 配置大小 | -| 内部 LLM 调用计费 | 全部记入 tenant,分 4 类 usage_category | +| 内部 LLM 调用计费 | 全部记入 tenant,分 4 类 usage_category;`TokenUsageMiddleware` 必须先升级为持久化 | | 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 index 63bfad88..3c2ce113 100644 --- 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 @@ -2,29 +2,35 @@ | 项目 | 内容 | |---|---| -| 状态 | 草稿(Draft) | +| 状态 | 草稿(Draft) · 2026-05-09 据审计修订 §1 / §8 / §10 / §11 / §12(Better Auth 假设作废)| | 决策日期 | TBD | | 决策者 | 前端 lead + 后端 lead + 产品 | | 关联 ADR | ADR-001 数据隔离、ADR-004 RBAC、ADR-006 运行时与渠道 | +| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) | --- ## 1. 背景 -ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最先看到的是**浏览器地址栏长什么样**。多租户产品形态决定了 URL 形态、cookie scope、登录跳转、Better Auth 接入方式。 +ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最先看到的是**浏览器地址栏长什么样**。多租户产品形态决定了 URL 形态、cookie scope、登录跳转、auth 改造方式。 当前 DeerFlow(`frontend/src/`): - nginx 把 `/api/*` → Gateway 8001、`/api/langgraph/*` → 同 Gateway(重写) -- 前端用 Better Auth 走 cookie session(路径作用域 `/`) +- 前端**没有 Better Auth**——auth 走后端自签 JWT:Gateway `app/gateway/auth/jwt.py` 签发 → `access_token` cookie(HttpOnly)→ 前端 `core/auth/server.ts:25-26` 读 cookie 调 `/auth/me` +- JWT payload 当前结构:`{sub, exp, iat, ver}`(`auth/jwt.py:14-19`),**无 tid/role** +- `users.token_version` 列已存在 + JWT `ver` claim 已用作失效(`persistence/user/model.py:49`) +- CSRF 双重 cookie 已实现(`csrf_middleware.py` + 前端 `core/api/api-client.ts:20-32`) - 没有租户概念,单 host 单工作区 -- LangGraph SDK client 在 `core/api/` 单例,所有 thread 操作共用一个 SDK 实例 +- LangGraph SDK client 在 `core/api/api-client.ts` 单例,所有 thread 操作共用一个 SDK 实例 多租户后必须回答: 1. URL 怎么标记 tenant? 2. 多 tenant 切换时 SDK 单例怎么处理? 3. cookie 怎么 scope(避免跨 tenant session 串)? -4. Better Auth 怎么知道当前 tenant? +4. JWT 怎么扩展才能带 tenant_id 和 role? + +> 审计纠正:原稿假设"前端用 Better Auth 走 cookie session"。`frontend/package.json` 实际不依赖 `better-auth`(grep 命中 0 次);前端 auth 是后端自签 JWT + `access_token` cookie 直通——所有"Better Auth 改造"段落都改为"扩展现有 `auth/jwt.py` `TokenPayload`",工作量更小。 --- @@ -39,7 +45,7 @@ ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最 | Tenant 解析点 | nginx 不解析;后端 `AuthMiddleware` 从 path + JWT 双向交叉校验 | | Cookie scope | `Path=/`、不绑 tenant;通过 JWT 内 `tid` 区分 | | SDK 单例 | 全局单例,但切租户时 `await invalidate()` + 强制 reload | -| Better Auth | 单一 `auth.deerflow.app` 登录域,登录后跳到 `/{slug}/`;多租户用户走 tenant picker | +| Auth | 沿用现有 `app/gateway/auth/jwt.py` 自签 JWT;扩 `TokenPayload` 加 `tid` + `role` 字段、bump `ver` 触发旧 token 失效 | --- @@ -51,7 +57,7 @@ ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最 - **本地开发劝退**:每个开发者要起 `*.localtest.me` 之类的通配 DNS,Docker compose 的 nginx 也要改 - **TLS 证书**:通配证书或 ACME 动态签证;自部署客户卡这一步 -- **Better Auth cookie 跨子域**:要走 `Domain=.deerflow.app`,scope 太宽,租户隔离反而变弱 +- **`access_token` cookie 跨子域**:要走 `Domain=.deerflow.app`,scope 太宽,租户隔离反而变弱 - **CSRF 双重 cookie**:当前 csrf_middleware 假定同源;跨子域要改写 **保留**作为 enterprise plan 的"自定义域名"功能(vanity domain),通过 `tenants.custom_domain` 解析回平台 tenant,但不作为默认。 @@ -71,7 +77,7 @@ ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最 ``` 公开(不带租户): / → 营销页 - /login → Better Auth 登录页 + /login → 登录页 /signup /accept-invite/{token} /pricing @@ -253,7 +259,7 @@ frontend/src/app/ | 维度 | 决策 | |---|---| -| Session cookie name | `deerflow_session`(不变) | +| Session cookie name | `access_token`(沿用现状,HttpOnly) | | Path scope | `/`(不绑 tenant slug) | | Domain | 平台主域(不跨子域) | | SameSite | `Lax`(默认) | @@ -268,17 +274,32 @@ frontend/src/app/ --- -## 8. Better Auth 接入 +## 8. Auth 改造(基于现有自签 JWT) -Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。多租户化改: +> 现状:`app/gateway/auth/jwt.py:14-19` `TokenPayload` 当前是 `{sub, exp, iat, ver}`;前端 cookie name 是 `access_token`;`users.token_version` 已存在,bump 该列即让所有旧 JWT 失效。 -1. **登录后落到 picker**:用户登录成功 → 检查 `tenant_memberships` 数量 +多租户化改造: + +1. **扩 `TokenPayload`**: + ```python + class TokenPayload(BaseModel): + sub: str # user_id + tid: str # tenant_id(新增) + role: str # owner | admin | member(新增) + exp: int + iat: int + ver: int # bump 即让所有旧 token 失效(沿用) + ``` + `ver` 字段已经在用——任何 membership 变更(加入/退出/role 调整)都 bump `users.token_version`,下次请求 `access_token` 校验失败强制重新登录。 +2. **签发流程**:用户登录成功 → 检查 `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}/` + - 1 个:直接签 `{tid=该 tenant.id, role=membership.role}`,跳 `/{slug}/` + - 多个:跳到 `/select-tenant` 让用户选;选后签对应 JWT,落 `users.default_tenant_id` +3. **切换 tenant**:`POST /api/auth/switch-tenant` → 校验 membership → 重签 JWT 覆写 `access_token` cookie → 客户端硬刷新(§6.2) +4. **SSO(v2)**:当前自签 JWT 模型可以直接配 SAML / OIDC provider,把外部 IdP 的 user/group 映射到 platform user + membership;不依赖 Better Auth,自由度更高 +5. **Invitation 流程**:`/accept-invite/{token}` 路径下点击 → 校验 invitation → 自动 attach membership + bump 用户 `token_version` → 跳到 `/{new_slug}/` + +**为什么不引入 Better Auth**:现有 JWT 实现已经有 `token_version` 失效机制 + cookie HttpOnly + CSRF 双重 cookie,扩 2 个字段比引入新 auth 框架的破坏面小得多。引入 Better Auth 反而要重写 `auth_middleware.py` + 前端 `core/auth/` + 所有 server actions 调用——工作量多 1 倍。 --- @@ -304,7 +325,7 @@ Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。 | `AuthMiddleware` path slug 解析 + 交叉校验 | 后端 | M | | `/api/auth/switch-tenant` 路由 | 后端 | S | | `/api/auth/me` 返回 tenant 列表 | 后端 | S | -| Better Auth session 改造,注入 `tid/role/tv` | 后端 + 前端 | M | +| `auth/jwt.py` `TokenPayload` 扩 `tid/role` 字段 + 签发流程 | 后端 | S | | 前端 `app/(tenant)/[slug]/layout.tsx` + Provider | 前端 | M | | 前端路由全部按 `(tenant)/[slug]/` 重组 | 前端 | L | | `useTenant()` hook + 所有 API 调用接入 | 前端 | M | @@ -325,7 +346,7 @@ Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。 | slug 冲突(保留字 / 已注册) | 注册流程强制校验黑名单;冲突时返显建议 slug | | 浏览器分享 URL 给非成员看 | 后端 403,前端展示"申请加入"按钮 | | 切换 tenant 时 streams 没断干净导致看到上租户的 events | hard reload 兜底;E2E 测试 stream cancellation | -| Better Auth 升级破坏 session 字段 | 锁版本;session 改造前先 fork 一份测 | +| `TokenPayload` 字段升级导致旧 cookie 校验失败 | 加 fallback:旧 4 字段 token 视为"无 tenant 上下文",强制走 `/select-tenant` 重发 | | 自定义域名灰区(DNS / TLS) | v2 才做,v1 不实现 | | `default_tenant_id` 被删除(成员被踢) | 登录时 fallback 到 memberships 第一个;都没了引导建租户 | | SEO 收录租户页 | 默认 `noindex`,租户开关启用公开页 | @@ -336,7 +357,7 @@ Better Auth 当前配置(`frontend/src/server/auth/`)走单一 user 池。 - v2 决定走子域名优先 → §2 决策切到子域名 + 兼容老 path 形态 6 个月 - 单页应用改成多页 / SSR 完整迁移 → 前端层重写,路由组结构会变 -- Better Auth 弃用 → 切换到自有 auth;JWT 部分不变 +- 决定改用 Better Auth / Auth.js 等成熟框架 → §8 重写为框架接入路径,但当前评估收益不抵迁移成本 --- diff --git a/docs/multi-tenant-redesign/01-redesign/adr-spike-langgraph-postgres.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-spike-langgraph-postgres.zh-CN.md new file mode 100644 index 00000000..a4d24c85 --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-spike-langgraph-postgres.zh-CN.md @@ -0,0 +1,170 @@ +# Spike: langgraph-checkpoint-postgres 的 per-acquire 注入能力验证 + +> 验证日期:2026-05-09 +> 触发原因:ADR-001 §4 / ADR-006 §2.1 假设 `langgraph-checkpoint-postgres>=2.0` 提供 `connection_factory`,可在每次连接 acquire 时注入 `SET LOCAL app.tenant_id`。本 spike 验证此假设是否成立,并给出可落地的备选路径。 +> 影响范围:ADR-001(数据隔离)、ADR-006(运行时与渠道层) + +--- + +## 1. 验证基线 + +- **库版本**:`langgraph-checkpoint-postgres==3.0.5`(PyPI 上传时间 2026-03-18) +- **当前调用点**:`backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py:73,117` + ```python + async with AsyncPostgresSaver.from_conn_string(conn_string) as saver: + await saver.setup() + yield saver + ``` +- **依赖锁定**:`backend/uv.lock` `>=3.0.5`(postgres extra) + +## 2. 库实际 API 形态 + +直接解包 v3.0.5 wheel 检查源码: + +### 2.1 `AsyncPostgresSaver.__init__` + +```python +# /tmp/lgcp/langgraph/checkpoint/postgres/aio.py:37-53 +def __init__( + self, + conn: _ainternal.Conn, + pipe: AsyncPipeline | None = None, + serde: SerializerProtocol | None = None, +) -> None: ... +``` + +`Conn` 类型定义(`_ainternal.py:10`): + +```python +Conn = AsyncConnection[DictRow] | AsyncConnectionPool[AsyncConnection[DictRow]] +``` + +→ 可以传一个**自建的 `AsyncConnectionPool`** 进去;这是唯一一个有运行期介入空间的口子。 + +### 2.2 `from_conn_string` classmethod + +```python +# aio.py:55-80 +@classmethod +@asynccontextmanager +async def from_conn_string( + cls, + conn_string: str, + *, + pipeline: bool = False, + serde: SerializerProtocol | None = None, +) -> AsyncIterator[AsyncPostgresSaver]: ... +``` + +→ **没有 `connection_factory` / `configure` / 任何 callback 参数**。这条路径完全封死。 + +### 2.3 `_cursor` 实现 + +```python +# aio.py:352-392 +@asynccontextmanager +async def _cursor(self, *, pipeline: bool = False): + async with self.lock, _ainternal.get_connection(self.conn) as conn: + ... +``` + +`get_connection` 行为(`_ainternal.py:13-23`): +- `self.conn` 是单 `AsyncConnection`:始终复用同一条 +- `self.conn` 是 `AsyncConnectionPool`:每次现取(`async with conn.connection() as conn`) + +→ 如果传 pool,**每次 cursor 调用确实独立 acquire**。 + +### 2.4 全局搜索 + +```bash +grep -rn -E "connection_factory|configure_connection|on_acquire" /tmp/lgcp +# 命中 0 处 +``` + +→ **库不存在 `connection_factory`**。ADR 写"langgraph-checkpoint-postgres>=2.0 支持 connection_factory"是事实错误。 + +## 3. 备选注入路径分析 + +### 3.1 Option A:自建 `AsyncConnectionPool` + 自定义 `getconn` + +```python +class TenantAwarePool(AsyncConnectionPool): + @asynccontextmanager + async def connection(self): + async with super().connection() as conn: + tenant_id = get_current_tenant() # ContextVar + await conn.execute(f"SET app.tenant_id = '{tenant_id}'") + try: + yield conn + finally: + await conn.execute("RESET app.tenant_id") + +pool = TenantAwarePool(conn_string, ...) +saver = AsyncPostgresSaver(conn=pool) +``` + +**可行性**:可以做。 +**问题**: +- 侵入 `psycopg_pool` 内部,psycopg-pool 升级时要 retest +- `psycopg_pool.AsyncConnectionPool.configure` callback 只在**物理连接首次创建**时跑,不能用——拿不到运行期 ContextVar +- `SET app.tenant_id`(不带 LOCAL)会在 conn 复用时残留 → 必须在 release 前 `RESET`,否则跨租户泄漏 +- 每次 acquire 多 2 次 round-trip(SET + RESET),延迟成本不可忽略 + +### 3.2 Option B:包一层 `TenantAwarePostgresSaver` + +在 `aput`/`aget`/`alist` 外面起事务 + `SET LOCAL`。 + +**问题**: +- `AsyncPostgresSaver` 内部已有 `self.lock` + 自己的 cursor 管理(`_cursor` 走 `async with self.lock`),外面套事务和内部锁/事务结构会冲突 +- `SET LOCAL` 仅在事务内有效,需要确保 langgraph 内部所有 SQL 都跑在同一个事务里——库当前实现并不全是这样(pipeline 模式下行为不一) +- 维护成本高,跟随 langgraph 升级风险大 + +### 3.3 Option C:放弃 langgraph 表上的 RLS(推荐) + +把租户隔离拆成两层: + +| 表归属 | 隔离机制 | +|---|---| +| **DeerFlow 自有表**(thread_meta / runs / feedback / users / 新增 tenant_*) | 标准 Postgres RLS + `SET LOCAL app.tenant_id` via SQLAlchemy session(DeerFlow 完全控制 conn pool) | +| **langgraph checkpoint 表**(checkpoints / checkpoint_blobs / checkpoint_writes / checkpoint_migrations) | 纯应用层兜底——入口处(`threads.py` + `thread_runs.py`)强制 `(tenant_id, thread_id)` 校验,依赖 thread_meta 表上 `(tenant_id, thread_id)` unique constraint | + +**优点**: +- 不依赖 langgraph 库的任何 hook,库升级风险归零 +- ADR-001 §4 里讨论的"subquery RLS"和"column upgrade path"的纠结都消解了——subquery RLS 只能保护读,写仍然要应用层兜,两条路径在 Option C 下统一为"应用层兜 + 自有表 RLS" +- 应用层校验点正好对齐审计报告里修正后的 ADR-001 hook 点(`threads.py` + `thread_runs.py`),不再绕错点 +- 实现路径短:thread_meta 加 `tenant_id` 列 + unique constraint + 入口校验 = 几十行;不用动 langgraph 表 schema + +**不足**: +- langgraph 表本身不是租户感知的,安全模型从"DB 强约束"降级为"应用层强约束"。需要承认这个 trade-off +- 如果将来出现绕过 `threads.py` / `thread_runs.py` 的写入路径(例如 LangGraph Studio 直连),租户隔离失效。需要在 CI 里加 boundary 测试禁止此类直连 + +### 3.4 不推荐路径:column upgrade(修改 langgraph 表 schema) + +ADR-001 §4 提到的"给 langgraph 表加 tenant_id 列再做 RLS"。 + +**风险**:langgraph 用 `MIGRATIONS` 数组管理 schema 升级(`aio.py:82-109`)。我们 ALTER 出来的列会跟库升级冲突——每次升 `langgraph-checkpoint-postgres` 都要 diff 一遍 `MIGRATIONS` 数组。**不接受这个长期维护成本**。 + +## 4. 结论与对 ADR 的修订建议 + +### 4.1 事实纠正 + +- ❌ **错误假设**:"langgraph-checkpoint-postgres>=2.0 支持 `connection_factory`" +- ✅ **现实**:v3.0.5 不存在 `connection_factory`;`from_conn_string` 无 hook;唯一能介入的只有 `__init__(conn=AsyncConnectionPool)` 这一个口子,且 pool 自带的 `configure` callback 不是 per-acquire + +### 4.2 推荐落地方案 + +**Option C:两层隔离模型** + +- DeerFlow 自有表:RLS + `SET LOCAL` via SQLAlchemy(沿用 ADR-001 思路) +- langgraph 表:应用层强校验(`threads.py` + `thread_runs.py`)+ thread_meta 表 unique constraint 兜底 + +### 4.3 待修订的 ADR 段落 + +- **ADR-001 §4**:删除 "subquery RLS" 和 "column upgrade path" 两段;替换为 "DeerFlow 表 RLS + langgraph 表应用层兜底" 的两层模型描述;hook 点保留审计报告修正后的 `threads.py` + `thread_runs.py` +- **ADR-006 §2.1 改造点 B**:删除 "RunManager binding 到 saver 的 conn";明确 RunManager 不持有 langgraph conn pool,租户校验在 router 层完成 +- **ADR-006 §1 表格**:MCP OAuth token 的描述同步修正(参见审计报告 cross-cutting risk #3) + +### 4.4 后续再确认事项(非阻塞) + +- 如果未来需要 langgraph 表也走 RLS,可以**向上游 PR 加 `connection_factory` 参数**,比 fork 或 hack pool 都干净。当前 v3.0.5 不阻塞 phase-0 +- psycopg_pool 是否有更优雅的 per-acquire hook(v3.x 有无新增),可以在 phase-1 再 spike——phase-0 用不上 diff --git a/docs/multi-tenant-redesign/01-redesign/adr-vs-code-audit.zh-CN.md b/docs/multi-tenant-redesign/01-redesign/adr-vs-code-audit.zh-CN.md new file mode 100644 index 00000000..f5c19d6d --- /dev/null +++ b/docs/multi-tenant-redesign/01-redesign/adr-vs-code-audit.zh-CN.md @@ -0,0 +1,193 @@ +# 多租户改造 ADR 与现状代码审计报告 + +> 审计日期:2026-05-09 +> 审计基线:分支 `docs/multi-tenant-redesign` @ `dce5e959` +> 目的:在动手写迁移代码之前,逐条核对 7 份 ADR + phase-0 计划对**当前代码**的假设是否成立,避免基于错误前提做架构决策。 + +--- + +## ADR-001: 数据隔离模型 + +### Assumptions about current code + +1. 仓储层使用 `user_id` ContextVar + `AUTO` 哨兵自动注入 — **HOLDS** + - Evidence: `backend/packages/harness/deerflow/runtime/user_context.py:135-167`(`AUTO` sentinel + `resolve_user_id`);`backend/packages/harness/deerflow/persistence/thread_meta/sql.py:35-41` 已按此模式调用。 +2. LangGraph checkpointer 使用 `AsyncPostgresSaver` 自带连接池 — **HOLDS** + - Evidence: `backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py:73,117` `AsyncPostgresSaver.from_conn_string(...)`;DeerFlow 不传 `connection_factory`。 +3. SQLAlchemy 引擎单例 + `AsyncSession` factory 已就位(即"DeerFlow 仓储侧连接池")— **HOLDS** + - Evidence: `backend/packages/harness/deerflow/persistence/engine.py:26-27,126`;`init_engine_from_config` 支持 sqlite/postgres/memory。 +4. `AssistantsCompat` 路由可作为 thread_id↔tenant_id 校验拦截点 — **DOES NOT HOLD** + - Evidence: `backend/app/gateway/routers/assistants_compat.py:1-50` 该路由仅服务 `/api/assistants` 的 `assistants.search/get` 静态 stub,**不**触达 thread。Thread 入口在 `backend/app/gateway/routers/threads.py` 与 `thread_runs.py`。 + - Reality: ADR 把 hook 点写错;实际拦截点应是 `threads.py` + `thread_runs.py`。 +5. 仓储层 `WHERE user_id` 已是默认行为,加一层 `tenant_id` 是平行扩展 — **PARTIALLY HOLDS** + - Evidence: `persistence/thread_meta/sql.py:69-70` 是**应用层 `if row.user_id != resolved_user_id`** 后过滤,而非 SQL `WHERE`;其他仓储多数才走 SQL WHERE。RLS"前导列必须是 tenant_id"的索引前提目前完全不存在。 +6. 当前是 SQLite 默认,Postgres 是可选 backend — **HOLDS** + - Evidence: `persistence/engine.py:80-114` 同时支持 sqlite/postgres/memory,README/CLAUDE 默认演示 sqlite。 + +### Highest-risk gaps + +- **`AssistantsCompat` hook 点错位**:迁移时若按 ADR 字面落地,会跳过实际 thread 创建路径 (`threads.py:create`),导致 `(tenant_id, thread_id)` 应用层校验缺位、RLS 兜底成唯一防线。 +- **SQLite/Postgres 双驱动现状**:ADR §4.4 写"在 dev 同时跑双驱动"——目前 SQLite 是首选 backend,没有 Postgres 测试夹具 / RLS 测试基础设施,迁移启动成本被低估。 + +--- + +## ADR-002: 沙箱隔离模型 + +### Assumptions about current code + +1. 存在两个 sandbox provider:`LocalSandboxProvider`(零隔离)+ `AioSandboxProvider`(Docker) — **HOLDS** + - Evidence: `backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py`、`backend/packages/harness/deerflow/community/aio_sandbox/`、`sandbox/security.py:6-7` 把 LocalSandboxProvider 的 host bash 显式禁用。 +2. SandboxProvider 是进程级单例,`acquire(thread_id)` 在 lifespan 创建一次共享 — **HOLDS** + - Evidence: `sandbox/sandbox_provider.py:41-58` `_default_sandbox_provider` + `get_sandbox_provider()` 单例;`sandbox/middleware.py:45-63` 直接调 `provider.acquire(thread_id)`。 +3. `SandboxAuditMiddleware` 已存在并记录工具调用 — **HOLDS** + - Evidence: `backend/packages/harness/deerflow/agents/middlewares/sandbox_audit_middleware.py` 文件存在;CLAUDE.md L164 列在中间件链。 +4. K8s/Provisioner 模式存在但仅作为 sandbox 选项 — **PARTIALLY HOLDS** + - Evidence: `backend/CLAUDE.md` 提到 `provisioner` port 8002 在配置 aio_sandbox+provisioner 时启动;但仓库中无 `K8sSandboxProvider`,没有 namespace/NetworkPolicy/gVisor 任何配套,威胁模型是"未来"而非"现状"。 +5. Sandbox audit 复用业务 DB session — **UNVERIFIABLE**(未深入审计中间件 DB 写入路径) + +### Highest-risk gaps + +- **`AioSandboxProvider` 出网/资源/cosign 全部缺位**:ADR §1 把它列为"起点不错",但实际上未禁出网、未限 CPU/memory、未 readOnly rootfs、未签名校验——MVP 多租户上线**绝不能直接复用现有 provider**。 +- **K8s Sandbox 几乎从零开工**:ADR-002 § 5.1 估"新建 K8sSandboxProvider"是单条 bullet,实际是子系统,估工 L+。 + +--- + +## ADR-003: LLM Key 与计费模型 + +### Assumptions about current code + +1. `create_chat_model()` 是进程级模型工厂、配置走 `config.yaml` + 环境变量替换 — **HOLDS** + - Evidence: `backend/packages/harness/deerflow/models/factory.py:50` 函数签名 `(name, thinking_enabled, *, app_config, **kwargs)`,无 tenant 参数;`models/factory.py` 通过 `resolve_class` 反射构造 LLM。 +2. 当前 `TokenUsageMiddleware` 在 `after_model` 一次性提交 token — **HOLDS** + - Evidence: `agents/middlewares/token_usage_middleware.py:288-294` `after_model` / `aafter_model` 调 `_apply`;`_apply` 仅 log + 更新 `additional_kwargs`,**不**写 DB(更不区分 usage_category)。 +3. 存在 `MemoryMiddleware` / `TitleMiddleware` / `SummarizationMiddleware` 三处内部 LLM 调用 — **HOLDS** + - Evidence: `agents/middlewares/{memory,title,summarization}_middleware.py` 全部存在(CLAUDE L169-170)。 +4. tenant_secrets / tenant_quotas / tenant_usage_daily 表已存在 — **DOES NOT HOLD** + - Evidence: `persistence/{user,thread_meta,run,feedback}/model.py` 即全部 ORM 模型;无任何 tenant_* 表。 +5. ADR 描述的 `TokenUsageMiddleware` 已"按 message id 累加 token"写表 — **DOES NOT HOLD** + - Evidence: `token_usage_middleware.py:268-275` 仅 logger.info,**未持久化** token;`runs/model.py:35-41` 的 `total_input_tokens` 在 `RunManager.update_run_completion` 时一次写 — 没有按租户/类别维度。 + +### Highest-risk gaps + +- **没有任何用量持久化基础**:ADR-003 §4.4 的 `QuotaMiddleware`、`tokens_reserved`、悲观预扣全部要从空白起;现状 `TokenUsageMiddleware` 仅 log,幽灵 token 防御从零开工。 +- **`create_chat_model` 是同步函数**:ADR §4.2 改造目标签名是 async(要 await secret_vault.get),但当前是 sync——所有调用点(lead agent factory、memory updater 等)要同步改 async 或换 secret 注入路径。 + +--- + +## ADR-004: 租户 ↔ 用户层级与 RBAC + +### Assumptions about current code + +1. `users.system_role` 存在且仅 `admin`/`user` 两值 — **HOLDS** + - Evidence: `persistence/user/model.py:33` `system_role: Mapped[str] = ... default="user"`;`auth/models.py:23` `Literal["admin", "user"]`。 +2. `users.token_version` 已存在用作 JWT 失效 — **HOLDS** + - Evidence: `persistence/user/model.py:49` `token_version: Mapped[int] ... default=0`;`auth/jwt.py:18,36` JWT payload 已带 `ver` claim。 +3. JWT payload 当前结构是 `{sub, exp, iat, ver}`(无 tid/role)— **HOLDS** + - Evidence: `app/gateway/auth/jwt.py:14-19,36`:`TokenPayload` 仅 4 字段,**没有 tid/role**。 +4. 已有 `@require_permission(resource, action, owner_check=...)` 装饰器,可以扩展 — **HOLDS** + - Evidence: `app/gateway/authz.py:197-280`;现状 `owner_check` 是 `bool`,ADR §5.4 想升级为 `"self"|"self_or_admin"|"admin_only"|"owner_only"|strict=True`,需要重构。 +5. `AuthMiddleware` 在 ContextVar 注入 user — **HOLDS** + - Evidence: `app/gateway/auth_middleware.py:122` `set_current_user(user)`;尚无 `set_current_tenant`。 +6. `tenant_memberships` 表存在 — **DOES NOT HOLD** + - Evidence: `persistence/` 目录无 tenants/memberships/invitations 任何表。 + +### Highest-risk gaps + +- **`token_version` 只是 column,无 cache 层 / membership 失效 path**:ADR §5.2.3 的 `MembershipCache` 30s LRU + bump 触发机制全部要新建。 +- **现有 `system_role="admin"` 是平台级管理员且唯一**:ADR §6 计划保留它做 platform_admin,但现状 admin 与"租户内 owner"语义未分离,迁移时首启逻辑("创建第一个 admin")会与新增"创建 default 租户 + 设其为 owner"耦合,需要兼容旧部署。 + +--- + +## ADR-005: 存储拓扑 + +### Assumptions about current code + +1. memory.json 落 `{base_dir}/users/{user_id}/memory.json` 文件 — **HOLDS** + - Evidence: `agents/memory/storage.py:84-102`;`config/paths.py:155-157` `user_memory_file()`。 +2. agent SOUL.md / config.yaml 落 `{base_dir}/users/{user_id}/agents/{name}/` — **HOLDS** + - Evidence: `config/paths.py:163-169` user_agent_dir / user_agent_memory_file 系列;CLAUDE backend.md L356-359 描述一致。 +3. 自定义 skills 走 `skills/custom/` 全局共享、非 per-user — **HOLDS** + - Evidence: `skills/storage/local_skill_storage.py:24-32` layout `/{public,custom}/...`;`` 来自 `config.skills.get_skills_path()`,没有 user_id 维度。 +4. `extensions_config.json` 在仓库根目录、被 mtime 失效驱动 — **HOLDS** + - Evidence: `mcp/cache.py:11-53` `_config_mtime` + `_is_cache_stale`;`config/extensions_config.py` 存在;Gateway 路由 `routers/skills.py:321,336` / `routers/mcp.py:142,164` 直接读写文件并 `reload_extensions_config()`。 +5. 上传走本地 thread 目录 — **HOLDS** + - Evidence: `uploads/manager.py:40-48` `get_paths().sandbox_uploads_dir(thread_id, user_id=...)`。 +6. `ObjectStorage` 抽象 / `LocalObjectStorage` / `S3ObjectStorage` 已存在 — **DOES NOT HOLD** + - Evidence: `find ... -name "storage*"` 仅命中 `agents/memory/storage.py` 与 `skills/storage/`;harness 内**没有** `storage/protocol.py`、`storage/s3.py` 任何 ObjectStorage 抽象。 +7. `agent_configs` / `memory_facts` / `tenant_skill_state` 表存在 — **DOES NOT HOLD** + - Evidence: `persistence/` 仅 `user/`/`thread_meta/`/`run/`/`feedback/`;ADR-005 §2.1 列出的 7 张新表全部不存在。 + +### Highest-risk gaps + +- **三层拓扑全部要新建**:ObjectStorage 抽象(约 800 行 Protocol+实现)+ 7 张新表 + 4 个迁移脚本——ADR §5 第 1 阶段被列为 4 步实际是 12+ 步子项。 +- **memory/agent 文件 → DB 迁移会触发 `agents/memory/storage.py` 全面重写**:当前缓存键 `(user_id, agent_name)` + 原子 `temp+rename` 写 + `MemoryUpdateQueue` 30s debounce 全部假设文件系统语义。 + +--- + +## ADR-006: 运行时与渠道层的租户化 + +### Assumptions about current code + +1. `mcp/cache.py:11` 是模块级单例 `_mcp_tools_cache: list[BaseTool]` — **HOLDS** + - Evidence: `mcp/cache.py:11-14` 完全字面命中。 +2. MCP cache 失效靠 `extensions_config.json` mtime — **HOLDS** + - Evidence: `mcp/cache.py:31-53` `_is_cache_stale` 比对 `os.path.getmtime`。 +3. `MultiServerMCPClient` 实例缓存在进程内 + 持有 OAuth token — **PARTIALLY HOLDS** + - Evidence: `mcp/oauth.py:25-31` `OAuthTokenManager` token 缓存是 `dict[str, _OAuthToken]` **进程内存**;ADR-006 §2.2 / §1 表格写"OAuth token 落本地文件"——**不正确**,目前**没有持久化**,每次进程重启重新刷 token。 + - Reality: token 在 memory only,迁移时挪到 `tenant_secrets` 是从 0 起,比"从文件挪到 DB"成本更高(要新加持久化 + 加密)。 +4. `LocalSandboxProvider` / `AioSandboxProvider` 在 lifespan 创建一次单例 — **HOLDS** + - Evidence: `sandbox/sandbox_provider.py:41-58` 全局单例 + `get_sandbox_provider`。 +5. `MemoryMiddleware` / `TitleMiddleware` / `SummarizationMiddleware` 都通过 `create_chat_model()` 拿 LLM — **HOLDS**(推断) + - Evidence: 三个 middleware 文件存在;`models/factory.py:50` 是唯一工厂(CLAUDE 已说明),统一入口意味着 ADR §2.5 改造点统一。 +6. `app/channels/store.py` 把 IM 用户映射到平台 user_id,落 `~/.deer-flow/channels.yaml` — **PARTIALLY HOLDS** + - Evidence: `app/channels/store.py:36-42` 默认路径是 `Paths.base_dir / "channels" / "store.json"`(不是 `channels.yaml`);存的是 `channel:chat → {thread_id, user_id}`。 + - Reality: 文件名是 `store.json`、且没有 `binding` 概念(IM workspace ↔ platform 映射),ADR §2.6 需新建 `channel_bindings` 表 + 重构 store。 +7. `RunManager` 创建/恢复 thread 前可以 issue `SET app.tenant_id` — **DOES NOT HOLD**(条件不具备) + - Evidence: `runtime/runs/manager.py:41-78` 该类是**纯内存 run 注册表**,不持有 LangGraph 连接池。LangGraph saver 由 `make_checkpointer` 独立 lifespan 管理(`checkpointer/async_provider.py:73,117`),DeerFlow 无法控制每次 acquire;ADR §2.1 改造点 B 假设 RunManager 能 binding 到 saver 的 conn——目前没有这条 binding 通路。 + +### Highest-risk gaps + +- **IM channel store 当前没有 binding 概念**(只有 `channel:chat → thread_id` 映射),ADR §2.6 描述的"webhook 来流量时按 binding 注入 tenant"需要先把 `store.json` 升级为 `channel_bindings` 表 + 重构 webhook handler 路径。 +- **LangGraph `SET LOCAL` 注入路径未验证**:ADR §2.1 说"langgraph-checkpoint-postgres>=2.0 支持 connection_factory"是假设,现状 `from_conn_string` 路径不传 factory;切换前要先验证库版本是否支持。 +- **MCP OAuth token 不持久化是事实但 ADR 描述错误**:迁移点不是"文件挪到 DB",而是"无持久化 → KMS 加密 DB"——实际工作量更大。 + +--- + +## ADR-007: 路由与前端租户化 + +### Assumptions about current code + +1. nginx 把 `/api/*` → Gateway 8001、`/api/langgraph/*` → 同 Gateway 重写 — **HOLDS** + - Evidence: 仓库根 CLAUDE.md "Architecture at a glance" 描述;backend CLAUDE L226 也确认。 +2. 前端用 Better Auth 走 cookie session — **DOES NOT HOLD** + - Evidence: `frontend/package.json` 不依赖 `better-auth`(grep 命中 0 次);`frontend/src/core/auth/proxy-policy.ts:52` cookie name 是 `access_token`、由后端 `app/gateway/auth/jwt.py` 自签 JWT;`frontend/src/core/auth/server.ts:25-26` 直接读 `access_token` cookie 调 Gateway `/auth/me`。 + - Reality: 前端 auth 是后端自有 JWT + cookie 直通,**不是 Better Auth**;ADR §8 "Better Auth 接入"整段需要重写为"自有 JWT 中间件接入"。 +3. LangGraph SDK 在 `core/api/` 单例 — **HOLDS** + - Evidence: `frontend/src/core/api/api-client.ts` `createCompatibleClient` 内 `new LangGraphClient(...)`;模块导出单一 client。 +4. 没有租户概念,单 host 单工作区 — **HOLDS** + - Evidence: `frontend/src/app` 路由组 `(auth)` / `[lang]` / `workspace` / `blog`,无 `(tenant)/[slug]/`;`grep -rn "tenant"` 在 frontend 命中也基本为零。 +5. CSRF middleware 已存在 — **HOLDS** + - Evidence: `app/gateway/csrf_middleware.py` 文件存在;前端 `core/api/api-client.ts:20-32` `injectCsrfHeader` 从 `csrf_token` cookie 读。 +6. AuthMiddleware 从 cookie 读 JWT、注入 user_id 到 ContextVar — **HOLDS** + - Evidence: `app/gateway/auth_middleware.py:84,112,122`。 +7. 存在 `/setup` 路径处理首启 — **HOLDS** + - Evidence: `frontend/src/app/(auth)/setup/`;`auth/repositories/sqlite.py:118` 数 admin 用户。 + +### Highest-risk gaps + +- **Better Auth 不存在**:ADR §8 整段"Better Auth 注入 tid/role/tv"前提作废。要么重写 ADR,要么把现有 `auth/jwt.py:14-19` `TokenPayload` 直接扩字段 + bump `ver`——后者其实更简单,但需要 ADR 显式承认。 +- **前端路由全部按 `(tenant)/[slug]/` 重组的工作量**:当前 `app/workspace/chats/[thread_id]` / `app/workspace/agents/...` 已是核心路径,整体 L 估工没有问题但会触动几乎所有 Server Components。 + +--- + +## Cross-cutting risks(跨 ADR) + +1. **整个代码库 0 处 `tenant_id` 字段 / 类型 / 引用**:`grep -rn "tenant" backend/packages/harness/ backend/app/` 命中为空。所有 ADR 假设的 ContextVar (`set_current_tenant`)、JWT claim (`tid`)、表列 (`tenant_id`)、路径 (`/tenants/{tid}/`)、缓存键全部不存在 — 任何"加 tenant 维度"的改造都是从零起,而非"扩展现有"。 + +2. **没有 ObjectStorage / 没有 KMS / 没有 Postgres 测试基础设施**:ADR-001 RLS、ADR-003 secret vault、ADR-005 三层存储、ADR-006 OAuth 持久化都共用同一组缺失底座 — 这组底座必须先于任何业务改造落地,否则各 ADR 互为前置条件死锁。 + +3. **Better Auth 与 LangGraph connection_factory 两个外部依赖假设错误**:ADR-007 假设有 Better Auth、ADR-001/006 假设 langgraph-checkpoint-postgres 支持 connection_factory;前者**当前不存在**、后者**当前未启用**。两个 ADR 写决策时把"外部库能力"误当现状,是同一类风险。 + +4. **`extensions_config.json` 是当前 MCP/skills 状态的唯一真源**:`mcp/cache.py` mtime 失效、`routers/{mcp,skills}.py` 直接读写文件、`tools/tools.py:115-119` 同样依赖;它向 DB 迁移会同时触动 ADR-005 §5.4(拆库)、ADR-006 §2.2/2.3(cache 重构)、ADR-004 §5.4(写敏感操作 strict)三个 ADR。 + +5. **当前代码的 user_id 过滤是"应用层后过滤 + 部分 SQL WHERE 混合"**:`thread_meta/sql.py:69-70` 是 `if row.user_id != resolved_user_id` 应用层比对,不是 SQL `WHERE`。RLS 假设"加 tenant_id 是平行扩展"在现状下被打了折扣 — 索引前导列、SQL `WHERE` 形态、应用层过滤路径都需要先标准化才能加 RLS 兜底。