38-auth-multi-tenant-isolation.md 23.6 KB

38 · 登录鉴权 + 公司级多租户隔离 — 实现计划

日期:2026-08-25 触发:代码要部署到远程服务器,现状零鉴权——任何人打开域名直接进系统,:companyId 全部路由零权限校验,谁都能看/改任何一家公司。用户要求:登录注册(种子一个默认账号)+ 全站 cookie 鉴权 + 每个用户只看得到自己的 company(system agent 全局共享不受影响)+ 每个用户的聊天记录互相隔离。 前置结论(已在同一会话里做过一次调研,详见对话记录,不重复展开):这不是从零建鉴权系统。server/routes/auth.js + server/middleware/jwt-auth.js 已经是一套完整可用的注册/登录/JWT-cookie/GitHub+Google OAuth 实现,/ws/claude 的 WebSocket 鉴权也已经做完了。真正缺的是两件事:①把这套已有的锁挂到目前完全不设防的路由上;②公司归属这个概念从 0 到 1 新建(company.json 目前连 owner 字段都没有)。 已拍板的产品决策:存量公司(data/companies/ 下现有的全部)迁移时统一划给种子默认账号;种子账号 admin / hex1234(首次部署后建议改密,见 §七)。 状态:✅ Phase 0/1/2/3 全部完成(2026-08-25)。四个阶段的实现细节、跟设计阶段的偏离(graph-run.js 双挂载点参数形状不同、okr.js 改成 admin-only 而非归属校验)、以及 Phase 3 真查出的一个漏洞(agent-chat.js 之前可跨租户读会话),都记在下面对应 Phase 小节里。


一、现状盘点(本次调研的精确定位,含文件:行号)

1.1 已经有、不用动的部分

  • 认证核心:server/routes/auth.js(279 行)—— POST /register(bcrypt.hash,SALT_ROUNDS)、POST /login(bcrypt.compare)、POST /logout、GET /me、GitHub/Google OAuth 全套,真实可用。
  • JWT + Cookie 中间件:server/middleware/jwt-auth.js —— signAndSetCookie(httpOnly、sameSite:'lax'、生产环境 secure:true、7 天过期、cookie 名 lemon_token)、extractUser(先读 cookie 再 fallback Authorization: Bearer)、requireAuth/optionalAuth/requireAdmin 三个中间件。生产环境 JWT_SECRET 未设直接拒绝启动(第 24-33 行),开发环境有兜底密钥但每次启动打警告。
  • 用户表:server/services/user-db.js:37-51,users 表已有 id/email/password_hash/display_name/avatar_url/provider/provider_id/github_login/role/api_key/created_at/last_login,字段齐全,不用改 schema。
  • WebSocket 鉴权:server/routes/claude-chat-ws.js:396-411 握手阶段解析 Cookie header、验证 JWT,未授权直接 close(4001)。同源部署(Vite dev 代理 /api+/ws 到 :3010,生产环境同一个 Express 进程托管前后端)下浏览器会自动带上 cookie,前端不需要为 WS 连接单独处理鉴权。
  • 已经挂了 requireAuth/optionalAuth 的路由:claude-chat.js、innovator.js、master-chat.js、users.js(仅 admin)——这几个文件里 req.user.id 的用法(尤其 claude-chat.js 用 req.user.id 隔离上传目录 + 每个 session 校验 session.userId !== String(req.user.id))是一份现成的、已经做对的参考实现,公司归属校验直接照这个模式抄,不用重新设计。

1.2 完全零防护的部分

server/index.js:82-99 挂载的路由里,除上面 4 个之外全部零 requireAuth:agent-chat.js、company.js、okr.js、tasks.js、devloop.js、graph-run.js(含它在 /api/company 下的第二个挂载点 .companyRouter,server/routes/graph-run.js:571)、arena.js、config.js、evolution.js、dream.js、whatsapp-auth.js。open-api.js 是个例外——它自己有一套独立的 OPEN_API_KEYS 鉴权(面向外部 Skill 市场调用方),必须显式排除在 cookie 鉴权闸门之外,不能被顺手扫进去。

