From ea4c56e7f653aeedf60e4df818eb038ed7246218 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:43:48 +0200 Subject: [PATCH] feat(outbound): admin CRUD REST + RBAC + audit New audit categories: OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE. Controller-level @PreAuthorize defaults to ADMIN; GETs relaxed to ADMIN|OPERATOR. SecurityConfig permits OPERATOR GETs on /api/v1/admin/outbound-connections/**. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OutboundConnectionAdminController.java | 117 +++++++++++++++ .../server/app/security/SecurityConfig.java | 5 +- .../OutboundConnectionAdminControllerIT.java | 135 ++++++++++++++++++ .../server/core/admin/AuditCategory.java | 3 +- 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java new file mode 100644 index 00000000..b2fb64f1 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java @@ -0,0 +1,117 @@ +package com.cameleer.server.app.outbound.controller; + +import com.cameleer.server.app.outbound.crypto.SecretCipher; +import com.cameleer.server.app.outbound.dto.OutboundConnectionDto; +import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest; +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.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +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.DeleteMapping; +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.PutMapping; +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; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/outbound-connections") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Outbound Connections Admin", description = "Admin-managed outbound HTTPS destinations") +public class OutboundConnectionAdminController { + + private final OutboundConnectionService service; + private final SecretCipher cipher; + private final AuditService audit; + + public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, AuditService audit) { + this.service = service; + this.cipher = cipher; + this.audit = audit; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public List list() { + return service.list().stream().map(OutboundConnectionDto::from).toList(); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public OutboundConnectionDto get(@PathVariable UUID id) { + return OutboundConnectionDto.from(service.get(id)); + } + + @GetMapping("/{id}/usage") + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public List usage(@PathVariable UUID id) { + return service.rulesReferencing(id); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody OutboundConnectionRequest req, + HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection draft = toDraft(req); + OutboundConnection saved = service.create(draft, userId); + audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved)); + } + + @PutMapping("/{id}") + public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req, + HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection draft = toDraft(req); + OutboundConnection saved = service.update(id, draft, userId); + audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + return OutboundConnectionDto.from(saved); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id, HttpServletRequest httpRequest) { + String userId = currentUserId(); + service.delete(id, userId); + audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.noContent().build(); + } + + private OutboundConnection toDraft(OutboundConnectionRequest req) { + String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank()) + ? null : cipher.encrypt(req.hmacSecret()); + // tenantId, id, timestamps, createdBy/updatedBy are filled by the service layer. + return new OutboundConnection( + null, null, req.name(), req.description(), + req.url(), req.method(), req.defaultHeaders(), req.defaultBodyTmpl(), + req.tlsTrustMode(), req.tlsCaPemPaths(), + cipherSecret, req.auth(), req.allowedEnvironmentIds(), + null, null, null, null); + } + + 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; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index 8dfb164c..65f8a7b6 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -161,7 +161,10 @@ public class SecurityConfig { // Runtime management (OPERATOR+) — legacy flat shape .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") - // Admin endpoints + // Outbound connections: list/get allow OPERATOR (method-level @PreAuthorize gates mutations) + .requestMatchers(HttpMethod.GET, "/api/v1/admin/outbound-connections", "/api/v1/admin/outbound-connections/**").hasAnyRole("OPERATOR", "ADMIN") + + // Admin endpoints (catch-all) .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // Everything else requires authentication diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java new file mode 100644 index 00000000..2a7a4381 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -0,0 +1,135 @@ +package com.cameleer.server.app.outbound.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.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + + private String adminJwt; + private String operatorJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + operatorJwt = securityHelper.operatorToken(); + viewerJwt = securityHelper.viewerToken(); + // Seed user rows matching the JWT subjects (users(user_id) is a FK target) + seedUser("test-admin"); + seedUser("test-operator"); + seedUser("test-viewer"); + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + } + + private void seedUser(String userId) { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + userId, userId + "@example.com", userId); + } + + private static final String CREATE_BODY = """ + {"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + + @Test + void adminCanCreate() throws Exception { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.path("name").asText()).isEqualTo("slack-ops"); + assertThat(body.path("hmacSecretSet").asBoolean()).isFalse(); + assertThat(body.path("id").asText()).isNotBlank(); + } + + @Test + void operatorCannotCreate() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void operatorCanList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCannotList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void nonHttpsUrlRejected() { + String body = """ + {"name":"bad","url":"http://x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void duplicateNameReturns409() { + restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + ResponseEntity dup = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void deleteRemoves() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity del = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + ResponseEntity get = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java index 968ee093..f76dff35 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java @@ -1,5 +1,6 @@ package com.cameleer.server.core.admin; public enum AuditCategory { - INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT + INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, + OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE }