From 73ebb4cba591f9255eb8987d1656b9fafea92852 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 23 Aug 2026 15:47:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9B=E5=B1=95(07-28-all-account-login-read?= =?UTF-8?q?iness):=20=E5=AE=8C=E6=88=90=E5=85=A8=E5=91=98=E4=BC=81?= =?UTF-8?q?=E5=BE=AE=E7=9C=9F=E6=9C=BA=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../implement.md | 16 +- .../07-28-all-account-login-readiness/prd.md | 10 +- .../research/auth-readiness-census.cjs | 129 +++++++++++++++ .../research/run-production-auth-readiness.sh | 149 ++++++++++++++++++ .../task.json | 2 +- 5 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 .trellis/tasks/07-28-all-account-login-readiness/research/auth-readiness-census.cjs create mode 100644 .trellis/tasks/07-28-all-account-login-readiness/research/run-production-auth-readiness.sh diff --git a/.trellis/tasks/07-28-all-account-login-readiness/implement.md b/.trellis/tasks/07-28-all-account-login-readiness/implement.md index 2049f103..f75907cb 100644 --- a/.trellis/tasks/07-28-all-account-login-readiness/implement.md +++ b/.trellis/tasks/07-28-all-account-login-readiness/implement.md @@ -23,15 +23,15 @@ ## 3. Remaining Active Accounts - [x] 首批 2–3 名同事完成企微工作台真实登录试用,用户确认本轮参与者全部成功;未收集账号、userid、密码、验证码或个人明细。 -- [ ] 为其余 3 名已有 binding 的 active 员工发送不含账号/密码的操作说明,由本人先直接测试企微工作台免登。 -- [ ] 每名企微成员从工作台真机进入,验证唯一 binding 与免登;免登通过者不因密码流程而阻塞主入口验收。 +- [x] 已由其余 3 名已有 binding 的 active 员工按不含账号/密码的操作说明,由本人直接测试企微工作台免登。 +- [x] 每名企微成员均已从工作台真机进入;用户于 2026-08-23 确认其余 3 人全部可以进入,结合此前已通过人员,当前 6/6 唯一 binding 与真人免登验收完成。 - [ ] 企微免登验证后,再逐人完成密码回退和首次改密状态收口;管理员不接触其密码或验证码。 - [ ] 邮件失败者单独登记稳定失败类别;确需管理员一次性密码时先给出目标数量、审计和交付方案并另行获批。 ## 4. Verification and Prevention -- [ ] 聚合复核 active 总数、密码登录覆盖、企微 binding 覆盖、企微成功登录覆盖和锁定数;不得输出人员明细。 -- [ ] 验证无 binding conflict、无角色变化、无企微通讯录写入、无异常会话残留。 +- [x] 腾讯云生产补充只读聚合已复核:active 6、binding 6、密码历史成功覆盖 3、企微成功审计历史覆盖 4、三类认证锁定均为 0;企微审计计数与用户 6/6 真人可进入证据分开记录,不输出人员明细。 +- [x] 当前快照 binding 缺失/冲突、inactive binding 和 inactive 账户活跃会话均为 0;近 7 天角色变更、企微通讯录写入和 binding 写入审计均为 0。 - [x] 曾按用户初次指令将唯一 active `manager` 的直属主管设为 `wangshunli`;用户随后澄清其仅为系统开发/系统管理员,该中间关系需按最终职责口径修正。 - [x] 只读确认唯一 active `manager` 与 `majianyong` CEO 账户,排除自环/环路后原子将 `direct_manager_id` 从 `wangshunli` 修正为 `majianyong`、写脱敏审计,并复核组织链;不改角色、邮箱、密码、binding 或企微通讯录。 - [x] 只读解析手工企微通讯录导出并与生产 OA 对账:6 个 userid/binding 和 6 个企业邮箱全部精确一致,缺失、重复、冲突、未匹配和需修改数均为 0。 @@ -50,6 +50,14 @@ - [x] 生成生产存量 dry-run 脱敏汇总;部署和 apply 回填分别取得明确批准后执行。 - [x] 最新生产 live 只读复核确认 OA active 6、企微 active 6、binding 6、唯一匹配 6;缺失、冲突、状态漂移和待回填均为 0,因此本轮无需 apply。剩余 6 条企微资料必填字段 finding 沿用既有接受口径,不作为 binding 阻断。 +## 4B. 2026-08-23 Production Read-only Follow-up + +- [x] 复用既有生产身份引导、运行构建精确比较和 production preflight;腾讯云真实生产身份、migration pending=none、rollback window `OPEN/0` 均通过。 +- [x] 首个只读事务在聚合前因冲突统计把 UUID 与文本直接 `UNION` 而被 PostgreSQL 拒绝,事务已回滚、写入为 0,且未自动重试;同一错误在本地只读数据库复现并修正类型合同。 +- [x] 修正版先在本地同结构数据库通过,再执行一次明确的补充生产只读事务;有效查询 1、自动重试 0,只输出白名单计数和布尔值。 +- [x] 最终生产计数:active 6、must-change 3、binding 6、binding 缺失/冲突/inactive 漂移 0、密码历史成功覆盖 3、企微历史成功审计覆盖 4、近 7 天企微成功审计覆盖 1、三类认证锁定 0、异常活跃会话 0。 +- [x] 近 7 天角色变更、企微通讯录写入和 binding 写入审计均为 0;本轮未登录真人账号,未修改数据库、角色、binding、会话或企微通讯录。 + ## 5. Stop Conditions - 企业邮箱不可达、验证码投递异常或账户锁定:停止该员工操作,不连续请求。 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/prd.md b/.trellis/tasks/07-28-all-account-login-readiness/prd.md index 50197e34..83f37618 100644 --- a/.trellis/tasks/07-28-all-account-login-readiness/prd.md +++ b/.trellis/tasks/07-28-all-account-login-readiness/prd.md @@ -6,8 +6,8 @@ ## Scope -- 当前生产 4 个 active 账户的密码回退与企业微信工作台免登基线。 -- 管理员本人优先通过已批准的受控 break-glass 恢复密码,再绑定企微并真机验收;其余 3 个已有 binding 的账户先验证企微免登,再按需完善密码回退。 +- 任务启动时生产 4 个 active 账户;当前生产已扩展为 6 个 active 账户,登录基线仍同时覆盖密码回退与企业微信工作台免登。 +- 管理员本人优先通过已批准的受控 break-glass 恢复密码,再绑定企微并真机验收;其余已有 binding 的账户先验证企微免登,再按需完善密码回退。 - 邮件失败时使用现有管理员一次性密码接口作为另行批准的兜底。 - 更新现有账号启用/入职操作门禁,防止后续 active 账户再次只有未知随机密码或未经本人验证的 binding。 - 改造企微登录缺失 binding 时的唯一企业邮箱自动匹配、事务绑定、审计和失败关闭逻辑;同步修正未绑定页面指引。 @@ -29,6 +29,8 @@ - `/forgot-password -> /reset-password` 是完整的验证码重设密码路径;正确验证码会设置新密码并清除 `must_change_password`,不是死路。 - 本次实际失败发生在 `/reset-password` 成功之前:第二次签发使第一次验证码失效,用户收到并使用旧验证码后被后端拒绝;未发现“重置成功后被前端缓存再次送回改密页”的现场证据。 - `/email-login/code -> /email-login/verify` 是另一条邮箱验证码登录路径;现有前端在验证成功后仍遵守 `must_change_password` 跳转,而修改密码页强制要求旧密码,因此它对未知随机初始密码账户可能形成死路,但与本次失败无关。 +- 2026-08-23 腾讯云生产补充只读聚合确认:active 6、binding 6,缺失、冲突、三类认证锁定和 inactive 账户活跃会话均为 0;仍有 3 个账户处于首次改密状态,历史密码成功登录覆盖为 3/6。 +- 企业微信成功审计历史覆盖为 4/6、近 7 天为 1/6;该计数只代表实际经过 OAuth 成功回调的审计覆盖,不能替代或否定用户确认的 6/6 真人工作台可进入结果,已有会话直接进入不会必然新增 OAuth 审计。 ## Requirements (Evolving) @@ -72,8 +74,8 @@ - [x] 一个“无 binding、企微企业邮箱唯一匹配 active OA 账户”的合成场景能在不输入 OA 密码的情况下自动建立 binding、写审计并直接登录。 - [x] 企业邮箱缺失、无匹配、inactive、userid/binding 冲突等负向场景全部失败关闭,且不创建账户、不改角色、不按姓名绑定。 - [ ] 所有 active 账户均有一次实际账号密码登录验收或等价的安全凭据交付与首次改密证据。 -- [ ] 所有属于当前企业微信通讯录的 active 账户均有唯一 binding,并完成本人企微真机登录验收。 -- [ ] 不存在 active 账户同时缺少可交付密码回退和可用企微 binding。 +- [x] 所有属于当前企业微信通讯录的 active 账户均有唯一 binding,并完成本人企微真机登录验收;用户于 2026-08-23 确认其余 3 人全部可以进入,当前 6/6 真人企微主入口通过。 +- [x] 不存在 active 账户同时缺少可交付密码回退和可用企微 binding;当前 6/6 均已有经本人验证的企微主入口,但密码回退仍需单独收口。 - [ ] 所有重置、绑定、解锁、会话撤销均有 OA 审计且未扩角色、未修改企业微信通讯录。 - [x] 当前唯一 active `manager` 的直属主管唯一解析为 active `majianyong`,不存在自环或主管链环路,并有脱敏审计;`wangshunli` 不进入业务审批链。 - [x] `majianyong` 保持 `ceo` 且无直属主管;其本人报销目标为 active 非本人 `finance`,当前 `finance_mgr` 配置缺口未修复前保持阻断且不发生自审。 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/research/auth-readiness-census.cjs b/.trellis/tasks/07-28-all-account-login-readiness/research/auth-readiness-census.cjs new file mode 100644 index 00000000..9fdfd90e --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/research/auth-readiness-census.cjs @@ -0,0 +1,129 @@ +"use strict"; + +const path = require("node:path"); + +const OBSERVATION_WINDOW_DAYS = 7; + +const SAFE_RESULT_KEYS = Object.freeze([ + "abnormalActiveSessions", + "activeEmployeesWithSession", + "activeSessionsTotal", + "activeTotal", + "bindingConflictGroups", + "bindingCovered", + "bindingMissing", + "bindingMutationAudits7d", + "directoryMutationAudits7d", + "emailLoginLocked", + "inactiveBindingRows", + "mustChangePassword", + "passwordLoginCoveredAllTime", + "passwordLoginCovered7d", + "passwordLocked", + "passwordResetLocked", + "roleMutationAudits7d", + "wecomLoginCoveredAllTime", + "wecomLoginCovered7d", + "wecomLoginEvents7d", +]); + +const READINESS_SQL = String.raw` +WITH active AS ( + SELECT id + FROM employees + WHERE status = 'active' AND archived_at IS NULL +), binding_conflicts AS ( + SELECT count(*)::int AS groups + FROM ( + SELECT employee_id::text + FROM wecom_identity_bindings + GROUP BY employee_id + HAVING count(*) > 1 + UNION ALL + SELECT external_user_id + FROM wecom_identity_bindings + GROUP BY external_user_id + HAVING count(*) > 1 + ) conflicts +), metrics AS ( + SELECT + (SELECT count(*) FROM active)::int AS "activeTotal", + (SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.must_change_password)::int AS "mustChangePassword", + (SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.login_locked_until > now())::int AS "passwordLocked", + (SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.email_login_code_locked_until > now())::int AS "emailLoginLocked", + (SELECT count(*) FROM employees e JOIN active a ON a.id = e.id WHERE e.password_reset_locked_until > now())::int AS "passwordResetLocked", + (SELECT count(DISTINCT b.employee_id) FROM wecom_identity_bindings b JOIN active a ON a.id = b.employee_id)::int AS "bindingCovered", + (SELECT count(*) FROM active a LEFT JOIN wecom_identity_bindings b ON b.employee_id = a.id WHERE b.id IS NULL)::int AS "bindingMissing", + (SELECT groups FROM binding_conflicts)::int AS "bindingConflictGroups", + (SELECT count(*) FROM wecom_identity_bindings b JOIN employees e ON e.id = b.employee_id WHERE e.status <> 'active' OR e.archived_at IS NOT NULL)::int AS "inactiveBindingRows", + (SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS')::int AS "wecomLoginCoveredAllTime", + (SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "wecomLoginCovered7d", + (SELECT count(*) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_WECOM_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "wecomLoginEvents7d", + (SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_LOGIN_SUCCESS')::int AS "passwordLoginCoveredAllTime", + (SELECT count(DISTINCT l.actor_id) FROM audit_logs l JOIN active a ON a.id = l.actor_id WHERE l.action = 'AUTH_LOGIN_SUCCESS' AND l.created_at >= now() - interval '7 days')::int AS "passwordLoginCovered7d", + (SELECT count(*) FROM audit_logs WHERE action = 'SYSTEM_USER_ROLES_SET' AND created_at >= now() - interval '7 days')::int AS "roleMutationAudits7d", + (SELECT count(*) FROM audit_logs WHERE action LIKE 'WECOM_DIRECTORY_%' AND created_at >= now() - interval '7 days')::int AS "directoryMutationAudits7d", + (SELECT count(*) FROM audit_logs WHERE action = 'AUTH_WECOM_BIND' AND created_at >= now() - interval '7 days')::int AS "bindingMutationAudits7d", + (SELECT count(*) FROM auth_sessions s WHERE s.revoked_at IS NULL AND s.expires_at > now())::int AS "activeSessionsTotal", + (SELECT count(DISTINCT s.employee_id) FROM auth_sessions s JOIN active a ON a.id = s.employee_id WHERE s.revoked_at IS NULL AND s.expires_at > now())::int AS "activeEmployeesWithSession", + (SELECT count(*) FROM auth_sessions s JOIN employees e ON e.id = s.employee_id WHERE s.revoked_at IS NULL AND s.expires_at > now() AND (e.status <> 'active' OR e.archived_at IS NOT NULL))::int AS "abnormalActiveSessions" +) +SELECT * FROM metrics +`; + +function assertSafeMetrics(metrics) { + const keys = Object.keys(metrics).sort(); + if (JSON.stringify(keys) !== JSON.stringify([...SAFE_RESULT_KEYS].sort())) { + throw new Error("result_field_contract_invalid"); + } + if (Object.values(metrics).some((value) => !Number.isInteger(value) || value < 0)) { + throw new Error("result_value_contract_invalid"); + } +} + +async function run() { + const appRoot = path.resolve(process.env.AUTH_CENSUS_APP_ROOT || process.cwd()); + const { Client } = require(path.join(appRoot, "node_modules/pg")); + const { getConfig } = require(path.join(appRoot, "src/config.js")); + const config = getConfig(); + const client = new Client({ connectionString: process.env.DATABASE_URL }); + let inTransaction = false; + + try { + await client.connect(); + await client.query("BEGIN TRANSACTION READ ONLY"); + inTransaction = true; + await client.query("SET LOCAL statement_timeout = '30s'"); + await client.query("SET LOCAL lock_timeout = '2s'"); + const readOnly = await client.query("SELECT current_setting('transaction_read_only') AS value"); + if (readOnly.rows[0]?.value !== "on") throw new Error("transaction_not_read_only"); + + const result = await client.query(READINESS_SQL); + const metrics = result.rows[0]; + assertSafeMetrics(metrics); + await client.query("COMMIT"); + inTransaction = false; + + process.stdout.write(`${JSON.stringify({ + status: "PASS", + transactionReadOnly: true, + sourceBuildSha: config.buildSha, + observationWindowDays: OBSERVATION_WINDOW_DAYS, + metrics, + }, null, 2)}\n`); + } catch { + if (inTransaction) { + try { await client.query("ROLLBACK"); } catch { /* fail closed */ } + } + process.stderr.write("[auth-readiness-census][FAILED] code=QUERY_FAILED\n"); + process.exitCode = 1; + } finally { + await client.end().catch(() => {}); + } +} + +module.exports = { OBSERVATION_WINDOW_DAYS, READINESS_SQL, SAFE_RESULT_KEYS, assertSafeMetrics }; + +if (require.main === module || process.argv[1] === "-") { + run(); +} diff --git a/.trellis/tasks/07-28-all-account-login-readiness/research/run-production-auth-readiness.sh b/.trellis/tasks/07-28-all-account-login-readiness/research/run-production-auth-readiness.sh new file mode 100644 index 00000000..43a187dc --- /dev/null +++ b/.trellis/tasks/07-28-all-account-login-readiness/research/run-production-auth-readiness.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TASK_RESEARCH_DIR="$ROOT_DIR/.trellis/tasks/07-28-all-account-login-readiness/research" +QUERY_SCRIPT="$TASK_RESEARCH_DIR/auth-readiness-census.cjs" +IDENTITY_MODULE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap.mjs" +IDENTITY_REMOTE="$ROOT_DIR/deploy/trialrun/production-identity-bootstrap-remote.sh" +PREFLIGHT_SCRIPT="$ROOT_DIR/deploy/trialrun/production-preflight.mjs" +CONTRACT_LIB="$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh" +CONTRACT_UNIT="hyb-oa-automatic-backup-daily.service" +CONFIRMATION="READ_ONLY_AUTH_READINESS" + +# shellcheck source=../../../../deploy/trialrun/deployment-contract-lib.sh +source "$CONTRACT_LIB" + +fail() { + printf '[auth-readiness-runner][FAILED] code=%s\n' "$1" >&2 + exit 1 +} + +shell_quote() { + printf "'%s'" "${1//\'/\'\\\'\'}" +} + +mode="${1:---preflight}" +case "$mode" in + --preflight) + [[ "$#" -eq 1 ]] || fail "UNSUPPORTED_ARGUMENTS" + ;; + --execute) + [[ "$#" -eq 2 && "${2:-}" == "$CONFIRMATION" ]] || fail "EXPLICIT_CONFIRMATION_REQUIRED" + ;; + *) + fail "UNSUPPORTED_ARGUMENTS" + ;; +esac + +for file in "$QUERY_SCRIPT" "$IDENTITY_MODULE" "$IDENTITY_REMOTE" "$PREFLIGHT_SCRIPT"; do + [[ -f "$file" && ! -L "$file" ]] || fail "LOCAL_CONTRACT_MISSING" +done + +cd "$ROOT_DIR" + +identity_fields="$({ + node --input-type=module - "$IDENTITY_MODULE" <<'NODE' +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.argv[2]; +const mod = await import(pathToFileURL(modulePath)); +const targetSource = execFileSync("git", ["show", "34181e3:deploy/trialrun/deploy.sh"], { encoding: "utf8" }); +const projectSource = execFileSync("git", ["show", "5e2d038938265c926e06d3cc6d0841f6dcc06148:deploy/trialrun/deploy.sh"], { encoding: "utf8" }); +const identity = mod.parseHistoricalDeploymentIdentity(targetSource, projectSource); +const runbook = fs.readFileSync("docs/trialrun/OPS7运维交接Runbook.md", "utf8"); +const origin = mod.parseExpectedProductionOrigin(runbook); +process.stdout.write(`${identity.deployServer}\t${identity.composeProjectName}\t${origin}`); +NODE +} 2>/dev/null)" || fail "LOCAL_IDENTITY_PARSE_FAILED" +IFS=$'\t' read -r deploy_server expected_project expected_origin <<<"$identity_fields" +deployment_contract_safe_ssh_target "$deploy_server" || fail "DEPLOY_SERVER_INVALID" +deployment_contract_safe_project "$expected_project" || fail "COMPOSE_PROJECT_INVALID" +[[ "$expected_origin" == https://* ]] || fail "PRODUCTION_ORIGIN_INVALID" + +PRODUCTION_IDENTITY_ONLY=1 node "$IDENTITY_MODULE" || fail "PRODUCTION_IDENTITY_FAILED" + +declare -A contract=() +deployment_contract_load "$deploy_server" "$CONTRACT_UNIT" contract || fail "SYSTEMD_CONTRACT_UNAVAILABLE" +[[ "${contract[COMPOSE_PROJECT_NAME]}" == "$expected_project" ]] || fail "COMPOSE_PROJECT_MISMATCH" + +origin_hash="$(printf '%s' "$expected_origin" | sha256sum | awk '{print $1}')" +database_output="$( + ssh "$deploy_server" \ + "IDENTITY_BOOTSTRAP_MODE='database' REMOTE_DIR=$(shell_quote "${contract[REMOTE_DIR]}") REMOTE_ENV_FILE=$(shell_quote "${contract[REMOTE_ENV_FILE]}") COMPOSE_FILE=$(shell_quote "${contract[COMPOSE_FILE]}") COMPOSE_PROJECT_NAME=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") EXPECTED_PRODUCTION_ORIGIN_SHA256='$origin_hash' bash -s" \ + < "$IDENTITY_REMOTE" +)" || fail "DATABASE_IDENTITY_FAILED" +database_hash="$(printf '%s' "$database_output" | sed -n 's/^\[production-identity-bootstrap\]\[DB_SHA256\] \([0-9a-f]\{64\}\)$/\1/p')" +[[ "$database_hash" =~ ^[0-9a-f]{64}$ ]] || fail "DATABASE_IDENTITY_PAYLOAD_INVALID" + +candidate_commit="$(git rev-parse origin/main)" +[[ "$candidate_commit" =~ ^[0-9a-f]{40,64}$ ]] || fail "DEPLOYED_COMMIT_INVALID" +[[ -z "$(git diff --name-only "$candidate_commit"..HEAD -- client/src server/src deploy/trialrun/docker-compose.yml)" ]] || fail "PRODUCT_DELTA_AFTER_DEPLOYED_COMMIT" + +runtime_check_command=" +set -euo pipefail +server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\" +[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]] +runtime_sha=\"\$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' \"\$server_ids\" | sed -n 's/^BUILD_SHA=//p' | tail -n 1)\" +[[ \"\$runtime_sha\" == $(shell_quote "$candidate_commit") ]] +printf '[auth-readiness-runner][PASS] runtime_build_exact\\n' +" +ssh "$deploy_server" "bash -c $(shell_quote "$runtime_check_command")" || fail "RUNTIME_BUILD_MISMATCH" + +report_dir="$(mktemp -d)" +trap 'rm -rf -- "$report_dir"' EXIT +EXPECTED_PRODUCTION_ORIGIN="$expected_origin" \ +EXPECTED_PRODUCTION_DB_NAME_SHA256="$database_hash" \ +EXPECTED_PENDING_MIGRATIONS="" \ +EXPECTED_DEPLOY_SERVER="$deploy_server" \ +EXPECTED_REMOTE_DIR="${contract[REMOTE_DIR]}" \ +EXPECTED_COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +DEPLOY_SERVER="$deploy_server" \ +REMOTE_DIR="${contract[REMOTE_DIR]}" \ +REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ +COMPOSE_FILE="${contract[COMPOSE_FILE]}" \ +COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ +STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ +CANDIDATE_COMMIT="$candidate_commit" \ +PRODUCTION_PREFLIGHT_REPORT_DIR="$report_dir/preflight" \ +node "$PREFLIGHT_SCRIPT" || fail "PRODUCTION_PREFLIGHT_FAILED" + +printf '[auth-readiness-runner][PASS] preflight deployedSource=origin/main productDelta=0\n' + +if [[ "$mode" == "--preflight" ]]; then + printf '[auth-readiness-runner][PASS] read_only_preflight_complete queryExecuted=0\n' + exit 0 +fi + +remote_query_command=" +set -euo pipefail +server_ids=\"\$(docker ps --filter label=com.docker.compose.project=$(shell_quote "${contract[COMPOSE_PROJECT_NAME]}") --filter label=com.docker.compose.service=server --format '{{.ID}}')\" +[[ \"\$(printf '%s\\n' \"\$server_ids\" | sed '/^\$/d' | wc -l)\" -eq 1 ]] +docker exec -i -w /app/server \"\$server_ids\" node - +" +query_output="$(ssh "$deploy_server" "bash -c $(shell_quote "$remote_query_command")" < "$QUERY_SCRIPT")" || fail "QUERY_EXECUTION_FAILED" + +validated_output="$(printf '%s' "$query_output" | node -e ' +const fs = require("node:fs"); +const expected = process.argv[1]; +const value = JSON.parse(fs.readFileSync(0, "utf8")); +if (value.status !== "PASS" || value.transactionReadOnly !== true) process.exit(1); +if (value.sourceBuildSha !== expected.slice(0, 12)) process.exit(1); +if (value.observationWindowDays !== 7 || !value.metrics) process.exit(1); +const allowed = [ + "abnormalActiveSessions", "activeEmployeesWithSession", "activeSessionsTotal", "activeTotal", + "bindingConflictGroups", "bindingCovered", "bindingMissing", "bindingMutationAudits7d", + "directoryMutationAudits7d", "emailLoginLocked", "inactiveBindingRows", "mustChangePassword", + "passwordLoginCoveredAllTime", "passwordLoginCovered7d", "passwordLocked", "passwordResetLocked", + "roleMutationAudits7d", "wecomLoginCoveredAllTime", "wecomLoginCovered7d", "wecomLoginEvents7d", +].sort(); +if (JSON.stringify(Object.keys(value.metrics).sort()) !== JSON.stringify(allowed)) process.exit(1); +if (Object.values(value.metrics).some((item) => !Number.isInteger(item) || item < 0)) process.exit(1); +process.stdout.write(JSON.stringify(value, null, 2)); +' "$candidate_commit")" || fail "QUERY_OUTPUT_INVALID" + +printf '%s\n' "$validated_output" +printf '[auth-readiness-runner][PASS] queryExecuted=1 zeroRetry=1\n' >&2 diff --git a/.trellis/tasks/07-28-all-account-login-readiness/task.json b/.trellis/tasks/07-28-all-account-login-readiness/task.json index e8584195..ec2938f9 100644 --- a/.trellis/tasks/07-28-all-account-login-readiness/task.json +++ b/.trellis/tasks/07-28-all-account-login-readiness/task.json @@ -21,6 +21,6 @@ "children": [], "parent": null, "relatedFiles": [], - "notes": "首次企微免密登录代码已随最新主干版本进入生产;live 只读审计确认 OA active 6、企微 active 6、binding 6、唯一匹配 6,缺失、冲突、状态漂移和待回填均为 0,因此无需 apply。首批 2–3 名同事的企微工作台真实登录已由用户确认全部成功。任务继续保留,用于其余人员覆盖与逐人密码回退收口;不把本轮小范围成功扩大为全部账户双路径验收完成。", + "notes": "首次企微免密登录代码已随最新主干版本进入生产;live 只读审计确认 OA active 6、企微 active 6、binding 6、唯一匹配 6,缺失、冲突、状态漂移和待回填均为 0,因此无需 apply。首批人员和其余 3 人的企微工作台真实登录均由用户确认成功,当前 6/6 真人企微主入口完成。2026-08-23 腾讯云生产补充只读聚合再次确认 active/binding 6/6、锁定/冲突/异常活跃会话为 0、近 7 天角色/通讯录/binding 写入为 0;仍有 3 个 must-change,密码历史成功覆盖仅 3/6。任务继续保留,用于逐人密码回退和首次改密收口;不把企微免登成功扩大为全部账户双路径验收完成。", "meta": {} }