feat(audit): audit deploy/stop/promote with DEPLOYMENT category

Wires AuditService and AppVersionRepository into DeploymentController.
Replaces null createdBy placeholder with currentUserId() on createDeployment/promote.
Adds audit log entries (SUCCESS + FAILURE) for deploy_app, stop_deployment,
and promote_deployment actions. Fixes FK violations in affected ITs by
seeding the test-operator and alice users into the users table before deploy calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 12:24:27 +02:00
parent 9043dc00b0
commit 47d5611462
7 changed files with 330 additions and 8 deletions

View File

@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.runtime.DeploymentExecutor;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.Environment;
@@ -12,14 +17,18 @@ import com.cameleer.server.core.runtime.RuntimeOrchestrator;
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.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@@ -42,17 +51,23 @@ public class DeploymentController {
private final RuntimeOrchestrator orchestrator;
private final AppService appService;
private final EnvironmentService environmentService;
private final AuditService auditService;
private final AppVersionRepository appVersionRepository;
public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator,
AppService appService,
EnvironmentService environmentService) {
EnvironmentService environmentService,
AuditService auditService,
AppVersionRepository appVersionRepository) {
this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator;
this.appService = appService;
this.environmentService = environmentService;
this.auditService = auditService;
this.appVersionRepository = appVersionRepository;
}
@GetMapping
@@ -86,13 +101,25 @@ public class DeploymentController {
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody DeployRequest request) {
@RequestBody DeployRequest request,
HttpServletRequest httpRequest) {
try {
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), null);
AppVersion appVersion = appVersionRepository.findById(request.appVersionId())
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + request.appVersionId()));
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), currentUserId());
deploymentExecutor.executeAsync(deployment);
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, deployment.id().toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug(),
"appVersionId", request.appVersionId().toString(),
"jarFilename", appVersion.jarFilename() != null ? appVersion.jarFilename() : "",
"version", appVersion.version()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) {
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, null,
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.notFound().build();
}
}
@@ -103,12 +130,19 @@ public class DeploymentController {
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId) {
@PathVariable UUID deploymentId,
HttpServletRequest httpRequest) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.notFound().build();
}
}
@@ -122,18 +156,25 @@ public class DeploymentController {
public ResponseEntity<?> promote(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
@RequestBody PromoteRequest request,
HttpServletRequest httpRequest) {
try {
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment source = deploymentService.getById(deploymentId);
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
// Target must also have the app with the same slug
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), null);
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), currentUserId());
deploymentExecutor.executeAsync(promoted);
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, promoted.id().toString(),
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
"appSlug", appSlug, "appVersionId", source.appVersionId().toString()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("appSlug", appSlug, "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
}
}
@@ -157,6 +198,15 @@ public class DeploymentController {
}
}
private String currentUserId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
}
String name = auth.getName();
return name.startsWith("user:") ? name.substring(5) : name;
}
public record DeployRequest(UUID appVersionId) {}
public record PromoteRequest(String targetEnvironment) {}
}

View File

@@ -65,6 +65,10 @@ class AppDirtyStateIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
}
// -----------------------------------------------------------------------

View File

@@ -0,0 +1,252 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DeploymentControllerAuditIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String aliceJwt;
private String adminJwt;
private String appSlug;
private String versionId;
@BeforeEach
void setUp() throws Exception {
// Mint JWT for alice (OPERATOR) — subject must start with "user:" for JwtAuthenticationFilter
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
adminJwt = securityHelper.adminToken();
// Clean up deployment-related tables
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM audit_log");
// Ensure alice exists in the users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('alice', 'local', 'Alice Test') ON CONFLICT (user_id) DO NOTHING");
// Create app in the seeded "default" environment
appSlug = "audit-test-" + UUID.randomUUID().toString().substring(0, 8);
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App"}
""", appSlug);
ResponseEntity<String> appResponse = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(appResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Upload a JAR version
byte[] jarContent = "fake-jar-for-audit-test".getBytes();
ByteArrayResource resource = new ByteArrayResource(jarContent) {
@Override
public String getFilename() {
return "audit-test.jar";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + aliceJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> versionResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
assertThat(versionResponse.getStatusCode().is2xxSuccessful()).isTrue();
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
}
@Test
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
String json = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void stop_writes_audit_row() throws Exception {
// First deploy
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate stop audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Stop the deployment
ResponseEntity<String> stopResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/stop",
HttpMethod.POST,
new HttpEntity<>(authHeadersNoBody(aliceJwt)),
String.class);
assertThat(stopResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> row = queryAuditRow("stop_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("stop_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target").toString()).isEqualTo(deploymentId);
}
@Test
void promote_writes_audit_row() throws Exception {
// Create a second environment for promotion target
String targetEnvSlug = "promote-target-" + UUID.randomUUID().toString().substring(0, 8);
String envJson = String.format("""
{"slug": "%s", "displayName": "Promote Target Env"}
""", targetEnvSlug);
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(envJson, authHeaders(adminJwt)),
String.class);
assertThat(envResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Create the same app slug in the target environment
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App (target)"}
""", appSlug);
ResponseEntity<String> targetAppResponse = restTemplate.exchange(
"/api/v1/environments/" + targetEnvSlug + "/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(targetAppResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Deploy in source (default) env
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate promote audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Promote to target env
String promoteJson = String.format("""
{"targetEnvironment": "%s"}
""", targetEnvSlug);
ResponseEntity<String> promoteResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/promote",
HttpMethod.POST,
new HttpEntity<>(promoteJson, authHeaders(aliceJwt)),
String.class);
assertThat(promoteResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("promote_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("promote_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void deploy_with_unknown_appVersion_writes_FAILURE_audit_row() throws Exception {
String unknownVersionId = UUID.randomUUID().toString();
String json = String.format("""
{"appVersionId": "%s"}
""", unknownVersionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("FAILURE");
}
// ---- helpers ----
private HttpHeaders authHeaders(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private HttpHeaders authHeadersNoBody(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
/** Query the most recent audit_log row for the given action. Returns null if not found. */
private Map<String, Object> queryAuditRow(String action) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT username, action, category, target, result FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
action);
return rows.isEmpty() ? null : rows.get(0);
}
}

View File

@@ -48,6 +48,10 @@ class DeploymentControllerIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Get default environment ID
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,

View File

@@ -62,6 +62,10 @@ class BlueGreenStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);

View File

@@ -69,6 +69,10 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
}
// -----------------------------------------------------------------------

View File

@@ -65,6 +65,10 @@ class RollingStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);