From ca24479a442c75d74ce210056032b1fe948b3e1e Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 1 Aug 2026 19:18:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E7=94=9F=E4=BA=A7=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=81=A2=E5=A4=8D=E5=90=88=E5=90=8C=EF=BC=8808-01-pro?= =?UTF-8?q?duction-secret-recovery-env-externalization=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/deployment-trialrun.md | 62 ++++++ .../check.jsonl | 1 + .../design.md | 89 +++++++++ .../implement.jsonl | 1 + .../implement.md | 48 +++++ .../prd.md | 77 ++++++++ .../task.json | 26 +++ .../trialrun/env-recovery-classification.json | 179 ++++++++++++++++++ deploy/trialrun/runbook.md | 47 ++++- deploy/trialrun/validate-env-contract.js | 99 ++++++++++ .../env-recovery-classification.test.js | 106 +++++++++++ 11 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/check.jsonl create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/design.md create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.jsonl create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.md create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/prd.md create mode 100644 .trellis/tasks/08-01-production-secret-recovery-env-externalization/task.json create mode 100644 deploy/trialrun/env-recovery-classification.json create mode 100644 server/tests/unit/scripts/env-recovery-classification.test.js diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 317456a5..9a419373 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -1342,3 +1342,65 @@ REMOTE_DIR="$(docker inspect --format '')" \ DEPLOY_SERVER= \ bash deploy/trialrun/deploy-existing.sh ``` + +## Scenario: Environment Recovery Classification And Secret Custody + +### 1. Scope / Trigger + +- Trigger: adding/removing a trialrun environment key, documenting host-loss recovery, or preparing an environment-file migration. +- Purpose: make environment loss recoverable without placing Secret values in Git, ordinary backup, logs or AI context. + +### 2. Signatures + +- Template contract: `deploy/trialrun/.env.example`. +- Recovery classification: `deploy/trialrun/env-recovery-classification.json`. +- Recovery card: the opening section of `deploy/trialrun/runbook.md`. +- Validator: `node deploy/trialrun/validate-env-contract.js`. +- Fixed categories: `template_rebuild`, `external_reissue`, `direct_rotate`, `downtime_reset`, `preserve_original`. + +### 3. Contracts + +- Every `.env.example` key appears in exactly one fixed recovery category; the classification file contains key names only, never values, defaults, hashes, accounts or platform addresses. +- `template_rebuild` comes from reviewed templates/runbooks; `external_reissue` comes from the owning platform; `direct_rotate` records the invalidation consequence; `downtime_reset` requires an isolated approved outage. +- `POSTGRES_PASSWORD` is a reset case. PostgreSQL stores a verifier in `pg_authid`, not recoverable plaintext. Any temporary authentication relaxation is local-socket only, never a trusted host rule, and the original authentication file must be restored byte-for-byte before reopening the application. +- `FIELD_ENCRYPTION_KEY` is `preserve_original` and the highest-risk class. Its current AES-256-GCM payload is `v1:::` without a key id; replacing a lost key does not recover existing bank, identity or notification-channel ciphertext. +- `preserve_original` custody must be available to both the system administrator and a company responsible person through a repository-external shared vault or encrypted sealed copy. Evidence records roles and status only. +- Original-key custody is a current recovery constraint, not a permanent rotation design. Key id, keyring, lazy/bulk re-encryption and old-key retirement require a separate approved security task. +- Database/attachment backups do not absorb plaintext environment files. Phase 1 recovery-contract work never reads production env or contacts production systems. + +### 4. Validation & Error Matrix + +- New `.env.example` key missing from classification -> validator fails. +- Key appears in two categories -> validator fails and names the duplicated key. +- Unknown category, extra key, illegal key name or value-like entry -> validator fails. +- `FIELD_ENCRYPTION_KEY` outside `preserve_original` -> validator fails. +- `POSTGRES_PASSWORD` outside `downtime_reset` -> validator fails. +- Original encryption key unavailable -> stop recovery and report ciphertext impact; never generate a replacement and claim success. +- Temporary database trust remains or a DB host port is published -> recovery fails and the external entry remains closed. + +### 5. Good/Base/Bad Cases + +- Good: a TTL is rebuilt from the template, a WeCom Secret is reissued by its platform owner, JWT is rotated with session invalidation, and the field-encryption key is restored from dual-access custody. +- Base: the production key set differs from the example contract; a separately approved read-only check explains key names/counts without reading values before migration. +- Bad: put `.env` into the paired data backup, print a secret hash as evidence, assume `pg_authid` reveals the old password, or replace `FIELD_ENCRYPTION_KEY` with a new random value. + +### 6. Tests Required + +- `node deploy/trialrun/validate-env-contract.js`. +- Focused unit tests must cover the complete contract plus missing, duplicate, extra, unknown-category, illegal/value-like, field-key and database-password misclassification cases. +- Run `node scripts/qa/check-sensitive-content.mjs`, Trellis governance, process contract, docs registry audit and `git diff --check` after classification/runbook changes. +- Production key-name comparison, shared-vault custody, env migration, systemd updates, restarts and old-copy deletion remain separately approved Phase 2 evidence. + +### 7. Wrong vs Correct + +#### Wrong + +```json +{"direct_rotate":["FIELD_ENCRYPTION_KEY"]} +``` + +#### Correct + +```json +{"preserve_original":["FIELD_ENCRYPTION_KEY"]} +``` diff --git a/.trellis/tasks/08-01-production-secret-recovery-env-externalization/check.jsonl b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/check.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/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-01-production-secret-recovery-env-externalization/design.md b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/design.md new file mode 100644 index 00000000..b52a82fa --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/design.md @@ -0,0 +1,89 @@ +# 生产密钥恢复与环境文件外置:设计 + +## 1. 设计目标 + +本任务解决两个相互关联但必须分阶段交付的问题: + +1. 主机或环境文件丢失时,人知道每个配置键如何恢复、代价是什么、由谁完成。 +2. 生产环境文件不再位于源码同步树内,任何 `rsync --delete` 都无法物理触及它。 + +Phase 1 只建立恢复合同;Phase 2 才改变生产运行路径。数据库/附件备份继续保持现有边界,不吸收明文 Secret。 + +## 2. Phase 1:机器可校验的恢复分类 + +### 2.1 分类清单 + +新增一个位于 `deploy/trialrun/` 的 JSON 合同文件,按类别保存 `.env.example` 键名数组: + +```json +{ + "version": 1, + "categories": { + "template_rebuild": [], + "external_reissue": [], + "direct_rotate": [], + "downtime_reset": [], + "preserve_original": [] + } +} +``` + +- 文件只允许大写环境键名和固定类别,不含值、默认值副本、长度、哈希、账号或外部平台地址。 +- `validate-env-contract.js` 解析 `.env.example` 和分类文件,断言全集相等、无重复、无未知类别、无多余键。 +- 普通非敏感配置也必须进入 `template_rebuild`,避免清单只覆盖看起来像 Secret 的少数键。 +- `FIELD_ENCRYPTION_KEY` 固定归入 `preserve_original`;`POSTGRES_PASSWORD`/`DATABASE_URL` 的关系在恢复卡中说明,数据库角色口令本身归入 `downtime_reset`。 + +### 2.2 恢复卡 + +更新现有 `deploy/trialrun/runbook.md` 紧急恢复卡,不新建第二份 runbook: + +- 每类给出丢失影响、责任角色、恢复来源、停止条件和恢复后验证。 +- 数据库流程只描述受控“重置”:停止应用写入与 DB 容器、备份认证配置、用无公网暴露的隔离启动临时调整认证、修改角色口令、恢复原认证配置、重启并执行 migration status/ready/runtime smoke。任何一步失败都保持入口关闭。 +- `preserve_original` 明确原值丢失会导致既有密文永久不可读;不得用新随机值冒充恢复成功。 +- 共享密码库由系统管理员和公司负责人双人可取。仓库只记录角色与季度取用演练结果,不记录产品地址、账号或恢复码。 + +### 2.3 Phase 1 验证 + +- 单元测试覆盖完整分类、重复键、未知键、漏键、非法类别和分类文件出现疑似值。 +- 环境合同继续检查 config/example/Compose;分类变化纳入同一个失败出口。 +- 敏感扫描证明新增文件只有键名与类别。 +- Phase 1 不通过 SSH、数据库、Compose 或 systemd 访问生产。 + +## 3. Phase 2:生产环境文件外置 + +### 3.1 运行合同 + +- `REMOTE_ENV_FILE` 成为唯一运行事实源,必须是同步树外的安全绝对路径、普通文件、root 所有、`0600`。 +- Compose 的四个 `env_file` 从字面量 `./.env` 改为必须显式提供的 `${REMOTE_ENV_FILE}`;同一变量也继续传给 Compose `--env-file` 负责插值。 +- `deploy.sh`、`deploy-existing.sh`、远端 preflight、自动备份/WARCH3 安装器、备份 runner、巡检和收口脚本统一接收该变量,不保留 `${REMOTE_DIR}/deploy/trialrun/.env` 的生产默认值。 +- 本地开发可显式使用仓库外的测试 env;validator 的否定合同针对生产 Compose/部署默认与路径嵌套,不因为开发者工作区存在 gitignored 测试文件而误报。 + +### 3.2 负向防护 + +- `deploy.sh` 在 provenance、mkdir、rsync 之前拒绝 `REMOTE_ENV_FILE == REMOTE_DIR` 或任一方向嵌套。 +- `preflight-remote.sh` 验证 env owner/mode/非 symlink,并确认路径不在 `REMOTE_DIR` 下。 +- `validate-env-contract.js` 拒绝 Compose 再出现 `env_file: ./.env`、部署脚本重新默认到同步树或 runbook 把生产事实源写回仓库目录。 +- `rsync` 的 `.env` exclude 可以作为纵深防御保留,但不再承担安全正确性。 + +### 3.3 受控迁移状态机 + +1. **Prepare**:只读冻结活动 Compose、env 路径、4 个 service、PGDATA/storage 和构建身份;检查共享密钥封存完成状态,不读取值。 +2. **Copy**:在主机上以 root 原子复制当前 env 到外置临时文件,核对键集合、owner/mode/非 symlink 后原子改名;不在日志输出 diff 或值。 +3. **Switch**:重装 4 个 units,使用新 env 完成 Compose config、新 init 配置预检并切换应用。 +4. **Verify**:migration status、`/ready`、external/runtime smoke、普通登录、企微登录/入口、automatic backup daily/watchdog 和 timer 身份均通过。 +5. **Retire**:在同一批准窗口内按精确旧路径删除旧副本;随后验证旧路径不存在、容器与 units 只引用新路径。 + +任何 Verify 失败都在旧副本尚存时切回旧合同并重新安装 units;禁止在验证完成前删除旧副本。Retire 后若需回滚,只能使用外置事实源,不能把 Secret 搬回同步树。 + +## 4. 密钥版本化的延期边界 + +当前 `v1:::` 没有 key id,单一 `FIELD_ENCRYPTION_KEY` 无法支持安全轮换。本任务只保证原值可恢复,不修改加密服务、数据库密文或敏感字段 API。 + +后续独立安全任务至少需要:新格式版本与 key id、keyring 配置合同、当前写入键、旧键只读、惰性与批量重加密、覆盖银行账号/身份证号/通知渠道密文的枚举与计数、轮换回滚和旧键删除门禁。 + +## 5. 风险与回滚 + +- 最大风险是两份 env 共存期间扩大 Secret 暴露面,因此窗口必须短、两份均 root-only,完成后有否定删除验证。 +- Compose 插值与 service `env_file` 是两个用途,必须同时验证,不能只改命令行 `--env-file`。 +- 数据库口令重置步骤属于事故恢复,不在常规部署或自动测试中执行;文档不得弱化停机和隔离要求。 +- 具体密码管理器和成员授权是仓库外 EXTERNAL_GATED 动作,缺少双人取用确认时 Phase 2 不得开始。 diff --git a/.trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.jsonl b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.jsonl new file mode 100644 index 00000000..9dd3234a --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/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-01-production-secret-recovery-env-externalization/implement.md b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.md new file mode 100644 index 00000000..2b0c9ba5 --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/implement.md @@ -0,0 +1,48 @@ +# 生产密钥恢复与环境文件外置:实施计划 + +## 0. 边界 + +- [x] 本任务按 Phase 1、Phase 2 分开批准、提交和验收;Phase 1 批准不授权生产访问或迁移。 +- [x] 不读取、打印、哈希或提交生产 Secret;只处理键名、类别、布尔状态和计数。 +- [x] `FIELD_ENCRYPTION_KEY` 版本化/keyring/重加密登记为后续任务,不在本轮顺手实现。 + +## 1. Phase 1:恢复卡与分类合同 + +- [x] 从 `.env.example` 自动提取当前键集合,逐项归入 5 个固定类别;对 `FIELD_ENCRYPTION_KEY`、数据库口令、JWT/pepper、外部平台凭据做人工复核。 +- [x] 新增无值的 JSON 分类合同,并扩展 `validate-env-contract.js`:全集相等、恰好一次、类别固定、键名格式正确、内容不含疑似赋值/Secret 正文。 +- [x] 更新 `deploy/trialrun/runbook.md` 顶部恢复卡,写清 5 类影响、责任人与恢复后验证;数据库为隔离停机重置,不写成明文找回。 +- [x] 更新 `.trellis/spec/hyb-oa/deployment-trialrun.md`,固化环境恢复分类、双人托管、`preserve_original` 最高风险和 keyring 延期边界。 +- [x] 增加聚焦单元测试:正常 164-key 合同、漏键、重复键、多余键、未知类别、非法键名/疑似值、`FIELD_ENCRYPTION_KEY` 与 `POSTGRES_PASSWORD` 错误分类。 +- [x] 运行环境合同、聚焦测试、server 全量测试、JS 语法、task/governance/process/docs/sensitive/diff 门禁。 +- [x] Phase 1 单独提交;证据明确“未访问或修改生产”,不把文档完成冒充密钥已封存。 + +## 5. Phase 1 验证证据(2026-08-01) + +- 环境合同:PASS,`.env.example` 164 个键,恢复分类 164 个键,恰好覆盖一次。 +- Prisma 与语法:`prisma validate`、validator/test `node --check` 均 PASS。 +- 服务端全量:173 个测试文件、1144 个测试 PASS;其中恢复分类聚焦用例 8/8 PASS,既有部署安全用例 7/7 PASS。 +- 治理门禁:task validate、Trellis governance、process contract、sensitive-content scan、`git diff --check` 均 PASS;docs registry audit 为 0 error、1 个既有汇总 warning(22 个未登记 docs 文件)。 +- 人工复核:五类语义、双人托管、PostgreSQL 隔离停机重置与 `FIELD_ENCRYPTION_KEY` 原值保留边界一致;清单仅含键名和类别。 +- 操作边界:本阶段未连接、读取或修改生产;未创建密码库条目,未迁移 env,未修改 Compose/systemd,未重启服务,也未删除任何生产文件。 + +## 2. Phase 2:代码与脚本准备(仍不自动迁移) + +- [ ] Compose 四个 service 使用显式 `${REMOTE_ENV_FILE}`;部署、备份、WARCH3、巡检和 QA 脚本移除同步树内生产默认。 +- [ ] `deploy.sh`、preflight 和环境合同拒绝 env 与同步树重叠,校验 root owner、`0600`、普通非 symlink 文件;保留 rsync exclude 仅作纵深防御。 +- [ ] 更新 runbook、checklist、管理员手册和 OPS7 中所有生产路径示例;本地示例明确使用非生产测试 env。 +- [ ] 扩展合成回归,覆盖旧路径拒绝、新路径通过、Compose v1/v2 config、4 个 unit 参数一致、rsync 前失败和旧副本否定检查。 +- [ ] 运行 Docker Compose config、部署合同、备份合同、全量测试与 Trellis 门禁;单独提交但不连接生产。 + +## 3. Phase 2:生产迁移(另行明确批准) + +- [ ] 只读冻结活动 project、构建、当前 env、PGDATA/storage、4 个 service/timer;确认系统管理员与公司负责人均可取用 `preserve_original` 封存项,只记录完成状态。 +- [ ] root 原子复制到同步树外目标,校验键集合、owner/mode/非 symlink;不输出值或文件内容。 +- [ ] 重装 4 个 units,运行 Compose config、新 init 配置预检并切换 server;未获批准不启用 worker/minio profile。 +- [ ] 验证 migration status、`/ready`、external/runtime smoke、普通登录、企微入口、automatic backup daily/watchdog、4 个 timer 和构建身份。 +- [ ] 验收全部通过后按精确旧路径删除旧副本;再次证明旧路径不存在,活动容器和 units 只引用新路径。 +- [ ] 任一验证失败时,在旧副本删除前回滚 unit/Compose 引用;记录失败尝试,不美化为一次成功。 + +## 4. 后续独立任务 + +- [ ] 建立 `FIELD_ENCRYPTION_KEY` 版本化与安全轮换任务:带 key id 的新密文格式、keyring、惰性/批量重加密、全字段覆盖、审计与旧键删除门禁。 +- [ ] 约 18 个孤儿 E2E 继续作为独立测试治理任务,不与密钥恢复混做。 diff --git a/.trellis/tasks/08-01-production-secret-recovery-env-externalization/prd.md b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/prd.md new file mode 100644 index 00000000..205b997b --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/prd.md @@ -0,0 +1,77 @@ +# 生产密钥恢复与环境文件外置 + +## Goal + +在不把生产 Secret 写入仓库、普通备份、日志或聊天的前提下,为 OA 建立可执行的环境配置恢复合同,并把生产环境文件迁出源码同步树,避免 rsync 误删、单机损坏或单管理员缺席导致系统无法恢复。 + +## Background + +- `5dac336` 首次发布误把 Compose 配置目录当作仓库根目录,`rsync --delete` 删除了远端 `.env`;本次仅因旧 server 容器仍在运行,才能按允许键恢复配置。 +- 当前数据库和附件已有异机备份,环境文件及密钥没有独立恢复合同。主机整体丢失时,仅恢复数据库与附件不足以启动 OA。 +- `07de198` 已把仓库根、远端 env、PGDATA 和 storage 的检查前移到 rsync 之前,封住最可能复发路径,但没有解决整机丢失、密钥托管或环境文件仍位于同步树内的问题。 +- 当前 `.env.example` 合同含 164 个键;生产事故恢复曾观察到 163 个可恢复键。第一阶段只按键名和用途分类,不读取、输出或提交生产值;第二阶段再只读解释生产键集合差异。 +- 仓库中不只有 4 个 systemd service 引用 `REMOTE_ENV_FILE`:Compose、部署/备份入口、自动备份安装器、日常巡检脚本、若干 QA 收口脚本及 runbook/checklist 都存在当前路径假设,迁移必须统一收敛。 +- `FIELD_ENCRYPTION_KEY` 已用于银行账号、身份证号和通知渠道 Secret 等数据库密文。原值丢失后不能从数据库反推;直接换新值会使既有密文不可读,除非持有旧值完成受控重加密。 + +## Scope + +- 当前获批并执行的范围仅为 Phase 1:脱敏恢复分类、现有恢复卡、环境合同校验、聚焦测试与稳定规约。 +- Phase 2 的代码合同、生产 env 外置及旧副本删除仍保留在本任务中规划,但必须分别取得后续明确批准后才可实施。 +- 本轮不访问生产,不读取或托管 Secret,不修改 Compose/systemd,不重启服务,也不执行任何生产删除。 + +## Requirements + +### Phase 1:恢复合同与分类(不碰生产) + +- 在现有 `deploy/trialrun/runbook.md` 的紧急恢复卡中增加环境配置恢复,不另建重复操作手册。 +- 用机器可校验的脱敏清单覆盖 `.env.example` 中每一个键;清单只包含键名和恢复类别,不含值、长度、哈希或原始 payload;各类别的影响和责任边界统一写入现有恢复卡。 +- 至少支持用户已确认的四类: + 1. `template_rebuild`:端口、路径、开关、TTL 等,从 `.env.example` 与 runbook 重建。 + 2. `external_reissue`:企微、Webhook、腾讯云、SMTP、OIDC、S3/AI/ERP 等外部凭据,从对应平台重新签发或获取。 + 3. `direct_rotate`:JWT、验证码 pepper、试运行管理员口令等可直接轮换项;必须写明会话失效、验证码失效或账号重置等代价。 + 4. `downtime_reset`:数据库口令等不能反推明文、但可在保留 PGDATA 时通过隔离停机操作重置的项目。恢复卡必须明确这是“重置”而非“找回”,先备份认证配置、停止应用写入、隔离数据库、临时调整认证、修改角色口令、恢复认证配置并做完整 smoke;不得让数据库临时暴露公网。 +- 第 5 类 `preserve_original` 为最高风险:`FIELD_ENCRYPTION_KEY` 等必须保存原值的密钥,只能从受控共享密码库/加密封存副本恢复;没有旧值时不得宣称轮换即可恢复,必须如实说明既有银行账号、身份证号和通知渠道密文将不可读并需重新收集/配置。 +- 密钥封存不得只由单一管理员持有。共享密码库或加密封存副本至少由系统管理员和公司负责人各自可取;仓库只记录责任角色、复核周期和恢复验证,不记录产品库地址、账号、恢复码或密钥正文。 +- 第一阶段限制为文档、脱敏分类清单、静态校验和测试,目标半天内完成;不连接生产、不创建密码库条目、不迁移 env、不重启服务。 + +### Phase 2:环境文件外置(另行生产批准) + +- 生产 `REMOTE_ENV_FILE` 迁移到源码同步树外的 root-only 绝对路径,目标权限为 root 所有、`0600`、普通非 symlink 文件。 +- Compose 的 `server`、`worker`、`init`、`minio_init`,部署入口、备份/巡检脚本和 4 个运维 service 必须统一使用显式 `REMOTE_ENV_FILE`,不再依赖 `./.env`。 +- `deploy.sh` 对 existing 生产部署拒绝 `REMOTE_ENV_FILE` 与 `REMOTE_DIR` 相同或互相嵌套;`validate-env-contract.js` 负向断言生产 Compose/部署合同不得把 env 放回同步树。不能仅依赖 rsync exclude。 +- 迁移使用受控短暂停机/切换窗口:从当前事实源原子复制到外置路径,验证 owner/mode/非 symlink 与键集合,更新全部 units,完成 Compose config、新 init 配置预检、应用切换、`/ready`、外部依赖、runtime smoke、登录/企微和备份 timer 验证。 +- 迁移期允许存在新旧两份密钥副本作为明确回退点;验收后必须按精确旧路径删除旧副本,并再次验证旧文件不存在、运行容器与 systemd 均引用新路径。删除属于生产破坏性动作,必须在第二阶段批准窗口内显式执行和记录。 +- 共享密码库具体产品、成员授权和生产 Secret 写入由用户/公司负责人在仓库外完成;Codex 只验证职责与完成状态,不读取 Secret。 + +## Acceptance Criteria + +- [x] `.env.example` 当前全部键恰好归入一个获批恢复类别;新增、删除或重复分类会使合同测试失败。 +- [x] 恢复卡能分别回答模板重建、外部重申请、直接轮换、数据库停机重置和原值保留;每类写明业务影响、操作边界和恢复后验证,`preserve_original` 标为最高风险。 +- [x] 数据库口令恢复明确使用隔离停机“重置”流程,不声称可从 `pg_authid` 反推明文,不留下 trust 认证或公网暴露。 +- [x] 密钥托管职责至少覆盖系统管理员与公司负责人两人;仓库、日志和普通数据库/附件备份中不出现密钥正文。 +- [x] Phase 1 的文档、分类清单、validator 和单元测试通过,且没有生产访问或写入。 +- [ ] Phase 2 代码合同使生产 env 物理位于同步树外,并负向拒绝同步树内 env;所有 Compose/部署/备份/巡检引用统一。 +- [ ] 获批生产迁移后,新路径 owner/mode、Compose config、init 配置预检、`/ready`、外部依赖、runtime smoke、登录/企微及 4 个 timer 均通过。 +- [ ] 旧副本删除后有否定验证,生产运行合同仍指向新路径;可回退点、删除时间和操作人有脱敏审计记录。 + +## Out of Scope + +- 不把明文 `.env` 加入现有数据库/附件备份或 Git。 +- 不在本任务内采购或替用户选择密码管理器产品。 +- 不让 Codex、CI、聊天或任务证据读取/保存生产 Secret。 +- 不借机轮换所有密钥、重加密全部敏感字段或重构配置中心。 +- 不在本任务实现密钥版本、keyring、惰性重加密或全量重加密;另立安全设计任务后才能改变密文格式。 +- 不在 Phase 1 迁移生产路径、更新 systemd、重启容器或删除旧 env。 + +## Known Limitation / Deferred + +- 当前 AES-256-GCM 密文格式为 `v1:::`,只有格式版本,没有 key id;服务端配置也只有单一 `FIELD_ENCRYPTION_KEY`,因此无法判断密文由哪把钥匙生成或安全并存新旧钥匙。 +- `preserve_original` 是当前恢复现实,不是永久设计。若将来怀疑密钥泄露,现有系统无法在保持既有密文可读的同时轮换。 +- 后续独立任务应设计带 key id 的新密文版本、受控 keyring、写入只用当前钥匙、旧密文读取后的惰性重加密,以及覆盖全部密文字段的可审计批量迁移/回滚;在该设计完成前不得删除旧钥匙。 + +## Test Plan + +- 环境与语法:`node deploy/trialrun/validate-env-contract.js`、相关 `node --check`、`cd server && npm run prisma:validate`。 +- 回归测试:聚焦运行恢复分类与部署安全单元测试,并运行 `cd server && npm test` 全量测试。 +- Trellis/文档门禁:task validate、Trellis governance、process contract、docs registry audit、sensitive-content scan 与 `git diff --check`。 +- 人工复核:逐项检查 164 个键的恢复语义、恢复卡的停机/隔离边界,以及新增材料不含生产值、哈希或真实托管位置。 diff --git a/.trellis/tasks/08-01-production-secret-recovery-env-externalization/task.json b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/task.json new file mode 100644 index 00000000..de8c03f3 --- /dev/null +++ b/.trellis/tasks/08-01-production-secret-recovery-env-externalization/task.json @@ -0,0 +1,26 @@ +{ + "id": "production-secret-recovery-env-externalization", + "name": "production-secret-recovery-env-externalization", + "title": "生产密钥恢复与环境文件外置", + "description": "分阶段建立生产环境配置恢复合同,并在另行批准后将生产 env 迁出源码同步树。", + "status": "in_progress", + "dev_type": null, + "scope": "Phase 1: 脱敏恢复分类与恢复卡;Phase 2: 生产 env 外置代码合同及另行批准的迁移", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-08-01", + "completedAt": null, + "branch": "main", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/deploy/trialrun/env-recovery-classification.json b/deploy/trialrun/env-recovery-classification.json new file mode 100644 index 00000000..6c02f1e5 --- /dev/null +++ b/deploy/trialrun/env-recovery-classification.json @@ -0,0 +1,179 @@ +{ + "version": 1, + "categories": { + "template_rebuild": [ + "AI_ASSIST_ENABLED", + "AI_BASE_URL", + "AI_MODEL", + "AI_PROVIDER", + "AI_TIMEOUT_MS", + "ANNOUNCEMENTS_ENABLED", + "APP_HOST_PORT", + "APP_ORIGIN", + "APP_TIMEZONE", + "ATTENDANCE_ENABLED", + "ATTENDANCE_UNIFIED_ENABLED", + "AUTH_CODE_REQUEST_COOLDOWN_SECONDS", + "AUTH_LOGIN_FAIL_MAX_ATTEMPTS", + "AUTH_LOGIN_FAIL_WINDOW_SECONDS", + "AUTH_LOGIN_LOCK_SECONDS", + "BUDGET_CONTROL_MODE", + "BUDGET_PRECHECK_ENABLED", + "CLIENT_DIST_DIR", + "CONFIDENTIAL_DOC_REFS_ENABLED", + "COOKIE_SECURE", + "CUTOVER_CONSOLE_ENABLED", + "DATABASE_URL", + "DELEGATION_REVOKE_CRON", + "DEV_SHOW_PASSWORD_RESET_CODE", + "EMAIL_BASE_URL", + "EMAIL_ENABLED", + "EMAIL_FROM", + "EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS", + "EMAIL_LOGIN_CODE_LOCK_SECONDS", + "EMAIL_LOGIN_CODE_MAX_ATTEMPTS", + "EMAIL_LOGIN_CODE_TTL_SECONDS", + "EMPLOYEE_LIFECYCLE_ENABLED", + "ERP_ASSET_EXPORT_ENDPOINT", + "ERP_ASSET_TIMEOUT_MS", + "ERP_JOURNAL_EXPORT_ENDPOINT", + "ERP_MASTER_DATA_ENDPOINT", + "ERP_MASTER_DATA_SYNC_CRON", + "ERP_MASTER_DATA_TIMEOUT_MS", + "ERP_PROCUREMENT_EXPORT_ENDPOINT", + "ERP_PROCUREMENT_TIMEOUT_MS", + "ERP_SYNC_ENABLED", + "ERP_SYNC_ENDPOINT", + "ERP_SYNC_PROVIDER", + "ERP_SYNC_TIMEOUT_MS", + "GLOBAL_SEARCH_ENABLED", + "HOST", + "IM_NOTIFIER_ENABLED", + "INTEGRATION_OUTBOX_BATCH_SIZE", + "INTEGRATION_OUTBOX_CRON", + "INTEGRATION_OUTBOX_MAX_ATTEMPTS", + "INTEGRATION_OUTBOX_RETRY_SECONDS", + "INVOICE_ARCHIVE_BATCH_SIZE", + "INVOICE_ARCHIVE_CRON", + "INVOICE_ARCHIVE_UNUSED_DAYS", + "INVOICE_VERIFICATION_ENABLED", + "JOBS_ENABLED", + "JOB_DRIVER", + "LEAVE_HOLIDAY_CALENDAR_ENABLED", + "MAX_UPLOAD_SIZE_BYTES", + "METRICS_ENABLED", + "MINIO_ROOT_USER", + "NODE_ENV", + "OCR_BATCH_SIZE", + "OCR_CRON_ENABLED", + "OCR_LOCK_TTL_SECONDS", + "OCR_MAX_ATTEMPTS", + "OCR_POLL_CRON", + "OCR_PROVIDER", + "OCR_TIMEOUT_MS", + "OIDC_ENABLED", + "OIDC_ISSUER_URL", + "OIDC_REDIRECT_URI", + "OIDC_SCOPES", + "OPENAPI_DOCS_ENABLED", + "OTEL_TRACES_ENABLED", + "PASSWORD_RESET_CODE_TTL_SECONDS", + "PASSWORD_RESET_FAIL_WINDOW_SECONDS", + "PASSWORD_RESET_LOCK_SECONDS", + "PASSWORD_RESET_MAX_ATTEMPTS", + "PASSWORD_RESET_REQUEST_MAX", + "PASSWORD_RESET_REQUEST_WINDOW_SECONDS", + "PAYMENT_BATCH_ENABLED", + "PG_BOSS_SCHEMA", + "PORT", + "PORTAL_HOME_ENABLED", + "POSTGRES_DATA_DIR", + "POSTGRES_DB", + "POSTGRES_USER", + "PRESENCE_BOARD_ENABLED", + "PROJECT_CLOSURE_CHECKLIST_ENABLED", + "PROJECT_COMMUNICATION_LOG_ENABLED", + "PROJECT_DOMAIN_ENABLED", + "PROJECT_LINKING_ENABLED", + "PUBLIC_SECURITY_HARDENING_ENABLED", + "RATE_LIMIT_ENABLED", + "RATE_LIMIT_LOGIN_MAX", + "RATE_LIMIT_LOGIN_WINDOW", + "S3_BUCKET", + "S3_ENDPOINT", + "S3_FORCE_PATH_STYLE", + "S3_REGION", + "SERVE_CLIENT_DIST", + "SHADOW_DATABASE_URL", + "SIGNED_URL_TTL_SECONDS", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_SECURE", + "STORAGE_DIR", + "STORAGE_DRIVER", + "STORAGE_HOST_DIR", + "TENCENT_OCR_REGION", + "TODO_BOARD_ENABLED", + "TRAVEL_SYNC_ENABLED", + "TRIALRUN_ADMIN_EMAIL", + "TRIALRUN_ADMIN_EMPLOYEE_NO", + "TRIALRUN_ADMIN_HIRE_DATE", + "TRIALRUN_ADMIN_NAME", + "TRIALRUN_ADMIN_PHONE", + "TRIALRUN_ADMIN_TITLE", + "TRUST_PROXY_HOPS", + "VERIFICATION_MAX_ATTEMPTS", + "VERIFICATION_POLL_CRON", + "VERIFICATION_PROVIDER", + "VERIFICATION_RETRY_COOLDOWN_SECONDS", + "VERIFICATION_TIMEOUT_MS", + "WECOM_ALLOWED_RETURN_HOSTS", + "WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE", + "WECOM_APPROVAL_CALLBACK_URL", + "WECOM_CHECKIN_SYNC_ENABLED", + "WECOM_CONTACT_SYNC_ENABLED", + "WECOM_ENABLED", + "WECOM_ENTRY_ENABLED", + "WECOM_HR_MASTER_SYNC_ENABLED", + "WECOM_OAUTH_ENABLED", + "WECOM_OAUTH_ONBOARDING_ENABLED", + "WECOM_PC_WEB_LOGIN_ENABLED", + "WECOM_REDIRECT_URI", + "WEEKLY_REPORT_ENABLED", + "WORKFLOW_DEFINITION_ADMIN_ENABLED" + ], + "external_reissue": [ + "AI_AUTH_TOKEN", + "ERP_SYNC_AUTH_TOKEN", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "SMTP_PASS", + "SMTP_USER", + "TENCENT_SECRET_ID", + "TENCENT_SECRET_KEY", + "WECOM_AGENT_ID", + "WECOM_AGENT_SECRET", + "WECOM_CALLBACK_ENCODING_AES_KEY", + "WECOM_CALLBACK_TOKEN", + "WECOM_CHECKIN_SECRET", + "WECOM_CONTACT_DEFAULT_DEPARTMENT_ID", + "WECOM_CONTACT_SECRET", + "WECOM_CORP_ID", + "WECOM_HR_ASSISTANT_SECRET" + ], + "direct_rotate": [ + "AUTH_CODE_PEPPER", + "JWT_SECRET", + "MINIO_ROOT_PASSWORD", + "TRIALRUN_ADMIN_PASSWORD" + ], + "downtime_reset": [ + "POSTGRES_PASSWORD" + ], + "preserve_original": [ + "FIELD_ENCRYPTION_KEY" + ] + } +} diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 33975003..0da75565 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -2,13 +2,43 @@ > 目标:按本文档在一台新机器完成 **启动 → 迁移 → 初始化管理员 → 验收 → 备份 → 恢复 → 回滚** 演练。 -## 紧急恢复卡(单管理员离线版) +## 紧急恢复卡(双人密钥托管版) -适用情形:生产主机不可恢复、数据库或附件损坏,且已由负责人批准进入事故恢复。公司当前只有一名系统管理员,由该管理员维护并执行本卡;不得在聊天或工单中粘贴凭证、业务正文、真实目录或原始日志。 +适用情形:生产主机不可恢复、环境文件丢失、数据库或附件损坏,且已由负责人批准进入事故恢复。系统管理员负责技术操作,公司负责人负责密钥取用复核;`preserve_original` 类必须确保二人均能从仓库外共享密码库或加密封存副本取用。不得在聊天、工单、命令历史或报告中粘贴凭证、业务正文、真实目录或原始日志。 -1. 先暂停外部入口和新业务写入,不删除故障主机、旧数据目录或 ARM 归档;记录故障时间、最近成功批次编号类别和恢复负责人。 -2. 在 ARM 的只读归档中选择最近一个 `complete` 批次,复核三文件、manifest、SHA-256 与来源标识;先复制到隔离恢复工作区,不直接对归档原件操作。 -3. 在获批的新主机/隔离目标依次执行以下三个入口;尖括号值只从受控运维配置取得,不写回本文: +### A. 先冻结现场 + +1. 暂停外部入口和新业务写入,不删除故障主机、旧容器、旧数据目录或 ARM 归档;记录故障时间、最近成功批次编号类别、恢复负责人和复核人。 +2. 若旧容器仍在运行,只允许按 `.env.example` 键名白名单提取缺失配置并原子写入 root-only 临时文件;不输出值,不把“容器还活着”当正式恢复机制。 +3. 在 ARM 的只读归档中选择最近一个 `complete` 批次,复核三文件、manifest、SHA-256 与来源标识;先复制到隔离恢复工作区,不直接对归档原件操作。 + +### B. 重建环境配置 + +先运行 `node deploy/trialrun/validate-env-contract.js`,确认 `.env.example` 的全部键已在 `env-recovery-classification.json` 中恰好分类一次。恢复配置只按以下五类处理: + +| 类别 | 丢失影响与恢复方式 | 恢复后验证 | +| --- | --- | --- | +| `template_rebuild` | 端口、路径、开关、TTL、公开地址等,从 `.env.example`、本 runbook 和已批准基础设施合同重填;不能从旧文档复制已过时值 | 配置预检、Compose config、路径/端口和功能开关基线 | +| `external_reissue` | 企微、腾讯云、OIDC、SMTP、S3、ERP、AI 等外部标识或凭据,由对应平台管理员重新获取或签发;不从数据库、日志或 Git 反推 | external deps smoke、对应平台只读配置核对,必要时人工真机验证 | +| `direct_rotate` | JWT、验证码 pepper、自建 MinIO 管理口令、试运行管理员口令等生成新值;必须接受全部会话、验证码或目标账号凭据失效 | 新旧会话边界、登录/重置、runtime smoke;旧值不得继续使用 | +| `downtime_reset` | PostgreSQL 角色口令无法从 `pg_authid` 反推明文;保留 PGDATA 时按下述隔离停机步骤重置,不称为“找回” | 原认证配置字节级恢复、数据库不暴露宿主端口、migration status、应用登录与备份 | +| `preserve_original` | **最高风险**。`FIELD_ENCRYPTION_KEY` 原值丢失会使既有银行账号、身份证号和通知渠道密文永久不可读;只能从双人可取的共享密码库/加密封存副本恢复,禁止生成新值冒充恢复成功 | 用合成探针验证加解密能力,并由授权页面抽样确认既有密文仍可解;不得输出明文 | + +分类描述恢复方式,不代表配置值可以公开;例如 `DATABASE_URL` 在数据库口令重置后按模板重建,但仍是只允许进入 root-only 环境文件的 Secret。`TRIALRUN_ADMIN_PASSWORD` 对既有管理员不会因改 env 自动生效,必须通过现有受控密码重置入口轮换;新初始化时才由 env 提供初始值。 + +`FIELD_ENCRYPTION_KEY` 当前密文只有 `v1` 格式版本,没有 key id 或 keyring;在独立轮换设计完成前必须保留原值。若封存副本无法取得,立即停止恢复并上报数据不可读影响,不要求员工重新提交敏感资料前先宣称系统已恢复。 + +数据库口令重置仅在 PGDATA 尚存、外部入口已暂停且负责人批准的停机窗口执行: + +1. 停止 server、worker、init 和数据库容器,确认没有进程仍挂载该 PGDATA;复制并校验当前 `pg_hba.conf`,记录校验状态而非文件内容。 +2. 使用相同 PostgreSQL 大版本在隔离容器中挂载该 PGDATA;不得发布 TCP 端口。优先使用 PostgreSQL 单用户/本地 socket 能力重置目标角色口令。 +3. 如环境限制必须临时调整认证,只允许增加本地 socket 的临时 `trust`,禁止任何 `host ... trust`,并通过交互式 `\password ` 输入新值,避免把口令写入命令行和日志。 +4. 停止隔离数据库,把 `pg_hba.conf` 按备份逐字节恢复并复核校验;同步更新恢复环境中的 `POSTGRES_PASSWORD` 与派生的 `DATABASE_URL`,文件保持 root-only。 +5. 按正常 Compose 合同启动数据库与应用,证明数据库无宿主发布端口、认证配置无临时改动、migration status、`/ready`、runtime/external smoke、登录和自动备份均通过。任一项失败都保持外部入口关闭。 + +### C. 恢复数据与应用 + +在获批的新主机/隔离目标依次执行以下三个入口;尖括号值只从受控运维配置取得,不写回本文: ```bash RESTORE_SCOPE=shadow RESTORE_CONFIRM_DATABASE= \ @@ -18,10 +48,11 @@ RESTORE_SCOPE=shadow ./scripts/restore-storage.sh \ curl -fsS ``` -4. 复核 `/ready`、运行构建标识、成功 migration 数、组织/权限/报销/付款等关键表计数、附件普通文件数及 DB/附件关联;任何一项不一致都停止切换。 -5. 真实生产恢复仍是 **EXTERNAL_GATED**:另建或更新获批 Trellis task,冻结新数据目录、回滚点和停机窗口后才切换。恢复后保留 ARM 归档和故障主机原数据,记录 RTO/RPO 与结论。 +复核 `/ready`、运行构建标识、成功 migration 数、组织/权限/报销/付款等关键表计数、附件普通文件数及 DB/附件关联;任何一项不一致都停止切换。 -唯一系统管理员必须每季度按本卡在 ARM 隔离目标完成人工影子恢复;自动 timer 只提醒,不自动恢复。可在 ARM 和离线介质各保留一份脱敏副本,但不得写入凭证、真实目录或原始日志。 +真实生产恢复仍是 **EXTERNAL_GATED**:另建或更新获批 Trellis task,冻结新数据目录、env 外置路径、回滚点和停机窗口后才切换。恢复后保留 ARM 归档和故障主机原数据,记录 RTO/RPO 与结论。 + +系统管理员与公司负责人必须每季度共同确认 `preserve_original` 封存项仍可由二人分别取用;至少一名操作人按本卡在 ARM 隔离目标完成人工影子恢复。只记录角色、时间和 PASS/FAIL,不记录密码库地址、账号、恢复码或 Secret。自动 timer 只提醒,不自动恢复。 ## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案) - 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。 diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 85163c06..e2e2dcb6 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -8,6 +8,10 @@ const configPath = path.join(rootDir, 'server/src/config.js'); const envExamplePath = path.resolve( process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'), ); +const recoveryClassificationPath = path.resolve( + process.env.ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH + || path.join(rootDir, 'deploy/trialrun/env-recovery-classification.json'), +); const composePath = path.resolve( process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'), ); @@ -57,6 +61,13 @@ const requiredBuildArgs = { worker: ['BUILD_SHA', 'VITE_BUILD_SHA'], init: ['BUILD_SHA', 'VITE_BUILD_SHA'], }; +const recoveryCategories = [ + 'template_rebuild', + 'external_reissue', + 'direct_rotate', + 'downtime_reset', + 'preserve_original', +]; function readFile(filePath) { return fs.readFileSync(filePath, 'utf8'); @@ -84,6 +95,86 @@ function extractEnvExampleKeys(content) { return keys; } +function validateRecoveryClassification(content, envKeys) { + const validationErrors = []; + let parsed; + try { + parsed = JSON.parse(content); + } catch { + return { errors: ['环境恢复分类清单必须是有效 JSON'], keyCount: 0 }; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { errors: ['环境恢复分类清单顶层必须是对象'], keyCount: 0 }; + } + const topLevelKeys = Object.keys(parsed).sort(); + if (topLevelKeys.join(',') !== 'categories,version') { + validationErrors.push('环境恢复分类清单顶层只允许 version 和 categories'); + } + if (parsed.version !== 1) { + validationErrors.push('环境恢复分类清单 version 必须为 1'); + } + if (!parsed.categories || typeof parsed.categories !== 'object' || Array.isArray(parsed.categories)) { + return { errors: [...validationErrors, '环境恢复分类清单 categories 必须是对象'], keyCount: 0 }; + } + + const actualCategories = Object.keys(parsed.categories).sort(); + const expectedCategories = [...recoveryCategories].sort(); + for (const category of expectedCategories) { + if (!actualCategories.includes(category)) { + validationErrors.push(`环境恢复分类清单缺少类别 ${category}`); + } + } + for (const category of actualCategories) { + if (!expectedCategories.includes(category)) { + validationErrors.push(`环境恢复分类清单存在未知类别 ${category}`); + } + } + + const occurrences = new Map(); + for (const category of expectedCategories) { + const entries = parsed.categories[category]; + if (!Array.isArray(entries)) { + validationErrors.push(`环境恢复类别 ${category} 必须是键名数组`); + continue; + } + for (const entry of entries) { + if (typeof entry !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry)) { + validationErrors.push(`环境恢复类别 ${category} 含非法键名或疑似值`); + continue; + } + const categories = occurrences.get(entry) ?? []; + categories.push(category); + occurrences.set(entry, categories); + } + } + + for (const [key, categories] of occurrences) { + if (categories.length !== 1) { + validationErrors.push(`环境恢复键 ${key} 重复分类: ${categories.join(', ')}`); + } + if (!envKeys.has(key)) { + validationErrors.push(`环境恢复分类包含 .env.example 外键 ${key}`); + } + } + for (const key of envKeys) { + if (!occurrences.has(key)) { + validationErrors.push(`环境恢复分类缺少 ${key}`); + } + } + + const preserveOriginal = parsed.categories.preserve_original; + if (!Array.isArray(preserveOriginal) || !preserveOriginal.includes('FIELD_ENCRYPTION_KEY')) { + validationErrors.push('FIELD_ENCRYPTION_KEY 必须归入 preserve_original'); + } + const downtimeReset = parsed.categories.downtime_reset; + if (!Array.isArray(downtimeReset) || !downtimeReset.includes('POSTGRES_PASSWORD')) { + validationErrors.push('POSTGRES_PASSWORD 必须归入 downtime_reset'); + } + + return { errors: validationErrors, keyCount: occurrences.size }; +} + function extractServiceBlock(composeContent, serviceName) { const lines = composeContent.split(/\r?\n/); let capture = false; @@ -154,6 +245,7 @@ function fail(message, errors) { const configContent = readFile(configPath); const envExampleContent = readFile(envExamplePath); +const recoveryClassificationContent = readFile(recoveryClassificationPath); const composeContent = readFile(composePath); const runbookContent = readFile(runbookPath); const checklistContent = readFile(checklistPath); @@ -177,6 +269,12 @@ const configKeys = extractConfigEnvKeys(configContent); const envExampleKeys = extractEnvExampleKeys(envExampleContent); const errors = []; +const recoveryClassification = validateRecoveryClassification( + recoveryClassificationContent, + envExampleKeys, +); +errors.push(...recoveryClassification.errors); + for (const key of configKeys) { if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) { errors.push(`.env.example 缺少 ${key}`); @@ -538,4 +636,5 @@ if (errors.length) { console.log('[R26][PASS] 部署环境契约校验通过'); console.log(`- config.js env 键数: ${configKeys.length}`); console.log(`- .env.example env 键数: ${envExampleKeys.size}`); +console.log(`- 环境恢复分类键数: ${recoveryClassification.keyCount}`); console.log(`- 已检查服务: ${requiredServices.join(', ')}`); diff --git a/server/tests/unit/scripts/env-recovery-classification.test.js b/server/tests/unit/scripts/env-recovery-classification.test.js new file mode 100644 index 00000000..7d91a236 --- /dev/null +++ b/server/tests/unit/scripts/env-recovery-classification.test.js @@ -0,0 +1,106 @@ +const { execFile } = require("node:child_process"); +const { mkdtemp, readFile, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); +const ROOT_DIR = path.resolve(__dirname, "../../../.."); +const VALIDATOR = path.join(ROOT_DIR, "deploy/trialrun/validate-env-contract.js"); +const CLASSIFICATION = path.join( + ROOT_DIR, + "deploy/trialrun/env-recovery-classification.json", +); + +describe("environment recovery classification contract", () => { + let fixtureRoot; + let baseClassification; + + beforeAll(async () => { + baseClassification = JSON.parse(await readFile(CLASSIFICATION, "utf8")); + }); + + beforeEach(async () => { + fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-env-recovery-")); + }); + + afterEach(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + }); + + async function validate(classification = baseClassification) { + const fixturePath = path.join(fixtureRoot, "classification.json"); + await writeFile(fixturePath, `${JSON.stringify(classification, null, 2)}\n`); + return execFileAsync("node", [VALIDATOR], { + cwd: ROOT_DIR, + env: { + ...process.env, + ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH: fixturePath, + }, + }); + } + + it("classifies every env contract key exactly once", async () => { + const result = await validate(); + expect(result.stdout).toContain("环境恢复分类键数: 164"); + }); + + it("rejects a missing key", async () => { + const classification = structuredClone(baseClassification); + classification.categories.template_rebuild = classification.categories.template_rebuild + .filter((key) => key !== "APP_TIMEZONE"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("环境恢复分类缺少 APP_TIMEZONE"), + }); + }); + + it("rejects duplicate classification", async () => { + const classification = structuredClone(baseClassification); + classification.categories.direct_rotate.push("APP_TIMEZONE"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("环境恢复键 APP_TIMEZONE 重复分类"), + }); + }); + + it("rejects keys outside the env contract", async () => { + const classification = structuredClone(baseClassification); + classification.categories.template_rebuild.push("SYNTHETIC_EXTRA_KEY"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("环境恢复分类包含 .env.example 外键 SYNTHETIC_EXTRA_KEY"), + }); + }); + + it("rejects unknown categories", async () => { + const classification = structuredClone(baseClassification); + classification.categories.unreviewed = []; + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("环境恢复分类清单存在未知类别 unreviewed"), + }); + }); + + it("rejects illegal key names and suspected values", async () => { + const classification = structuredClone(baseClassification); + classification.categories.template_rebuild.push("JWT_SECRET=not-a-key-name"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("含非法键名或疑似值"), + }); + }); + + it("keeps FIELD_ENCRYPTION_KEY in preserve_original", async () => { + const classification = structuredClone(baseClassification); + classification.categories.preserve_original = []; + classification.categories.direct_rotate.push("FIELD_ENCRYPTION_KEY"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("FIELD_ENCRYPTION_KEY 必须归入 preserve_original"), + }); + }); + + it("keeps POSTGRES_PASSWORD in downtime_reset", async () => { + const classification = structuredClone(baseClassification); + classification.categories.downtime_reset = []; + classification.categories.direct_rotate.push("POSTGRES_PASSWORD"); + await expect(validate(classification)).rejects.toMatchObject({ + stderr: expect.stringContaining("POSTGRES_PASSWORD 必须归入 downtime_reset"), + }); + }); +});