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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 16:34:09 +02:00
parent 642c040116
commit 94b5db0f5b
2 changed files with 114 additions and 0 deletions

View File

@@ -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<UUID> removed = existing.allowedEnvironmentIds().stream()
.filter(e -> !draft.allowedEnvironmentIds().contains(e))
.toList();
if (!removed.isEmpty()) {
List<UUID> 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<UUID> 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<OutboundConnection> list() {
return repo.listByTenant(tenantId);
}
@Override
public List<UUID> 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);
}
});
}
}

View File

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