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
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 38s
This commit is contained in:
parent
628bb31c98
commit
d32411760f
12
prisma/migrations/20260523203611_add_secret/migration.sql
Normal file
12
prisma/migrations/20260523203611_add_secret/migration.sql
Normal 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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
20
src/modules/secret/secret.controller.ts
Normal file
20
src/modules/secret/secret.controller.ts
Normal 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() }
|
||||
}
|
||||
8
src/modules/secret/secret.module.ts
Normal file
8
src/modules/secret/secret.module.ts
Normal 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 {}
|
||||
56
src/modules/secret/secret.service.ts
Normal file
56
src/modules/secret/secret.service.ts
Normal 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 }) }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user