feat: 补齐 Agent 配置变更审计
This commit is contained in:
@@ -1,11 +1,24 @@
|
|||||||
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
|
import { AdminOnlyMessage } from '../auth/admin-only-message.decorator';
|
||||||
import { AuthGuard } from '../auth/auth.guard';
|
import { AuthGuard } from '../auth/auth.guard';
|
||||||
import { AuthenticatedUser } from '../auth/auth.types';
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
AuthRequestContext,
|
||||||
|
RequestWithUser,
|
||||||
|
} from '../auth/auth.types';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { PasswordChangeGuard } from '../auth/password-change.guard';
|
import { PasswordChangeGuard } from '../auth/password-change.guard';
|
||||||
import { SuperAdminGuard } from '../auth/super-admin.guard';
|
import { SuperAdminGuard } from '../auth/super-admin.guard';
|
||||||
|
import { resolveRequestId } from '../common/http/request-id';
|
||||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||||
import {
|
import {
|
||||||
SaveAgentConfigDto,
|
SaveAgentConfigDto,
|
||||||
@@ -37,11 +50,16 @@ export class AgentConfigController {
|
|||||||
@ApiBody({ type: SaveAgentConfigDto })
|
@ApiBody({ type: SaveAgentConfigDto })
|
||||||
@ApiOkResponse({ description: '保存 Agent 配置。' })
|
@ApiOkResponse({ description: '保存 Agent 配置。' })
|
||||||
async saveConfig(
|
async saveConfig(
|
||||||
|
@Req() request: RequestWithUser,
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@Body(new ZodValidationPipe(saveAgentConfigSchema))
|
@Body(new ZodValidationPipe(saveAgentConfigSchema))
|
||||||
body: SaveAgentConfigInput,
|
body: SaveAgentConfigInput,
|
||||||
): Promise<AgentConfigSummary> {
|
): Promise<AgentConfigSummary> {
|
||||||
return this.agentConfigService.saveConfig(user, body);
|
return this.agentConfigService.saveConfig(
|
||||||
|
user,
|
||||||
|
body,
|
||||||
|
this.auditContextFor(request),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
@@ -51,4 +69,14 @@ export class AgentConfigController {
|
|||||||
): Promise<AgentConnectionTestResult> {
|
): Promise<AgentConnectionTestResult> {
|
||||||
return this.agentConfigService.testConnection(user);
|
return this.agentConfigService.testConnection(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private auditContextFor(request: RequestWithUser): AuthRequestContext {
|
||||||
|
const userAgent = request.headers['user-agent'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId: resolveRequestId(request.headers),
|
||||||
|
sourceIp: request.ip ?? request.socket?.remoteAddress,
|
||||||
|
userAgent: Array.isArray(userAgent) ? userAgent[0] : userAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,26 @@ describe('AgentConfigService', () => {
|
|||||||
|
|
||||||
expect(audit.record).toHaveBeenCalledWith(
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
before: expect.objectContaining({
|
||||||
|
hasBaseURL: true,
|
||||||
|
hasKey: true,
|
||||||
|
model: 'gpt-test',
|
||||||
|
}),
|
||||||
after: expect.objectContaining({
|
after: expect.objectContaining({
|
||||||
|
hasBaseURL: true,
|
||||||
|
hasKey: true,
|
||||||
|
model: 'gpt-new',
|
||||||
changedFields: expect.arrayContaining(['key', 'model']),
|
changedFields: expect.arrayContaining(['key', 'model']),
|
||||||
}),
|
}),
|
||||||
|
parameterDigest: expect.objectContaining({
|
||||||
|
keyProvided: true,
|
||||||
|
baseURLProvided: true,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(JSON.stringify(audit.record.mock.calls)).not.toContain(
|
const auditPayload = JSON.stringify(audit.record.mock.calls);
|
||||||
'sk-new-abcdef123456',
|
|
||||||
);
|
expect(auditPayload).not.toContain('sk-new-abcdef123456');
|
||||||
|
expect(auditPayload).not.toContain('https://api.example.com/v1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AuthenticatedUser } from '../auth/auth.types';
|
import { AuthenticatedUser, AuthRequestContext } from '../auth/auth.types';
|
||||||
import { AppError } from '../common/errors/app-error';
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { redactSensitive } from '../common/security/redact-sensitive';
|
import { redactSensitive } from '../common/security/redact-sensitive';
|
||||||
import { SaveAgentConfigInput } from './agent-config.dto';
|
import { SaveAgentConfigInput } from './agent-config.dto';
|
||||||
@@ -29,26 +29,36 @@ export class AgentConfigService {
|
|||||||
async saveConfig(
|
async saveConfig(
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
input: SaveAgentConfigInput,
|
input: SaveAgentConfigInput,
|
||||||
|
context: AuthRequestContext = {},
|
||||||
): Promise<AgentConfigSummary> {
|
): Promise<AgentConfigSummary> {
|
||||||
this.assertSuperAdmin(user);
|
this.assertSuperAdmin(user);
|
||||||
const before = await this.agentConfigRepository.getRuntimeConfig();
|
const before = await this.agentConfigRepository.getRuntimeConfig();
|
||||||
const savedConfig = await this.agentConfigRepository.saveConfig(input);
|
const savedConfig = await this.agentConfigRepository.saveConfig(input);
|
||||||
|
const changedFields = this.changedFields(
|
||||||
|
before,
|
||||||
|
savedConfig,
|
||||||
|
Boolean(input.key),
|
||||||
|
);
|
||||||
|
|
||||||
await this.auditService.record({
|
await this.auditService.record({
|
||||||
action: 'AGENT_CONFIG_UPDATED',
|
action: 'AGENT_CONFIG_UPDATED',
|
||||||
resourceType: 'agent_config',
|
resourceType: 'agent_config',
|
||||||
|
resourceId: 'system-agent-config',
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
actorName: user.account,
|
actorName: user.account,
|
||||||
before: {
|
requestId: context.requestId,
|
||||||
baseURL: before.baseURL,
|
sourceIp: context.sourceIp,
|
||||||
model: before.model,
|
userAgent: context.userAgent,
|
||||||
hasKey: Boolean(before.key),
|
before: this.auditConfigSummary(before),
|
||||||
},
|
|
||||||
after: {
|
after: {
|
||||||
baseURL: savedConfig.baseURL,
|
...this.auditConfigSummary(savedConfig),
|
||||||
model: savedConfig.model,
|
changedFields,
|
||||||
hasKey: Boolean(savedConfig.key),
|
},
|
||||||
changedFields: this.changedFields(before, savedConfig, Boolean(input.key)),
|
parameterDigest: {
|
||||||
|
changedFields,
|
||||||
|
keyProvided: Boolean(input.key),
|
||||||
|
baseURLProvided: Boolean(input.baseURL),
|
||||||
|
model: input.model,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,6 +178,20 @@ export class AgentConfigService {
|
|||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 配置审计只记录状态和模型名,禁止把 key 或 baseURL 原文写入审计表。
|
||||||
|
*/
|
||||||
|
private auditConfigSummary(
|
||||||
|
config: Partial<AgentRuntimeConfig> & { updatedAt?: string },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
hasKey: Boolean(config.key),
|
||||||
|
hasBaseURL: Boolean(config.baseURL),
|
||||||
|
model: config.model,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private trimSlash(value: string): string {
|
private trimSlash(value: string): string {
|
||||||
return value.replace(/\/+$/, '');
|
return value.replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user