feat(api-security): encrypt user fields and ai usage logs

This commit is contained in:
2026-04-06 15:55:27 +08:00
parent 13abfc1e52
commit 4c6aeb3e6c
9 changed files with 595 additions and 67 deletions
+167 -46
View File
@@ -5,7 +5,14 @@ import { Prisma, PrismaClient } from "../generated/prisma/client";
import { DataEncryptionService } from "../src/security/data-encryption.service";
type MigrationCounter = Record<
"aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations",
| "users"
| "authIdentities"
| "aiBindings"
| "publicPools"
| "aiUsageLogs"
| "tasks"
| "attachments"
| "syncOperations",
number
>;
@@ -28,6 +35,26 @@ function encryptStringIfNeeded(
return dataEncryptionService.encryptString(value) ?? null;
}
function assignRequiredEncryptedString<T extends Record<string, unknown>, K extends keyof T>(
target: T,
key: K,
value: string | null | undefined
): void {
if (typeof value === "string") {
target[key] = value as T[K];
}
}
function assignOptionalEncryptedString<T extends Record<string, unknown>, K extends keyof T>(
target: T,
key: K,
value: string | null | undefined
): void {
if (value !== undefined) {
target[key] = value as T[K];
}
}
function encryptJsonIfNeeded(
value: Prisma.JsonValue | null,
dataEncryptionService: DataEncryptionService
@@ -45,6 +72,19 @@ function encryptJsonIfNeeded(
| Prisma.NullableJsonNullValueInput;
}
function resolvePlainString(
value: string | null,
dataEncryptionService: DataEncryptionService
): string | null {
if (value === null) {
return null;
}
return dataEncryptionService.isEncryptedString(value)
? (dataEncryptionService.decryptString(value) ?? null)
: value;
}
async function main(): Promise<void> {
if (!process.env["DATABASE_URL"]) {
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
@@ -61,14 +101,96 @@ async function main(): Promise<void> {
});
const dataEncryptionService = createEncryptionService();
const counter: MigrationCounter = {
users: 0,
authIdentities: 0,
aiBindings: 0,
publicPools: 0,
aiUsageLogs: 0,
tasks: 0,
attachments: 0,
syncOperations: 0
};
try {
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
emailHash: true,
nickname: true,
avatarUrl: true
}
});
for (const user of users) {
const normalizedEmail = resolvePlainString(user.email, dataEncryptionService)?.toLowerCase();
if (!normalizedEmail) {
continue;
}
const nextEmailHash = dataEncryptionService.createLookupHash("user.email", normalizedEmail);
const data: Prisma.UserUpdateInput = {};
const email = encryptStringIfNeeded(user.email, dataEncryptionService);
const nickname = encryptStringIfNeeded(user.nickname, dataEncryptionService);
const avatarUrl = encryptStringIfNeeded(user.avatarUrl, dataEncryptionService);
assignRequiredEncryptedString(data, "email", email);
if (user.emailHash !== nextEmailHash) {
data.emailHash = nextEmailHash;
}
assignOptionalEncryptedString(data, "nickname", nickname);
assignOptionalEncryptedString(data, "avatarUrl", avatarUrl);
if (Object.keys(data).length === 0) {
continue;
}
await prisma.user.update({
where: {
id: user.id
},
data
});
counter.users += 1;
}
const authIdentities = await prisma.authIdentity.findMany({
select: {
id: true,
email: true,
emailHash: true
}
});
for (const authIdentity of authIdentities) {
const data: Prisma.AuthIdentityUpdateInput = {};
const email = encryptStringIfNeeded(authIdentity.email, dataEncryptionService);
const normalizedIdentityEmail = resolvePlainString(authIdentity.email, dataEncryptionService);
const nextEmailHash =
normalizedIdentityEmail === null
? null
: dataEncryptionService.createLookupHash(
"auth_identity.email",
normalizedIdentityEmail.toLowerCase()
);
assignOptionalEncryptedString(data, "email", email);
if (authIdentity.emailHash !== nextEmailHash) {
data.emailHash = nextEmailHash;
}
if (Object.keys(data).length === 0) {
continue;
}
await prisma.authIdentity.update({
where: {
id: authIdentity.id
},
data
});
counter.authIdentities += 1;
}
const aiBindings = await prisma.aiProviderBinding.findMany({
select: {
id: true,
@@ -90,24 +212,12 @@ async function main(): Promise<void> {
const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService);
const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService);
if (providerName !== undefined) {
data.providerName = providerName;
}
if (model !== undefined) {
data.model = model;
}
if (configId !== undefined) {
data.configId = configId;
}
if (configName !== undefined) {
data.configName = configName;
}
if (endpoint !== undefined) {
data.endpoint = endpoint;
}
if (encryptedApiKey !== undefined) {
data.encryptedApiKey = encryptedApiKey;
}
assignRequiredEncryptedString(data, "providerName", providerName);
assignOptionalEncryptedString(data, "model", model);
assignOptionalEncryptedString(data, "configId", configId);
assignOptionalEncryptedString(data, "configName", configName);
assignOptionalEncryptedString(data, "endpoint", endpoint);
assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey);
if (Object.keys(data).length === 0) {
continue;
@@ -142,18 +252,10 @@ async function main(): Promise<void> {
dataEncryptionService
);
if (providerName !== undefined) {
data.providerName = providerName;
}
if (model !== undefined) {
data.model = model;
}
if (endpoint !== undefined) {
data.endpoint = endpoint;
}
if (encryptedApiKey !== undefined) {
data.encryptedApiKey = encryptedApiKey;
}
assignOptionalEncryptedString(data, "providerName", providerName);
assignOptionalEncryptedString(data, "model", model);
assignOptionalEncryptedString(data, "endpoint", endpoint);
assignOptionalEncryptedString(data, "encryptedApiKey", encryptedApiKey);
if (Object.keys(data).length === 0) {
continue;
@@ -168,6 +270,35 @@ async function main(): Promise<void> {
counter.publicPools += 1;
}
const aiUsageLogs = await prisma.aiUsageLog.findMany({
select: {
id: true,
providerName: true,
model: true
}
});
for (const aiUsageLog of aiUsageLogs) {
const data: Prisma.AiUsageLogUpdateInput = {};
const providerName = encryptStringIfNeeded(aiUsageLog.providerName, dataEncryptionService);
const model = encryptStringIfNeeded(aiUsageLog.model, dataEncryptionService);
assignOptionalEncryptedString(data, "providerName", providerName);
assignOptionalEncryptedString(data, "model", model);
if (Object.keys(data).length === 0) {
continue;
}
await prisma.aiUsageLog.update({
where: {
id: aiUsageLog.id
},
data
});
counter.aiUsageLogs += 1;
}
const tasks = await prisma.task.findMany({
select: {
id: true,
@@ -183,15 +314,11 @@ async function main(): Promise<void> {
const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService);
const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService);
if (title !== undefined) {
data.title = title;
}
assignRequiredEncryptedString(data, "title", title);
if (contentJson !== undefined) {
data.contentJson = contentJson;
}
if (contentText !== undefined) {
data.contentText = contentText;
}
assignOptionalEncryptedString(data, "contentText", contentText);
if (Object.keys(data).length === 0) {
continue;
@@ -221,15 +348,9 @@ async function main(): Promise<void> {
const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService);
const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService);
if (url !== undefined) {
data.url = url;
}
if (fileName !== undefined) {
data.fileName = fileName;
}
if (checksum !== undefined) {
data.checksum = checksum;
}
assignRequiredEncryptedString(data, "url", url);
assignOptionalEncryptedString(data, "fileName", fileName);
assignOptionalEncryptedString(data, "checksum", checksum);
if (Object.keys(data).length === 0) {
continue;