feat(server): 敏感字段加密与脱敏(F25)

- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY)
- users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计
- 权限新增 system.users_sensitive(hr/admin)
- 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
2026-02-09 20:51:34 +08:00
父节点 c791e67b20
当前提交 1855a82011
共修改 9 个文件,包含 428 行新增和 7 行删除
+1
查看文件
@@ -26,6 +26,7 @@ function getConfig() {
appOrigin: process.env.APP_ORIGIN ?? "",
jwtSecret: requiredEnv("JWT_SECRET"),
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "",
accessTokenTtl: "15m",
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
+1 -1
查看文件
@@ -42,7 +42,7 @@ const ROLE_PERMISSIONS = {
"invoice.upload",
"invoice.view_own",
],
hr: ["system.users", "expense.view_all"],
hr: ["system.users", "system.users_sensitive", "expense.view_all"],
admin: ["*"],
};
+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 }),
);
},
);
+147
查看文件
@@ -0,0 +1,147 @@
const crypto = require("node:crypto");
const ENCRYPTION_VERSION_PREFIX = "v1:";
const AES_ALGO = "aes-256-gcm";
const IV_LENGTH_BYTES = 12;
const TAG_LENGTH_BYTES = 16;
function resolveFieldEncryptionKey(config) {
const raw = config?.fieldEncryptionKey;
if (!raw || typeof raw !== "string" || !raw.trim()) {
return null;
}
let key;
try {
key = Buffer.from(raw.trim(), "base64");
} catch {
return null;
}
if (key.length !== 32) {
return null;
}
return key;
}
function encryptString(plaintext, key) {
const input = typeof plaintext === "string" ? plaintext : "";
if (!input) {
return "";
}
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error("Invalid encryption key");
}
const iv = crypto.randomBytes(IV_LENGTH_BYTES);
const cipher = crypto.createCipheriv(AES_ALGO, key, iv);
const cipherText = Buffer.concat([cipher.update(input, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = `${ENCRYPTION_VERSION_PREFIX}${iv.toString("base64url")}:${tag.toString(
"base64url",
)}:${cipherText.toString("base64url")}`;
return payload;
}
function decryptString(value, key) {
const input = typeof value === "string" ? value : "";
if (!input) {
return null;
}
if (!input.startsWith(ENCRYPTION_VERSION_PREFIX)) {
return null;
}
if (!Buffer.isBuffer(key) || key.length !== 32) {
throw new Error("Invalid encryption key");
}
const body = input.slice(ENCRYPTION_VERSION_PREFIX.length);
const parts = body.split(":");
if (parts.length !== 3) {
return null;
}
const [ivB64, tagB64, cipherB64] = parts;
const iv = Buffer.from(ivB64, "base64url");
const tag = Buffer.from(tagB64, "base64url");
const cipherText = Buffer.from(cipherB64, "base64url");
if (iv.length !== IV_LENGTH_BYTES || tag.length !== TAG_LENGTH_BYTES || !cipherText.length) {
return null;
}
const decipher = crypto.createDecipheriv(AES_ALGO, key, iv);
decipher.setAuthTag(tag);
const plain = Buffer.concat([decipher.update(cipherText), decipher.final()]).toString("utf8");
return plain;
}
function decryptStringSafe(config, encrypted, logger) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
return null;
}
try {
return decryptString(encrypted, key);
} catch (error) {
logger?.warn?.({ err: error }, "Sensitive field decrypt failed");
return null;
}
}
function encryptStringOrThrow(config, plaintext) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
throw new Error("Missing FIELD_ENCRYPTION_KEY");
}
return encryptString(plaintext, key);
}
function decryptStringOrThrow(config, encrypted) {
const key = resolveFieldEncryptionKey(config);
if (!key) {
throw new Error("Missing FIELD_ENCRYPTION_KEY");
}
return decryptString(encrypted, key);
}
function maskKeepEnds(value, options = {}) {
const input = typeof value === "string" ? value.trim() : "";
if (!input) {
return null;
}
const keepStart = Math.max(0, Number(options.keepStart ?? 0));
const keepEnd = Math.max(0, Number(options.keepEnd ?? 0));
if (input.length <= keepStart + keepEnd) {
return "*".repeat(input.length);
}
const start = keepStart ? input.slice(0, keepStart) : "";
const end = keepEnd ? input.slice(-keepEnd) : "";
return `${start}${"*".repeat(input.length - keepStart - keepEnd)}${end}`;
}
function maskBankAccount(account) {
return maskKeepEnds(account, { keepStart: 4, keepEnd: 4 });
}
function maskIdNumber(idNumber) {
return maskKeepEnds(idNumber, { keepStart: 6, keepEnd: 4 });
}
module.exports = {
decryptStringSafe,
decryptStringOrThrow,
encryptStringOrThrow,
maskBankAccount,
maskIdNumber,
resolveFieldEncryptionKey,
};