feat(api-security): encrypt sensitive data at rest

This commit is contained in:
2026-04-06 15:25:10 +08:00
parent 13d0d7707a
commit 13abfc1e52
13 changed files with 739 additions and 80 deletions
+63 -33
View File
@@ -9,6 +9,7 @@ import {
TaskStatus
} from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
import { AiProviderRegistryService } from "./ai-provider-registry.service";
import { AiChatDto } from "./dto/ai-chat.dto";
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
@@ -93,7 +94,8 @@ export class AiService {
constructor(
private readonly prismaService: PrismaService,
private readonly aiProviderRegistryService: AiProviderRegistryService
private readonly aiProviderRegistryService: AiProviderRegistryService,
private readonly dataEncryptionService: DataEncryptionService
) {}
async listBindings(userId: string): Promise<ListAiBindingsResponse> {
@@ -119,8 +121,8 @@ export class AiService {
publicPool: publicPool
? {
enabled: publicPool.enabled,
providerName: publicPool.providerName,
model: publicPool.model,
providerName: this.readDecryptedString(publicPool.providerName),
model: this.readDecryptedString(publicPool.model),
hasApiKey: Boolean(publicPool.encryptedApiKey)
}
: null
@@ -191,12 +193,12 @@ export class AiService {
data: {
userId,
channel: dto.channel,
providerName: this.normalizeProviderName(dto.providerName),
model: this.normalizeOptionalString(dto.model),
configId: this.normalizeOptionalString(dto.configId),
configName: this.normalizeOptionalString(dto.configName),
endpoint: this.normalizeOptionalString(dto.endpoint),
encryptedApiKey: this.normalizeOptionalString(dto.apiKey),
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
model: this.encryptOptionalString(dto.model),
configId: this.encryptOptionalString(dto.configId),
configName: this.encryptOptionalString(dto.configName),
endpoint: this.encryptOptionalString(dto.endpoint),
encryptedApiKey: this.encryptOptionalString(dto.apiKey),
isEnabled: dto.isEnabled ?? true
}
});
@@ -204,19 +206,19 @@ export class AiService {
const updateData: Prisma.AiProviderBindingUpdateInput = {
channel: dto.channel,
providerName: this.normalizeProviderName(dto.providerName),
model: this.normalizeOptionalString(dto.model),
configId: this.normalizeOptionalString(dto.configId),
configName: this.normalizeOptionalString(dto.configName),
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
model: this.encryptOptionalString(dto.model),
configId: this.encryptOptionalString(dto.configId),
configName: this.encryptOptionalString(dto.configName),
isEnabled: dto.isEnabled ?? existingBinding.isEnabled
};
if (dto.endpoint !== undefined) {
updateData.endpoint = this.normalizeOptionalString(dto.endpoint);
updateData.endpoint = this.encryptOptionalString(dto.endpoint);
}
if (dto.apiKey !== undefined) {
updateData.encryptedApiKey = this.normalizeOptionalString(dto.apiKey);
updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey);
}
return tx.aiProviderBinding.update({
@@ -398,12 +400,12 @@ export class AiService {
channel: binding.channel,
source: "binding",
sourceId: binding.id,
providerName: binding.providerName,
model: binding.model,
configId: binding.configId,
configName: binding.configName,
endpoint: binding.endpoint,
apiKey: binding.encryptedApiKey
providerName: this.readDecryptedString(binding.providerName) ?? "",
model: this.readDecryptedString(binding.model),
configId: this.readDecryptedString(binding.configId),
configName: this.readDecryptedString(binding.configName),
endpoint: this.readDecryptedString(binding.endpoint),
apiKey: this.readDecryptedString(binding.encryptedApiKey)
};
}
@@ -412,27 +414,34 @@ export class AiService {
channel: AiChannel.PUBLIC_POOL,
source: "public_pool",
sourceId: publicPool.id,
providerName: publicPool.providerName ?? "public-pool",
model: publicPool.model,
providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool",
model: this.readDecryptedString(publicPool.model),
configId: null,
configName: null,
endpoint: publicPool.endpoint,
apiKey: publicPool.encryptedApiKey
endpoint: this.readDecryptedString(publicPool.endpoint),
apiKey: this.readDecryptedString(publicPool.encryptedApiKey)
};
}
private serializeBinding(binding: AiProviderBinding): AiBindingSummary {
const decryptedProviderName = this.readDecryptedString(binding.providerName) ?? "";
const decryptedModel = this.readDecryptedString(binding.model);
const decryptedConfigId = this.readDecryptedString(binding.configId);
const decryptedConfigName = this.readDecryptedString(binding.configName);
const decryptedEndpoint = this.readDecryptedString(binding.endpoint);
const decryptedApiKey = this.readDecryptedString(binding.encryptedApiKey);
return {
id: binding.id,
channel: binding.channel,
providerName: binding.providerName,
model: binding.model,
configId: binding.configId,
configName: binding.configName,
endpoint: binding.endpoint,
providerName: decryptedProviderName,
model: decryptedModel,
configId: decryptedConfigId,
configName: decryptedConfigName,
endpoint: decryptedEndpoint,
isEnabled: binding.isEnabled,
hasApiKey: Boolean(binding.encryptedApiKey),
maskedApiKey: this.maskSecret(binding.encryptedApiKey),
maskedApiKey: this.maskSecret(decryptedApiKey),
updatedAt: binding.updatedAt.toISOString()
};
}
@@ -523,14 +532,16 @@ export class AiService {
const visibleTasks = sortedTasks.slice(0, this.maxContextTasks);
const lines = visibleTasks.map((task, index) => {
const taskTitle = this.readDecryptedString(task.title) ?? "未命名任务";
const contentText = this.readDecryptedString(task.contentText);
const parts = [
`${index + 1}. ${task.title}`,
`${index + 1}. ${taskTitle}`,
`优先级:${this.getPriorityLabel(task.priority)}`,
`状态:${this.getStatusLabel(task.status)}`,
`DDL${task.ddl ? task.ddl.toISOString() : "未设置"}`
];
const contentSnippet = this.getContentSnippet(task.contentText);
const contentSnippet = this.getContentSnippet(contentText);
if (contentSnippet) {
parts.push(`内容摘要:${contentSnippet}`);
}
@@ -592,6 +603,25 @@ export class AiService {
return this.normalizeOptionalString(value) ?? "";
}
private encryptOptionalString(value: string | undefined): string | null | undefined {
const normalizedValue = this.normalizeOptionalString(value);
return this.dataEncryptionService.encryptString(normalizedValue);
}
private encryptRequiredString(value: string): string {
const encryptedValue = this.dataEncryptionService.encryptString(value);
if (!encryptedValue) {
throw new BadRequestException("敏感配置加密失败");
}
return encryptedValue;
}
private readDecryptedString(value: string | null): string | null {
const decryptedValue = this.dataEncryptionService.decryptString(value);
return typeof decryptedValue === "string" ? decryptedValue : null;
}
private validateBindingInput(dto: UpsertAiProviderBindingDto): void {
const providerName = this.normalizeOptionalString(dto.providerName);
const configId = this.normalizeOptionalString(dto.configId);