diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/check.jsonl b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/design.md b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/design.md new file mode 100644 index 00000000..c5d9f70f --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/design.md @@ -0,0 +1,145 @@ +# 生产员工 Smoke Break-glass 重置:设计说明 + +## 1. 边界与分阶段授权 + +本任务修复专用 employee smoke 的认证能力,不改变员工角色或权限,也不执行合同负向验收。整个任务分三层:本地工具实现、正常候选发布、生产账号 reset。`task.py start` 只进入第一层;发布和账号写入各有独立审批停点。 + +新脚本必须通过正常发布进入镜像。带外复制虽然改动面看似更小,却绕过 provenance、运行脚本 SHA 和可复现发布合同,不能用于唯一一次生产认证写入。 + +## 2. 现有 helper 的真实副作用 + +`repair-oa-only-smoke-accounts.js` 的 dry-run 和 apply 共用 `planForTarget`: + +```text +restore_active_employee -> status/employment_status/archived_at +clear_login_lock -> login_failed_* / login_locked_until +add_missing_roles -> INSERT employee_roles, never delete extras +every applied target -> token_version +1 + lifecycle event +password_hash -> never touched +session/challenge -> never revoked/invalidated +``` + +因此它既不能恢复密码,也不能建立精确角色不变量。本任务仅运行 dry-run 作为“现场仍无需生命周期/角色修复”的独立证明;任何 planned action 都阻塞新脚本 apply,helper apply 永久禁止。 + +## 3. 新脚本目标模型 + +新增 `reset-employee-smoke-password.js`,复用现有 helper 导出的 smoke 纯判据,但不调用其 `execute/applyTarget`。目标由数据库状态自行发现,不传 identifier: + +```text +status == active && employment_status == active && archived_at == null +&& email/phone identifier ready +&& current login lock is clear +&& must_change_password == false +&& smoke(row.employee_no | row.email | row.name) +&& sorted(roleCodes) == ["employee"] +&& resolvePermissionsForRoles(roleCodes) + excludes ["*", "project.manage", "project.view_all", "system.settings"] +``` + +查询最多保留两个匹配用于判断唯一性,稳定输出只包含 count/boolean。角色精确断言同时保证没有管理员、经理、财务、人事等额外角色;权限断言独立存在,防止未来 `ROLE_PERMISSIONS.employee` 漂移后角色仍正确但权限已扩大。 + +## 4. 状态机 + +```text +PLANNING + -> local implementation/tests/read-only review + -> STOP: deployment approval + -> commit/push/remoteVerified -> normal deploy -> runtime script SHA match + -> production read-only identity/migration/window + -> repair helper dry-run: OK/no actions, apply=0 + -> employee script --preflight: PASS/1/exact role/no manage permissions + -> old-hash limited material + temp/final secrets ready + -> STOP: production account-write approval + -> --apply EMPLOYEE_SMOKE_BREAK_GLASS exactly once + -> --verify + independent role/permission snapshot + -> only if post-write negative premise PASS: one login + -> /change-password once -> stop at /portal + -> --verify-password-changed + final role/permission snapshot + -> secret update + sensitive cleanup + read-only reviewer + -> RESET_COMPLETE -> handoff 08-08 readiness +``` + +任一箭头失败即停止。发布失败不会触及账号;apply 结果不确定时仅 `--verify`,不得重试;写后权限不变量失败时不得登录。 + +## 5. 命令接口与稳定输出 + +脚本接口: + +- `--preflight`:目标计数、唯一性、smoke/identifier/status/role/permission 布尔。 +- `--verify`:上述不变量 + must-change、locks、active session/code、audit 状态。 +- `--verify-password-changed`:上述不变量 + must-change=false、一个新 active session、两类 audit。 +- `--diagnose-wecom-binding`(可选只读):仅记录绑定/同步/近期认证计数,不输出 external user id。 +- `--apply EMPLOYEE_SMOKE_BREAK_GLASS`:唯一写入口,密码从 stdin。 + +错误只输出 allowlist category,例如 `employee_smoke_target_not_unique`、`employee_smoke_target_changed`、`explicit_confirmation_required`、密码输入/策略错误;未知异常只输出安全类别,不回显数据库消息或目标字段。 + +## 6. 事务写入合同 + +apply 在事务外发现唯一目标并取得 token version,生成同源 bcrypt hash;事务内再次读取并核对所有目标/权限不变量。通过后仅执行: + +1. 更新目标 `password_hash`、`must_change_password=true`;清三类认证失败/锁定字段与 password-reset request 字段;token version +1。 +2. 以 `employee_id=target.id` 撤销 active auth sessions、失效 password reset codes、消耗 email login codes。 +3. 写 `SYSTEM_EMPLOYEE_SMOKE_BREAK_GLASS_PASSWORD_RESET` 审计,actor/target 均为目标,extra 只记录 `credentialRecorded=false`。 + +事务不包含 `employee_roles`、status/employment/archive、identifier、企微或业务表。单测必须对这些缺席路径做断言,而不只验证预期 update 存在。 + +## 7. 严格一次与不确定结果 + +执行前记录专用 attempt 哨兵和本次审计基线;一旦发出 apply,`applyExecutionCount` 固定为 1。结果分支: + +- 完整 `PASS`:立即 `--verify`。 +- 明确 `BLOCKED`/非零:停止,不重试。 +- SSH 中断、输出缺失、JSON 不可解析:标记 `APPLY_UNCERTAIN`,只运行 `--verify`。 + +verify 若显示 must-change、audit/session/code 等状态已落地,则按成功写入处理并禁止第二次 apply;审计计数相对写前恰好 +1 作为独立交叉证明。 + +## 8. 写后权限再确认 + +写前诊断不是写后证据。apply 后先由脚本 `--verify` 重算目标、角色和权限,再以独立 `BEGIN READ ONLY` 查询重算同一组不变量: + +```text +targetMatches=1 +rolesExact=true +hasWildcard=false +hasProjectManage=false +hasProjectViewAll=false +hasSystemSettings=false +``` + +任何一个值不符都停止在无登录状态。不得通过删除角色、改权限或改验收断言补救;这类生产写入需要新任务。 + +## 9. 凭据、强制改密与恢复材料 + +临时/最终密码由受控 CSPRNG 分别生成,均满足应用策略且不相同。临时密码通过 stdin 进入 apply;正式仓库外 employee secret 仅在 verify PASS 后原子更新为正确 identifier + 临时密码,强制改密完成后再替换为最终密码。 + +旧 hash 只在生产现场 `0700/0600` 暂存,最晚 apply 后 24 小时销毁。它不是完整或部分回滚;即使另行获批写回,也不能恢复 token、会话、challenge、计数和审计。 + +浏览器使用单 context、workers=1、retries=0:一次登录必须到 `/change-password`,一次提交允许到 `/portal` 后立即关闭;业务 API 发送为 0。随后 `--verify-password-changed` 和最终独立权限快照都 PASS 才清理材料。 + +## 10. 发布与脚本身份 + +本地实现和只读 reviewer 通过后形成 clean/published 候选。生产发布沿用现有 identity bootstrap、preflight、migration/window 和 `deploy-existing.sh`,不手工同步单文件。 + +发布后以 `git show :server/src/scripts/reset-employee-smoke-password.js` 为基线,对运行容器同路径做 SHA-256 比较;只记录 `scriptShaMatch=true`。脚本不存在或不一致即停止账号阶段。候选没有 migration 时 pending 必须为空;任何意外 migration 或生产门禁 finding 都停止。 + +## 11. 失败矩阵 + +| 阶段 | 失败 | 动作 | +| --- | --- | --- | +| local tests/review | 非 PASS | 修复本地代码,不发布 | +| deploy approval/provenance | 缺失或不一致 | 停止,零生产变更 | +| deployment | 失败或运行 SHA 不符 | 停止,零账号写入 | +| helper dry-run | planned action / unsafe target | PAUSED,helper apply=0 | +| employee preflight | 非唯一/角色或权限不符 | 停止,不选择目标 | +| recovery/secret | 无法安全准备 | 停止,不 apply | +| apply 明确失败 | BLOCKED | 停止,不重试 | +| apply 通道不确定 | 无完整结果 | 仅 `--verify` 定性 | +| post-write role/permission | 任一不变量失败 | 禁止登录,另行决策 | +| login/change-password | 首次失败 | 停止,零 retry,不再 apply | +| cleanup | 失败 | 标记敏感清理未完成,不归档 | + +## 12. Reviewer 与交接 + +本地实现后必须启动只读 reviewer,重点检查目标发现、角色/权限精确断言、事务写集、失败类别和测试反例;reviewer 不改代码。生产执行后再由只读 reviewer 核对候选/脚本 SHA、apply 次数、审计增量、目标限定副作用、写后权限证据和材料清理。 + +最终只向原 readiness task 交接稳定布尔与计数,不交接凭据。该 task 才负责登录后的合同负向验收和最终 readiness 口径。 diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.jsonl b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.md b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.md new file mode 100644 index 00000000..1fc92209 --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/implement.md @@ -0,0 +1,102 @@ +# 生产员工 Smoke Break-glass 重置:执行计划 + +> 当前状态:RELEASE APPROVED / PRE-COMMIT。用户已批准提交、推送/provenance、生产只读 preflight、正常候选发布和运行脚本 SHA-256 比对;尚未批准生产账号写入、登录或强制改密。 + +## 1. 规划事实与决策 + +- [x] 目标是专用 OA-only smoke employee,不是真人员工;`08-08` 已只读证明唯一资格目标,登录 attempts=0。 +- [x] 已完整审阅 `repair-oa-only-smoke-accounts.js`:它不重置密码;apply 可恢复状态、清登录锁、token +1、补缺失角色并写 lifecycle event,但不删除额外角色、不撤销 session/challenge。 +- [x] 现有 helper 只作为一次 dry-run 对照,禁止 apply;若 action 非空即停止。 +- [x] 不扩展管理员脚本目标面;新增员工 smoke 专用脚本,角色/权限只断言、不写入。 +- [x] 新脚本不在生产镜像,必须经正常候选发布;禁止单文件复制、inline code 或手写 SQL。 +- [x] 生产写后重新验证 `roles == ['employee']` 和四项管理权限全 false,未通过前禁止登录。 + +## 2. 启动门禁 + +- [x] 用户完成本规划只读复核并明确批准 `task.py start`;该批准只覆盖本地实现与测试。 +- [x] 读取 `trellis-before-dev`、backend 与 hyb-oa 相关 spec,再编辑 server 脚本/测试。 +- [x] 确认工作区仅含本 task 规划及其实现改动;未混入 08-08 或其他任务改动。 + +## 3. Phase A:先实现最窄工具 + +- [x] 新增 `server/src/scripts/reset-employee-smoke-password.js`,复用现有 smoke 判据、管理员 stdin 密码校验和应用同源 crypto/password policy,未扩展管理员目标面。 +- [x] 实现不接收/不输出 identifier 的唯一目标发现;精确角色与独立权限断言必须同时成立。 +- [x] 实现默认/`--dry-run`/`--preflight`、`--verify`、`--verify-password-changed` 和精确 apply 确认串;本地最小范围未加入非必要企微诊断。 +- [x] apply 使用 Serializable 事务,先锁 Employee、再锁其角色行并重新核对 id/token/status/smoke/role/permission;只写目标认证字段、目标 session/challenge 和专用 audit。 +- [x] 未知异常脱敏;稳定输出不含账号、identifier、id、路径、hash、payload,privacy 口径沿用现有 helper 且更窄。 + +## 4. Phase B:再补覆盖与只读 review + +- [x] 新增 `server/tests/unit/scripts/reset-employee-smoke-password.test.js`,23 条用例覆盖零/多目标、非 smoke、identifier/status/employment/archive/锁/must-change、额外角色、四个禁用权限、事务内 role/token 漂移、确认串、目标限定写入、verify 与最终改密 verify。 +- [x] apply 的 Employee update 键集合被精确断言;无 role/status/identifier/企微/业务写入,session/challenge where 精确限定 target id。 +- [x] `reset-active-admin-password.test.js` 6/6 PASS,管理员脚本目标面未扩大。 +- [x] `repair-oa-only-smoke-accounts.test.js` 5/5 PASS,并新增完整副作用合同:只补角色、不删额外角色、不重置密码、不写 session/challenge。 +- [x] node check、focused 34/34、Prisma validate、client typecheck/build/PWA 生成全部 PASS;build 仅有既有 Browserslist 数据陈旧提示。 +- [x] 用户只读 reviewer 复核 diff 后结论 PASS:确认唯一 Employee update 仅为认证字段,角色路径纯读,默认 dry-run/精确确认串 fail-closed,两个 verify 均重算精确角色与四项权限;reviewer 未修改代码。 +- [x] reviewer 额外确认事务以 Serializable 隔离级别先锁 Employee、再锁角色行,并在写入前重验目标/token/角色/权限;候选到写入之间的前提漂移会中止事务。 + +## 5. STOP A:发布审批 + +- [ ] 本地实现、测试和 reviewer PASS 后提交中文 commit,引用 `08-10-production-employee-smoke-break-glass-reset`。 +- [x] 已向用户报告精确 diff、测试、脚本完整写集/不写集与发布必要性;用户只读审查通过,当前进入发布审批门,未取得明确发布批准前不 commit/push/deploy。 +- [x] 用户已明确批准五步发布范围:commit、push/provenance、发布前只读 preflight、`deploy-existing.sh` 正常发布、发布后运行脚本 SHA-256 比对;批准不包含员工账号 apply、登录或强制改密。 +- [ ] 获批后 push 并验证 provenance remoteVerified;再次确认候选无 migration、无意外产品改动。 + +## 6. Phase C:正常发布与运行脚本身份 + +- [ ] 运行生产 identity/read-only preflight:身份、65 migration、pending、运行/公网 SHA、rollback window 全部按部署合同通过。 +- [ ] 仅在独立发布批准下使用 `deploy-existing.sh` 发布候选;失败立即停止,不进入账号阶段。 +- [ ] 发布后复核 ready/public SHA、匿名鉴权和 migration;比较运行镜像新脚本 SHA 与候选基线,要求 `scriptShaMatch=true`。 +- [ ] 不执行 `docker cp`、SSH 单文件同步、工作区脚本覆盖或 inline 写入代码。 + +## 7. Phase D:生产只读前置 + +- [ ] 取得独立生产账号写入批准;确认批准包含脚本声明的完整认证副作用与一次强制改密,不包含角色或业务写入。 +- [ ] rollback window PRE。 +- [ ] 运行现有 helper 一次 dry-run:目标 safe、status=OK、actions=[]、passwordReset=false;记录 helperApplyCount=0。 +- [ ] 运行新脚本 `--preflight`:targetMatches=1、exactTarget/rolesExact/identifierReady/locksClear=true,四项管理权限 false。 +- [ ] 用独立 `BEGIN READ ONLY` 查询复核相同角色/权限基线;读取 break-glass audit 写前计数。 +- [ ] 可选写前企微诊断只记录稳定布尔/计数,不修改绑定。 +- [ ] 准备 `0700/0600` 旧 hash 有限恢复材料、互不相同的临时/最终密码和 apply attempt 哨兵;任何敏感值不回显。 + +## 8. Phase E:唯一账号写入 + +- [ ] 从 stdin 执行且仅执行一次 `--apply EMPLOYEE_SMOKE_BREAK_GLASS`;`applyExecutionCount=1`。 +- [ ] 完整 PASS 后立即 `--verify`;若结果不确定,进入 `APPLY_UNCERTAIN`,只允许 `--verify` 定性,禁止第二次 apply。 +- [ ] verify 要求 must-change、locks、session/challenge、token/audit 和目标/角色/权限全部 PASS。 +- [ ] 独立 `BEGIN READ ONLY` 再次确认 targetMatches=1、`roles == ['employee']`、四项管理权限 false;审计计数较写前恰好 +1。 +- [ ] 任一写后权限不变量失败,停止且不登录;不得在本任务修角色或权限。 + +## 9. Phase F:一次登录与强制改密 + +- [ ] 仅在 Phase E 全 PASS 后,原子更新仓库外 employee smoke secret 为正确 identifier + 临时密码,权限 `0600`。 +- [ ] 单 browser/context、workers=1、retries=0 登录一次,必须进入 `/change-password`。 +- [ ] 使用已准备的最终密码提交一次;到 `/portal` 后立即停止,业务 API 实际发送数为 0。 +- [ ] `--verify-password-changed` 与最终独立权限快照 PASS;最终 secret 原子更新且 `0600`。 +- [ ] 写后企微诊断(若启用)证明绑定状态未变;不得做企微登录/绑定写入。 + +## 10. Phase G:清理、review 与交接 + +- [ ] 销毁临时 secret、浏览器状态、attempt 哨兵和旧 hash;记录 `temporaryCredentialDestroyed/browserStateCleared/recoveryMaterialDestroyed=true`。 +- [ ] 最终生产 read-only preflight、migration 和 rollback window POST;合同/schema/权限/其他账号写入计数为 0。 +- [ ] 只读 reviewer 核对运行脚本 SHA、apply 次数、审计 +1、目标限定副作用、写后精确角色/权限、零业务写入和清理证据。 +- [ ] 更新本 task 稳定证据并运行 task/process/governance/sensitive/diff 门禁。 +- [ ] 只向 `08-08-production-authenticated-readiness` 交接最终员工凭据可用与负向权限前提未变;回原 task 执行员工合同权限负向。 + +## 11. 验证命令 + +```bash +node --check server/src/scripts/reset-employee-smoke-password.js +cd server && npm test -- tests/unit/scripts/reset-employee-smoke-password.test.js tests/unit/scripts/reset-active-admin-password.test.js tests/unit/scripts/repair-oa-only-smoke-accounts.test.js +cd server && npm run prisma:validate +cd client && npm run typecheck && npm run build +python3 ./.trellis/scripts/task.py validate 08-10-production-employee-smoke-break-glass-reset +node scripts/qa/check-process-contract.mjs --baseline --json +node scripts/qa/check-trellis-governance.mjs --json +node scripts/qa/check-sensitive-content.mjs +git diff --check +``` + +## 12. 当前结论 + +本地实现、自检与用户只读审查均已完成,结论 PASS。新脚本默认只读 preflight,只有精确确认串进入写入;角色路径只有读取、锁定与精确断言。`--verify` 和 `--verify-password-changed` 均重新输出实际 must-change、`rolesExact` 及四项管理权限布尔。用户已批准五步候选发布,当前进入提交前检查;账号写入、登录和强制改密仍需发布成功后的独立批准。 diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/prd.md b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/prd.md new file mode 100644 index 00000000..395c6656 --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/prd.md @@ -0,0 +1,132 @@ +# 生产员工 Smoke Break-glass 重置 + +## Goal + +为 `08-08-production-authenticated-readiness` 已只读收敛出的唯一专用 OA-only smoke employee 恢复可控登录凭据,同时保持其角色精确为 `employee`、不获得合同管理权限、不影响真人员工。任务只负责重置生命周期;员工合同权限负向验收仍回到原 readiness task 完成。 + +本任务含本地安全工具实现、受控发布和一次生产账号写入三个风险层。任务启动只授权本地实现;发布候选与生产账号写入必须分别取得明确批准,任何一步均不得由前一步批准推定。 + +## Background and Confirmed Facts + +- `08-08-production-authenticated-readiness` 已在生产以只读方式从 5 个活跃非管理员候选中收敛出唯一合格目标:identifier ready、未锁定、失败计数为 0、无需首次改密、可进入合同只读范围且不具备合同管理权限;该目标符合运行容器既有 smoke 命名判据,不是真人员工。员工登录 attempts 保持 0。 +- 原仓库外员工 smoke 密码对 5 个候选均不匹配,必须恢复凭据后才能完成员工登录态负向验收。 +- `repair-oa-only-smoke-accounts.js:357-367,391-446` 的 repair 语义不是密码重置:它可恢复 active/employment/archive 状态、清登录锁、递增 token version、补缺失角色并写 lifecycle event,但不更新 `password_hash`。 +- 该 helper 的角色写入使用 `INSERT ... ON CONFLICT DO NOTHING`,只补角色、不删除额外角色,因此不能证明或恢复“角色恰好等于期望列表”;其报告还明确声明 `passwordReset=false`(`:520-523`)。 +- helper 的默认 dry-run 使用 `BEGIN READ ONLY`(`:540-588`),且 `isSafeOaOnlySmokeTarget`(`:110-130`)可作为目标安全判据;`--apply` 同时要求 CLI flag 与 `OA_ONLY_SMOKE_REPAIR_APPLY=1`(`:37-41,259-263`)。 +- 管理员既有 `reset-active-admin-password.js` 已证明同源 bcrypt、stdin 密码、字面确认串、事务内二次核对、目标限定的 session/challenge 撤销、审计和 `APPLY_UNCERTAIN -> verify` 模式可行;但不得把其目标面扩展到任意员工。 +- 新员工脚本当前不在生产镜像。禁止通过 SSH 临时复制、`docker cp`、工作区覆盖、`node -e` 或手写 SQL 注入;必须先作为正常候选发布,再以运行镜像脚本 SHA 与该候选精确一致为执行前提。 +- 生产合同终止回滚窗口最近九次均为 `OPEN / terminatedRows=0`。本任务不读写合同;登录和强制改密会产生预期认证写入,但不得进入业务页面。 + +## Scope + +1. 新增员工 smoke 专用 break-glass 密码重置脚本及 focused tests;不改管理员脚本的目标语义,不改现有 repair helper 的 apply 行为。 +2. 在本地证明脚本只选择唯一安全目标,角色必须精确为 `['employee']`,权限不得包含 `project.manage`、`project.view_all`、`system.settings` 或 `*`,并在事务内复核这些不变量。 +3. 本地实现通过只读 reviewer 后,另行获批提交、推送并走现有生产发布合同,使脚本进入运行镜像;不得带外复制脚本。 +4. 另行获批后执行一次生产 reset:repair helper 仅 dry-run 对照,新脚本 preflight、单次 apply、verify、写后角色/权限复核、一次登录与一次强制改密、最终 verify 和 secret/恢复材料清理。 +5. 完成后只向 `08-08-production-authenticated-readiness` 交接“员工最终凭据已就绪且负向权限前提仍成立”,不在本任务执行合同 UI/API 负向验收。 + +## Requirements + +### R1 现有 repair helper 只读对照 + +- 获批进入生产窗口后,先对唯一目标运行一次默认 `--dry-run`,保存脱敏动作集合与 `passwordReset=false` 结论;不得运行其 `--apply`。 +- dry-run 若计划 `restore_active_employee`、`clear_login_lock` 或 `add_missing_roles`,说明现场已偏离既有诊断,本任务立即 `PAUSED`,不得让 helper 修复后继续。 +- dry-run 必须继续通过 `isSafeOaOnlySmokeTarget`,且不能用其“包含期望角色”结论替代精确角色断言。 + +### R2 新脚本的唯一目标与权限不变量 + +- 新增 `server/src/scripts/reset-employee-smoke-password.js`。脚本自行从生产状态发现目标,不从 argv/env 接收完整 identifier,不输出 email、phone、employee_no、name 或目标 id。 +- 目标必须同时满足:`status=active`、`employment_status=active`、未 archived、smoke 判据命中、至少一个合法 email/phone 登录 identifier、当前未锁定、`must_change_password=false`、实际角色排序后精确等于 `['employee']`。 +- 从实际角色解析出的权限不得包含 `project.manage`、`project.view_all`、`system.settings` 或 `*`。候选数不是恰好 1、角色多/少一项、权限扩大、状态变化或 smoke 判据失败均 `BLOCKED`。 +- 脚本只断言角色和权限,禁止 create/delete/update `employee_roles`,禁止激活、归档、修改 employment/status、identifier 或任何业务数据。 +- 事务内必须按目标 id、写前 token version、状态、smoke、精确角色和权限再次核对;任一变化导致事务零写入。 + +### R3 受控密码重置合同 + +- 提供 `--preflight`、`--verify`、`--verify-password-changed`;可提供只读 `--diagnose-wecom-binding`。唯一写入口必须是 `--apply EMPLOYEE_SMOKE_BREAK_GLASS`,确认串不精确即 `explicit_confirmation_required`。 +- 临时密码只从 stdin 读取,使用应用同源 `hashPassword` 与 `validatePassword`;不得进入 argv、环境变量、history、stdout、artifact、task 或对话。 +- 单次事务仅更新目标认证状态:新 password hash、`must_change_password=true`、三类失败/锁定域与 password-reset request 状态清零、token version +1;仅撤销目标 session、失效目标 password reset code、消耗目标 email login code,并写专用 break-glass audit。 +- 所有 session/challenge 写入必须带 `employee_id=target.id`;审计 actor/target 仅为该目标。不得修改角色、权限、启用/归档状态、企微绑定或其他账号。 +- apply 必须严格一次。通道中断、输出缺失或 JSON 不可解析进入 `APPLY_UNCERTAIN`,此时只允许 `--verify`;verify 证明已落地时禁止 retry。 + +### R4 写后重新验证负向前提 + +- `--verify` 不只验证 must-change、锁、session/challenge 与 audit,还必须重新发现唯一目标并证明角色精确为 `['employee']`、四项合同管理权限均为 false。 +- verify 后再执行一份独立只读角色/权限快照,与写前基线逐项对账;不能沿用 `08-08` 的旧诊断,也不能只断言“包含 employee”。 +- 只有 `rolesExact=true`、`contractManagePermissions=false`、目标仍唯一且 reset 副作用全部 PASS,才允许更新临时仓库外 secret 并进行一次登录。 + +### R5 强制改密、secret 与交接 + +- 写前生成互不相同的临时密码和最终密码;保存旧 hash 为生产现场 `0700/0600`、最多 24 小时的高敏有限恢复材料。它不能恢复 token version、session、challenge、锁定计数或审计,不得称为回滚。 +- 一次登录必须按预期进入 `/change-password`;一次强制改密提交成功后立即停在 `/portal`,不得进入合同或其他业务页,业务 API 实际发送数为 0,自动 retry 为 0。 +- `--verify-password-changed` 必须证明 must-change=false、locks cleared、activeSessions=1、password-change audit 与本次 employee-smoke break-glass audit 均存在,且角色/权限不变量仍成立。 +- 最终 secret 原子更新且权限为 `0600`;临时凭据、浏览器状态和旧 hash 在验证通过后销毁,只记录清理布尔。 +- 回到 `08-08-production-authenticated-readiness` 后再执行员工合同权限负向验收;本任务成功不等于该负向验收已经通过。 + +### R6 发布、证据与停止条件 + +- 本地代码必须先通过 focused tests、管理员脚本/repair helper 回归和只读 reviewer;reviewer 不改代码。 +- 新脚本必须进入一个 clean/published、remoteVerified 的正常部署候选,经现有 `deploy-existing.sh` 合同另行批准发布;发布前不得生产写入,发布失败立即停止。 +- 生产执行前,运行镜像脚本 SHA-256 必须与获批候选中的脚本精确一致;不得复制或替换运行脚本。 +- 生产发布与生产账号写入分别需要明确批准。任何审批、SHA、身份、migration、目标、权限、恢复材料或 secret 门禁失败均 fail-closed。 +- 任务证据只记录 SHA match、枚举、布尔、计数、apply/login/change-password 次数和清理状态,不记录生产身份、完整路径、identifier、账号、密码、hash、token、cookie 或原始 payload。 + +## Acceptance Criteria + +- [ ] AC1:现有 repair helper 的完整 apply 副作用已由源码和 focused regression 固化;生产仅运行一次 dry-run,输出证明 `passwordReset=false` 且 action 集合为空,helper apply 次数为 0。 +- [ ] AC2:新脚本的 preflight 仅在唯一 active-employment/unarchived smoke employee、identifier ready、未锁定、must-change=false、`roles == ['employee']` 且四项管理权限全 false 时 PASS;所有非唯一/额外角色/权限扩大/状态漂移用例均 fail-closed。 +- [ ] AC3:新脚本的 apply 只接受精确确认串和 stdin 密码;事务内二次核对目标/token/smoke/状态/精确角色/权限,且没有任何角色、权限、状态、identifier 或业务数据写入路径。 +- [ ] AC4:focused tests 证明密码、锁、token、目标限定 session/challenge 与专用 audit 副作用;同时回归管理员 reset 与 repair helper,未扩大既有工具目标面。 +- [ ] AC5:本地只读 reviewer 复核脚本与测试后通过;随后只在另行获批的正常发布中把脚本送入生产镜像,运行脚本 SHA 与 clean/published 候选精确一致,带外复制次数为 0。 +- [ ] AC6:生产 preflight PASS 后 apply 恰好一次;明确结果或 `APPLY_UNCERTAIN -> verify` 证明写入状态,审计计数写前/写后增量恰好 1,不存在第二次 apply。 +- [ ] AC7:apply 后 `--verify` 与独立只读快照均重新证明 `roles == ['employee']`、四项管理权限全 false、目标唯一,且 must-change、locks、session/challenge、token/audit 副作用符合合同;未沿用写前结论。 +- [ ] AC8:一次登录按预期进入强制改密,一次提交后 `--verify-password-changed` PASS;非认证业务 API 发送数为 0,最终 secret 为 `0600`,临时凭据、浏览器状态和旧 hash 已销毁。 +- [ ] AC9:生产其他账号、角色/权限、企微绑定、合同、schema 与业务数据写入为 0;任务内无敏感值泄露,执行后只读 reviewer 和 task/process/governance/sensitive/diff 门禁通过。 +- [ ] AC10:只向 `08-08-production-authenticated-readiness` 交接最终员工凭据可用与负向前提仍成立;员工合同权限负向仍由该 task 执行,本任务不宣称产品已可告知用户测试。 + +## Out of Scope + +- 在本任务内执行合同列表/详情/终止入口或员工合同权限负向验收。 +- 修改 employee 角色、权限、active/employment/archive 状态、identifier、企微绑定,创建新 smoke 账号或影响真人员工。 +- 执行 `repair-oa-only-smoke-accounts.js --apply`,扩展管理员 break-glass 脚本到任意员工,或使用手写 SQL/Prisma、`node -e`、临时脚本复制完成写入。 +- 启用邮件、SMTP、自助忘记密码、生产 feature flag、schema/migration 或合同数据。 +- 把旧 hash 写回、删除审计或恢复历史 session/challenge;如确需此类写入必须另行批准。 + +## Accepted Risks and Deferred Items + +- 正常发布会重启/切换应用,即使候选只新增运维脚本也属于生产变更;因此发布审批独立于 task start 和账号写入审批。 +- smoke 账号风险低于唯一管理员,但 reset 和强制改密仍会递增 token version、撤销目标会话并写审计,不能视为零成本操作。 +- 旧 hash 仅保留一个格式正确的历史值,不能还原其他认证状态;apply 后不存在完整回滚,只能依赖 verify、最终改密或另行批准的 roll-forward。 +- 员工负向验收和合同 UI 样本阻塞不由本任务收口;本任务完成后原 readiness task 仍可能保持 PARTIAL/PAUSED。 + +## Open Questions + +- 无。现有 helper 不具备密码重置能力,技术路径已收敛为新专用脚本;发布和生产写入在执行期分别走明确审批停点。 + +## Test Plan + +### 本地实现与复核 + +- `node --check server/src/scripts/reset-employee-smoke-password.js` +- `cd server && npm test -- tests/unit/scripts/reset-employee-smoke-password.test.js tests/unit/scripts/reset-active-admin-password.test.js tests/unit/scripts/repair-oa-only-smoke-accounts.test.js` +- `cd server && npm run prisma:validate` +- `cd client && npm run typecheck && npm run build`(仅在形成可部署候选前执行) +- `python3 ./.trellis/scripts/task.py validate 08-10-production-employee-smoke-break-glass-reset` +- `node scripts/qa/check-process-contract.mjs --baseline --json` +- `node scripts/qa/check-trellis-governance.mjs --json` +- `node scripts/qa/check-sensitive-content.mjs` +- `git diff --check` + +### 另行获批的生产阶段 + +- clean/published provenance、生产 identity/read-only preflight、65 migration、运行/公网 SHA、rollback window。 +- 正常部署新候选;部署完成后运行镜像新脚本 SHA 与候选精确一致。 +- repair helper 单次 dry-run(禁止 apply)→ 新脚本 `--preflight` → 独立权限快照。 +- 受控旧 hash、临时/最终 secret 就绪后单次 `--apply EMPLOYEE_SMOKE_BREAK_GLASS` → `--verify` → 写后独立角色/权限快照。 +- 一次登录/强制改密 → `--verify-password-changed` → secret/hash/browser 清理 → 最终 read-only preflight/window。 +- 执行后只读 reviewer 核对脚本 SHA、apply 次数、审计增量、副作用、写后角色/权限与清理证据。 + +## Evidence Rules + +- 允许记录:候选/脚本 SHA、match 布尔、目标/审计/会话/challenge 计数、角色是否精确、权限布尔、attempt 次数、HTTP 状态、清理布尔。 +- 禁止记录:identifier、email、phone、employee_no、name、目标 id、临时/最终密码、旧/新 hash、cookie/token、生产路径/身份/连接串和原始 payload。 diff --git a/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/task.json b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/task.json new file mode 100644 index 00000000..3f5368d8 --- /dev/null +++ b/.trellis/tasks/08-10-production-employee-smoke-break-glass-reset/task.json @@ -0,0 +1,43 @@ +{ + "id": "production-employee-smoke-break-glass-reset", + "name": "production-employee-smoke-break-glass-reset", + "title": "生产员工 Smoke Break-glass 重置", + "description": "为唯一专用 OA-only smoke employee 新增最窄 break-glass 密码重置工具,经正常候选发布后执行一次受控认证恢复;角色精确为 employee 且合同管理权限写后重新验证,负向业务验收仍归 08-08 task。", + "status": "in_progress", + "dev_type": "operations", + "scope": "server employee-smoke break-glass script/tests, controlled production delivery and credential reset lifecycle; no role/permission or contract writes", + "package": "hyb-oa", + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-10", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/scripts/repair-oa-only-smoke-accounts.js", + "server/tests/unit/scripts/repair-oa-only-smoke-accounts.test.js", + "server/src/scripts/reset-active-admin-password.js", + "server/tests/unit/scripts/reset-active-admin-password.test.js", + "server/src/scripts/reset-employee-smoke-password.js", + "server/tests/unit/scripts/reset-employee-smoke-password.test.js", + "server/src/constants/permissions.js", + "server/src/utils/crypto.js", + "server/src/utils/passwordPolicy.js", + "server/src/routes/auth.js", + "server/src/services/contractRegistryFilters.js", + "deploy/trialrun/deploy-existing.sh", + ".trellis/tasks/08-08-production-authenticated-readiness", + ".trellis/spec/hyb-oa/backend-fastify-prisma.md", + ".trellis/spec/hyb-oa/deployment-trialrun.md", + ".trellis/spec/hyb-oa/security-privacy.md" + ], + "notes": "本地实现与用户只读审查 PASS:新增员工 smoke 专用脚本与 23 条 focused 用例,三组脚本测试 34/34、Prisma、client build 和治理门禁 PASS;reviewer 另确认 Serializable + Employee/role lock 可阻止写入窗口内权限前提漂移。当前停在 STOP A,尚未提交/推送/发布/连接生产;正常生产发布与账号写入仍分别另行批准。现有 repair helper 仅 dry-run,严禁 apply。", + "meta": {} +} diff --git a/server/src/scripts/repair-oa-only-smoke-accounts.js b/server/src/scripts/repair-oa-only-smoke-accounts.js index c499d552..f598194c 100644 --- a/server/src/scripts/repair-oa-only-smoke-accounts.js +++ b/server/src/scripts/repair-oa-only-smoke-accounts.js @@ -646,6 +646,7 @@ if (require.main === module) { module.exports = { DEFAULT_TARGETS, + applyTarget, execute, isSafeOaOnlySmokeTarget, isSmokeLikeValue, diff --git a/server/src/scripts/reset-employee-smoke-password.js b/server/src/scripts/reset-employee-smoke-password.js new file mode 100644 index 00000000..2f4cb0fd --- /dev/null +++ b/server/src/scripts/reset-employee-smoke-password.js @@ -0,0 +1,503 @@ +#!/usr/bin/env node + +const crypto = require("node:crypto"); + +const { createPrismaClient } = require("../prismaClient"); +const { resolvePermissionsForRoles } = require("../constants/permissions"); +const { hashPassword } = require("../utils/crypto"); +const { readPasswordFromStdin } = require("./reset-active-admin-password"); +const { isSmokeLikeValue } = require("./repair-oa-only-smoke-accounts"); + +const APPLY_CONFIRMATION = "EMPLOYEE_SMOKE_BREAK_GLASS"; +const AUDIT_ACTION = "SYSTEM_EMPLOYEE_SMOKE_BREAK_GLASS_PASSWORD_RESET"; +const PASSWORD_CHANGED_ACTION = "AUTH_PASSWORD_CHANGED"; +const EXPECTED_ROLES = Object.freeze(["employee"]); +const FORBIDDEN_PERMISSIONS = Object.freeze([ + "*", + "project.manage", + "project.view_all", + "system.settings", +]); +const PRIVACY = + "target count/status/role/permission booleans only; no password/token/secret/identifier/full email/full phone"; + +function writeResult(result) { + process.stdout.write(`${JSON.stringify({ ...result, privacy: PRIVACY })}\n`); +} + +function sortedRoleCodes(row) { + const roles = Array.isArray(row?.roles) ? row.roles : []; + return roles + .map((item) => (typeof item === "string" ? item : item?.role_code)) + .filter((item) => typeof item === "string") + .sort(); +} + +function rolesAreExactEmployee(row) { + const roles = sortedRoleCodes(row); + return roles.length === EXPECTED_ROLES.length && roles[0] === EXPECTED_ROLES[0]; +} + +function isSmokeRow(row) { + return [row?.employee_no, row?.email, row?.name].some(isSmokeLikeValue); +} + +function isIdentifierReady(row) { + return [row?.email, row?.phone].some( + (value) => typeof value === "string" && value.trim().length > 0, + ); +} + +function buildPermissionState(row) { + const permissionCodes = resolvePermissionsForRoles(sortedRoleCodes(row)); + return { + hasWildcard: permissionCodes.includes("*"), + hasProjectManage: permissionCodes.includes("project.manage"), + hasProjectViewAll: permissionCodes.includes("project.view_all"), + hasSystemSettings: permissionCodes.includes("system.settings"), + }; +} + +function allManagementPermissionsAbsent(permissionState) { + return FORBIDDEN_PERMISSIONS.every((permission) => { + if (permission === "*") return !permissionState.hasWildcard; + if (permission === "project.manage") return !permissionState.hasProjectManage; + if (permission === "project.view_all") return !permissionState.hasProjectViewAll; + return !permissionState.hasSystemSettings; + }); +} + +function buildTargetState(row, { expectedMustChangePassword }) { + const permissionState = buildPermissionState(row); + const locksCleared = Boolean( + row && + row.login_failed_count === 0 && + row.login_failed_first_at === null && + row.login_locked_until === null && + row.email_login_code_failed_count === 0 && + row.email_login_code_failed_first_at === null && + row.email_login_code_locked_until === null && + row.password_reset_failed_count === 0 && + row.password_reset_failed_first_at === null && + row.password_reset_locked_until === null, + ); + const state = { + activeEmployment: Boolean( + row && + row.status === "active" && + row.employment_status === "active" && + row.archived_at === null, + ), + smokeTarget: Boolean(row && isSmokeRow(row)), + identifierReady: Boolean(row && isIdentifierReady(row)), + mustChangePassword: Boolean(row?.must_change_password), + mustChangePasswordMatches: + Boolean(row) && row.must_change_password === expectedMustChangePassword, + locksCleared, + rolesExact: Boolean(row && rolesAreExactEmployee(row)), + ...permissionState, + }; + return { + ...state, + contractManagePermissionsAbsent: allManagementPermissionsAbsent(state), + }; +} + +function targetStatePasses(state) { + return Boolean( + state.activeEmployment && + state.smokeTarget && + state.identifierReady && + state.mustChangePasswordMatches && + state.locksCleared && + state.rolesExact && + state.contractManagePermissionsAbsent, + ); +} + +const TARGET_SELECT = Object.freeze({ + id: true, + token_version: true, + employee_no: true, + name: true, + email: true, + phone: true, + status: true, + employment_status: true, + archived_at: true, + must_change_password: true, + login_failed_count: true, + login_failed_first_at: true, + login_locked_until: true, + email_login_code_failed_count: true, + email_login_code_failed_first_at: true, + email_login_code_locked_until: true, + password_reset_failed_count: true, + password_reset_failed_first_at: true, + password_reset_locked_until: true, + roles: { select: { role_code: true } }, +}); + +async function findPreflightCandidates(prisma) { + const rows = await prisma.employee.findMany({ + where: { + must_change_password: false, + roles: { some: { role_code: "employee" } }, + }, + select: TARGET_SELECT, + }); + return rows.filter((row) => isSmokeRow(row) && rolesAreExactEmployee(row)); +} + +async function findAuditTargets(prisma) { + const auditTargets = await prisma.auditLog.findMany({ + where: { + action: AUDIT_ACTION, + target_type: "employee", + target_id: { not: null }, + }, + select: { target_id: true }, + distinct: ["target_id"], + take: 2, + }); + const targetIds = auditTargets.map((item) => item.target_id).filter(Boolean); + if (targetIds.length === 0) return []; + return prisma.employee.findMany({ + where: { id: { in: targetIds } }, + select: TARGET_SELECT, + }); +} + +function summarizeTarget(rows, expectedMustChangePassword) { + const exactTarget = rows.length === 1; + const targetState = buildTargetState(exactTarget ? rows[0] : null, { + expectedMustChangePassword, + }); + return { + targetMatches: rows.length, + exactTarget, + ...targetState, + pass: exactTarget && targetStatePasses(targetState), + }; +} + +async function preflight(prisma) { + const summary = summarizeTarget(await findPreflightCandidates(prisma), false); + return { + status: summary.pass ? "PASS" : "BLOCKED", + ...summary, + }; +} + +async function countTargetAuthenticationState(prisma, targetId) { + const [activeSessions, activePasswordResetCodes, activeEmailLoginCodes, auditEntries] = + await Promise.all([ + prisma.authSession.count({ + where: { employee_id: targetId, revoked_at: null }, + }), + prisma.passwordResetCode.count({ + where: { employee_id: targetId, invalidated_at: null }, + }), + prisma.emailLoginCode.count({ + where: { employee_id: targetId, used_at: null }, + }), + prisma.auditLog.count({ + where: { + actor_id: targetId, + target_id: targetId, + action: AUDIT_ACTION, + }, + }), + ]); + return { + activeSessions, + activePasswordResetCodes, + activeEmailLoginCodes, + auditPresent: auditEntries > 0, + }; +} + +async function verifyState(prisma) { + const rows = await findAuditTargets(prisma); + const summary = summarizeTarget(rows, true); + if (!summary.exactTarget) { + return { status: "BLOCKED", ...summary }; + } + const authState = await countTargetAuthenticationState(prisma, rows[0].id); + const pass = Boolean( + summary.pass && + authState.activeSessions === 0 && + authState.activePasswordResetCodes === 0 && + authState.activeEmailLoginCodes === 0 && + authState.auditPresent, + ); + return { + status: pass ? "PASS" : "BLOCKED", + ...summary, + ...authState, + pass, + }; +} + +async function verifyCompletedPasswordChange(prisma) { + const rows = await findAuditTargets(prisma); + const summary = summarizeTarget(rows, false); + if (!summary.exactTarget) { + return { status: "BLOCKED", ...summary }; + } + const targetId = rows[0].id; + const [activeSessions, passwordChangeAudits, breakGlassAudits] = await Promise.all([ + prisma.authSession.count({ + where: { employee_id: targetId, revoked_at: null }, + }), + prisma.auditLog.count({ + where: { + actor_id: targetId, + target_id: targetId, + action: PASSWORD_CHANGED_ACTION, + }, + }), + prisma.auditLog.count({ + where: { + actor_id: targetId, + target_id: targetId, + action: AUDIT_ACTION, + }, + }), + ]); + const pass = Boolean( + summary.pass && + activeSessions === 1 && + passwordChangeAudits > 0 && + breakGlassAudits > 0, + ); + return { + status: pass ? "PASS" : "BLOCKED", + ...summary, + activeSessions, + passwordChangeAuditPresent: passwordChangeAudits > 0, + breakGlassAuditPresent: breakGlassAudits > 0, + pass, + }; +} + +async function lockTargetSnapshot(tx, targetId) { + const employeeRows = await tx.$queryRaw` + SELECT + id, + token_version, + employee_no, + name, + email, + phone, + status, + employment_status, + archived_at, + must_change_password, + login_failed_count, + login_failed_first_at, + login_locked_until, + email_login_code_failed_count, + email_login_code_failed_first_at, + email_login_code_locked_until, + password_reset_failed_count, + password_reset_failed_first_at, + password_reset_locked_until + FROM "employees" + WHERE id = ${targetId}::uuid + FOR UPDATE + `; + if (!Array.isArray(employeeRows) || employeeRows.length !== 1) return null; + const roleRows = await tx.$queryRaw` + SELECT role_code::text + FROM "employee_roles" + WHERE employee_id = ${targetId}::uuid + ORDER BY role_code::text + FOR UPDATE + `; + return { + ...employeeRows[0], + roles: Array.isArray(roleRows) ? roleRows : [], + }; +} + +function assertLockedTargetUnchanged(current, target) { + const state = buildTargetState(current, { expectedMustChangePassword: false }); + if ( + !current || + current.id !== target.id || + current.token_version !== target.token_version || + !targetStatePasses(state) + ) { + throw new Error("employee_smoke_target_changed"); + } +} + +async function applyReset(prisma, password) { + const candidates = await findPreflightCandidates(prisma); + const summary = summarizeTarget(candidates, false); + if (!summary.pass) { + throw new Error("employee_smoke_target_not_unique_or_safe"); + } + + const target = candidates[0]; + const passwordHash = await hashPassword(password); + const now = new Date(); + const requestId = `ops-employee-smoke-break-glass-${crypto.randomUUID()}`; + + return prisma.$transaction( + async (tx) => { + const current = await lockTargetSnapshot(tx, target.id); + assertLockedTargetUnchanged(current, target); + + const employee = await tx.employee.update({ + where: { id: target.id }, + data: { + password_hash: passwordHash, + must_change_password: true, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + password_reset_request_count: 0, + password_reset_request_first_at: null, + password_reset_last_requested_at: null, + token_version: { increment: 1 }, + }, + select: { must_change_password: true, token_version: true }, + }); + const revokedSessions = await tx.authSession.updateMany({ + where: { employee_id: target.id, revoked_at: null }, + data: { revoked_at: now, last_seen_at: now }, + }); + const invalidatedPasswordResetCodes = await tx.passwordResetCode.updateMany({ + where: { employee_id: target.id, invalidated_at: null }, + data: { invalidated_at: now }, + }); + const consumedEmailLoginCodes = await tx.emailLoginCode.updateMany({ + where: { employee_id: target.id, used_at: null }, + data: { used_at: now }, + }); + await tx.auditLog.create({ + data: { + request_id: requestId, + actor_id: target.id, + action: AUDIT_ACTION, + target_type: "employee", + target_id: target.id, + ip_address: null, + user_agent: "controlled-ops-script", + extra: { credentialRecorded: false }, + }, + }); + + return { + status: "PASS", + targetMatches: 1, + exactTarget: true, + passwordUpdated: true, + mustChangePassword: employee.must_change_password === true, + tokenVersionIncremented: + employee.token_version === target.token_version + 1, + locksCleared: true, + rolesExact: true, + contractManagePermissionsAbsent: true, + revokedSessions: revokedSessions.count, + invalidatedPasswordResetCodes: invalidatedPasswordResetCodes.count, + consumedEmailLoginCodes: consumedEmailLoginCodes.count, + auditCreated: true, + }; + }, + { isolationLevel: "Serializable" }, + ); +} + +function resolveMode(argv) { + if (argv.length === 0 || (argv.length === 1 && ["--dry-run", "--preflight"].includes(argv[0]))) { + return { mode: "preflight" }; + } + if (argv.length === 1 && argv[0] === "--verify") { + return { mode: "verify" }; + } + if (argv.length === 1 && argv[0] === "--verify-password-changed") { + return { mode: "verify_password_changed" }; + } + if (argv[0] === "--apply") { + if (argv.length !== 2 || argv[1] !== APPLY_CONFIRMATION) { + throw new Error("explicit_confirmation_required"); + } + return { mode: "apply" }; + } + throw new Error("unsupported_arguments"); +} + +async function main() { + const operation = resolveMode(process.argv.slice(2)); + const { prisma, pool } = createPrismaClient(); + try { + if (operation.mode === "preflight") { + writeResult(await preflight(prisma)); + return; + } + if (operation.mode === "verify") { + writeResult(await verifyState(prisma)); + return; + } + if (operation.mode === "verify_password_changed") { + writeResult(await verifyCompletedPasswordChange(prisma)); + return; + } + const password = await readPasswordFromStdin(); + writeResult(await applyReset(prisma, password)); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +if (require.main === module) { + main().catch((error) => { + const allowedCategories = new Set([ + "password_stdin_required", + "password_input_too_long", + "password_input_invalid", + "password_policy_failed", + "employee_smoke_target_not_unique_or_safe", + "employee_smoke_target_changed", + "explicit_confirmation_required", + "unsupported_arguments", + ]); + const category = allowedCategories.has(error?.message) + ? error.message + : typeof error?.code === "string" + ? error.code + : typeof error?.name === "string" + ? error.name + : "runtime_error"; + writeResult({ + status: "BLOCKED", + error: "employee_smoke_break_glass_operation_failed", + category, + }); + process.exitCode = 1; + }); +} + +module.exports = { + APPLY_CONFIRMATION, + AUDIT_ACTION, + applyReset, + buildTargetState, + findAuditTargets, + findPreflightCandidates, + preflight, + resolveMode, + rolesAreExactEmployee, + verifyCompletedPasswordChange, + verifyState, +}; diff --git a/server/tests/unit/scripts/repair-oa-only-smoke-accounts.test.js b/server/tests/unit/scripts/repair-oa-only-smoke-accounts.test.js index e6459676..74570db4 100644 --- a/server/tests/unit/scripts/repair-oa-only-smoke-accounts.test.js +++ b/server/tests/unit/scripts/repair-oa-only-smoke-accounts.test.js @@ -1,4 +1,5 @@ const { + applyTarget, isSafeOaOnlySmokeTarget, isSmokeLikeValue, normalizeRoleList, @@ -85,4 +86,56 @@ describe("repair-oa-only-smoke-accounts", () => { expect(normalizeRoleList(["admin", "admin", "invalid", "employee"])).toEqual(["admin", "employee"]); expect(process.env.OA_ONLY_SMOKE_REPAIR_APPLY).toBeUndefined(); }); + + it("applies only lifecycle, login-lock, token, missing-role, and audit repairs", async () => { + const current = row({ + status: "archived", + employmentStatus: "inactive", + archived: true, + roles: ["employee"], + }); + const planned = planForTarget(target(), current); + const client = { + query: vi.fn(async (sql) => { + const text = String(sql); + if (text.includes("SELECT")) { + return { + rows: [ + { + id: current.id, + employee_no: current.employeeNo, + name: current.name, + email: current.email, + phone: current.phone, + status: "active", + employment_status: "active", + archived: false, + login_failed_count: 0, + has_login_failed_first_at: false, + login_locked_until: null, + roles: ["admin", "employee"], + active_auth_sessions: 0, + }, + ], + }; + } + return { rows: [] }; + }), + }; + + await expect( + applyTarget(client, target(), planned, new Date("2026-08-10T00:00:00.000Z")), + ).resolves.toMatchObject({ result: "applied" }); + + const sql = client.query.mock.calls.map(([statement]) => String(statement)).join("\n"); + expect(sql).toContain("token_version = token_version + 1"); + expect(sql).toContain("INSERT INTO employee_roles"); + expect(sql).toContain("ON CONFLICT (employee_id, role_code) DO NOTHING"); + expect(sql).toContain("INSERT INTO employee_lifecycle_events"); + expect(sql).not.toContain("DELETE FROM employee_roles"); + expect(sql).not.toContain("password_hash"); + expect(sql).not.toMatch(/(?:UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+auth_sessions/iu); + expect(sql).not.toMatch(/(?:UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+password_reset_codes/iu); + expect(sql).not.toMatch(/(?:UPDATE|INSERT\s+INTO|DELETE\s+FROM)\s+email_login_codes/iu); + }); }); diff --git a/server/tests/unit/scripts/reset-employee-smoke-password.test.js b/server/tests/unit/scripts/reset-employee-smoke-password.test.js new file mode 100644 index 00000000..4444ec47 --- /dev/null +++ b/server/tests/unit/scripts/reset-employee-smoke-password.test.js @@ -0,0 +1,329 @@ +const { + AUDIT_ACTION, + applyReset, + preflight, + resolveMode, + verifyCompletedPasswordChange, + verifyState, +} = require("../../../src/scripts/reset-employee-smoke-password"); +const { ROLE_PERMISSIONS } = require("../../../src/constants/permissions"); + +const TARGET_ID = "00000000-0000-4000-8000-000000000101"; + +function employeeRow(overrides = {}) { + return { + id: TARGET_ID, + token_version: 7, + employee_no: "SMOKE-EMPLOYEE", + name: "OA试运行账号", + email: "smoke-employee@example.test", + phone: null, + status: "active", + employment_status: "active", + archived_at: null, + must_change_password: false, + login_failed_count: 0, + login_failed_first_at: null, + login_locked_until: null, + email_login_code_failed_count: 0, + email_login_code_failed_first_at: null, + email_login_code_locked_until: null, + password_reset_failed_count: 0, + password_reset_failed_first_at: null, + password_reset_locked_until: null, + roles: [{ role_code: "employee" }], + ...overrides, + }; +} + +function rawEmployeeRow(row) { + const { roles: _roles, ...raw } = row; + return raw; +} + +function createFixture({ rows = [employeeRow()], auditTargetIds = [TARGET_ID] } = {}) { + const tx = { + $queryRaw: vi.fn(async (strings) => { + const sql = strings.join(" "); + if (sql.includes('FROM "employees"')) { + return rows.length === 1 ? [rawEmployeeRow(rows[0])] : []; + } + if (sql.includes('FROM "employee_roles"')) { + return rows.length === 1 ? rows[0].roles : []; + } + throw new Error("unexpected_query"); + }), + employee: { + update: vi + .fn() + .mockResolvedValue({ must_change_password: true, token_version: 8 }), + }, + authSession: { updateMany: vi.fn().mockResolvedValue({ count: 2 }) }, + passwordResetCode: { updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + emailLoginCode: { updateMany: vi.fn().mockResolvedValue({ count: 3 }) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + const prisma = { + employee: { + findMany: vi.fn(async (query) => { + if (query?.where?.id?.in) { + return rows.filter((row) => query.where.id.in.includes(row.id)); + } + return rows; + }), + }, + authSession: { count: vi.fn().mockResolvedValue(0) }, + passwordResetCode: { count: vi.fn().mockResolvedValue(0) }, + emailLoginCode: { count: vi.fn().mockResolvedValue(0) }, + auditLog: { + findMany: vi + .fn() + .mockResolvedValue(auditTargetIds.map((target_id) => ({ target_id }))), + count: vi.fn().mockResolvedValue(1), + }, + $transaction: vi.fn(async (callback, options) => { + expect(options).toEqual({ isolationLevel: "Serializable" }); + return callback(tx); + }), + }; + return { prisma, tx }; +} + +describe("employee smoke break-glass password reset", () => { + it("defaults to read-only preflight and requires the exact apply confirmation", () => { + expect(resolveMode([])).toEqual({ mode: "preflight" }); + expect(resolveMode(["--dry-run"])).toEqual({ mode: "preflight" }); + expect(resolveMode(["--preflight"])).toEqual({ mode: "preflight" }); + expect(resolveMode(["--apply", "EMPLOYEE_SMOKE_BREAK_GLASS"])).toEqual({ + mode: "apply", + }); + expect(() => resolveMode(["--apply"])).toThrow("explicit_confirmation_required"); + expect(() => resolveMode(["--apply", "wrong"])).toThrow( + "explicit_confirmation_required", + ); + }); + + it("passes preflight only for one exact employee smoke role without management permissions", async () => { + const { prisma } = createFixture(); + + await expect(preflight(prisma)).resolves.toMatchObject({ + status: "PASS", + targetMatches: 1, + exactTarget: true, + mustChangePassword: false, + mustChangePasswordMatches: true, + rolesExact: true, + hasWildcard: false, + hasProjectManage: false, + hasProjectViewAll: false, + hasSystemSettings: false, + contractManagePermissionsAbsent: true, + }); + }); + + it.each([ + ["no target", []], + ["multiple targets", [employeeRow(), employeeRow({ id: "00000000-0000-4000-8000-000000000102" })]], + ["non smoke", [employeeRow({ employee_no: "E100", name: "普通员工", email: "person@example.test" })]], + ["identifier missing", [employeeRow({ email: null, phone: null })]], + ["status inactive", [employeeRow({ status: "inactive" })]], + ["employment inactive", [employeeRow({ employment_status: "inactive" })]], + ["archived", [employeeRow({ archived_at: new Date() })]], + ["locked", [employeeRow({ login_locked_until: new Date() })]], + ["must change already", [employeeRow({ must_change_password: true })]], + ["extra role", [employeeRow({ roles: [{ role_code: "employee" }, { role_code: "manager" }] })]], + ])("blocks preflight for %s", async (_label, rows) => { + const { prisma } = createFixture({ rows }); + await expect(preflight(prisma)).resolves.toMatchObject({ status: "BLOCKED" }); + }); + + it("blocks when the employee role permission mapping gains contract management", async () => { + ROLE_PERMISSIONS.employee.push("project.manage"); + try { + const { prisma } = createFixture(); + await expect(preflight(prisma)).resolves.toMatchObject({ + status: "BLOCKED", + rolesExact: true, + hasProjectManage: true, + contractManagePermissionsAbsent: false, + }); + } finally { + ROLE_PERMISSIONS.employee.pop(); + } + }); + + it("writes only target authentication state and never writes roles or employee identity", async () => { + const { prisma, tx } = createFixture(); + + await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).resolves.toEqual({ + status: "PASS", + targetMatches: 1, + exactTarget: true, + passwordUpdated: true, + mustChangePassword: true, + tokenVersionIncremented: true, + locksCleared: true, + rolesExact: true, + contractManagePermissionsAbsent: true, + revokedSessions: 2, + invalidatedPasswordResetCodes: 1, + consumedEmailLoginCodes: 3, + auditCreated: true, + }); + + const update = tx.employee.update.mock.calls[0][0]; + expect(update.where).toEqual({ id: TARGET_ID }); + expect(Object.keys(update.data).sort()).toEqual( + [ + "email_login_code_failed_count", + "email_login_code_failed_first_at", + "email_login_code_locked_until", + "login_failed_count", + "login_failed_first_at", + "login_locked_until", + "must_change_password", + "password_hash", + "password_reset_failed_count", + "password_reset_failed_first_at", + "password_reset_last_requested_at", + "password_reset_locked_until", + "password_reset_request_count", + "password_reset_request_first_at", + "token_version", + ].sort(), + ); + expect(update.data).not.toHaveProperty("roles"); + expect(update.data).not.toHaveProperty("status"); + expect(update.data).not.toHaveProperty("employment_status"); + expect(update.data).not.toHaveProperty("archived_at"); + expect(update.data).not.toHaveProperty("email"); + expect(update.data).not.toHaveProperty("phone"); + expect(tx.authSession.updateMany).toHaveBeenCalledWith({ + where: { employee_id: TARGET_ID, revoked_at: null }, + data: { revoked_at: expect.any(Date), last_seen_at: expect.any(Date) }, + }); + expect(tx.passwordResetCode.updateMany).toHaveBeenCalledWith({ + where: { employee_id: TARGET_ID, invalidated_at: null }, + data: { invalidated_at: expect.any(Date) }, + }); + expect(tx.emailLoginCode.updateMany).toHaveBeenCalledWith({ + where: { employee_id: TARGET_ID, used_at: null }, + data: { used_at: expect.any(Date) }, + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actor_id: TARGET_ID, + target_id: TARGET_ID, + action: AUDIT_ACTION, + extra: { credentialRecorded: false }, + }), + }); + }); + + it("fails closed when the exact role changes after preflight", async () => { + const { prisma, tx } = createFixture(); + tx.$queryRaw.mockImplementation(async (strings) => { + const sql = strings.join(" "); + if (sql.includes('FROM "employees"')) return [rawEmployeeRow(employeeRow())]; + return [{ role_code: "employee" }, { role_code: "manager" }]; + }); + + await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow( + "employee_smoke_target_changed", + ); + expect(tx.employee.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + }); + + it("fails closed when the token version changes after preflight", async () => { + const { prisma, tx } = createFixture(); + tx.$queryRaw.mockImplementation(async (strings) => { + const sql = strings.join(" "); + if (sql.includes('FROM "employees"')) { + return [rawEmployeeRow(employeeRow({ token_version: 8 }))]; + } + return [{ role_code: "employee" }]; + }); + + await expect(applyReset(prisma, "UniqueBreakGlass9Qz!")).rejects.toThrow( + "employee_smoke_target_changed", + ); + expect(tx.employee.update).not.toHaveBeenCalled(); + expect(tx.auditLog.create).not.toHaveBeenCalled(); + }); + + it("verifies reset state with exact role and all four management permissions absent", async () => { + const row = employeeRow({ must_change_password: true }); + const { prisma } = createFixture({ rows: [row] }); + + await expect(verifyState(prisma)).resolves.toMatchObject({ + status: "PASS", + mustChangePassword: true, + mustChangePasswordMatches: true, + targetMatches: 1, + rolesExact: true, + hasWildcard: false, + hasProjectManage: false, + hasProjectViewAll: false, + hasSystemSettings: false, + contractManagePermissionsAbsent: true, + activeSessions: 0, + activePasswordResetCodes: 0, + activeEmailLoginCodes: 0, + auditPresent: true, + }); + }); + + it("blocks verify when the reset target has any extra role", async () => { + const row = employeeRow({ + must_change_password: true, + roles: [{ role_code: "employee" }, { role_code: "manager" }], + }); + const { prisma } = createFixture({ rows: [row] }); + + await expect(verifyState(prisma)).resolves.toMatchObject({ + status: "BLOCKED", + targetMatches: 1, + rolesExact: false, + }); + }); + + it.each(["*", "project.manage", "project.view_all", "system.settings"])( + "blocks verify when employee permissions contain %s", + async (permission) => { + ROLE_PERMISSIONS.employee.push(permission); + try { + const row = employeeRow({ must_change_password: true }); + const { prisma } = createFixture({ rows: [row] }); + const result = await verifyState(prisma); + expect(result).toMatchObject({ + status: "BLOCKED", + rolesExact: true, + contractManagePermissionsAbsent: false, + }); + } finally { + ROLE_PERMISSIONS.employee.pop(); + } + }, + ); + + it("verifies the completed forced password change without exposing the target", async () => { + const { prisma } = createFixture(); + prisma.authSession.count.mockResolvedValue(1); + prisma.auditLog.count.mockResolvedValue(1); + + const result = await verifyCompletedPasswordChange(prisma); + expect(result).toMatchObject({ + status: "PASS", + mustChangePassword: false, + mustChangePasswordMatches: true, + rolesExact: true, + contractManagePermissionsAbsent: true, + activeSessions: 1, + passwordChangeAuditPresent: true, + breakGlassAuditPresent: true, + }); + expect(JSON.stringify(result)).not.toContain("smoke-employee@example.test"); + expect(JSON.stringify(result)).not.toContain(TARGET_ID); + }); +});