From 73e8dfa8c8564a5c2e85dfc2758ddf8ad410cef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:17:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20=E5=A2=9E=E5=8A=A0=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E4=BA=8B=E4=BB=B6=E5=9F=BA=E7=A1=80=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 25 ++++++ apps/server/prisma/schema.prisma | 20 +++++ apps/server/src/app.module.ts | 2 + .../modules/audit/audit.controller.spec.ts | 20 +++++ .../src/modules/audit/audit.controller.ts | 17 ++++ apps/server/src/modules/audit/audit.module.ts | 10 +++ .../src/modules/audit/audit.service.spec.ts | 74 +++++++++++++++ .../server/src/modules/audit/audit.service.ts | 90 +++++++++++++++++++ .../audit/dto/query-audit-events.dto.ts | 39 ++++++++ 9 files changed, 297 insertions(+) create mode 100644 apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql create mode 100644 apps/server/src/modules/audit/audit.controller.spec.ts create mode 100644 apps/server/src/modules/audit/audit.controller.ts create mode 100644 apps/server/src/modules/audit/audit.module.ts create mode 100644 apps/server/src/modules/audit/audit.service.spec.ts create mode 100644 apps/server/src/modules/audit/audit.service.ts create mode 100644 apps/server/src/modules/audit/dto/query-audit-events.dto.ts diff --git a/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql b/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql new file mode 100644 index 0000000..eda85c1 --- /dev/null +++ b/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql @@ -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); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 005491c..d6c8033 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -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") diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 92cb2f3..6620393 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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, diff --git a/apps/server/src/modules/audit/audit.controller.spec.ts b/apps/server/src/modules/audit/audit.controller.spec.ts new file mode 100644 index 0000000..c0867fd --- /dev/null +++ b/apps/server/src/modules/audit/audit.controller.spec.ts @@ -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' }); + }); +}); diff --git a/apps/server/src/modules/audit/audit.controller.ts b/apps/server/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..d8e2b44 --- /dev/null +++ b/apps/server/src/modules/audit/audit.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/audit/audit.module.ts b/apps/server/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..3695111 --- /dev/null +++ b/apps/server/src/modules/audit/audit.module.ts @@ -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 {} diff --git a/apps/server/src/modules/audit/audit.service.spec.ts b/apps/server/src/modules/audit/audit.service.spec.ts new file mode 100644 index 0000000..8e5e1a1 --- /dev/null +++ b/apps/server/src/modules/audit/audit.service.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/server/src/modules/audit/audit.service.ts b/apps/server/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..a29705a --- /dev/null +++ b/apps/server/src/modules/audit/audit.service.ts @@ -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 = {}; + for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) { + if (query[key]) where[key] = query[key]; + } + + const dateRange: Record = {}; + 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).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; +} diff --git a/apps/server/src/modules/audit/dto/query-audit-events.dto.ts b/apps/server/src/modules/audit/dto/query-audit-events.dto.ts new file mode 100644 index 0000000..b7d1630 --- /dev/null +++ b/apps/server/src/modules/audit/dto/query-audit-events.dto.ts @@ -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; +}