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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 16:43:48 +02:00
parent a3c35c7df9
commit ea4c56e7f6
4 changed files with 258 additions and 2 deletions

View File

@@ -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<OutboundConnectionDto> 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<UUID> usage(@PathVariable UUID id) {
return service.rulesReferencing(id);
}
@PostMapping
public ResponseEntity<OutboundConnectionDto> 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<Void> 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;
}
}

View File

@@ -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

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
}
}

View File

@@ -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
}