diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index d09393c..a6b9cd2 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -42,11 +42,18 @@ export class AgentService { ); } - async createInvocation(input: CreateAgentInvocationInput): Promise { + async createInvocation( + input: CreateAgentInvocationInput, + ): Promise { if (!allowedTypes.includes(input.type)) { - throw new AppError('VALIDATION_FAILED', 'Unsupported agent invocation type', 400, { - type: input.type, - }); + throw new AppError( + 'VALIDATION_FAILED', + 'Unsupported agent invocation type', + 400, + { + type: input.type, + }, + ); } const llmHealth = this.llmClient.healthSummary(); @@ -74,7 +81,7 @@ export class AgentService { status: 'success', resultMarkdown: completion.text || mockResults[input.type], }; - this.recordInvocationAudit(invocation, 'llm', completion.model); + await this.recordInvocationAudit(invocation, 'llm', completion.model); } catch (error) { invocation = { ...baseInvocation, @@ -84,7 +91,7 @@ export class AgentService { ? redactSensitive({ message: error.message }).message : 'LLM invocation failed', }; - this.recordInvocationAudit(invocation, 'llm_failed'); + await this.recordInvocationAudit(invocation, 'llm_failed'); } } else { invocation = { @@ -92,25 +99,30 @@ export class AgentService { status: 'success', resultMarkdown: mockResults[input.type], }; - this.recordInvocationAudit(invocation, 'mock', undefined, llmHealth.message); + await this.recordInvocationAudit( + invocation, + 'mock', + undefined, + llmHealth.message, + ); } this.invocations.unshift(invocation); return invocation; } - private recordInvocationAudit( + private async recordInvocationAudit( invocation: AgentInvocation, mode: 'mock' | 'llm' | 'llm_failed', model?: string, reason?: string, - ): void { + ): Promise { const action = invocation.status === 'failed' ? 'AGENT_INVOCATION_FAILED' : 'AGENT_INVOCATION_CREATED'; - this.auditService.record({ + await this.auditService.record({ action, resourceType: 'agent_invocation', resourceId: invocation.id, diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts index f7e90de..8731a97 100644 --- a/src/audit/audit.controller.ts +++ b/src/audit/audit.controller.ts @@ -1,3 +1,6 @@ +/** + * 审计日志查询入口只返回脱敏后的最近记录。 + */ import { Controller, Get } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { AuditRecordSummary, AuditService } from './audit.service'; @@ -9,7 +12,7 @@ export class AuditController { @Get() @ApiOkResponse({ description: 'Recent sanitized audit records.' }) - listRecentRecords(): AuditRecordSummary[] { + async listRecentRecords(): Promise { return this.auditService.getRecentRecords(); } } diff --git a/src/audit/audit.service.spec.ts b/src/audit/audit.service.spec.ts index 8d36390..4d96f65 100644 --- a/src/audit/audit.service.spec.ts +++ b/src/audit/audit.service.spec.ts @@ -4,10 +4,10 @@ import { AuditService } from './audit.service'; * 审计记录必须先脱敏再暴露给前端或 Agent。 */ describe('AuditService', () => { - it('stores recent audit records with sensitive values redacted', () => { + it('stores recent audit records with sensitive values redacted', async () => { const service = new AuditService(); - service.record({ + await service.record({ action: 'SECRET_CHANGED', resourceType: 'secret', parameterDigest: { @@ -16,7 +16,7 @@ describe('AuditService', () => { }, }); - const [record] = service.getRecentRecords(); + const [record] = await service.getRecentRecords(); expect(record.action).toBe('SECRET_CHANGED'); expect(record.parameterDigest).toEqual({ diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts index c097778..3efa644 100644 --- a/src/audit/audit.service.ts +++ b/src/audit/audit.service.ts @@ -1,5 +1,13 @@ +/** + * 审计服务统一完成敏感信息脱敏、MySQL 持久化和本地降级存储。 + * 业务服务只提交动作摘要,禁止把 token/webhook/连接串原文写入审计。 + */ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuditLog, Prisma } from '@prisma/client'; import { redactSensitive } from '../common/security/redact-sensitive'; +import { EnvConfig } from '../config/env.schema'; +import { PrismaService } from '../prisma/prisma.service'; export type AuditRecordInput = { action: string; @@ -7,30 +15,154 @@ export type AuditRecordInput = { resourceId?: string; actorId?: string; actorName?: string; + requestId?: string; + sourceIp?: string; + userAgent?: string; before?: Record; after?: Record; parameterDigest?: Record; }; -export type AuditRecordSummary = AuditRecordInput & { createdAt: string }; +export type AuditRecordSummary = AuditRecordInput & { + id: string; + createdAt: string; +}; + +type SanitizedAuditRecordInput = Omit< + AuditRecordInput, + 'before' | 'after' | 'parameterDigest' +> & { + before?: Prisma.InputJsonValue; + after?: Prisma.InputJsonValue; + parameterDigest?: Prisma.InputJsonValue; +}; @Injectable() export class AuditService { private readonly memoryRecords: AuditRecordSummary[] = []; - record(input: AuditRecordInput): void { + constructor( + private readonly config?: ConfigService, + private readonly prisma?: PrismaService, + ) {} + + async record(input: AuditRecordInput): Promise { + const sanitized = this.sanitize(input); + const prisma = this.databaseClient(); + + if (!prisma) { + this.pushMemoryRecord(sanitized); + return; + } + + await prisma.auditLog.create({ + data: { + deployRunId: + sanitized.resourceType === 'deploy_run' + ? sanitized.resourceId + : undefined, + actorId: sanitized.actorId, + actorName: sanitized.actorName, + action: sanitized.action, + resourceType: sanitized.resourceType, + resourceId: sanitized.resourceId, + requestId: sanitized.requestId, + sourceIp: sanitized.sourceIp, + userAgent: sanitized.userAgent, + before: sanitized.before, + after: sanitized.after, + parameterDigest: sanitized.parameterDigest, + }, + }); + } + + async getRecentRecords(): Promise { + const prisma = this.databaseClient(); + + if (prisma) { + const records = await prisma.auditLog.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: 50, + }); + + return records.map((record) => this.toSummary(record)); + } + + return [...this.memoryRecords].slice(-50).reverse(); + } + + private sanitize(input: AuditRecordInput): SanitizedAuditRecordInput { + return { + ...input, + before: this.toJsonValue(input.before), + after: this.toJsonValue(input.after), + parameterDigest: this.toJsonValue(input.parameterDigest), + }; + } + + private toJsonValue( + value: Record | undefined, + ): Prisma.InputJsonValue | undefined { + if (!value) { + return undefined; + } + + return JSON.parse( + JSON.stringify(redactSensitive(value)), + ) as Prisma.InputJsonValue; + } + + private pushMemoryRecord(input: SanitizedAuditRecordInput): void { this.memoryRecords.push({ ...input, - before: input.before ? redactSensitive(input.before) : undefined, - after: input.after ? redactSensitive(input.after) : undefined, - parameterDigest: input.parameterDigest - ? redactSensitive(input.parameterDigest) - : undefined, + id: `audit_${Date.now()}_${this.memoryRecords.length + 1}`, + before: input.before as Record | undefined, + after: input.after as Record | undefined, + parameterDigest: input.parameterDigest as + | Record + | undefined, createdAt: new Date().toISOString(), }); } - getRecentRecords(): AuditRecordSummary[] { - return [...this.memoryRecords].slice(-50).reverse(); + private toSummary(record: AuditLog): AuditRecordSummary { + return { + id: record.id, + action: record.action, + resourceType: record.resourceType, + resourceId: record.resourceId ?? undefined, + actorId: record.actorId ?? undefined, + actorName: record.actorName ?? undefined, + requestId: record.requestId ?? undefined, + sourceIp: record.sourceIp ?? undefined, + userAgent: record.userAgent ?? undefined, + before: this.jsonToRecord(record.before), + after: this.jsonToRecord(record.after), + parameterDigest: this.jsonToRecord(record.parameterDigest), + createdAt: record.createdAt.toISOString(), + }; + } + + private jsonToRecord( + value: Prisma.JsonValue, + ): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value; + } + + private databaseClient(): PrismaService | undefined { + if ( + !this.config?.get('USE_DATABASE_READS', { infer: true }) || + !this.config.get('DATABASE_URL', { infer: true }) + ) { + return undefined; + } + + return this.prisma; } } diff --git a/src/deploy-runs/deploy-execution.service.ts b/src/deploy-runs/deploy-execution.service.ts index 2d0c4e8..4e2ab2d 100644 --- a/src/deploy-runs/deploy-execution.service.ts +++ b/src/deploy-runs/deploy-execution.service.ts @@ -42,7 +42,7 @@ export class DeployExecutionService { ); let currentStep: string | undefined; - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_EXECUTION_STARTED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -95,7 +95,7 @@ export class DeployExecutionService { }), ); - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_SUCCEEDED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -132,7 +132,7 @@ export class DeployExecutionService { currentRun = await this.advanceNotification(currentRun, 'failed'); - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_FAILED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -197,7 +197,7 @@ export class DeployExecutionService { `Jenkins build #${queueItem.buildNumber} is running.`, ); - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_BUILD_STARTED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -268,7 +268,7 @@ export class DeployExecutionService { 'success', ); - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_DRY_RUN_COMPLETED', resourceType: 'deploy_run', resourceId: run.id, @@ -316,7 +316,7 @@ export class DeployExecutionService { : `Jenkins queue ${queueItem.queueId} accepted.`, ); - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_BUILD_TRIGGERED', resourceType: 'deploy_run', resourceId: run.id, @@ -352,7 +352,7 @@ export class DeployExecutionService { notification.status === 'sent' ? undefined : notification.reason, ); - this.auditService.record({ + await this.auditService.record({ action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, @@ -393,7 +393,7 @@ export class DeployExecutionService { }), ); - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_SUCCEEDED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -429,7 +429,7 @@ export class DeployExecutionService { })) ?? currentRun; currentRun = await this.advanceNotification(currentRun, 'failed'); - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_FAILED', resourceType: 'deploy_run', resourceId: currentRun.id, @@ -538,7 +538,7 @@ export class DeployExecutionService { const notification = await this.deployNotificationService.sendDeployRunEvent(run, event); - this.auditService.record({ + await this.auditService.record({ action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, diff --git a/src/deploy-runs/deploy-runs.service.ts b/src/deploy-runs/deploy-runs.service.ts index fe708ac..d04ac45 100644 --- a/src/deploy-runs/deploy-runs.service.ts +++ b/src/deploy-runs/deploy-runs.service.ts @@ -61,10 +61,10 @@ export class DeployRunsService { } const project = await this.projectsService.getProject(input.projectKey); - this.validateReleaseRequest(input, project); + await this.validateReleaseRequest(input, project); const run = await this.deployRunRepository.create(input); - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_REQUESTED', resourceType: 'deploy_run', resourceId: run.id, @@ -94,7 +94,7 @@ export class DeployRunsService { ); } - this.auditService.record({ + await this.auditService.record({ action: 'DEPLOY_CANCELED', resourceType: 'deploy_run', resourceId: run.id, @@ -150,7 +150,7 @@ export class DeployRunsService { } } - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_SYNC_SWEEP_COMPLETED', resourceType: 'deploy_run', after: { @@ -175,7 +175,7 @@ export class DeployRunsService { const notification = await this.deployNotificationService.sendDeployRunEvent(run, event); - this.auditService.record({ + await this.auditService.record({ action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`, resourceType: 'deploy_run', resourceId: run.id, @@ -194,12 +194,12 @@ export class DeployRunsService { ); } - private validateReleaseRequest( + private async validateReleaseRequest( input: CreateDeployRunInput, project: ProjectSummary, - ): ProjectEnvironmentSummary { + ): Promise { if (project.status !== 'active') { - this.recordDeployRejected(input, 'Project is archived'); + await this.recordDeployRejected(input, 'Project is archived'); throw new AppError( 'VALIDATION_FAILED', `Project ${project.key} is archived and cannot be deployed`, @@ -213,7 +213,7 @@ export class DeployRunsService { ); if (!environment) { - this.recordDeployRejected(input, 'Environment is not configured'); + await this.recordDeployRejected(input, 'Environment is not configured'); throw new AppError( 'VALIDATION_FAILED', `Project ${project.key} does not expose ${input.environment} environment`, @@ -223,7 +223,10 @@ export class DeployRunsService { } if (!new RegExp(environment.refPattern).test(input.ref)) { - this.recordDeployRejected(input, 'Ref does not match release policy'); + await this.recordDeployRejected( + input, + 'Ref does not match release policy', + ); throw new AppError( 'VALIDATION_FAILED', `Ref ${input.ref} does not match ${environment.releasePolicy} release policy`, @@ -241,11 +244,11 @@ export class DeployRunsService { return environment; } - private recordDeployRejected( + private async recordDeployRejected( input: CreateDeployRunInput, reason: string, - ): void { - this.auditService.record({ + ): Promise { + await this.auditService.record({ action: 'DEPLOY_REJECTED', resourceType: 'deploy_run', actorName: input.operator, diff --git a/src/deploy-runs/jenkins-sync-scheduler.service.ts b/src/deploy-runs/jenkins-sync-scheduler.service.ts index f9beb77..1f30bb6 100644 --- a/src/deploy-runs/jenkins-sync-scheduler.service.ts +++ b/src/deploy-runs/jenkins-sync-scheduler.service.ts @@ -54,7 +54,7 @@ export class JenkinsSyncSchedulerService try { const summary = await this.deployRunsService.syncJenkinsRuns(); - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED', resourceType: 'deploy_run', after: { @@ -67,7 +67,7 @@ export class JenkinsSyncSchedulerService const message = error instanceof Error ? error.message : 'Jenkins auto sync failed'; - this.auditService.record({ + await this.auditService.record({ action: 'JENKINS_AUTO_SYNC_TICK_FAILED', resourceType: 'deploy_run', after: { diff --git a/src/webhooks/gitea/gitea-webhook.service.ts b/src/webhooks/gitea/gitea-webhook.service.ts index d80c2ed..84539f7 100644 --- a/src/webhooks/gitea/gitea-webhook.service.ts +++ b/src/webhooks/gitea/gitea-webhook.service.ts @@ -32,20 +32,33 @@ export class GiteaWebhookService { this.assertSignature(input); if (input.headers.event !== 'push') { - return this.ignore(input.headers, 'Only push events can trigger test deploys'); + return this.ignore( + input.headers, + 'Only push events can trigger test deploys', + ); } const payload = this.asPushPayload(input.payload); if (!payload.ref.startsWith('refs/heads/')) { - return this.ignore(input.headers, 'Only branch push events trigger test deploys'); + return this.ignore( + input.headers, + 'Only branch push events trigger test deploys', + ); } const branch = payload.ref.replace('refs/heads/', ''); const projects = await this.projectsService.listProjects(); const project = projects.find((item) => { - const repositoryName = item.repositoryUrl.replace(/\.git$/, '').split('/').pop(); - const fullName = item.repositoryUrl.replace(/\.git$/, '').split('/').slice(-2).join('/'); + const repositoryName = item.repositoryUrl + .replace(/\.git$/, '') + .split('/') + .pop(); + const fullName = item.repositoryUrl + .replace(/\.git$/, '') + .split('/') + .slice(-2) + .join('/'); return ( item.key === payload.repository.name || @@ -55,10 +68,17 @@ export class GiteaWebhookService { }); if (!project) { - return this.ignore(input.headers, 'Repository is not managed by this platform'); + return this.ignore( + input.headers, + 'Repository is not managed by this platform', + ); } - const allowedBranches = new Set([project.defaultBranch, 'develop', 'master']); + const allowedBranches = new Set([ + project.defaultBranch, + 'develop', + 'master', + ]); if (!allowedBranches.has(branch)) { return this.ignore( @@ -87,7 +107,7 @@ export class GiteaWebhookService { trigger: 'gitea_webhook', }); - this.auditService.record({ + await this.auditService.record({ action: 'GITEA_WEBHOOK_ACCEPTED', resourceType: 'deploy_run', resourceId: run.id, @@ -124,32 +144,44 @@ export class GiteaWebhookService { hubSignature256: input.headers.hubSignature256, }) ) { - throw new AppError('VALIDATION_FAILED', 'Invalid Gitea webhook signature', 401); + throw new AppError( + 'VALIDATION_FAILED', + 'Invalid Gitea webhook signature', + 401, + ); } } private asPushPayload(payload: unknown): GiteaPushPayload { if (!isRecord(payload) || !isRecord(payload.repository)) { - throw new AppError('VALIDATION_FAILED', 'Invalid Gitea push payload', 400); + throw new AppError( + 'VALIDATION_FAILED', + 'Invalid Gitea push payload', + 400, + ); } const ref = typeof payload.ref === 'string' ? payload.ref : ''; const after = typeof payload.after === 'string' ? payload.after : ''; if (!ref || !after) { - throw new AppError('VALIDATION_FAILED', 'Gitea push payload missing ref or after', 400); + throw new AppError( + 'VALIDATION_FAILED', + 'Gitea push payload missing ref or after', + 400, + ); } return payload as GiteaPushPayload; } - private ignore( + private async ignore( headers: GiteaWebhookHeaders, reason: string, projectKey?: string, branch?: string, - ): GiteaWebhookResult { - this.auditService.record({ + ): Promise { + await this.auditService.record({ action: 'GITEA_WEBHOOK_IGNORED', resourceType: 'gitea_webhook', resourceId: headers.delivery,