From d32411760f8caac220c10a717be7078712b2f9de Mon Sep 17 00:00:00 2001 From: WangDL Date: Sat, 23 May 2026 20:36:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M0-12=20Secret=20&=20Vendor=20Asset=20?= =?UTF-8?q?=E2=80=94=20AES-256-GCM=20encrypted=20key=20storage=20+=20Admin?= =?UTF-8?q?=20AAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260523203611_add_secret/migration.sql | 12 ++++ prisma/schema.prisma | 26 +++++++++ src/app.module.ts | 2 + src/modules/secret/secret.controller.ts | 20 +++++++ src/modules/secret/secret.module.ts | 8 +++ src/modules/secret/secret.service.ts | 56 +++++++++++++++++++ 6 files changed, 124 insertions(+) create mode 100644 prisma/migrations/20260523203611_add_secret/migration.sql create mode 100644 src/modules/secret/secret.controller.ts create mode 100644 src/modules/secret/secret.module.ts create mode 100644 src/modules/secret/secret.service.ts diff --git a/prisma/migrations/20260523203611_add_secret/migration.sql b/prisma/migrations/20260523203611_add_secret/migration.sql new file mode 100644 index 0000000..7421ed8 --- /dev/null +++ b/prisma/migrations/20260523203611_add_secret/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS SecretRecord ( + id VARCHAR(191) NOT NULL, name VARCHAR(100) NOT NULL, provider VARCHAR(32) NOT NULL, + encrypted TEXT NOT NULL, maskLast4 VARCHAR(4) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active', + expiresAt DATETIME(3), rotatedFrom VARCHAR(100), + createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updatedAt DATETIME(3) NOT NULL, + UNIQUE INDEX SecretRecord_name_key(name), INDEX SecretRecord_provider_idx(provider), PRIMARY KEY (id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS SecretAccessLog ( + id VARCHAR(191) NOT NULL, secretId VARCHAR(191) NOT NULL, secretName VARCHAR(100) NOT NULL, + accessedBy VARCHAR(100) NOT NULL, createdAt DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX SecretAccessLog_secretId_idx(secretId), INDEX SecretAccessLog_createdAt_idx(createdAt), PRIMARY KEY (id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8e635e5..17369f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1021,3 +1021,29 @@ model CostDailySummary { @@unique([date, provider, model]) } + +model SecretRecord { + id String @id @default(cuid()) + name String @unique @db.VarChar(100) + provider String @db.VarChar(32) + encrypted String @db.Text + maskLast4 String @db.VarChar(4) + status String @default("active") @db.VarChar(16) + expiresAt DateTime? + rotatedFrom String? @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([provider]) +} + +model SecretAccessLog { + id String @id @default(cuid()) + secretId String + secretName String @db.VarChar(100) + accessedBy String @db.VarChar(100) + createdAt DateTime @default(now()) + + @@index([secretId]) + @@index([createdAt]) +} diff --git a/src/app.module.ts b/src/app.module.ts index c20247c..fc0a70c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; import { AdminUsersModule } from './modules/admin-users/admin-users.module'; import { ContentSafetyModule } from './modules/content-safety/content-safety.module'; +import { SecretModule } from './modules/secret/secret.module'; import { QuotaModule } from './modules/quota/quota.module'; import { AdminMetricsModule } from './modules/admin-metrics/admin-metrics.module'; import { AdminThrottleModule } from './modules/admin-throttle/admin-throttle.module'; @@ -108,6 +109,7 @@ import appleConfig from './config/apple.config'; AdminDashboardModule, AdminUsersModule, ContentSafetyModule, + SecretModule, QuotaModule, AdminMetricsModule, AdminThrottleModule, diff --git a/src/modules/secret/secret.controller.ts b/src/modules/secret/secret.controller.ts new file mode 100644 index 0000000..5630cce --- /dev/null +++ b/src/modules/secret/secret.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { SecretService } from './secret.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import type { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-secret') +@Controller('admin-api/secrets') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@ApiBearerAuth() +export class SecretController { + constructor(private readonly svc: SecretService) {} + + @Get() @AdminRoles('SUPER_ADMIN' as AdminRole) async list() { return this.svc.list() } + @Post() @AdminRoles('SUPER_ADMIN' as AdminRole) async create(@Body() d: { name: string; provider: string; value: string; expiresAt?: string }) { return this.svc.create(d.name, d.provider, d.value, d.expiresAt) } + @Delete(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async delete(@Param('id') id: string) { await this.svc.delete(id); return { success: true } } + @Get('logs') @AdminRoles('SUPER_ADMIN' as AdminRole) async logs() { return this.svc.accessLogs() } +} diff --git a/src/modules/secret/secret.module.ts b/src/modules/secret/secret.module.ts new file mode 100644 index 0000000..9396655 --- /dev/null +++ b/src/modules/secret/secret.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SecretController } from './secret.controller'; +import { SecretService } from './secret.service'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +@Module({ controllers: [SecretController], providers: [SecretService, PrismaService, AdminAuthGuard, AdminRolesGuard], exports: [SecretService] }) +export class SecretModule {} diff --git a/src/modules/secret/secret.service.ts b/src/modules/secret/secret.service.ts new file mode 100644 index 0000000..a040093 --- /dev/null +++ b/src/modules/secret/secret.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +const ALGO = 'aes-256-gcm'; +const MASTER_KEY = process.env.SECRET_MASTER_KEY || 'zhixi-secret-master-key-2026-32b!!'; +const IV_LEN = 16; +const TAG_LEN = 16; +const KEY = Buffer.from(MASTER_KEY.padEnd(32, '0').slice(0, 32)); + +@Injectable() +export class SecretService { + private readonly logger = new Logger(SecretService.name); + + constructor(private readonly prisma: PrismaService) {} + + encrypt(plaintext: string): string { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv(ALGO, KEY, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString('base64'); + } + + decrypt(encoded: string): string { + const buf = Buffer.from(encoded, 'base64'); + const iv = buf.slice(0, IV_LEN); + const tag = buf.slice(IV_LEN, IV_LEN + TAG_LEN); + const encrypted = buf.slice(IV_LEN + TAG_LEN); + const decipher = createDecipheriv(ALGO, KEY, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); + } + + async create(name: string, provider: string, value: string, expiresAt?: string) { + const encrypted = this.encrypt(value); + const maskLast4 = value.slice(-4); + return this.prisma.secretRecord.create({ + data: { name, provider, encrypted, maskLast4, expiresAt: expiresAt ? new Date(expiresAt) : null }, + select: { id: true, name: true, provider: true, maskLast4: true, status: true, expiresAt: true, createdAt: true }, + }); + } + + async getDecrypted(name: string, accessedBy: string): Promise { + const record = await this.prisma.secretRecord.findUnique({ where: { name } }); + if (!record || record.status !== 'active') return null; + await this.prisma.secretAccessLog.create({ data: { secretId: record.id, secretName: name, accessedBy } }).catch(() => {}); + return this.decrypt(record.encrypted); + } + + async list() { return this.prisma.secretRecord.findMany({ select: { id: true, name: true, provider: true, maskLast4: true, status: true, expiresAt: true, createdAt: true }, orderBy: { createdAt: 'desc' } }) } + + async delete(id: string) { await this.prisma.secretRecord.delete({ where: { id } }) } + + async accessLogs(limit = 50) { return this.prisma.secretAccessLog.findMany({ orderBy: { createdAt: 'desc' }, take: limit }) } +}