feat(api-security): encrypt user fields and ai usage logs
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user