1.3 公司归属:真正的 0→1

server/services/company-service.js 全程没有 owner/user_id 概念:

  • get(companyId)(约 300 行)直接读 data/companies/{id}/company.json,list()(308 行起)无条件遍历 DATA_DIR 下所有目录返回全部公司。
  • create({id, name, goal, description})(243 行)不接收 owner 参数。
  • 没有 companies SQL 表——公司身份完全是文件系统 + company.json,不是 DB 行。这点利好这次改造:加 ownerId 字段是往一个 JSON 文件里加一个键,不需要写 SQL migration。

:companyId 作为路由参数被信任的范围比"只有 /list"大得多:company.js 一个文件就有 50+ 条 /:companyId/... 路由(goals/tasks/hire/fire/org-graph/pipelines/files/...)。

1.4 两个容易漏掉的边角

  • server/routes/devloop.js:路由用的是 /projects/:pid/...,不是 :companyId——URL 上看不出公司归属。但项目创建时强制要求 company_id(devloop.js:46-48),path-resolver.js 也按 pid → {root_path, mode, company_id} 缓存(path-resolver.js:74,87,218-219,317)——归属信息在,只是要多一跳:校验 :pid 时得先查出它的 company_id,再判断这个 company 是不是当前用户的,不能直接复用 company.js 那种"路由参数本身就是 companyId"的中间件写法。
  • server/routes/okr.js(挂载于 /api/okr,前端消费方是 OkrDashboard.vue)是一套独立的、目前完全没有公司维度的全局 OKR 视图——OkrService 类本身支持按 companyId 分目录存储(okr-service.js:28-33),但 okr.js:20 实例化时是 new OkrService(),没传 companyId,落在"向后兼容"的全局模式,读写的是 data/okr/,跟任何公司都不挂钩。这是一个此前就存在、这次调研才浮出水面的独立缺口,不是这次改造引入的——要不要把它并入这次的公司隔离范围,见 §六。

二、目标与非目标

目标:

  1. 未登录访问任何页面 → 跳转登录页;登录后正常使用。
  2. 每个普通用户只能看到/操作自己名下的 company(含其 goals/tasks/pipelines/devloop 项目/graph-runs/聊天记录)。
  3. system/agents/ 全局共享,不受影响(本来就是文件系统级共享,天然满足,见 §一验证过)。
  4. 种子一个默认账号 admin,密码 hex1234,存量公司全部归它。

