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