feat: 持久化运维审计日志
This commit is contained in:
+22
-10
@@ -42,11 +42,18 @@ export class AgentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInvocation(input: CreateAgentInvocationInput): Promise<AgentInvocation> {
|
async createInvocation(
|
||||||
|
input: CreateAgentInvocationInput,
|
||||||
|
): Promise<AgentInvocation> {
|
||||||
if (!allowedTypes.includes(input.type)) {
|
if (!allowedTypes.includes(input.type)) {
|
||||||
throw new AppError('VALIDATION_FAILED', 'Unsupported agent invocation type', 400, {
|
throw new AppError(
|
||||||
type: input.type,
|
'VALIDATION_FAILED',
|
||||||
});
|
'Unsupported agent invocation type',
|
||||||
|
400,
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const llmHealth = this.llmClient.healthSummary();
|
const llmHealth = this.llmClient.healthSummary();
|
||||||
@@ -74,7 +81,7 @@ export class AgentService {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
resultMarkdown: completion.text || mockResults[input.type],
|
resultMarkdown: completion.text || mockResults[input.type],
|
||||||
};
|
};
|
||||||
this.recordInvocationAudit(invocation, 'llm', completion.model);
|
await this.recordInvocationAudit(invocation, 'llm', completion.model);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
invocation = {
|
invocation = {
|
||||||
...baseInvocation,
|
...baseInvocation,
|
||||||
@@ -84,7 +91,7 @@ export class AgentService {
|
|||||||
? redactSensitive({ message: error.message }).message
|
? redactSensitive({ message: error.message }).message
|
||||||
: 'LLM invocation failed',
|
: 'LLM invocation failed',
|
||||||
};
|
};
|
||||||
this.recordInvocationAudit(invocation, 'llm_failed');
|
await this.recordInvocationAudit(invocation, 'llm_failed');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
invocation = {
|
invocation = {
|
||||||
@@ -92,25 +99,30 @@ export class AgentService {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
resultMarkdown: mockResults[input.type],
|
resultMarkdown: mockResults[input.type],
|
||||||
};
|
};
|
||||||
this.recordInvocationAudit(invocation, 'mock', undefined, llmHealth.message);
|
await this.recordInvocationAudit(
|
||||||
|
invocation,
|
||||||
|
'mock',
|
||||||
|
undefined,
|
||||||
|
llmHealth.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.invocations.unshift(invocation);
|
this.invocations.unshift(invocation);
|
||||||
return invocation;
|
return invocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordInvocationAudit(
|
private async recordInvocationAudit(
|
||||||
invocation: AgentInvocation,
|
invocation: AgentInvocation,
|
||||||
mode: 'mock' | 'llm' | 'llm_failed',
|
mode: 'mock' | 'llm' | 'llm_failed',
|
||||||
model?: string,
|
model?: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
): void {
|
): Promise<void> {
|
||||||
const action =
|
const action =
|
||||||
invocation.status === 'failed'
|
invocation.status === 'failed'
|
||||||
? 'AGENT_INVOCATION_FAILED'
|
? 'AGENT_INVOCATION_FAILED'
|
||||||
: 'AGENT_INVOCATION_CREATED';
|
: 'AGENT_INVOCATION_CREATED';
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action,
|
action,
|
||||||
resourceType: 'agent_invocation',
|
resourceType: 'agent_invocation',
|
||||||
resourceId: invocation.id,
|
resourceId: invocation.id,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 审计日志查询入口只返回脱敏后的最近记录。
|
||||||
|
*/
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuditRecordSummary, AuditService } from './audit.service';
|
import { AuditRecordSummary, AuditService } from './audit.service';
|
||||||
@@ -9,7 +12,7 @@ export class AuditController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ description: 'Recent sanitized audit records.' })
|
@ApiOkResponse({ description: 'Recent sanitized audit records.' })
|
||||||
listRecentRecords(): AuditRecordSummary[] {
|
async listRecentRecords(): Promise<AuditRecordSummary[]> {
|
||||||
return this.auditService.getRecentRecords();
|
return this.auditService.getRecentRecords();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { AuditService } from './audit.service';
|
|||||||
* 审计记录必须先脱敏再暴露给前端或 Agent。
|
* 审计记录必须先脱敏再暴露给前端或 Agent。
|
||||||
*/
|
*/
|
||||||
describe('AuditService', () => {
|
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();
|
const service = new AuditService();
|
||||||
|
|
||||||
service.record({
|
await service.record({
|
||||||
action: 'SECRET_CHANGED',
|
action: 'SECRET_CHANGED',
|
||||||
resourceType: 'secret',
|
resourceType: 'secret',
|
||||||
parameterDigest: {
|
parameterDigest: {
|
||||||
@@ -16,7 +16,7 @@ describe('AuditService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [record] = service.getRecentRecords();
|
const [record] = await service.getRecentRecords();
|
||||||
|
|
||||||
expect(record.action).toBe('SECRET_CHANGED');
|
expect(record.action).toBe('SECRET_CHANGED');
|
||||||
expect(record.parameterDigest).toEqual({
|
expect(record.parameterDigest).toEqual({
|
||||||
|
|||||||
+141
-9
@@ -1,5 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 审计服务统一完成敏感信息脱敏、MySQL 持久化和本地降级存储。
|
||||||
|
* 业务服务只提交动作摘要,禁止把 token/webhook/连接串原文写入审计。
|
||||||
|
*/
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuditLog, Prisma } from '@prisma/client';
|
||||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
|
import { EnvConfig } from '../config/env.schema';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
export type AuditRecordInput = {
|
export type AuditRecordInput = {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -7,30 +15,154 @@ export type AuditRecordInput = {
|
|||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
actorName?: string;
|
actorName?: string;
|
||||||
|
requestId?: string;
|
||||||
|
sourceIp?: string;
|
||||||
|
userAgent?: string;
|
||||||
before?: Record<string, unknown>;
|
before?: Record<string, unknown>;
|
||||||
after?: Record<string, unknown>;
|
after?: Record<string, unknown>;
|
||||||
parameterDigest?: 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()
|
@Injectable()
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
private readonly memoryRecords: AuditRecordSummary[] = [];
|
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({
|
this.memoryRecords.push({
|
||||||
...input,
|
...input,
|
||||||
before: input.before ? redactSensitive(input.before) : undefined,
|
id: `audit_${Date.now()}_${this.memoryRecords.length + 1}`,
|
||||||
after: input.after ? redactSensitive(input.after) : undefined,
|
before: input.before as Record<string, unknown> | undefined,
|
||||||
parameterDigest: input.parameterDigest
|
after: input.after as Record<string, unknown> | undefined,
|
||||||
? redactSensitive(input.parameterDigest)
|
parameterDigest: input.parameterDigest as
|
||||||
: undefined,
|
| Record<string, unknown>
|
||||||
|
| undefined,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getRecentRecords(): AuditRecordSummary[] {
|
private toSummary(record: AuditLog): AuditRecordSummary {
|
||||||
return [...this.memoryRecords].slice(-50).reverse();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class DeployExecutionService {
|
|||||||
);
|
);
|
||||||
let currentStep: string | undefined;
|
let currentStep: string | undefined;
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_EXECUTION_STARTED',
|
action: 'DEPLOY_EXECUTION_STARTED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -95,7 +95,7 @@ export class DeployExecutionService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_SUCCEEDED',
|
action: 'DEPLOY_SUCCEEDED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -132,7 +132,7 @@ export class DeployExecutionService {
|
|||||||
|
|
||||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_FAILED',
|
action: 'DEPLOY_FAILED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -197,7 +197,7 @@ export class DeployExecutionService {
|
|||||||
`Jenkins build #${queueItem.buildNumber} is running.`,
|
`Jenkins build #${queueItem.buildNumber} is running.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_BUILD_STARTED',
|
action: 'JENKINS_BUILD_STARTED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -268,7 +268,7 @@ export class DeployExecutionService {
|
|||||||
'success',
|
'success',
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_DRY_RUN_COMPLETED',
|
action: 'JENKINS_DRY_RUN_COMPLETED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -316,7 +316,7 @@ export class DeployExecutionService {
|
|||||||
: `Jenkins queue ${queueItem.queueId} accepted.`,
|
: `Jenkins queue ${queueItem.queueId} accepted.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_BUILD_TRIGGERED',
|
action: 'JENKINS_BUILD_TRIGGERED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -352,7 +352,7 @@ export class DeployExecutionService {
|
|||||||
notification.status === 'sent' ? undefined : notification.reason,
|
notification.status === 'sent' ? undefined : notification.reason,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -393,7 +393,7 @@ export class DeployExecutionService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_SUCCEEDED',
|
action: 'DEPLOY_SUCCEEDED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -429,7 +429,7 @@ export class DeployExecutionService {
|
|||||||
})) ?? currentRun;
|
})) ?? currentRun;
|
||||||
currentRun = await this.advanceNotification(currentRun, 'failed');
|
currentRun = await this.advanceNotification(currentRun, 'failed');
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_FAILED',
|
action: 'DEPLOY_FAILED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: currentRun.id,
|
resourceId: currentRun.id,
|
||||||
@@ -538,7 +538,7 @@ export class DeployExecutionService {
|
|||||||
const notification =
|
const notification =
|
||||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ export class DeployRunsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await this.projectsService.getProject(input.projectKey);
|
const project = await this.projectsService.getProject(input.projectKey);
|
||||||
this.validateReleaseRequest(input, project);
|
await this.validateReleaseRequest(input, project);
|
||||||
const run = await this.deployRunRepository.create(input);
|
const run = await this.deployRunRepository.create(input);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_REQUESTED',
|
action: 'DEPLOY_REQUESTED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -94,7 +94,7 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_CANCELED',
|
action: 'DEPLOY_CANCELED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -150,7 +150,7 @@ export class DeployRunsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
|
action: 'JENKINS_SYNC_SWEEP_COMPLETED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
after: {
|
after: {
|
||||||
@@ -175,7 +175,7 @@ export class DeployRunsService {
|
|||||||
const notification =
|
const notification =
|
||||||
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
await this.deployNotificationService.sendDeployRunEvent(run, event);
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -194,12 +194,12 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateReleaseRequest(
|
private async validateReleaseRequest(
|
||||||
input: CreateDeployRunInput,
|
input: CreateDeployRunInput,
|
||||||
project: ProjectSummary,
|
project: ProjectSummary,
|
||||||
): ProjectEnvironmentSummary {
|
): Promise<ProjectEnvironmentSummary> {
|
||||||
if (project.status !== 'active') {
|
if (project.status !== 'active') {
|
||||||
this.recordDeployRejected(input, 'Project is archived');
|
await this.recordDeployRejected(input, 'Project is archived');
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} is archived and cannot be deployed`,
|
`Project ${project.key} is archived and cannot be deployed`,
|
||||||
@@ -213,7 +213,7 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
this.recordDeployRejected(input, 'Environment is not configured');
|
await this.recordDeployRejected(input, 'Environment is not configured');
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Project ${project.key} does not expose ${input.environment} environment`,
|
`Project ${project.key} does not expose ${input.environment} environment`,
|
||||||
@@ -223,7 +223,10 @@ export class DeployRunsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
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(
|
throw new AppError(
|
||||||
'VALIDATION_FAILED',
|
'VALIDATION_FAILED',
|
||||||
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
||||||
@@ -241,11 +244,11 @@ export class DeployRunsService {
|
|||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordDeployRejected(
|
private async recordDeployRejected(
|
||||||
input: CreateDeployRunInput,
|
input: CreateDeployRunInput,
|
||||||
reason: string,
|
reason: string,
|
||||||
): void {
|
): Promise<void> {
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'DEPLOY_REJECTED',
|
action: 'DEPLOY_REJECTED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
actorName: input.operator,
|
actorName: input.operator,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class JenkinsSyncSchedulerService
|
|||||||
try {
|
try {
|
||||||
const summary = await this.deployRunsService.syncJenkinsRuns();
|
const summary = await this.deployRunsService.syncJenkinsRuns();
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
|
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
after: {
|
after: {
|
||||||
@@ -67,7 +67,7 @@ export class JenkinsSyncSchedulerService
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Jenkins auto sync failed';
|
error instanceof Error ? error.message : 'Jenkins auto sync failed';
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
|
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
after: {
|
after: {
|
||||||
|
|||||||
@@ -32,20 +32,33 @@ export class GiteaWebhookService {
|
|||||||
this.assertSignature(input);
|
this.assertSignature(input);
|
||||||
|
|
||||||
if (input.headers.event !== 'push') {
|
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);
|
const payload = this.asPushPayload(input.payload);
|
||||||
|
|
||||||
if (!payload.ref.startsWith('refs/heads/')) {
|
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 branch = payload.ref.replace('refs/heads/', '');
|
||||||
const projects = await this.projectsService.listProjects();
|
const projects = await this.projectsService.listProjects();
|
||||||
const project = projects.find((item) => {
|
const project = projects.find((item) => {
|
||||||
const repositoryName = item.repositoryUrl.replace(/\.git$/, '').split('/').pop();
|
const repositoryName = item.repositoryUrl
|
||||||
const fullName = item.repositoryUrl.replace(/\.git$/, '').split('/').slice(-2).join('/');
|
.replace(/\.git$/, '')
|
||||||
|
.split('/')
|
||||||
|
.pop();
|
||||||
|
const fullName = item.repositoryUrl
|
||||||
|
.replace(/\.git$/, '')
|
||||||
|
.split('/')
|
||||||
|
.slice(-2)
|
||||||
|
.join('/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
item.key === payload.repository.name ||
|
item.key === payload.repository.name ||
|
||||||
@@ -55,10 +68,17 @@ export class GiteaWebhookService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
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)) {
|
if (!allowedBranches.has(branch)) {
|
||||||
return this.ignore(
|
return this.ignore(
|
||||||
@@ -87,7 +107,7 @@ export class GiteaWebhookService {
|
|||||||
trigger: 'gitea_webhook',
|
trigger: 'gitea_webhook',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'GITEA_WEBHOOK_ACCEPTED',
|
action: 'GITEA_WEBHOOK_ACCEPTED',
|
||||||
resourceType: 'deploy_run',
|
resourceType: 'deploy_run',
|
||||||
resourceId: run.id,
|
resourceId: run.id,
|
||||||
@@ -124,32 +144,44 @@ export class GiteaWebhookService {
|
|||||||
hubSignature256: input.headers.hubSignature256,
|
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 {
|
private asPushPayload(payload: unknown): GiteaPushPayload {
|
||||||
if (!isRecord(payload) || !isRecord(payload.repository)) {
|
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 ref = typeof payload.ref === 'string' ? payload.ref : '';
|
||||||
const after = typeof payload.after === 'string' ? payload.after : '';
|
const after = typeof payload.after === 'string' ? payload.after : '';
|
||||||
|
|
||||||
if (!ref || !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;
|
return payload as GiteaPushPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ignore(
|
private async ignore(
|
||||||
headers: GiteaWebhookHeaders,
|
headers: GiteaWebhookHeaders,
|
||||||
reason: string,
|
reason: string,
|
||||||
projectKey?: string,
|
projectKey?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
): GiteaWebhookResult {
|
): Promise<GiteaWebhookResult> {
|
||||||
this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'GITEA_WEBHOOK_IGNORED',
|
action: 'GITEA_WEBHOOK_IGNORED',
|
||||||
resourceType: 'gitea_webhook',
|
resourceType: 'gitea_webhook',
|
||||||
resourceId: headers.delivery,
|
resourceId: headers.delivery,
|
||||||
|
|||||||
Reference in New Issue
Block a user