feat(audit): 增加审计事件基础模块

This commit is contained in:
2026-07-08 16:17:36 +08:00
parent 64f49c512f
commit 73e8dfa8c8
9 changed files with 297 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
CREATE TABLE "audit_events" (
"id" TEXT NOT NULL,
"actor_id" TEXT,
"actor_name" TEXT NOT NULL DEFAULT '',
"action" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT NOT NULL,
"product_id" TEXT,
"project_id" TEXT,
"version_id" TEXT,
"scope" JSONB NOT NULL DEFAULT '{}',
"before" JSONB,
"after" JSONB,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id", "created_at")
) PARTITION BY RANGE ("created_at");
CREATE TABLE "audit_events_default" PARTITION OF "audit_events" DEFAULT;
CREATE INDEX "audit_events_actor_created_at_idx" ON "audit_events"("actor_id", "created_at" DESC);
CREATE INDEX "audit_events_entity_created_at_idx" ON "audit_events"("entity_type", "entity_id", "created_at" DESC);
CREATE INDEX "audit_events_product_created_at_idx" ON "audit_events"("product_id", "created_at" DESC);
CREATE INDEX "audit_events_project_created_at_idx" ON "audit_events"("project_id", "created_at" DESC);
CREATE INDEX "audit_events_version_created_at_idx" ON "audit_events"("version_id", "created_at" DESC);

View File

@@ -423,6 +423,26 @@ model AiLog {
@@map("ai_logs")
}
model AuditEvent {
id String @default(cuid())
actorId String? @map("actor_id")
actorName String @default("") @map("actor_name")
action String
entityType String @map("entity_type")
entityId String @map("entity_id")
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
scope Json @default("{}")
before Json?
after Json?
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("audit_events")
}
model XiaobaoRiskSummary {
versionId String @id @map("version_id")
riskLevel String @map("risk_level")

View File

@@ -3,6 +3,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
import { AuthModule } from './common/auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuditModule } from './modules/audit/audit.module';
import { ProductModule } from './modules/product/product.module';
import { ProjectModule } from './modules/project/project.module';
import { RequirementModule } from './modules/requirement/requirement.module';
@@ -26,6 +27,7 @@ import { HealthModule } from './modules/health/health.module';
imports: [
PrismaModule,
AuthModule,
AuditModule,
ProductModule,
ProjectModule,
VersionModule,

View File

@@ -0,0 +1,20 @@
import { Reflector } from '@nestjs/core';
import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator';
import { AuditController } from './audit.controller';
describe('AuditController', () => {
it('requires audit:view for audit queries', () => {
const metadata = new Reflector().get(PERMISSION_METADATA_KEY, AuditController.prototype.findAll);
expect(metadata).toEqual({ permission: 'audit:view' });
});
it('delegates list query parameters to the audit service', async () => {
const service = { query: jest.fn().mockResolvedValue([{ id: 'audit-1' }]) };
const controller = new AuditController(service as any);
await expect(controller.findAll({ actorId: 'm-8' })).resolves.toEqual([{ id: 'audit-1' }]);
expect(service.query).toHaveBeenCalledWith({ actorId: 'm-8' });
});
});

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { PermissionGuard } from '../../common/auth/permission.guard';
import { RequirePermission } from '../../common/auth/permission.decorator';
import { AuditService } from './audit.service';
import { QueryAuditEventsDto } from './dto/query-audit-events.dto';
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@UseGuards(PermissionGuard)
@RequirePermission('audit:view')
findAll(@Query() query: QueryAuditEventsDto) {
return this.auditService.query(query);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,74 @@
import { AuditService } from './audit.service';
describe('AuditService', () => {
const create = jest.fn();
const findMany = jest.fn();
const prisma = { auditEvent: { create, findMany } } as any;
const service = new AuditService(prisma);
beforeEach(() => {
jest.clearAllMocks();
});
it('writes append-only audit events with sensitive fields redacted', async () => {
create.mockResolvedValue({ id: 'audit-1' });
await service.record({
actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' },
action: 'product.update',
entityType: 'product',
entityId: 'product-1',
productId: 'product-1',
before: { name: 'Old', password: '123456' },
after: { name: 'New', nested: { apiKey: 'sk-test', keep: 'visible' } },
metadata: { authorization: 'Bearer token', reason: 'manual edit' },
});
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({
actorId: 'm-8',
actorName: '超级管理员',
action: 'product.update',
entityType: 'product',
entityId: 'product-1',
productId: 'product-1',
before: { name: 'Old', password: '[REDACTED]' },
after: { name: 'New', nested: { apiKey: '[REDACTED]', keep: 'visible' } },
metadata: { authorization: '[REDACTED]', reason: 'manual edit' },
}),
});
});
it('queries by actor, entity, scope, and date range with bounded page size', async () => {
findMany.mockResolvedValue([]);
await service.query({
actorId: 'm-8',
entityType: 'bug',
entityId: 'bug-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
dateFrom: '2026-07-01T00:00:00.000Z',
dateTo: '2026-07-08T23:59:59.000Z',
take: '500',
});
expect(findMany).toHaveBeenCalledWith({
where: {
actorId: 'm-8',
entityType: 'bug',
entityId: 'bug-1',
productId: 'product-1',
projectId: 'project-1',
versionId: 'version-1',
createdAt: {
gte: new Date('2026-07-01T00:00:00.000Z'),
lte: new Date('2026-07-08T23:59:59.000Z'),
},
},
orderBy: { createdAt: 'desc' },
take: 100,
});
});
});

View File

@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { CurrentUser } from '../../common/auth/auth-context.service';
import type { QueryAuditEventsDto } from './dto/query-audit-events.dto';
export interface AuditRecordInput {
actor?: CurrentUser | null;
action: string;
entityType: string;
entityId: string;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
scope?: unknown;
before?: unknown;
after?: unknown;
metadata?: unknown;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
record(input: AuditRecordInput) {
return this.prisma.auditEvent.create({
data: {
actorId: input.actor?.id ?? null,
actorName: input.actor?.name ?? input.actor?.username ?? '',
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
scope: toJson(input.scope ?? {}),
before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)),
after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)),
metadata: toJson(redactSensitive(input.metadata ?? {})),
},
});
}
query(query: QueryAuditEventsDto) {
return this.prisma.auditEvent.findMany({
where: buildWhere(query),
orderBy: { createdAt: 'desc' },
take: clampTake(query.take),
});
}
}
function buildWhere(query: QueryAuditEventsDto) {
const where: Record<string, unknown> = {};
for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) {
if (query[key]) where[key] = query[key];
}
const dateRange: Record<string, Date> = {};
if (query.dateFrom) dateRange.gte = new Date(query.dateFrom);
if (query.dateTo) dateRange.lte = new Date(query.dateTo);
if (Object.keys(dateRange).length > 0) where.createdAt = dateRange;
return where;
}
function clampTake(value: string | undefined): number {
const parsed = Number(value ?? 50);
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
return Math.min(100, Math.floor(parsed));
}
function redactSensitive(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
if (value instanceof Date) return value.toISOString();
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item),
]));
}
function isSensitiveKey(key: string): boolean {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '');
return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive));
}
function toJson(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,39 @@
import { IsOptional, IsString } from 'class-validator';
export class QueryAuditEventsDto {
@IsOptional()
@IsString()
actorId?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsString()
entityId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
@IsOptional()
@IsString()
dateFrom?: string;
@IsOptional()
@IsString()
dateTo?: string;
@IsOptional()
@IsString()
take?: string;
}