From db92d024be69514852dcf0af1d914d18703fdf61 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 25 Aug 2026 18:33:30 +0800 Subject: [PATCH] docs(38): 移入 done/ —— 登录鉴权 + 公司级多租户隔离四个 Phase 全部完成 --- docs/README.md | 2 +- docs/done/system-design/38-auth-multi-tenant-isolation.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/todo/system-design/38-auth-multi-tenant-isolation.md | 141 --------------------------------------------------------------------------------------------------------------------------------------------- 3 files changed, 142 insertions(+), 142 deletions(-) create mode 100644 docs/done/system-design/38-auth-multi-tenant-isolation.md delete mode 100644 docs/todo/system-design/38-auth-multi-tenant-isolation.md diff --git a/docs/README.md b/docs/README.md index a5a7c46..0e912ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ | [system-design/27-p2-org-graph.md](./done/system-design/27-p2-org-graph.md) | P2 Org Graph — ✅ 已实现(`70a4727` + `f20d0d8`):org graph 定位为 company 级 workflow(执行流边),`orggraph-planning` agent 建公司时自动规划并招聘。真实建公司端到端验证通过 | | [system-design/27-p3c-goal-task-model.md](./done/system-design/27-p3c-goal-task-model.md) | **P3 收尾** goal / task 两级模型 + 控制台任务列表 — ✅ 已完成(2026-08-20):五批实施 + §十七 清尾。含队列 / 并行槽位、`task-planning`、两套任务体系统一、墙钟预算改「只算活跃时长」。**§17.5 是真跑验收** —— 返工闭环第一次由真 agent(不是注入的假 executor)走通 | | [system-design/27-p3c-batch3-handover.md](./done/system-design/27-p3c-batch3-handover.md) | 批 3 交接 —— ✅ 已收口(2026-08-20)。**事故记录归档**:一份未经复核的代码被第三方会话提交进 main。留着是因为 §六 那几条多会话协作规则是真踩出来的 | +| [system-design/38-auth-multi-tenant-isolation.md](./done/system-design/38-auth-multi-tenant-isolation.md) | 登录鉴权 + 公司级多租户隔离 —— ✅ Phase 0/1/2/3 全部完成(2026-08-25)。种子账号 `admin`/`hex1234` + 登录页;全站挂 `requireAuth`(`agent-chat.js` 用 `requireAuthOrInternal` 放行三处服务端自环调用);`company.json` 加 `ownerId`,`canAccessCompany` 是全仓库唯一归属判据,`company.js`/`graph-run.js`/`devloop.js` 分别接入(`graph-run.js` 双挂载点参数形状不同,设计阶段描述不准确,实现时才发现);`okr.js` 因是零导航入口的孤儿页面改挂 `requireAdmin` 而非归属校验。**Phase 3 验证阶段真查出一个漏洞**:`agent-chat.js` 靠 `:agentId` 的 `"companyId:role"` 前缀区分会话归属却从没做过归属校验,任意登录用户可跨租户读别家公司会话,已修复。全量 `vitest` 1433 passed | | [system-design/01-26](./done/system-design/) | 系统设计正文 01~26 章(14、25-harness-engineering-improvements 例外,见 todo/future/;27 例外,见 todo/),各章节状态见文内「状态」标记 | ## todo/(当前主线) @@ -49,7 +50,6 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | -| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | ✅ **Phase 0/1/2/3 全部完成(2026-08-25)**。远程部署前置需求:登录鉴权 + 公司级多租户隔离。**Phase 0**:种子账号 `admin`/`hex1234` + 登录页 + 路由守卫 + 401 拦截器。**Phase 1**:9 个此前零防护的路由文件挂 `requireAuth`/`requireAdmin`;`agent-chat.js` 用新写的 `requireAuthOrInternal`(进程内 `INTERNAL_TOKEN`)放行三处服务端自环调用(招聘/训练自动流水线、goal 汇报回调);新增 `route-auth-gate.test.js` 结构化守卫测试。**Phase 2**:`company.json` 加 `ownerId`,`canAccessCompany` 作为全仓库唯一归属判据;`company.js`/`graph-run.js`(两个挂载点参数形状不同,设计阶段描述不准确,实现时才发现)/`devloop.js` 分别接入;`okr.js` 发现是零导航入口的孤儿页面,改挂 `requireAdmin` 而非投入前端"选公司"工作量(与设计阶段方案的偏离);存量公司迁移脚本已跑。**Phase 3**:验证阶段真查出一个漏洞——`agent-chat.js` 靠 `:agentId` 里的 `"companyId:role"` 前缀区分会话归属,但一直没做归属校验,任意登录用户能跨租户读别家公司的 agent 会话;已修复(`router.param('agentId')` + `canAccessCompany`,全局 agent/内部自环调用例外放行),新增 `agent-chat-company-gate.test.js`。全程新增/修复 9 个既有测试文件(用纯字符串公司 id 从未真正建过公司,新增共享 helper `ensure-test-company.js`)。全量 `vitest` 1433 passed,3 个无关既有失败不受影响 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | diff --git a/docs/done/system-design/38-auth-multi-tenant-isolation.md b/docs/done/system-design/38-auth-multi-tenant-isolation.md new file mode 100644 index 0000000..0543002 --- /dev/null +++ b/docs/done/system-design/38-auth-multi-tenant-isolation.md @@ -0,0 +1,141 @@ +# 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', ...)` 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行): + +```js +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"这条边界,避免误操作把普通用户升成了能看所有公司的角色。 diff --git a/docs/todo/system-design/38-auth-multi-tenant-isolation.md b/docs/todo/system-design/38-auth-multi-tenant-isolation.md deleted file mode 100644 index 0543002..0000000 --- a/docs/todo/system-design/38-auth-multi-tenant-isolation.md +++ /dev/null @@ -1,141 +0,0 @@ -# 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', ...)` 收口一次,而不是逐条路由里手写检查——这样"新增一条路由忘了查权限"这类问题在写路由的时候就不可能发生(少了一步就报错,不是少了一步就悄悄放行): - -```js -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"这条边界,避免误操作把普通用户升成了能看所有公司的角色。 -- libgit2 0.21.4