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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user