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