新增两份评审产出物: - 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) <noreply@anthropic.com>
17 KiB
多租户改造 ADR 与现状代码审计报告
审计日期:2026-05-09 审计基线:分支
docs/multi-tenant-redesign@dce5e959目的:在动手写迁移代码之前,逐条核对 7 份 ADR + phase-0 计划对当前代码的假设是否成立,避免基于错误前提做架构决策。
ADR-001: 数据隔离模型
Assumptions about current code
- 仓储层使用
user_idContextVar +AUTO哨兵自动注入 — HOLDS- Evidence:
backend/packages/harness/deerflow/runtime/user_context.py:135-167(AUTOsentinel +resolve_user_id);backend/packages/harness/deerflow/persistence/thread_meta/sql.py:35-41已按此模式调用。
- Evidence:
- LangGraph checkpointer 使用
AsyncPostgresSaver自带连接池 — HOLDS- Evidence:
backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py:73,117AsyncPostgresSaver.from_conn_string(...);DeerFlow 不传connection_factory。
- Evidence:
- SQLAlchemy 引擎单例 +
AsyncSessionfactory 已就位(即"DeerFlow 仓储侧连接池")— HOLDS- Evidence:
backend/packages/harness/deerflow/persistence/engine.py:26-27,126;init_engine_from_config支持 sqlite/postgres/memory。
- Evidence:
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。
- Evidence:
- 仓储层
WHERE user_id已是默认行为,加一层tenant_id是平行扩展 — PARTIALLY HOLDS- Evidence:
persistence/thread_meta/sql.py:69-70是应用层if row.user_id != resolved_user_id后过滤,而非 SQLWHERE;其他仓储多数才走 SQL WHERE。RLS"前导列必须是 tenant_id"的索引前提目前完全不存在。
- Evidence:
- 当前是 SQLite 默认,Postgres 是可选 backend — HOLDS
- Evidence:
persistence/engine.py:80-114同时支持 sqlite/postgres/memory,README/CLAUDE 默认演示 sqlite。
- Evidence:
Highest-risk gaps
AssistantsCompathook 点错位:迁移时若按 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
- 存在两个 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 显式禁用。
- Evidence:
- 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)。
- Evidence:
SandboxAuditMiddleware已存在并记录工具调用 — HOLDS- Evidence:
backend/packages/harness/deerflow/agents/middlewares/sandbox_audit_middleware.py文件存在;CLAUDE.md L164 列在中间件链。
- Evidence:
- K8s/Provisioner 模式存在但仅作为 sandbox 选项 — PARTIALLY HOLDS
- Evidence:
backend/CLAUDE.md提到provisionerport 8002 在配置 aio_sandbox+provisioner 时启动;但仓库中无K8sSandboxProvider,没有 namespace/NetworkPolicy/gVisor 任何配套,威胁模型是"未来"而非"现状"。
- Evidence:
- 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
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。
- Evidence:
- 当前
TokenUsageMiddleware在after_model一次性提交 token — HOLDS- Evidence:
agents/middlewares/token_usage_middleware.py:288-294after_model/aafter_model调_apply;_apply仅 log + 更新additional_kwargs,不写 DB(更不区分 usage_category)。
- Evidence:
- 存在
MemoryMiddleware/TitleMiddleware/SummarizationMiddleware三处内部 LLM 调用 — HOLDS- Evidence:
agents/middlewares/{memory,title,summarization}_middleware.py全部存在(CLAUDE L169-170)。
- Evidence:
- tenant_secrets / tenant_quotas / tenant_usage_daily 表已存在 — DOES NOT HOLD
- Evidence:
persistence/{user,thread_meta,run,feedback}/model.py即全部 ORM 模型;无任何 tenant_* 表。
- Evidence:
- 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时一次写 — 没有按租户/类别维度。
- Evidence:
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
users.system_role存在且仅admin/user两值 — HOLDS- Evidence:
persistence/user/model.py:33system_role: Mapped[str] = ... default="user";auth/models.py:23Literal["admin", "user"]。
- Evidence:
users.token_version已存在用作 JWT 失效 — HOLDS- Evidence:
persistence/user/model.py:49token_version: Mapped[int] ... default=0;auth/jwt.py:18,36JWT payload 已带verclaim。
- Evidence:
- JWT payload 当前结构是
{sub, exp, iat, ver}(无 tid/role)— HOLDS- Evidence:
app/gateway/auth/jwt.py:14-19,36:TokenPayload仅 4 字段,没有 tid/role。
- Evidence:
- 已有
@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,需要重构。
- Evidence:
AuthMiddleware在 ContextVar 注入 user — HOLDS- Evidence:
app/gateway/auth_middleware.py:122set_current_user(user);尚无set_current_tenant。
- Evidence:
tenant_memberships表存在 — DOES NOT HOLD- Evidence:
persistence/目录无 tenants/memberships/invitations 任何表。
- Evidence:
Highest-risk gaps
token_version只是 column,无 cache 层 / membership 失效 path:ADR §5.2.3 的MembershipCache30s LRU + bump 触发机制全部要新建。- 现有
system_role="admin"是平台级管理员且唯一:ADR §6 计划保留它做 platform_admin,但现状 admin 与"租户内 owner"语义未分离,迁移时首启逻辑("创建第一个 admin")会与新增"创建 default 租户 + 设其为 owner"耦合,需要兼容旧部署。
ADR-005: 存储拓扑
Assumptions about current code
- memory.json 落
{base_dir}/users/{user_id}/memory.json文件 — HOLDS- Evidence:
agents/memory/storage.py:84-102;config/paths.py:155-157user_memory_file()。
- Evidence:
- agent SOUL.md / config.yaml 落
{base_dir}/users/{user_id}/agents/{name}/— HOLDS- Evidence:
config/paths.py:163-169user_agent_dir / user_agent_memory_file 系列;CLAUDE backend.md L356-359 描述一致。
- Evidence:
- 自定义 skills 走
skills/custom/全局共享、非 per-user — HOLDS- Evidence:
skills/storage/local_skill_storage.py:24-32layout<root>/{public,custom}/...;<root>来自config.skills.get_skills_path(),没有 user_id 维度。
- Evidence:
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()。
- Evidence:
- 上传走本地 thread 目录 — HOLDS
- Evidence:
uploads/manager.py:40-48get_paths().sandbox_uploads_dir(thread_id, user_id=...)。
- Evidence:
ObjectStorage抽象 /LocalObjectStorage/S3ObjectStorage已存在 — DOES NOT HOLD- Evidence:
find ... -name "storage*"仅命中agents/memory/storage.py与skills/storage/;harness 内没有storage/protocol.py、storage/s3.py任何 ObjectStorage 抽象。
- Evidence:
agent_configs/memory_facts/tenant_skill_state表存在 — DOES NOT HOLD- Evidence:
persistence/仅user//thread_meta//run//feedback/;ADR-005 §2.1 列出的 7 张新表全部不存在。
- Evidence:
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写 +MemoryUpdateQueue30s debounce 全部假设文件系统语义。
ADR-006: 运行时与渠道层的租户化
Assumptions about current code
mcp/cache.py:11是模块级单例_mcp_tools_cache: list[BaseTool]— HOLDS- Evidence:
mcp/cache.py:11-14完全字面命中。
- Evidence:
- MCP cache 失效靠
extensions_config.jsonmtime — HOLDS- Evidence:
mcp/cache.py:31-53_is_cache_stale比对os.path.getmtime。
- Evidence:
MultiServerMCPClient实例缓存在进程内 + 持有 OAuth token — PARTIALLY HOLDS- Evidence:
mcp/oauth.py:25-31OAuthTokenManagertoken 缓存是dict[str, _OAuthToken]进程内存;ADR-006 §2.2 / §1 表格写"OAuth token 落本地文件"——不正确,目前没有持久化,每次进程重启重新刷 token。 - Reality: token 在 memory only,迁移时挪到
tenant_secrets是从 0 起,比"从文件挪到 DB"成本更高(要新加持久化 + 加密)。
- Evidence:
LocalSandboxProvider/AioSandboxProvider在 lifespan 创建一次单例 — HOLDS- Evidence:
sandbox/sandbox_provider.py:41-58全局单例 +get_sandbox_provider。
- Evidence:
MemoryMiddleware/TitleMiddleware/SummarizationMiddleware都通过create_chat_model()拿 LLM — HOLDS(推断)- Evidence: 三个 middleware 文件存在;
models/factory.py:50是唯一工厂(CLAUDE 已说明),统一入口意味着 ADR §2.5 改造点统一。
- Evidence: 三个 middleware 文件存在;
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。
- Evidence:
RunManager创建/恢复 thread 前可以 issueSET 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 通路。
- Evidence:
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
- nginx 把
/api/*→ Gateway 8001、/api/langgraph/*→ 同 Gateway 重写 — HOLDS- Evidence: 仓库根 CLAUDE.md "Architecture at a glance" 描述;backend CLAUDE L226 也确认。
- 前端用 Better Auth 走 cookie session — DOES NOT HOLD
- Evidence:
frontend/package.json不依赖better-auth(grep 命中 0 次);frontend/src/core/auth/proxy-policy.ts:52cookie name 是access_token、由后端app/gateway/auth/jwt.py自签 JWT;frontend/src/core/auth/server.ts:25-26直接读access_tokencookie 调 Gateway/auth/me。 - Reality: 前端 auth 是后端自有 JWT + cookie 直通,不是 Better Auth;ADR §8 "Better Auth 接入"整段需要重写为"自有 JWT 中间件接入"。
- Evidence:
- LangGraph SDK 在
core/api/单例 — HOLDS- Evidence:
frontend/src/core/api/api-client.tscreateCompatibleClient内new LangGraphClient(...);模块导出单一 client。
- Evidence:
- 没有租户概念,单 host 单工作区 — HOLDS
- Evidence:
frontend/src/app路由组(auth)/[lang]/workspace/blog,无(tenant)/[slug]/;grep -rn "tenant"在 frontend 命中也基本为零。
- Evidence:
- CSRF middleware 已存在 — HOLDS
- Evidence:
app/gateway/csrf_middleware.py文件存在;前端core/api/api-client.ts:20-32injectCsrfHeader从csrf_tokencookie 读。
- Evidence:
- AuthMiddleware 从 cookie 读 JWT、注入 user_id 到 ContextVar — HOLDS
- Evidence:
app/gateway/auth_middleware.py:84,112,122。
- Evidence:
- 存在
/setup路径处理首启 — HOLDS- Evidence:
frontend/src/app/(auth)/setup/;auth/repositories/sqlite.py:118数 admin 用户。
- Evidence:
Highest-risk gaps
- Better Auth 不存在:ADR §8 整段"Better Auth 注入 tid/role/tv"前提作废。要么重写 ADR,要么把现有
auth/jwt.py:14-19TokenPayload直接扩字段 + bumpver——后者其实更简单,但需要 ADR 显式承认。 - 前端路由全部按
(tenant)/[slug]/重组的工作量:当前app/workspace/chats/[thread_id]/app/workspace/agents/...已是核心路径,整体 L 估工没有问题但会触动几乎所有 Server Components。
Cross-cutting risks(跨 ADR)
-
整个代码库 0 处
tenant_id字段 / 类型 / 引用:grep -rn "tenant" backend/packages/harness/ backend/app/命中为空。所有 ADR 假设的 ContextVar (set_current_tenant)、JWT claim (tid)、表列 (tenant_id)、路径 (/tenants/{tid}/)、缓存键全部不存在 — 任何"加 tenant 维度"的改造都是从零起,而非"扩展现有"。 -
没有 ObjectStorage / 没有 KMS / 没有 Postgres 测试基础设施:ADR-001 RLS、ADR-003 secret vault、ADR-005 三层存储、ADR-006 OAuth 持久化都共用同一组缺失底座 — 这组底座必须先于任何业务改造落地,否则各 ADR 互为前置条件死锁。
-
Better Auth 与 LangGraph connection_factory 两个外部依赖假设错误:ADR-007 假设有 Better Auth、ADR-001/006 假设 langgraph-checkpoint-postgres 支持 connection_factory;前者当前不存在、后者当前未启用。两个 ADR 写决策时把"外部库能力"误当现状,是同一类风险。
-
extensions_config.json是当前 MCP/skills 状态的唯一真源:mcp/cache.pymtime 失效、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。 -
当前代码的 user_id 过滤是"应用层后过滤 + 部分 SQL WHERE 混合":
thread_meta/sql.py:69-70是if row.user_id != resolved_user_id应用层比对,不是 SQLWHERE。RLS 假设"加 tenant_id 是平行扩展"在现状下被打了折扣 — 索引前导列、SQLWHERE形态、应用层过滤路径都需要先标准化才能加 RLS 兜底。