建立生产密钥恢复合同(08-01-production-secret-recovery-env-externalization)

这个提交包含在:
2026-08-01 19:18:54 +08:00
父节点 07de198b51
当前提交 ca24479a44
共修改 11 个文件,包含 727 行新增和 8 行删除
@@ -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"
]
}
}
+39 -8
查看文件
@@ -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 <role>` 输入新值,避免把口令写入命令行和日志。
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=<shadow-db> <explicit-db-env> \
@@ -18,10 +48,11 @@ RESTORE_SCOPE=shadow ./scripts/restore-storage.sh \
curl -fsS <shadow-ready-url>
```
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` 静态托管,不要改官网目录和配置。
+99
查看文件
@@ -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(', ')}`);