feat(server): 敏感字段加密与脱敏(F25)
- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY) - users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计 - 权限新增 system.users_sensitive(hr/admin) - 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
+263
-5
@@ -7,6 +7,13 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { hashPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const {
|
||||
decryptStringOrThrow,
|
||||
decryptStringSafe,
|
||||
encryptStringOrThrow,
|
||||
maskBankAccount,
|
||||
maskIdNumber,
|
||||
} = require("../services/crypto");
|
||||
|
||||
const roleCodeSchema = z.enum([
|
||||
"employee",
|
||||
@@ -58,6 +65,14 @@ const resetPasswordBodySchema = z.object({
|
||||
newPassword: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const sensitiveBodySchema = z
|
||||
.object({
|
||||
bankName: z.string().trim().max(100).nullable().optional(),
|
||||
bankAccount: z.string().trim().max(100).nullable().optional(),
|
||||
idNumber: z.string().trim().max(64).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function parseUserId(params) {
|
||||
const parsed = z.string().uuid().safeParse(params?.id);
|
||||
if (!parsed.success) {
|
||||
@@ -156,12 +171,21 @@ function mapEmployeeSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDetail(row) {
|
||||
function mapEmployeeDetail(row, options = {}) {
|
||||
const config = options.config ?? null;
|
||||
const logger = options.logger ?? null;
|
||||
|
||||
const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger);
|
||||
const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger);
|
||||
|
||||
return {
|
||||
...mapEmployeeSummary(row),
|
||||
directManagerId: row.direct_manager_id ?? null,
|
||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
||||
bankName: row.bank_name ? String(row.bank_name) : null,
|
||||
bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null,
|
||||
idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +204,29 @@ function normalizeUniqueErrorDetails(error) {
|
||||
return Object.keys(details).length ? details : { unique: "duplicate" };
|
||||
}
|
||||
|
||||
function parseSensitiveBody(body) {
|
||||
const parsed = sensitiveBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function usersRoutes(fastify, opts = {}) {
|
||||
const basePathRaw =
|
||||
typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users";
|
||||
@@ -276,6 +323,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -291,7 +341,11 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(employee));
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(employee, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -368,8 +422,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
direct_manager_id: body.directManagerId ?? null,
|
||||
hire_date: hireDate,
|
||||
status: "active",
|
||||
bank_account_encrypted: "ENC_TBD",
|
||||
bank_name: "TBD",
|
||||
bank_account_encrypted: "",
|
||||
bank_name: "",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
@@ -535,6 +589,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -605,6 +662,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -638,7 +698,205 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(updated));
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
let bankAccount = null;
|
||||
let idNumber = null;
|
||||
try {
|
||||
bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted);
|
||||
idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted);
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
bankName: employee.bank_name ? String(employee.bank_name) : null,
|
||||
bankAccount,
|
||||
idNumber,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const body = parseSensitiveBody(request.body);
|
||||
const now = new Date();
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
const data = {
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
|
||||
const nextBankName =
|
||||
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
|
||||
data.bank_name = nextBankName ? String(nextBankName) : "";
|
||||
changes.bankName = {
|
||||
before: current.bank_name ? String(current.bank_name) : null,
|
||||
after: data.bank_name || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankAccount")) {
|
||||
const nextBankAccount =
|
||||
typeof body.bankAccount === "string" ? body.bankAccount.trim() : body.bankAccount;
|
||||
data.bank_account_encrypted = nextBankAccount
|
||||
? encryptStringOrThrow(fastify.config, nextBankAccount)
|
||||
: "";
|
||||
changes.bankAccountMasked = nextBankAccount ? maskBankAccount(nextBankAccount) : null;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "idNumber")) {
|
||||
const nextIdNumber =
|
||||
typeof body.idNumber === "string" ? body.idNumber.trim() : body.idNumber;
|
||||
data.id_number_encrypted = nextIdNumber
|
||||
? encryptStringOrThrow(fastify.config, nextIdNumber)
|
||||
: null;
|
||||
changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null;
|
||||
}
|
||||
|
||||
const employee = await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_UPDATED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { changes },
|
||||
}),
|
||||
});
|
||||
|
||||
return employee;
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户