Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-vs-code-audit.zh-CN.md
T
1445043649 27c4f14233 docs(multi-tenant): 加入 ADR 审计 + spike,并据其修订 ADR-001/006/007
新增两份评审产出物:
- 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>
2026-05-09 21:03:16 +08:00

17 KiB
Raw Blame History

多租户改造 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-167AUTO 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,126init_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/assistantsassistants.search/get 静态 stub触达 thread。Thread 入口在 backend/app/gateway/routers/threads.pythread_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/memoryREADME/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 providerLocalSandboxProvider(零隔离)+ AioSandboxProviderDockerHOLDS
    • Evidence: backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.pybackend/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. 当前 TokenUsageMiddlewareafter_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未持久化 tokenruns/model.py:35-41total_input_tokensRunManager.update_run_completion 时一次写 — 没有按租户/类别维度。

Highest-risk gaps

  • 没有任何用量持久化基础ADR-003 §4.4 的 QuotaMiddlewaretokens_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=0auth/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,36TokenPayload 仅 4 字段,没有 tid/role
  4. 已有 @require_permission(resource, action, owner_check=...) 装饰器,可以扩展 — HOLDS
    • Evidence: app/gateway/authz.py:197-280;现状 owner_checkboolADR §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 失效 pathADR §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-102config/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 <root>/{public,custom}/...<root> 来自 config.skills.get_skills_path(),没有 user_id 维度。
  4. extensions_config.json 在仓库根目录、被 mtime 失效驱动 — HOLDS
    • Evidence: mcp/cache.py:11-53 _config_mtime + _is_cache_staleconfig/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.pyskills/storage/harness 内没有 storage/protocol.pystorage/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.yamlPARTIALLY 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_idDOES NOT HOLD(条件不具备)
    • Evidence: runtime/runs/manager.py:41-78 该类是纯内存 run 注册表,不持有 LangGraph 连接池。LangGraph saver 由 make_checkpointer 独立 lifespan 管理(checkpointer/async_provider.py:73,117),DeerFlow 无法控制每次 acquireADR §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-authgrep 命中 0 次);frontend/src/core/auth/proxy-policy.ts:52 cookie name 是 access_token、由后端 app/gateway/auth/jwt.py 自签 JWTfrontend/src/core/auth/server.ts:25-26 直接读 access_token cookie 调 Gateway /auth/me
    • Reality: 前端 auth 是后端自有 JWT + cookie 直通,不是 Better AuthADR §8 "Better Auth 接入"整段需要重写为"自有 JWT 中间件接入"。
  3. LangGraph SDK 在 core/api/ 单例 — HOLDS
    • Evidence: frontend/src/core/api/api-client.ts createCompatibleClientnew 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 injectCsrfHeadercsrf_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.3cache 重构)、ADR-004 §5.4(写敏感操作 strict)三个 ADR。

  5. 当前代码的 user_id 过滤是"应用层后过滤 + 部分 SQL WHERE 混合"thread_meta/sql.py:69-70if row.user_id != resolved_user_id 应用层比对,不是 SQL WHERE。RLS 假设"加 tenant_id 是平行扩展"在现状下被打了折扣 — 索引前导列、SQL WHERE 形态、应用层过滤路径都需要先标准化才能加 RLS 兜底。