非目标(这次不做,未来需要再单独立项):

  • 不做团队/组织级共享(一个 company 只属于一个 user,不支持"邀请协作者")。
  • 不重新设计 GitHub/Google OAuth(已经能用,保持现状,登录页并列展示"账号密码登录"和"第三方登录"两个入口即可)。
  • 不处理 /api/open/* 的 Skill 市场访问控制(它自己的 OPEN_API_KEYS 体系是独立需求)。

三、实现方案

3.1 归属校验的统一收口模式

每个持有 :companyId 的 router 用 router.param('companyId', ...) 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行):

router.param('companyId', (req, res, next, companyId) => {
  const company = companyService.get(companyId);
  if (!company) return res.status(404).json({ error: `公司 ${companyId} 不存在` });
  if (req.user.role !== 'admin' && company.ownerId !== req.user.id) {
    return res.status(404).json({ error: `公司 ${companyId} 不存在` }); // 404 不是 403:不暴露"这个 id 存在但不是你的"
  }
  req.company = company;
  next();
});

返回 404 而不是 403 是刻意的:403 会告诉攻击者"这个 company id 是有效的,只是你无权访问",等于把 id 空间泄漏出去;未登录/无权都统一表现为"不存在"。

company.js、graph-run.js(两个挂载点都要加)都走这个模式。okr.js/tasks.js 目前不接 :companyId 路由参数(见 §一.4),需要看 §六 的决定再定具体接入方式。

3.2 devloop.js 的间接校验

router.param('pid', ...) 里先用 path-resolver 查出 company_id,再复用 3.1 同一份归属判断逻辑(抽成共享函数,不要复制一份判断代码到两个地方)。

3.3 前端

  • 新增登录页(账号密码表单 + 第三方登录按钮,直接调用已有的 /api/auth/login、/api/auth/github)。
  • web/src/router/index.js 加全局 beforeEach:调 GET /api/auth/me(optionalAuth,不 401)判断登录态,未登录且目标路由非登录页 → 重定向登录页。
  • 一个全局的 fetch/axios 拦截器:收到 401 → 清本地状态、跳登录页(避免每个页面各自处理一次 401)。
  • 退出登录入口(调 POST /api/auth/logout 清 cookie)。

3.4 种子账号

不新增脚本,直接复用已有的 POST /api/auth/register(或等价的一次性 seed 脚本包一层 userDb.createUser + bcrypt.hash('hex1234', SALT_ROUNDS)),部署时跑一次。

3.5 存量数据迁移

一次性脚本:遍历 data/companies/*/company.json,无 ownerId 字段的统一写入种子账号的 user.id。脚本本身是幂等的(ownerId 已存在就跳过),可以放心重复跑。


四、分 Phase 实施计划

每个 Phase 独立可验收,不依赖后一个 Phase 就能上线(参考 27-p2-org-graph.md 的分阶段惯例)。

Phase 0 — 种子账号 + 登录页跑通(不改任何业务路由)✅ 已完成(2026-08-25)

  • scripts/seed-admin-user.js(npm run seed:admin)——幂等,建出 admin/hex1234、role=admin。已在本机跑过,账号 id=396。
  • 前端:web/src/views/Login.vue(登录页)+ web/src/composables/useAuth.js(登录态 + checkAuth/login/logout/clearAuth)+ router/index.js 的 beforeEach 守卫(未登录 → 跳 /login?redirect=...,登录后带着原目标跳回)+ main.js 的全局 401 拦截器(中途 cookie 过期同样兜住,不止页面切换那一刻)+ AppNav.vue 加了用户名/退出按钮。
  • 顺手修了一处过程中发现的泄漏:App.vue 原来无条件渲染 MasterChatDrawer,未登录时登录页右上角也会露出这个悬浮入口——改成 v-if="showChrome"(route.name !== 'Login' && !!user),登录页干净了。
  • 验收(真实浏览器走过一遍,非仅测试通过):未登录访问 /company 等任意页面 → 跳 /login?redirect=/company;admin/hex1234 登录成功 → 跳回原目标页,导航栏显示 admin + 退出按钮;直接刷新/新开页面访问受保护路由(非 SPA 内跳转)同样正确放行;点退出 → 清 cookie、跳回 /login;npm run build 产物构建通过,无报错。因为业务路由还没加锁,登录后依然能看到现状全部数据(等价于"登录墙搭好了,屋里还没上锁"),符合这一阶段的验收边界。

