feat: SOC2 audit log completeness — hybrid interceptor + explicit calls
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 54s
CI / docker (push) Successful in 51s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

Add AuditInterceptor as a safety net that auto-audits any POST/PUT/DELETE
without an explicit audit call (excludes data ingestion + heartbeat).
AuditService sets a request attribute so the interceptor skips when
explicit logging already happened.

New explicit audit calls:
- ApplicationConfigController: view/update app config
- AgentCommandController: send/broadcast commands (AGENT category)
- AgentRegistrationController: agent register + token refresh
- UiAuthController: UI token refresh
- OidcAuthController: OIDC callback failure
- AuditLogController: view audit log (sensitive read)
- UserAdminController: view users (sensitive read)
- OidcConfigAdminController: view OIDC config (sensitive read)

New AuditCategory.AGENT added. Frontend audit log filter updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 16:41:10 +01:00
parent 0e6de69cd9
commit 0d94132c98
13 changed files with 152 additions and 18 deletions

View File

@@ -1,5 +1,6 @@
package com.cameleer3.server.app.config;
import com.cameleer3.server.app.interceptor.AuditInterceptor;
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@@ -7,17 +8,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC configuration.
* <p>
* Registers the {@link ProtocolVersionInterceptor} on data and agent endpoint paths,
* excluding health, API docs, and Swagger UI paths that do not require protocol versioning.
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ProtocolVersionInterceptor protocolVersionInterceptor;
private final AuditInterceptor auditInterceptor;
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) {
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
AuditInterceptor auditInterceptor) {
this.protocolVersionInterceptor = protocolVersionInterceptor;
this.auditInterceptor = auditInterceptor;
}
@Override
@@ -33,5 +34,14 @@ public class WebConfig implements WebMvcConfigurer {
"/api/v1/agents/register",
"/api/v1/agents/*/refresh"
);
// Safety-net audit: catches any unaudited POST/PUT/DELETE
registry.addInterceptor(auditInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns(
"/api/v1/data/**",
"/api/v1/agents/*/heartbeat",
"/api/v1/health"
);
}
}

View File

