feat: SOC2 audit log completeness — hybrid interceptor + explicit calls
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:
@@ -1,5 +1,6 @@
|
|||||||
package com.cameleer3.server.app.config;
|
package com.cameleer3.server.app.config;
|
||||||
|
|
||||||
|
import com.cameleer3.server.app.interceptor.AuditInterceptor;
|
||||||
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
import com.cameleer3.server.app.interceptor.ProtocolVersionInterceptor;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
@@ -7,17 +8,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Web MVC configuration.
|
* 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
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||||
|
private final AuditInterceptor auditInterceptor;
|
||||||
|
|
||||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor) {
|
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||||
|
AuditInterceptor auditInterceptor) {
|
||||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||||
|
this.auditInterceptor = auditInterceptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -33,5 +34,14 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
"/api/v1/agents/register",
|
"/api/v1/agents/register",
|
||||||
"/api/v1/agents/*/refresh"
|
"/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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import com.cameleer3.server.app.dto.CommandAckRequest;
|
|||||||
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
|
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
|
||||||
import com.cameleer3.server.app.dto.CommandRequest;
|
import com.cameleer3.server.app.dto.CommandRequest;
|
||||||
import com.cameleer3.server.app.dto.CommandSingleResponse;
|
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.AgentCommand;
|
||||||
import com.cameleer3.server.core.agent.AgentEventService;
|
import com.cameleer3.server.core.agent.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
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.cameleer3.server.core.agent.CommandType;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -51,15 +55,18 @@ public class AgentCommandController {
|
|||||||
private final SseConnectionManager connectionManager;
|
private final SseConnectionManager connectionManager;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AgentEventService agentEventService;
|
private final AgentEventService agentEventService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public AgentCommandController(AgentRegistryService registryService,
|
public AgentCommandController(AgentRegistryService registryService,
|
||||||
SseConnectionManager connectionManager,
|
SseConnectionManager connectionManager,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
AgentEventService agentEventService) {
|
AgentEventService agentEventService,
|
||||||
|
AuditService auditService) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.connectionManager = connectionManager;
|
this.connectionManager = connectionManager;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.agentEventService = agentEventService;
|
this.agentEventService = agentEventService;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/commands")
|
@PostMapping("/{id}/commands")
|
||||||
@@ -69,7 +76,8 @@ public class AgentCommandController {
|
|||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||||
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
|
public ResponseEntity<CommandSingleResponse> sendCommand(@PathVariable String id,
|
||||||
@RequestBody CommandRequest request) throws JsonProcessingException {
|
@RequestBody CommandRequest request,
|
||||||
|
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||||
AgentInfo agent = registryService.findById(id);
|
AgentInfo agent = registryService.findById(id);
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||||
@@ -81,6 +89,10 @@ public class AgentCommandController {
|
|||||||
|
|
||||||
String status = connectionManager.isConnected(id) ? "DELIVERED" : "PENDING";
|
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)
|
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||||
.body(new CommandSingleResponse(command.id(), status));
|
.body(new CommandSingleResponse(command.id(), status));
|
||||||
}
|
}
|
||||||
@@ -91,7 +103,8 @@ public class AgentCommandController {
|
|||||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
||||||
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
|
public ResponseEntity<CommandBroadcastResponse> sendGroupCommand(@PathVariable String group,
|
||||||
@RequestBody CommandRequest request) throws JsonProcessingException {
|
@RequestBody CommandRequest request,
|
||||||
|
HttpServletRequest httpRequest) throws JsonProcessingException {
|
||||||
CommandType type = mapCommandType(request.type());
|
CommandType type = mapCommandType(request.type());
|
||||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||||
|
|
||||||
@@ -106,6 +119,10 @@ public class AgentCommandController {
|
|||||||
commandIds.add(command.id());
|
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)
|
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||||
.body(new CommandBroadcastResponse(commandIds, agents.size()));
|
.body(new CommandBroadcastResponse(commandIds, agents.size()));
|
||||||
}
|
}
|
||||||
@@ -115,7 +132,8 @@ public class AgentCommandController {
|
|||||||
description = "Sends a command to all agents currently in LIVE state")
|
description = "Sends a command to all agents currently in LIVE state")
|
||||||
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
@ApiResponse(responseCode = "202", description = "Commands accepted")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid command payload")
|
@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());
|
CommandType type = mapCommandType(request.type());
|
||||||
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
|
||||||
|
|
||||||
@@ -127,6 +145,10 @@ public class AgentCommandController {
|
|||||||
commandIds.add(command.id());
|
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)
|
return ResponseEntity.status(HttpStatus.ACCEPTED)
|
||||||
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
|
.body(new CommandBroadcastResponse(commandIds, liveAgents.size()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
|
|||||||
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
import com.cameleer3.server.app.dto.AgentRegistrationResponse;
|
||||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||||
import com.cameleer3.server.app.security.BootstrapTokenValidator;
|
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.AgentEventService;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
@@ -58,6 +61,7 @@ public class AgentRegistrationController {
|
|||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final Ed25519SigningService ed25519SigningService;
|
private final Ed25519SigningService ed25519SigningService;
|
||||||
private final AgentEventService agentEventService;
|
private final AgentEventService agentEventService;
|
||||||
|
private final AuditService auditService;
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
public AgentRegistrationController(AgentRegistryService registryService,
|
public AgentRegistrationController(AgentRegistryService registryService,
|
||||||
@@ -66,6 +70,7 @@ public class AgentRegistrationController {
|
|||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
Ed25519SigningService ed25519SigningService,
|
Ed25519SigningService ed25519SigningService,
|
||||||
AgentEventService agentEventService,
|
AgentEventService agentEventService,
|
||||||
|
AuditService auditService,
|
||||||
JdbcTemplate jdbc) {
|
JdbcTemplate jdbc) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -73,6 +78,7 @@ public class AgentRegistrationController {
|
|||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.ed25519SigningService = ed25519SigningService;
|
this.ed25519SigningService = ed25519SigningService;
|
||||||
this.agentEventService = agentEventService;
|
this.agentEventService = agentEventService;
|
||||||
|
this.auditService = auditService;
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +119,10 @@ public class AgentRegistrationController {
|
|||||||
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||||
"Agent registered: " + request.name());
|
"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
|
// Issue JWT tokens with AGENT role
|
||||||
List<String> roles = List.of("AGENT");
|
List<String> roles = List.of("AGENT");
|
||||||
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
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 = "401", description = "Invalid or expired refresh token")
|
||||||
@ApiResponse(responseCode = "404", description = "Agent not found")
|
@ApiResponse(responseCode = "404", description = "Agent not found")
|
||||||
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
|
public ResponseEntity<AgentRefreshResponse> refresh(@PathVariable String id,
|
||||||
@RequestBody AgentRefreshRequest request) {
|
@RequestBody AgentRefreshRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
|
if (request.refreshToken() == null || request.refreshToken().isBlank()) {
|
||||||
return ResponseEntity.status(401).build();
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
@@ -169,6 +180,9 @@ public class AgentRegistrationController {
|
|||||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
||||||
String newRefreshToken = jwtService.createRefreshToken(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));
|
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.cameleer3.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer3.common.model.ApplicationConfig;
|
import com.cameleer3.common.model.ApplicationConfig;
|
||||||
import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository;
|
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.AgentCommand;
|
||||||
import com.cameleer3.server.core.agent.AgentInfo;
|
import com.cameleer3.server.core.agent.AgentInfo;
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -35,20 +39,24 @@ public class ApplicationConfigController {
|
|||||||
private final PostgresApplicationConfigRepository configRepository;
|
private final PostgresApplicationConfigRepository configRepository;
|
||||||
private final AgentRegistryService registryService;
|
private final AgentRegistryService registryService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
|
public ApplicationConfigController(PostgresApplicationConfigRepository configRepository,
|
||||||
AgentRegistryService registryService,
|
AgentRegistryService registryService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
AuditService auditService) {
|
||||||
this.configRepository = configRepository;
|
this.configRepository = configRepository;
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all application configs",
|
@Operation(summary = "List all application configs",
|
||||||
description = "Returns stored configurations for all applications")
|
description = "Returns stored configurations for all applications")
|
||||||
@ApiResponse(responseCode = "200", description = "Configs returned")
|
@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());
|
return ResponseEntity.ok(configRepository.findAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +64,9 @@ public class ApplicationConfigController {
|
|||||||
@Operation(summary = "Get application config",
|
@Operation(summary = "Get application config",
|
||||||
description = "Returns the current configuration for an application. Returns defaults if none stored.")
|
description = "Returns the current configuration for an application. Returns defaults if none stored.")
|
||||||
@ApiResponse(responseCode = "200", description = "Config returned")
|
@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(
|
return ResponseEntity.ok(
|
||||||
configRepository.findByApplication(application)
|
configRepository.findByApplication(application)
|
||||||
.orElse(defaultConfig(application)));
|
.orElse(defaultConfig(application)));
|
||||||
@@ -68,7 +78,8 @@ public class ApplicationConfigController {
|
|||||||
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||||
public ResponseEntity<ApplicationConfig> updateConfig(@PathVariable String application,
|
public ResponseEntity<ApplicationConfig> updateConfig(@PathVariable String application,
|
||||||
@RequestBody ApplicationConfig config,
|
@RequestBody ApplicationConfig config,
|
||||||
Authentication auth) {
|
Authentication auth,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
String updatedBy = auth != null ? auth.getName() : "system";
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
|
||||||
config.setApplication(application);
|
config.setApplication(application);
|
||||||
@@ -77,6 +88,10 @@ public class ApplicationConfigController {
|
|||||||
int pushed = pushConfigToAgents(application, saved);
|
int pushed = pushConfigToAgents(application, saved);
|
||||||
log.info("Config v{} saved for '{}', pushed to {} agent(s)", saved.getVersion(), application, pushed);
|
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);
|
return ResponseEntity.ok(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
|
import com.cameleer3.server.core.admin.AuditRepository.AuditPage;
|
||||||
import com.cameleer3.server.core.admin.AuditRepository.AuditQuery;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -26,14 +29,17 @@ import java.time.ZoneOffset;
|
|||||||
public class AuditLogController {
|
public class AuditLogController {
|
||||||
|
|
||||||
private final AuditRepository auditRepository;
|
private final AuditRepository auditRepository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public AuditLogController(AuditRepository auditRepository) {
|
public AuditLogController(AuditRepository auditRepository, AuditService auditService) {
|
||||||
this.auditRepository = auditRepository;
|
this.auditRepository = auditRepository;
|
||||||
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Search audit log entries with pagination")
|
@Operation(summary = "Search audit log entries with pagination")
|
||||||
public ResponseEntity<AuditLogPageResponse> getAuditLog(
|
public ResponseEntity<AuditLogPageResponse> getAuditLog(
|
||||||
|
HttpServletRequest httpRequest,
|
||||||
@RequestParam(required = false) String username,
|
@RequestParam(required = false) String username,
|
||||||
@RequestParam(required = false) String category,
|
@RequestParam(required = false) String category,
|
||||||
@RequestParam(required = false) String search,
|
@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);
|
AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size);
|
||||||
AuditPage result = auditRepository.find(query);
|
AuditPage result = auditRepository.find(query);
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ public class OidcConfigAdminController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Get OIDC configuration")
|
@Operation(summary = "Get OIDC configuration")
|
||||||
@ApiResponse(responseCode = "200", description = "Current OIDC configuration (client_secret masked)")
|
@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();
|
Optional<OidcConfig> config = configRepository.find();
|
||||||
if (config.isEmpty()) {
|
if (config.isEmpty()) {
|
||||||
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
|
return ResponseEntity.ok(OidcAdminConfigResponse.unconfigured());
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ public class UserAdminController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List all users with RBAC detail")
|
@Operation(summary = "List all users with RBAC detail")
|
||||||
@ApiResponse(responseCode = "200", description = "User list returned")
|
@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());
|
return ResponseEntity.ok(rbacService.listUsers());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,9 @@ public class OidcAuthController {
|
|||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("OIDC callback failed: {}", e.getMessage(), 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,
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
|
||||||
"OIDC authentication failed: " + e.getMessage());
|
"OIDC authentication failed: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ public class UiAuthController {
|
|||||||
@ApiResponse(responseCode = "200", description = "Token refreshed")
|
@ApiResponse(responseCode = "200", description = "Token refreshed")
|
||||||
@ApiResponse(responseCode = "401", description = "Invalid refresh token",
|
@ApiResponse(responseCode = "401", description = "Invalid refresh token",
|
||||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request) {
|
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
try {
|
try {
|
||||||
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
|
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
|
||||||
if (!result.subject().startsWith("user:")) {
|
if (!result.subject().startsWith("user:")) {
|
||||||
@@ -138,6 +139,7 @@ public class UiAuthController {
|
|||||||
String displayName = userRepository.findById(result.subject())
|
String displayName = userRepository.findById(result.subject())
|
||||||
.map(UserInfo::displayName)
|
.map(UserInfo::displayName)
|
||||||
.orElse(result.subject());
|
.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));
|
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
|
||||||
} catch (ResponseStatusException e) {
|
} catch (ResponseStatusException e) {
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.cameleer3.server.core.admin;
|
package com.cameleer3.server.core.admin;
|
||||||
|
|
||||||
public enum AuditCategory {
|
public enum AuditCategory {
|
||||||
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
|
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public class AuditService {
|
|||||||
|
|
||||||
repository.insert(record);
|
repository.insert(record);
|
||||||
|
|
||||||
|
if (request != null) {
|
||||||
|
request.setAttribute("audit.logged", true);
|
||||||
|
}
|
||||||
|
|
||||||
log.info("AUDIT: user={} action={} category={} target={} result={}",
|
log.info("AUDIT: user={} action={} category={} target={} result={}",
|
||||||
username, action, category, target, result);
|
username, action, category, target, result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const CATEGORIES = [
|
|||||||
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||||
{ value: 'CONFIG', label: 'CONFIG' },
|
{ value: 'CONFIG', label: 'CONFIG' },
|
||||||
{ value: 'RBAC', label: 'RBAC' },
|
{ value: 'RBAC', label: 'RBAC' },
|
||||||
|
{ value: 'AGENT', label: 'AGENT' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatTimestamp(iso: string): string {
|
function formatTimestamp(iso: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user