feat: 持久化运维审计日志

This commit is contained in:
湛兮
2026-06-11 22:57:05 +08:00
parent 2181fc7389
commit 588caa4209
8 changed files with 243 additions and 61 deletions
+21 -9
View File
@@ -42,11 +42,18 @@ export class AgentService {
);
}
async createInvocation(input: CreateAgentInvocationInput): Promise<AgentInvocation> {
async createInvocation(
input: CreateAgentInvocationInput,
): Promise<AgentInvocation> {
if (!allowedTypes.includes(input.type)) {
throw new AppError('VALIDATION_FAILED', 'Unsupported agent invocation type', 400, {
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<void> {
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,
+4 -1
View File
@@ -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<AuditRecordSummary[]> {
return this.auditService.getRecentRecords();
}
}
+3 -3
View File
@@ -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({
+141 -9
View File
@@ -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<string, unknown>;
after?: Record<string, unknown>;
parameterDigest?: Record<string, unknown>;
};
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<EnvConfig, true>,
private readonly prisma?: PrismaService,
) {}
async record(input: AuditRecordInput): Promise<void> {
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<AuditRecordSummary[]> {
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<string, unknown> | 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<string, unknown> | undefined,
after: input.after as Record<string, unknown> | undefined,
parameterDigest: input.parameterDigest as
| Record<string, unknown>
| 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<string, unknown> | 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;
}
}
+10 -10
View File
@@ -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,
+16 -13
View File
@@ -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<ProjectEnvironmentSummary> {
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<void> {
await this.auditService.record({
action: 'DEPLOY_REJECTED',
resourceType: 'deploy_run',
actorName: input.operator,
@@ -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: {
+45 -13
View File
@@ -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<GiteaWebhookResult> {
await this.auditService.record({
action: 'GITEA_WEBHOOK_IGNORED',
resourceType: 'gitea_webhook',
resourceId: headers.delivery,