实现(08-10-production-employee-smoke-break-glass-reset): 增加员工Smoke受控重置工具
这个提交包含在:
@@ -646,6 +646,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TARGETS,
|
||||
applyTarget,
|
||||
execute,
|
||||
isSafeOaOnlySmokeTarget,
|
||||
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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户