@@ -5,6 +5,9 @@ import com.cameleer3.server.app.dto.CommandAckRequest;
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
import com.cameleer3.server.app.dto.CommandRequest;
import com.cameleer3.server.app.dto.CommandSingleResponse;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.agent.AgentCommand;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentInfo;
@@ -13,6 +16,7 @@ import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -51,15 +55,18 @@ public class AgentCommandController {
private final SseConnectionManager connectionManager;
private final ObjectMapper objectMapper;
private final AgentEventService agentEventService;
private final AuditService auditService;
public AgentCommandController(AgentRegistryService registryService,
SseConnectionManager connectionManager,
ObjectMapper objectMapper,
AgentEventService agentEventService) {
AgentEventService agentEventService,
AuditService auditService) {
this.registryService = registryService;
this.connectionManager = connectionManager;
this.objectMapper = objectMapper;
this.agentEventService = agentEventService;
this.auditService = auditService;
}
@PostMapping("/{id}/commands")
@@ -69,7 +76,8 @@ public class AgentCommandController {
@ApiResponse(responseCode = "400", description = "Invalid command payload")
@ApiResponse(responseCode = "404", description = "Agent not registered")
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
@RequestBody CommandRequest request) throws JsonProcessingException {
@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
AgentInfo agent = registryService.findById(id);
if (agent == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
@@ -81,6 +89,10 @@ public class AgentCommandController {
String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING";
auditService.log("send_agent_command", AuditCategory.AGENT, id,
java.util.Map.of("type", request.type(), "status", status),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new CommandSingleResponse(command.id(), status));
}
@@ -91,7 +103,8 @@ public class AgentCommandController {
@ApiResponse(responseCode = "202", description = "Commands accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
@RequestBody CommandRequest request) throws JsonProcessingException {
@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
@@ -106,6 +119,10 @@ public class AgentCommandController {
commandIds.add(command.id());
}
auditService.log("broadcast_group_command", AuditCategory.AGENT, group,
java.util.Map.of("type", request.type(), "agentCount", agents.size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new CommandBroadcastResponse(commandIds, agents.size()));
}
@@ -115,7 +132,8 @@ public class AgentCommandController {
description = "Sends a command to all agents currently in LIVE state")
@ApiResponse(responseCode = "202", description = "Commands accepted")
@ApiResponse(responseCode = "400", description = "Invalid command payload")
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request) throws JsonProcessingException {
public ResponseEntity<CommandBroadcastResponse> broadcastCommand(@RequestBody CommandRequest request,
HttpServletRequest httpRequest) throws JsonProcessingException {
CommandType type = mapCommandType(request.type());
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
@@ -127,6 +145,10 @@ public class AgentCommandController {
commandIds.add(command.id());
}
auditService.log("broadcast_all_command", AuditCategory.AGENT, null,
java.util.Map.of("type", request.type(), "agentCount", liveAgents.size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
}

View File

@@ -8,6 +8,9 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.security.BootstrapTokenValidator;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
@@ -58,6 +61,7 @@ public class AgentRegistrationController {
private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService;
private final AgentEventService agentEventService;
private final AuditService auditService;
private final JdbcTemplate jdbc;
public AgentRegistrationController(AgentRegistryService registryService,
@@ -66,6 +70,7 @@ public class AgentRegistrationController {
JwtService jwtService,
Ed25519SigningService ed25519SigningService,
AgentEventService agentEventService,
AuditService auditService,
JdbcTemplate jdbc) {
this.registryService = registryService;
this.config = config;
@@ -73,6 +78,7 @@ public class AgentRegistrationController {
this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService;
this.agentEventService = agentEventService;
this.auditService = auditService;
this.jdbc = jdbc;
}
@@ -113,6 +119,10 @@ public class AgentRegistrationController {
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
"Agent registered: " + request.name());
auditService.log(request.agentId(), "agent_register", AuditCategory.AGENT, request.agentId(),
Map.of("application", application, "name", request.name()),
AuditResult.SUCCESS, httpRequest);
// Issue JWT tokens with AGENT role
List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
@@ -135,7 +145,8 @@ public class AgentRegistrationController {
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
@ApiResponse(responseCode = "404", description = "Agent not found")
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
@RequestBody AgentRefreshRequest request) {
@RequestBody AgentRefreshRequest request,
HttpServletRequest httpRequest) {
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
return ResponseEntity.status(401).build();
}
@@ -169,6 +180,9 @@ public class AgentRegistrationController {
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
auditService.log(agentId, "agent_token_refresh", AuditCategory.AUTH, agentId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
}

View File

@@ -2,6 +2,9 @@ package com.cameleer3.server.app.controller;
import com.cameleer3.common.model.ApplicationConfig;
import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import com.cameleer3.server.core.agent.AgentCommand;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
@@ -12,6 +15,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -35,20 +39,24 @@ public class ApplicationConfigController {
private final PostgresApplicationConfigRepository configRepository;
private final AgentRegistryService registryService;
private final ObjectMapper objectMapper;
private final AuditService auditService;
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
AgentRegistryService registryService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
AuditService auditService) {
this.configRepository = configRepository;
this.registryService = registryService;
this.objectMapper = objectMapper;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all application configs",
description = "Returns stored configurations for all applications")
@ApiResponse(responseCode = "200", description = "Configs returned")
public ResponseEntity<List<ApplicationConfig>> listConfigs() {
public ResponseEntity<List<ApplicationConfig>> listConfigs(HttpServletRequest httpRequest) {
auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(configRepository.findAll());
}
@@ -56,7 +64,9 @@ public class ApplicationConfigController {
@Operation(summary = "Get application config",
description = "Returns the current configuration for an application. Returns defaults if none stored.")
@ApiResponse(responseCode = "200", description = "Config returned")
public ResponseEntity<ApplicationConfig> getConfig(@PathVariable String application) {
public ResponseEntity<ApplicationConfig> getConfig(@PathVariable String application,
HttpServletRequest httpRequest) {
auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(
configRepository.findByApplication(application)
.orElse(defaultConfig(application)));
@@ -68,7 +78,8 @@ public class ApplicationConfigController {
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
public ResponseEntity<ApplicationConfig> updateConfig(@PathVariable String application,
@RequestBody ApplicationConfig config,
Authentication auth) {
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(application);
@@ -77,6 +88,10 @@ public class ApplicationConfigController {
int pushed = pushConfigToAgents(application, saved);
log.info("Config v{} saved for '{}', pushed to {} agent(s)", saved.getVersion(), application, pushed);
auditService.log("update_app_config", AuditCategory.CONFIG, application,
Map.of("version", saved.getVersion(), "agentsPushed", pushed),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(saved);
}

View File

@@ -5,8 +5,11 @@ import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditRepository;
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
import com.cameleer3.server.core.admin.AuditRepository.AuditQuery;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -26,14 +29,17 @@ import java.time.ZoneOffset;
public class AuditLogController {
private final AuditRepository auditRepository;
private final AuditService auditService;
public AuditLogController(AuditRepository auditRepository) {
public AuditLogController(AuditRepository auditRepository, AuditService auditService) {
this.auditRepository = auditRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "Search audit log entries with pagination")
public ResponseEntity<AuditLogPageResponse> getAuditLog(
HttpServletRequest httpRequest,
@RequestParam(required = false) String username,
@RequestParam(required = false) String category,
@RequestParam(required = false) String search,
@@ -58,6 +64,8 @@ public class AuditLogController {
}
}
auditService.log("view_audit_log", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
AuditPage result = auditRepository.find(query);

View File

@@ -61,7 +61,8 @@ public class OidcConfigAdminController {
@GetMapping
@Operation(summary = "Get OIDC configuration")
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
public ResponseEntity<OidcAdminConfigResponse> getConfig() {
public ResponseEntity<OidcAdminConfigResponse> getConfig(HttpServletRequest httpRequest) {
auditService.log("view_oidc_config", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest);
Optional<OidcConfig> config = configRepository.find();
if (config.isEmpty()) {
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());

View File

@@ -58,7 +58,8 @@ public class UserAdminController {
@GetMapping
@Operation(summary = "List all users with RBAC detail")
@ApiResponse(responseCode = "200", description = "User list returned")
public ResponseEntity<List<UserDetail>> listUsers() {
public ResponseEntity<List<UserDetail>> listUsers(HttpServletRequest httpRequest) {
auditService.log("view_users", AuditCategory.USER_MGMT, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(rbacService.listUsers());
}

View File

@@ -0,0 +1,53 @@
package com.cameleer3.server.app.interceptor;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Map;
import java.util.Set;
/**
* Safety-net audit interceptor that logs a basic entry for any state-changing
* request (POST/PUT/DELETE) that was not explicitly audited by the controller.
* <p>
* Controllers that call {@link AuditService#log} set the {@code audit.logged}
* request attribute, which this interceptor checks to avoid double-recording.
*/
@Component
public class AuditInterceptor implements HandlerInterceptor {
private static final Set<String> AUDITABLE_METHODS = Set.of("POST", "PUT", "DELETE");
private final AuditService auditService;
public AuditInterceptor(AuditService auditService) {
this.auditService = auditService;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
if (!AUDITABLE_METHODS.contains(request.getMethod())) {
return;
}
if (Boolean.TRUE.equals(request.getAttribute("audit.logged"))) {
return;
}
String path = request.getRequestURI();
AuditResult result = response.getStatus() < 400 ? AuditResult.SUCCESS : AuditResult.FAILURE;
auditService.log(
"HTTP " + request.getMethod() + " " + path,
AuditCategory.INFRA,
path,
Map.of("status", response.getStatus()),
result,
request);
}
}

View File

@@ -159,6 +159,9 @@ public class OidcAuthController {
throw e;
} catch (Exception e) {
log.error("OIDC callback failed: {}", e.getMessage(), e);
auditService.log("unknown", "login_oidc", AuditCategory.AUTH, null,
Map.of("reason", e.getMessage() != null ? e.getMessage() : "unknown"),
AuditResult.FAILURE, httpRequest);
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"OIDC authentication failed: " + e.getMessage());
}

View File

@@ -123,7 +123,8 @@ public class UiAuthController {
@ApiResponse(responseCode = "200", description = "Token refreshed")
@ApiResponse(responseCode = "401", description = "Invalid refresh token",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request) {
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request,
HttpServletRequest httpRequest) {
try {
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
if (!result.subject().startsWith("user:")) {
@@ -138,6 +139,7 @@ public class UiAuthController {
String displayName = userRepository.findById(result.subject())
.map(UserInfo::displayName)
.orElse(result.subject());
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
} catch (ResponseStatusException e) {
throw e;

View File

@@ -1,5 +1,5 @@
package com.cameleer3.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT
}

View File

@@ -34,6 +34,10 @@ public class AuditService {
repository.insert(record);
if (request != null) {
request.setAttribute("audit.logged", true);
}
log.info("AUDIT: user={} action={} category={} target={} result={}",
username, action, category, target, result);
}

View File

@@ -13,6 +13,7 @@ const CATEGORIES = [
{ value: 'USER_MGMT', label: 'USER_MGMT' },
{ value: 'CONFIG', label: 'CONFIG' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'AGENT', label: 'AGENT' },
];
function formatTimestamp(iso: string): string {