feat: M0-12 Secret & Vendor Asset — AES-256-GCM encrypted key storage + Admin AAPI
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 38s

This commit is contained in:
WangDL 2026-05-23 20:36:39 +08:00
parent 628bb31c98
commit d32411760f
6 changed files with 124 additions and 0 deletions

View File

@ -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;

View File

@ -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])
}

View File

@ -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,

View File

@ -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() }
}

View File

@ -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 {}

View File

@ -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<string | null> {
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 }) }
}