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 new file mode 100644 index 00000000..da8fe21a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -0,0 +1,26 @@ +package com.cameleer.server.app.outbound.config; + +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.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +@Configuration +public class OutboundBeanConfig { + + @Bean + public OutboundConnectionRepository outboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) { + return new PostgresOutboundConnectionRepository(jdbc, mapper); + } + + @Bean + public SecretCipher secretCipher( + @Value("${cameleer.server.security.jwtsecret:dev-default-jwt-secret-do-not-use-in-production}") + String jwtSecret) { + return new SecretCipher(jwtSecret); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java new file mode 100644 index 00000000..02955bde --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java @@ -0,0 +1,147 @@ +package com.cameleer.server.app.outbound.storage; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundAuthKind; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundMethod; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Array; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository { + + private final JdbcTemplate jdbc; + private final ObjectMapper mapper; + + public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public OutboundConnection save(OutboundConnection c) { + boolean exists = findById(c.tenantId(), c.id()).isPresent(); + if (exists) { + jdbc.update(""" + UPDATE outbound_connections + SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum, + default_headers = ?::jsonb, default_body_tmpl = ?, + tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb, + hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum, + auth_config = ?::jsonb, allowed_environment_ids = ?, + updated_at = now(), updated_by = ? + WHERE tenant_id = ? AND id = ?""", + c.name(), c.description(), c.url(), c.method().name(), + writeJson(c.defaultHeaders()), c.defaultBodyTmpl(), + c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()), + c.hmacSecretCiphertext(), c.auth().kind().name(), + writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()), + c.updatedBy(), c.tenantId(), c.id()); + } else { + jdbc.update(""" + INSERT INTO outbound_connections ( + id, tenant_id, name, description, url, method, + default_headers, default_body_tmpl, + tls_trust_mode, tls_ca_pem_paths, + hmac_secret_ciphertext, auth_kind, auth_config, + allowed_environment_ids, created_by, updated_by) + VALUES (?,?,?,?,?,?::outbound_method_enum, + ?::jsonb,?, + ?::trust_mode_enum,?::jsonb, + ?,?::outbound_auth_kind_enum,?::jsonb, + ?,?,?)""", + c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(), + writeJson(c.defaultHeaders()), c.defaultBodyTmpl(), + c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()), + c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()), + toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy()); + } + return findById(c.tenantId(), c.id()).orElseThrow(); + } + + @Override + public Optional findById(String tenantId, UUID id) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND id = ?", + rowMapper, tenantId, id).stream().findFirst(); + } + + @Override + public Optional findByName(String tenantId, String name) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND name = ?", + rowMapper, tenantId, name).stream().findFirst(); + } + + @Override + public List listByTenant(String tenantId) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? ORDER BY name", + rowMapper, tenantId); + } + + @Override + public void delete(String tenantId, UUID id) { + jdbc.update("DELETE FROM outbound_connections WHERE tenant_id = ? AND id = ?", tenantId, id); + } + + private final RowMapper rowMapper = (rs, i) -> new OutboundConnection( + rs.getObject("id", UUID.class), rs.getString("tenant_id"), + rs.getString("name"), rs.getString("description"), + rs.getString("url"), OutboundMethod.valueOf(rs.getString("method")), + readMapString(rs.getString("default_headers")), rs.getString("default_body_tmpl"), + TrustMode.valueOf(rs.getString("tls_trust_mode")), + readListString(rs.getString("tls_ca_pem_paths")), + rs.getString("hmac_secret_ciphertext"), + readAuth(OutboundAuthKind.valueOf(rs.getString("auth_kind")), rs.getString("auth_config")), + readUuidArray(rs.getArray("allowed_environment_ids")), + rs.getTimestamp("created_at").toInstant(), rs.getString("created_by"), + rs.getTimestamp("updated_at").toInstant(), rs.getString("updated_by")); + + private String writeJson(Object v) { + try { return mapper.writeValueAsString(v); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private Map readMapString(String json) { + try { return mapper.readValue(json, new TypeReference<>() {}); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private List readListString(String json) { + try { return mapper.readValue(json, new TypeReference<>() {}); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private OutboundAuth readAuth(OutboundAuthKind kind, String cfg) { + try { + return switch (kind) { + case NONE -> new OutboundAuth.None(); + case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class); + case BASIC -> mapper.readValue(cfg, OutboundAuth.Basic.class); + }; + } catch (Exception e) { throw new IllegalStateException(e); } + } + + private Array toUuidArray(List ids) { + return jdbc.execute((ConnectionCallback) conn -> + conn.createArrayOf("uuid", ids.toArray())); + } + + private List readUuidArray(Array arr) throws SQLException { + if (arr == null) return List.of(); + Object[] raw = (Object[]) arr.getArray(); + List out = new ArrayList<>(raw.length); + for (Object o : raw) out.add((UUID) o); + return out; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java new file mode 100644 index 00000000..ce762734 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java @@ -0,0 +1,111 @@ +package com.cameleer.server.app.outbound.storage; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundMethod; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT { + + @Autowired + OutboundConnectionRepository repo; + + private static final String TENANT = "default"; + private static final String USER = "test-alice"; + + @BeforeEach + void seedUser() { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO NOTHING", + USER, "test", "alice@example.com", "Alice"); + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = ?", TENANT); + } + + private OutboundConnection draft(String name) { + return new OutboundConnection( + UUID.randomUUID(), TENANT, name, "desc", + "https://hooks.slack.com/services/T/B/X", OutboundMethod.POST, + Map.of("Content-Type", "application/json"), null, + TrustMode.SYSTEM_DEFAULT, List.of(), + null, new OutboundAuth.None(), List.of(), + Instant.now(), USER, Instant.now(), USER); + } + + @Test + void saveAndRead() { + OutboundConnection c = draft("slack-ops"); + repo.save(c); + OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow(); + assertThat(loaded.name()).isEqualTo("slack-ops"); + assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json"); + assertThat(loaded.method()).isEqualTo(OutboundMethod.POST); + assertThat(loaded.tlsTrustMode()).isEqualTo(TrustMode.SYSTEM_DEFAULT); + assertThat(loaded.auth()).isInstanceOf(OutboundAuth.None.class); + } + + @Test + void uniqueNamePerTenant() { + OutboundConnection a = draft("slack-ops"); + repo.save(a); + OutboundConnection b = draft("slack-ops"); + assertThatThrownBy(() -> repo.save(b)) + .isInstanceOf(org.springframework.dao.DuplicateKeyException.class); + } + + @Test + void allowedEnvironmentIdsRoundTrip() { + UUID env1 = UUID.randomUUID(); + UUID env2 = UUID.randomUUID(); + OutboundConnection c = new OutboundConnection( + UUID.randomUUID(), TENANT, "multi-env", null, + "https://example.com", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), + null, new OutboundAuth.None(), List.of(env1, env2), + Instant.now(), USER, Instant.now(), USER); + repo.save(c); + OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow(); + assertThat(loaded.allowedEnvironmentIds()).containsExactly(env1, env2); + } + + @Test + void listByTenantOnlyReturnsCurrentTenant() { + repo.save(draft("in-tenant")); + // The outbound_connections.tenant_id is a plain varchar, so we can insert for a different tenant + // without schema changes. Insert another tenant via the repo directly: + UUID otherId = UUID.randomUUID(); + OutboundConnection other = new OutboundConnection( + otherId, "other-tenant", "other-tenant-conn", null, + "https://example.com", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), + null, new OutboundAuth.None(), List.of(), + Instant.now(), USER, Instant.now(), USER); + repo.save(other); + try { + List list = repo.listByTenant(TENANT); + assertThat(list).extracting(OutboundConnection::name).containsExactly("in-tenant"); + } finally { + repo.delete("other-tenant", otherId); + } + } + + @Test + void deleteRemovesRow() { + OutboundConnection c = draft("to-delete"); + repo.save(c); + repo.delete(TENANT, c.id()); + assertThat(repo.findById(TENANT, c.id())).isEmpty(); + } +} diff --git a/cameleer-server-app/src/test/resources/application-test.yml b/cameleer-server-app/src/test/resources/application-test.yml index 82d07651..ce02814a 100644 --- a/cameleer-server-app/src/test/resources/application-test.yml +++ b/cameleer-server-app/src/test/resources/application-test.yml @@ -16,3 +16,4 @@ cameleer: bootstraptoken: test-bootstrap-token bootstraptokenprevious: old-bootstrap-token infrastructureendpoints: true + jwtsecret: test-jwt-secret-for-integration-tests-only