Phase 1 — 全站要求登录(不做归属过滤)✅ 已完成(2026-08-25)

  • 给 company.js/okr.js/tasks.js/devloop.js/graph-run.js(两个挂载点:router + companyRouter)/arena.js/config.js/evolution.js/whatsapp-auth.js 挂 requireAuth,显式排除 open-api.js(自己一套 OPEN_API_KEYS)、auth.js 自身、/api/health。
  • dream.js 挂 requireAdmin(已决策,见 §五)——系统级经验汇流视图,只有 admin 能看。
  • agent-chat.js 挂的是新写的 requireAuthOrInternal,不是普通 requireAuth——实现过程中发现的真实风险:company.js:82/506(招聘/训练自动流水线)、task-settle.js:452(goal 汇报回调)三处服务端自环调用会 fetch('http://127.0.0.1:.../api/agent/...') 打这个文件,这三处根本拿不到任何用户 cookie,直接挂 requireAuth 会把公司自动创建、招聘训练、任务汇报这几条核心自动化链路全部打断成 401。解法:jwt-auth.js 新增进程内 INTERNAL_TOKEN(启动时随机生成、只活在内存里,不落盘不读环境变量——调用方和校验方永远在同一个 Node 进程),requireAuthOrInternal 先认这个头(x-internal-token),认不出再退回普通 requireAuth;三处自环调用改成带上这个头。
  • 测试套件的连带问题:仓库里已有 17 个测试文件、67+ 处用例直接拿 supertest 打这几个路由,从没设过登录 cookie(测的是业务逻辑,不是鉴权)——挂完锁后这批测试集体从 200/201 变 401。没有逐个文件补登录态(会引入 bcrypt 开销拖慢整个套件,且这些用例本来就不该关心鉴权),改为:jwt-auth.js 的 requireAuth/requireAdmin 在 NODE_ENV==='test'(vitest 自动设置)时默认放行,除非显式设了 FORCE_REAL_AUTH=1;新增 server/tests/unit/route-auth-gate.test.js 专门在 FORCE_REAL_AUTH=1 下做结构检查(router.stack 里有没有那个中间件函数本身、且排在所有具体路由前面),不发真实 HTTP、不碰 DB,确保"锁真的挂上了"这件事有测试兜底,而不是被 67 处业务测试的默认放行悄悄掩盖。
  • 验收:真实 curl 走过一遍——无 cookie 访问 company.js/tasks.js/devloop.js/okr.js/graph-run.js/dream.js 全部 401;admin/hex1234 登录后全部可访问;新注册的非 admin 用户能访问 company.js 等(Phase 1 还没做归属过滤,符合预期),但访问 dream.js 得到 403;open-api.js/auth.js 未受影响(前者仍是自己的 API Key 报错文案,后者登录本身不能被锁死)。手工临时删掉 tasks.js 的 router.use(requireAuth) 验证过守卫测试真的会红。全量 npx vitest run:1427 passed(含新增 13 条守卫测试),3 个失败与本次改动无关(git stash 验证过,改动前后同样失败:agent-md-refs/graduation-criteria-reality/graph-engine-extend-budget,是仓库里并发的其它工作留下的,非本次引入)。

