feat(audit): 增加审计事件基础模块
This commit is contained in:
@@ -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);
|
||||||
@@ -423,6 +423,26 @@ model AiLog {
|
|||||||
@@map("ai_logs")
|
@@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 {
|
model XiaobaoRiskSummary {
|
||||||
versionId String @id @map("version_id")
|
versionId String @id @map("version_id")
|
||||||
riskLevel String @map("risk_level")
|
riskLevel String @map("risk_level")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { APP_INTERCEPTOR } from '@nestjs/core';
|
|||||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||||
import { AuthModule } from './common/auth/auth.module';
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ProductModule } from './modules/product/product.module';
|
import { ProductModule } from './modules/product/product.module';
|
||||||
import { ProjectModule } from './modules/project/project.module';
|
import { ProjectModule } from './modules/project/project.module';
|
||||||
import { RequirementModule } from './modules/requirement/requirement.module';
|
import { RequirementModule } from './modules/requirement/requirement.module';
|
||||||
@@ -26,6 +27,7 @@ import { HealthModule } from './modules/health/health.module';
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AuditModule,
|
||||||
ProductModule,
|
ProductModule,
|
||||||
ProjectModule,
|
ProjectModule,
|
||||||
VersionModule,
|
VersionModule,
|
||||||
|
|||||||
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
17
apps/server/src/modules/audit/audit.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/server/src/modules/audit/audit.module.ts
Normal file
10
apps/server/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||||
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/server/src/modules/audit/audit.service.ts
Normal file
90
apps/server/src/modules/audit/audit.service.ts
Normal 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;
|
||||||
|
}
|
||||||
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user