实现(08-10-production-employee-smoke-break-glass-reset): 增加员工Smoke受控重置工具
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-10 21:01:59 +08:00
父节点 b807762c52
当前提交 ec68256588
共修改 10 个文件,包含 1310 行新增和 0 行删除
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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 <candidate>: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 口径。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
@@ -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` 及四项管理权限布尔。用户已批准五步候选发布,当前进入提交前检查;账号写入、登录和强制改密仍需发布成功后的独立批准。
@@ -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。
@@ -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": {}
}
@@ -646,6 +646,7 @@ if (require.main === module) {
module.exports = { module.exports = {
DEFAULT_TARGETS, DEFAULT_TARGETS,
applyTarget,
execute, execute,
isSafeOaOnlySmokeTarget, isSafeOaOnlySmokeTarget,
isSmokeLikeValue, isSmokeLikeValue,
@@ -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,
};
@@ -1,4 +1,5 @@
const { const {
applyTarget,
isSafeOaOnlySmokeTarget, isSafeOaOnlySmokeTarget,
isSmokeLikeValue, isSmokeLikeValue,
normalizeRoleList, normalizeRoleList,
@@ -85,4 +86,56 @@ describe("repair-oa-only-smoke-accounts", () => {
expect(normalizeRoleList(["admin", "admin", "invalid", "employee"])).toEqual(["admin", "employee"]); expect(normalizeRoleList(["admin", "admin", "invalid", "employee"])).toEqual(["admin", "employee"]);
expect(process.env.OA_ONLY_SMOKE_REPAIR_APPLY).toBeUndefined(); 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);
});
}); });
@@ -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: "[email protected]",
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: "[email protected]" })]],
["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("[email protected]");
expect(JSON.stringify(result)).not.toContain(TARGET_ID);
});
});