Phase 2 — 公司归属隔离(这次真正的硬骨头)✅ 已完成(2026-08-25)

  • company-service.js:company.json 加 ownerId(无 SQL 表,纯 JSON 字段);create() 接收 ownerId;list({ user }) 按传入的 user 过滤,不传就不过滤——company-heartbeat-service.js 这类系统内部巡检要看全部公司,不代表任何用户;新增 canAccessCompany(company, user) 作为全仓库唯一一份归属判据(admin 不过滤,404 不是 403)。
  • 存量数据迁移脚本 scripts/backfill-company-owner.js(npm run backfill:company-owner)跑过,e2e-demo/ovf-co 两家存量公司已划给种子账号,幂等验证过。
  • company.js 用 router.param('companyId', ...) 统一收口(约 50 条路由,含 okrPath() 系列动态路由,全部核实过参数名一致)。
  • graph-run.js 的两个挂载点参数形状不一样,不能照抄同一份逻辑——设计阶段的描述不准确,实现时才发现:companyRouter 唯一路由是 /:companyId/graph-runs,直接查;但主 router 全部路由是 /:id/...,:id 指的是 run,不是公司(GET /:id 吐的是 run 实例)。改成 router.param('id', ...):先 getRun(runId) 拿到 run.company_id,再查这家公司归不归当前用户,run 不存在或公司不是你的都统一回"run 不存在"(不暴露哪种情况)。
  • devloop.js 用 router.param('pid', ...) 反查 pathResolver.resolveProjectMeta(pid).company_id 再校验;POST /projects 单独处理(建的是全新项目,还没有 :pid 可拦,company_id 从 body 里点名,校验顺序放在基本字段校验之后——避免"归属检查抢在输入校验前面"打乱错误信息的优先级);GET /projects 按 company_id 过滤列表。
  • okr.js 的处理跟设计阶段的方案不一样,是权衡后的偏离,如实记录:原计划是给这个独立挂载点接上 companyId 归属校验。实现前查证:OkrDashboard.vue(唯一消费方)挂载在路由 /okr,全站没有任何导航入口指向它(AppNav.vue/其它页面零引用),是个孤儿页面。真正在用的公司级 OKR 是 company.js 自己的 /api/company/:id/okr(CompanyDashboard.vue 团队页 OKR tab 在用),这条已经随 company.js 一起被收口,不需要额外工作。没有为一个访问不到的页面新建"选公司"前端交互,改成 requireAdmin(跟 dream.js 同款处理)——堵住"能拼 URL 直接看到跨公司聚合数据"的口子,比投入前端工作量收益高。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。
  • 测试套件的连带修复,比预想的更细碎:8 个既有测试文件(agent-transcript/graph-run-route-io/graph-run-route-slot-busy/p3c-recovery-http/task-slots-parallel/company-files-path-guard/heartbeat-company-scoped/devloop 下 3 个文件)用的公司 id 都是纯字符串标签,从没通过 companyService.create() 建过真公司——新增共享测试 helper server/tests/helpers/ensure-test-company.js(ensureTestCompany/removeTestCompany),在每个文件生成测试公司 id 的唯一入口处补一次 company.json;两个文件里原有的"跳过清理已存在 company.json 的目录(那是真公司)"防御逻辑,判据从"有没有 company.json"改成"ownerId 是不是 -1 这个测试专用标记",否则会把这批新出现的测试夹具误判成真公司、永远不清理。
  • 验收:全部真实 curl 走过(种子账号登录看到全部存量公司;新注册账号登录看到空列表;新账号直接拼 URL 访问种子账号的 company id → 404;新建 company 后只有创建者自己的 list 能看到、admin 两个都看到、ownerId 落的确实是创建者的 users.id)。全量 npx vitest run:1427 passed,同样 3 个无关的既有失败(未受影响)。

