From 94b5db0f5ba076f93101f768ec1186a1d38211cd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:34:09 +0200 Subject: [PATCH] feat(outbound): service with uniqueness + narrow-envs + delete-if-referenced guards rulesReferencing() is stubbed; wired to AlertRuleRepository in Plan 02. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OutboundConnectionServiceImpl.java | 105 ++++++++++++++++++ .../outbound/config/OutboundBeanConfig.java | 9 ++ 2 files changed, 114 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java new file mode 100644 index 00000000..6ce204c2 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -0,0 +1,105 @@ +package com.cameleer.server.app.outbound; + +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundConnectionService; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public class OutboundConnectionServiceImpl implements OutboundConnectionService { + + private final OutboundConnectionRepository repo; + private final String tenantId; + + public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, String tenantId) { + this.repo = repo; + this.tenantId = tenantId; + } + + @Override + public OutboundConnection create(OutboundConnection draft, String actingUserId) { + assertNameUnique(draft.name(), null); + OutboundConnection c = new OutboundConnection( + UUID.randomUUID(), tenantId, draft.name(), draft.description(), + draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(), + draft.tlsTrustMode(), draft.tlsCaPemPaths(), + draft.hmacSecretCiphertext(), + draft.auth(), draft.allowedEnvironmentIds(), + Instant.now(), actingUserId, Instant.now(), actingUserId); + return repo.save(c); + } + + @Override + public OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId) { + OutboundConnection existing = repo.findById(tenantId, id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!existing.name().equals(draft.name())) { + assertNameUnique(draft.name(), id); + } + + // Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs, + // find any envs that existed before but are absent in the draft. + // Skip entirely if either side is empty (empty = "allowed in all envs"). + if (!existing.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().isEmpty()) { + List removed = existing.allowedEnvironmentIds().stream() + .filter(e -> !draft.allowedEnvironmentIds().contains(e)) + .toList(); + if (!removed.isEmpty()) { + List refs = rulesReferencing(id); // Plan 01 stub + if (!refs.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Narrowing allowed environments while rules still reference this connection in removed envs: " + refs); + } + } + } + + OutboundConnection updated = new OutboundConnection( + id, tenantId, draft.name(), draft.description(), draft.url(), draft.method(), + draft.defaultHeaders(), draft.defaultBodyTmpl(), + draft.tlsTrustMode(), draft.tlsCaPemPaths(), + // Retain existing secret if the draft omitted one (null = leave unchanged). + draft.hmacSecretCiphertext() != null ? draft.hmacSecretCiphertext() : existing.hmacSecretCiphertext(), + draft.auth(), draft.allowedEnvironmentIds(), + existing.createdAt(), existing.createdBy(), Instant.now(), actingUserId); + return repo.save(updated); + } + + @Override + public void delete(UUID id, String actingUserId) { + List refs = rulesReferencing(id); // Plan 01 stub + if (!refs.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Outbound connection is referenced by rules: " + refs); + } + repo.delete(tenantId, id); + } + + @Override + public OutboundConnection get(UUID id) { + return repo.findById(tenantId, id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } + + @Override + public List list() { + return repo.listByTenant(tenantId); + } + + @Override + public List rulesReferencing(UUID id) { + // Plan 01 stub. Plan 02 will wire this to AlertRuleRepository. + return List.of(); + } + + private void assertNameUnique(String name, UUID excludingId) { + repo.findByName(tenantId, name).ifPresent(c -> { + if (!c.id().equals(excludingId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Connection name already exists: " + name); + } + }); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java index da8fe21a..a4e9d8c8 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -1,8 +1,10 @@ package com.cameleer.server.app.outbound.config; +import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl; import com.cameleer.server.app.outbound.crypto.SecretCipher; import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository; import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundConnectionService; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -23,4 +25,11 @@ public class OutboundBeanConfig { String jwtSecret) { return new SecretCipher(jwtSecret); } + + @Bean + public OutboundConnectionService outboundConnectionService( + OutboundConnectionRepository repo, + @Value("${cameleer.server.tenant.id:default}") String tenantId) { + return new OutboundConnectionServiceImpl(repo, tenantId); + } }