Phase 3 — 聊天记录隔离验证 + 前端收尾体验 ✅ 已完成(2026-08-25)

  • 验证不是走过场,真查出了一个之前没预料到的真实漏洞。 agent-chat.js(/api/agent/*)一直靠 :agentId 里的命名空间前缀("{companyId}:{role}",locator.companyStateDir 解析)区分会话属于哪家公司,但 Phase 1 只给它挂了登录门槛(requireAuthOrInternal),Phase 2 的归属收口只覆盖了 company.js/graph-run.js/devloop.js,完全没顾上这个文件。真实 curl 验证:任意登录用户对 GET /api/agent/{别人的companyId}:{role}/conversations 依然 200——能跨租户读别家公司的 agent 会话。
  • 修法跟 company.js 同一套思路:router.param('agentId', ...),解析 : 前的 companyId 段,过 canAccessCompany。三个例外都要照顾到:①没有 : 前缀的全局 agent(master/org-innovator 等,落 _global)不做归属校验——它们本来就是全系统共享,不属于任何公司;②内部自环调用(req.user.internal,见 Phase 1 的 INTERNAL_TOKEN)直接放行,招聘/训练自动流水线要能打到任意公司名下;③管理员一如既往不受限制。
  • 新增单元测试 server/tests/unit/agent-chat-company-gate.test.js:直接拿 Express 暴露的 router.params.agentId[0] 单测这个 param handler(不起真 HTTP server),覆盖同公司放行/跨公司 404/admin 不受限/公司不存在 404/全局 agent 放行/内部自环放行六种情况。
  • 顺带修好一个既有测试文件的连带失败:agent-chat-tenant-isolation.test.js(测另一类更早的租户隔离 bug——todo/plan 落点、CLI cwd)用的 zz-tenant-alpha/zz-tenant-beta 也是从没真的建过公司的字符串标签,同样用 ensure-test-company.js 补上。
  • 前端:直接拿一个新注册、不属于任何公司的账号在真实浏览器里跑一遍——CompanyDashboard.vue 对"当前没有可用公司"这个状态已经有现成的空状态处理("自治架构观测站 · 没有任何正在运行中的公司或工作舱"),不需要为这次改造专门新写空状态;导航栏/退出登录跟 Phase 0 验证过的行为一致。
  • 验收:curl 复测跨租户读 agent-chat.js 会话从 200 变 404,admin/所有者依然 200,全局 agent 不受影响,公司不存在依然 404;真实浏览器用全新账号访问一家不属于自己的公司的 URL,看到的是既有的、体验良好的空状态而不是报错/崩溃/白屏。全量 npx vitest run:1433 passed(含新增 6 条),同样 3 个无关既有失败。

五、开放问题(已拍板)

  1. okr.js 的全局 OkrService 要不要并入这次范围?→ 并入的初衷成立,最终落地方式在实现阶段调整了,如实记录。 用户明确要求"OKR 跟着 company 走,需要隔离"——这个诉求在真正在用的 OKR 功能上已经满足:company.js 自己的 /api/company/:id/okr(CompanyDashboard.vue 团队页 OKR tab 在用)随 company.js 的 router.param('companyId') 收口一起隔离了,不算独立工作量。但独立的遗留挂载点 /api/okr(okr.js + 未接 companyId 的 OkrService)在实现阶段查证后发现是个孤儿页面——OkrDashboard.vue 挂载在路由 /okr,全站没有任何导航入口指向它。给一个访问不到的页面新建"选公司"前端交互投入产出比不划算,改成 requireAdmin(详见 §四 Phase 2),先堵住直接拼 URL 能看到跨公司数据的口子。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。
  2. 首次登录是否强制改密 → 不需要。 种子密码 hex1234 保持明文可用,不加强制改密流程。
  3. arena.js/evolution.js → 确认为疑似废弃代码,不投入隔离工作量。 核实结果:web/src/ 全局搜索零处引用 /api/arena(注意与 open-api.js 下仍在用的 /api/open/arena/leaderboard 是两个不同文件,后者继续保留)、零处引用 /api/evolution;两个文件最后一次改动都停在 2026-03-23(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 requireAuth(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。
  4. dream.js → 系统级,只有 admin 能看。 已更新 §四 Phase 1:挂 requireAdmin 而不是 requireAuth。
  5. config.js 归属未定:现状调研没有深入,且用户这轮没有明确指示。Phase 1 先按 arena.js/evolution.js 同款处理——挂 requireAuth,不做归属过滤,留到看内部是否有 company 维度数据时再决定要不要提级。

六、风险

  1. 归属校验漏挂——见 §一.2/§四 Phase 2 的守卫测试建议,这是本次最大的风险点,一条路由漏挂 = 直接的越权数据读写。
  2. graph-run.js 的双挂载点——server/index.js:97-98 同一个文件以两种方式挂载(/api/graph-runs 全量 + /api/company 下的 .companyRouter 子集),改的时候容易只改一处。
  3. devloop.js 的间接校验容易被绕过——如果新增一条 devloop 路由时忘了走 :pid 收口点(比如直接接收 company_id 作为 body 参数而不是从已归属校验过的 :pid 反查),归属检查会被绕过。建议 code review 时专门盯这一类新路由。
  4. admin 角色的越权范围——requireAdmin/role==='admin' 目前语义是"看得到所有公司",这次种子账号本身就是 admin,等于事实上的超级账号;如果以后要开放更多真实用户注册,需要明确"什么样的用户会被设成 admin"这条边界,避免误操作把普通用户升成了能看所有公司的角色。