From 116038262ab0a4257a925f9bc69936af8120bcb8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:33:39 +0200 Subject: [PATCH 01/22] feat(outbound): V11 flyway migration for outbound_connections table --- .../migration/V11__outbound_connections.sql | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql diff --git a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql new file mode 100644 index 00000000..102695f9 --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql @@ -0,0 +1,30 @@ +-- V11 — Outbound connections (admin-managed HTTPS destinations) +-- See: docs/superpowers/specs/2026-04-19-alerting-design.md §6 + +CREATE TYPE trust_mode_enum AS ENUM ('SYSTEM_DEFAULT','TRUST_ALL','TRUST_PATHS'); +CREATE TYPE outbound_method_enum AS ENUM ('POST','PUT','PATCH'); +CREATE TYPE outbound_auth_kind_enum AS ENUM ('NONE','BEARER','BASIC'); + +CREATE TABLE outbound_connections ( + id uuid PRIMARY KEY, + tenant_id varchar(64) NOT NULL, + name varchar(100) NOT NULL, + description text, + url text NOT NULL, + method outbound_method_enum NOT NULL, + default_headers jsonb NOT NULL DEFAULT '{}', + default_body_tmpl text, + tls_trust_mode trust_mode_enum NOT NULL DEFAULT 'SYSTEM_DEFAULT', + tls_ca_pem_paths jsonb NOT NULL DEFAULT '[]', + hmac_secret_ciphertext text, + auth_kind outbound_auth_kind_enum NOT NULL DEFAULT 'NONE', + auth_config jsonb NOT NULL DEFAULT '{}', + allowed_environment_ids uuid[] NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid NOT NULL REFERENCES users(id), + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by uuid NOT NULL REFERENCES users(id), + CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name) +); + +CREATE INDEX outbound_connections_tenant_idx ON outbound_connections (tenant_id); -- 2.49.1 From ffdfd6cd9aa24bd15824184db3142ae810d24791 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:37:35 +0200 Subject: [PATCH 02/22] feat(outbound): add HTTPS CHECK constraint on outbound_connections.url Defense-in-depth per code review. DTO layer already validates HTTPS at save time; this DB-level check guards against future code paths that might bypass the DTO validator. Mustache template variables in the URL (e.g., {{env.slug}}) remain valid since only the scheme prefix is constrained. --- .../main/resources/db/migration/V11__outbound_connections.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql index 102695f9..aa9576b1 100644 --- a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql +++ b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql @@ -10,7 +10,7 @@ CREATE TABLE outbound_connections ( tenant_id varchar(64) NOT NULL, name varchar(100) NOT NULL, description text, - url text NOT NULL, + url text NOT NULL CHECK (url ~ '^https://'), method outbound_method_enum NOT NULL, default_headers jsonb NOT NULL DEFAULT '{}', default_body_tmpl text, -- 2.49.1 From 2224f7d90220e09bc8281e2a648591309ea7ce93 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:39:57 +0200 Subject: [PATCH 03/22] feat(http): core outbound HTTP interfaces and property records --- cameleer-server-core/pom.xml | 4 ++++ .../core/http/OutboundHttpClientFactory.java | 8 ++++++++ .../core/http/OutboundHttpProperties.java | 20 +++++++++++++++++++ .../core/http/OutboundHttpRequestContext.java | 20 +++++++++++++++++++ .../cameleer/server/core/http/TrustMode.java | 7 +++++++ 5 files changed, 59 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java diff --git a/cameleer-server-core/pom.xml b/cameleer-server-core/pom.xml index 9c959ab3..b93977fd 100644 --- a/cameleer-server-core/pom.xml +++ b/cameleer-server-core/pom.xml @@ -37,6 +37,10 @@ spring-security-core provided + + org.apache.httpcomponents.client5 + httpclient5 + com.fasterxml.jackson.datatype jackson-datatype-jsr310 diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java new file mode 100644 index 00000000..64bf0f35 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java @@ -0,0 +1,8 @@ +package com.cameleer.server.core.http; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; + +public interface OutboundHttpClientFactory { + /** Returns a memoized client configured for the given TLS/timeout context. */ + CloseableHttpClient clientFor(OutboundHttpRequestContext context); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java new file mode 100644 index 00000000..cb586ef9 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java @@ -0,0 +1,20 @@ +package com.cameleer.server.core.http; + +import java.time.Duration; +import java.util.List; + +public record OutboundHttpProperties( + boolean trustAll, + List trustedCaPemPaths, + Duration defaultConnectTimeout, + Duration defaultReadTimeout, + String proxyUrl, + String proxyUsername, + String proxyPassword +) { + public OutboundHttpProperties { + trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths); + if (defaultConnectTimeout == null) defaultConnectTimeout = Duration.ofMillis(2000); + if (defaultReadTimeout == null) defaultReadTimeout = Duration.ofMillis(5000); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java new file mode 100644 index 00000000..a09ef217 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java @@ -0,0 +1,20 @@ +package com.cameleer.server.core.http; + +import java.time.Duration; +import java.util.List; + +public record OutboundHttpRequestContext( + TrustMode trustMode, + List trustedCaPemPaths, + Duration connectTimeout, + Duration readTimeout +) { + public OutboundHttpRequestContext { + if (trustMode == null) trustMode = TrustMode.SYSTEM_DEFAULT; + trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths); + } + + public static OutboundHttpRequestContext systemDefault() { + return new OutboundHttpRequestContext(TrustMode.SYSTEM_DEFAULT, List.of(), null, null); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java new file mode 100644 index 00000000..a1db2e8c --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java @@ -0,0 +1,7 @@ +package com.cameleer.server.core.http; + +public enum TrustMode { + SYSTEM_DEFAULT, + TRUST_ALL, + TRUST_PATHS +} -- 2.49.1 From 262ee91684769d8b3840b2bfe37650820de31925 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:54:15 +0200 Subject: [PATCH 04/22] feat(http): SslContextBuilder supports system/trust-all/trust-paths modes Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/http/SslContextBuilder.java | 82 +++++++++++++++++++ .../app/http/SslContextBuilderTest.java | 52 ++++++++++++ .../src/test/resources/test-ca.pem | 19 +++++ 3 files changed, 153 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java create mode 100644 cameleer-server-app/src/test/resources/test-ca.pem diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java new file mode 100644 index 00000000..3f47ed7e --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java @@ -0,0 +1,82 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class SslContextBuilder { + + public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx) throws Exception { + SSLContext sslContext = SSLContext.getInstance("TLS"); + + if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) { + sslContext.init(null, new TrustManager[]{new TrustAllManager()}, null); + return sslContext; + } + + List extraCerts = new ArrayList<>(); + List paths = new ArrayList<>(systemProps.trustedCaPemPaths()); + if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) { + paths.addAll(ctx.trustedCaPemPaths()); + } + for (String p : paths) { + extraCerts.addAll(loadPem(p)); + } + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + // Load JDK default trust roots + TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultTmf.init((KeyStore) null); + int i = 0; + for (TrustManager tm : defaultTmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509tm) { + for (X509Certificate cert : x509tm.getAcceptedIssuers()) { + ks.setCertificateEntry("default-" + (i++), cert); + } + } + } + // Add configured extras + for (X509Certificate cert : extraCerts) { + ks.setCertificateEntry("extra-" + (i++), cert); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } + + private Collection loadPem(String path) throws IOException, java.security.cert.CertificateException { + Path p = Path.of(path); + if (!Files.exists(p)) { + throw new IllegalArgumentException("CA file not found: " + path); + } + try (var in = Files.newInputStream(p)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + @SuppressWarnings("unchecked") + Collection certs = (Collection) cf.generateCertificates(in); + return certs; + } + } + + private static final class TrustAllManager implements X509TrustManager { + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java new file mode 100644 index 00000000..1bed80b2 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java @@ -0,0 +1,52 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLContext; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SslContextBuilderTest { + private final OutboundHttpProperties systemProps = + new OutboundHttpProperties(false, List.of(), Duration.ofMillis(2000), Duration.ofMillis(5000), + null, null, null); + private final SslContextBuilder builder = new SslContextBuilder(); + + @Test + void systemDefaultUsesJdkTrustStore() throws Exception { + SSLContext ctx = builder.build(systemProps, OutboundHttpRequestContext.systemDefault()); + assertThat(ctx).isNotNull(); + assertThat(ctx.getProtocol()).isEqualTo("TLS"); + } + + @Test + void trustAllSkipsValidation() throws Exception { + SSLContext ctx = builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null)); + assertThat(ctx).isNotNull(); + } + + @Test + void trustPathsLoadsPemFile() throws Exception { + Path pem = Path.of("src/test/resources/test-ca.pem"); + assertThat(pem).exists(); + SSLContext ctx = builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of(pem.toString()), null, null)); + assertThat(ctx).isNotNull(); + } + + @Test + void trustPathsMissingFileThrows() { + assertThatThrownBy(() -> builder.build(systemProps, + new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of("/no/such/file.pem"), null, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("CA file not found"); + } +} diff --git a/cameleer-server-app/src/test/resources/test-ca.pem b/cameleer-server-app/src/test/resources/test-ca.pem new file mode 100644 index 00000000..37ced8f3 --- /dev/null +++ b/cameleer-server-app/src/test/resources/test-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUawEUnI7oAYTMsqYYJUBqGJhM8LMwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQY2FtZWxlZXItdGVzdC1jYTAeFw0yNjA0MTkxMzUzMTNa +Fw0zNjA0MTYxMzUzMTNaMBsxGTAXBgNVBAMMEGNhbWVsZWVyLXRlc3QtY2EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCalBl0hjPSXYZjFLImDT5awayX +nPmX7/TyduX2xSksLnGBfD1YcLYhIaOU3Bp6o+Wld8+nzNjwgMUcKdv1mWqDL8t0 +nlf0JGAntNEQWGwxFUGSLcmzgVf8/d1s0mpZ1/mS6RDzoQ8i8rNq5mYPXkWFAvgD +G1FQpaBs71VC7vVcEgnJ4kK/8cNcQ/nvaI+T/Tk+sciu57XuMoa8AoJV/Oz/hdkc +fIoYeUpFp36pYn71yFyZ1N+1xCTi1ruBmMekYWKNaNY6/edk2z3iTFgv4Dk4QS1O +6GEdgUi9RQIgQwyYltQK2z+wLA8e982JK5HllsLYU0AcY6fvg+JlhEEpCsdnAgMB +AAGjUzBRMB0GA1UdDgQWBBQYRTifnA5YLxiqjIYMDcZLgDrtUDAfBgNVHSMEGDAW +gBQYRTifnA5YLxiqjIYMDcZLgDrtUDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBpNfv6I9dsUflmslv6el5VvDOyZ3GWQIE/Zy0p+i0NYjeWMWtX +rFPw1mnfdhlyFMpNkP8ddyDaSoqnnFsC4pXqXibrey0NDYX7zujyVIaXixf6koU9 +2/vNIb4evnJyVT9CcophDrobkR4NwU9SrMO7KAecb/U6shrfgmjIanUnCsfGdwsP +f85DQDbOd9UhnMfMB43I8tjn2Po155npmwGK7J4hZpWTUj/rbNt3fmy2mx6rqdkp +p61nLY3ba61bnl7QQ7VW7nhaIC7t5sO6NFDdv06MG8Cr5kcvoJDPe93iNeVT8iLp +Rxs85FvIVYIt58jMvr+RSEfTD8fIvXl0uRDy +-----END CERTIFICATE----- -- 2.49.1 From 49227485990827db1e8440b27b592740a815e90b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:59:06 +0200 Subject: [PATCH 05/22] refactor(http): tighten SslContextBuilder throws clause, classpath test fixture, system trust-all test Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cameleer/server/app/http/SslContextBuilder.java | 9 ++++++++- .../server/app/http/SslContextBuilderTest.java | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java index 3f47ed7e..9207a609 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java @@ -10,7 +10,11 @@ import javax.net.ssl.X509TrustManager; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.KeyManagementException; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -19,7 +23,9 @@ import java.util.List; public class SslContextBuilder { - public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx) throws Exception { + public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx) + throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, + CertificateException, IOException { SSLContext sslContext = SSLContext.getInstance("TLS"); if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) { @@ -28,6 +34,7 @@ public class SslContextBuilder { } List extraCerts = new ArrayList<>(); + // System-level extras are always merged; per-request paths apply only in TRUST_PATHS mode. List paths = new ArrayList<>(systemProps.trustedCaPemPaths()); if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) { paths.addAll(ctx.trustedCaPemPaths()); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java index 1bed80b2..75693225 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java @@ -35,7 +35,7 @@ class SslContextBuilderTest { @Test void trustPathsLoadsPemFile() throws Exception { - Path pem = Path.of("src/test/resources/test-ca.pem"); + Path pem = Path.of(getClass().getClassLoader().getResource("test-ca.pem").toURI()); assertThat(pem).exists(); SSLContext ctx = builder.build(systemProps, new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of(pem.toString()), null, null)); @@ -49,4 +49,14 @@ class SslContextBuilderTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("CA file not found"); } + + @Test + void systemTrustAllShortCircuitsEvenWithSystemDefaultContext() throws Exception { + OutboundHttpProperties trustAllProps = new OutboundHttpProperties( + true, List.of(), Duration.ofMillis(2000), Duration.ofMillis(5000), + null, null, null); + SSLContext ctx = builder.build(trustAllProps, OutboundHttpRequestContext.systemDefault()); + assertThat(ctx).isNotNull(); + assertThat(ctx.getProtocol()).isEqualTo("TLS"); + } } -- 2.49.1 From 000e9d284727078315250a3a20342c2fd5e69cab Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:03:56 +0200 Subject: [PATCH 06/22] feat(http): ApacheOutboundHttpClientFactory with memoization and startup validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ApacheOutboundHttpClientFactory (Apache HttpClient 5) that memoizes CloseableHttpClient instances keyed on effective TLS + timeout config, and OutboundHttpConfig (@ConfigurationProperties) that validates trusted CA paths at startup and exposes OutboundHttpClientFactory as a Spring bean. TRUST_ALL mode disables both cert validation (TrustAllManager in SslContextBuilder) and hostname verification (NoopHostnameVerifier on SSLConnectionSocketFactoryBuilder). WireMock HTTPS integration test covers trust-all bypass, system-default PKIX rejection, and client memoization. OIDC audit: OidcProviderHelper and OidcTokenExchanger use Nimbus SDK's own HTTP layer (DefaultResourceRetriever for JWKS, HTTPRequest.send() for token exchange) plus the bespoke InsecureTlsHelper for TLS skip-verify; neither uses OutboundHttpClientFactory. Retrofit deferred to a separate follow-up per plan §20. Co-Authored-By: Claude Opus 4.7 (1M context) --- cameleer-server-app/pom.xml | 6 ++ .../http/ApacheOutboundHttpClientFactory.java | 94 +++++++++++++++++++ .../app/http/config/OutboundHttpConfig.java | 76 +++++++++++++++ .../src/main/resources/application.yml | 8 ++ .../ApacheOutboundHttpClientFactoryIT.java | 67 +++++++++++++ 5 files changed, 251 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java diff --git a/cameleer-server-app/pom.xml b/cameleer-server-app/pom.xml index b828c850..9ee3c5b8 100644 --- a/cameleer-server-app/pom.xml +++ b/cameleer-server-app/pom.xml @@ -144,6 +144,12 @@ awaitility test + + org.wiremock + wiremock-standalone + 3.9.1 + test + diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java new file mode 100644 index 00000000..8b6811b6 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java @@ -0,0 +1,94 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; + +public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory { + + private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class); + + private final OutboundHttpProperties systemProps; + private final SslContextBuilder sslBuilder; + private final ConcurrentMap clients = new ConcurrentHashMap<>(); + + public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) { + this.systemProps = systemProps; + this.sslBuilder = sslBuilder; + } + + @Override + public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) { + CacheKey key = CacheKey.of(systemProps, ctx); + return clients.computeIfAbsent(key, k -> build(ctx)); + } + + private CloseableHttpClient build(OutboundHttpRequestContext ctx) { + try { + var sslContext = sslBuilder.build(systemProps, ctx); + boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL; + var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext); + if (trustAll) { + sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE); + } + var sslFactory = sslFactoryBuilder.build(); + var connMgr = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslFactory) + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.of( + ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout())) + .setSocketTimeout(Timeout.of( + ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout())) + .build()) + .build(); + log.debug("Built outbound HTTP client: trustMode={}, caPaths={}", + ctx.trustMode(), ctx.trustedCaPemPaths()); + return HttpClients.custom() + .setConnectionManager(connMgr) + .setDefaultRequestConfig(RequestConfig.custom().build()) + .build(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build outbound HTTP client", e); + } + } + + private record CacheKey( + boolean trustAll, + List caPaths, + TrustMode mode, + Duration connect, + Duration read + ) { + static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) { + List mergedPaths = Stream.concat( + sp.trustedCaPemPaths().stream(), + ctx.trustedCaPemPaths().stream() + ).toList(); + return new CacheKey( + sp.trustAll(), + List.copyOf(mergedPaths), + ctx.trustMode(), + ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(), + ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout() + ); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java new file mode 100644 index 00000000..4305d0c6 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java @@ -0,0 +1,76 @@ +package com.cameleer.server.app.http.config; + +import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory; +import com.cameleer.server.app.http.SslContextBuilder; +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpProperties; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "cameleer.server.outbound-http") +public class OutboundHttpConfig { + + private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class); + + private boolean trustAll = false; + private List trustedCaPemPaths = List.of(); + private long defaultConnectTimeoutMs = 2000; + private long defaultReadTimeoutMs = 5000; + private String proxyUrl; + private String proxyUsername; + private String proxyPassword; + + public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; } + public void setTrustedCaPemPaths(List paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); } + public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; } + public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; } + public void setProxyUrl(String v) { this.proxyUrl = v; } + public void setProxyUsername(String v) { this.proxyUsername = v; } + public void setProxyPassword(String v) { this.proxyPassword = v; } + + @PostConstruct + void validate() { + if (trustAll) { + log.warn("cameleer.server.outbound-http.trust-all is ON - all outbound HTTPS cert validation is DISABLED. Do not use in production."); + } + for (String p : trustedCaPemPaths) { + if (!Files.exists(Path.of(p))) { + throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p); + } + log.info("Outbound HTTP: trusting additional CA from {}", p); + } + } + + @Bean + public OutboundHttpProperties outboundHttpProperties() { + return new OutboundHttpProperties( + trustAll, + trustedCaPemPaths, + Duration.ofMillis(defaultConnectTimeoutMs), + Duration.ofMillis(defaultReadTimeoutMs), + proxyUrl, + proxyUsername, + proxyPassword + ); + } + + @Bean + public SslContextBuilder sslContextBuilder() { + return new SslContextBuilder(); + } + + @Bean + public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) { + return new ApacheOutboundHttpClientFactory(props, builder); + } +} diff --git a/cameleer-server-app/src/main/resources/application.yml b/cameleer-server-app/src/main/resources/application.yml index d0402871..7a73d3a3 100644 --- a/cameleer-server-app/src/main/resources/application.yml +++ b/cameleer-server-app/src/main/resources/application.yml @@ -79,6 +79,14 @@ cameleer: jwkseturi: ${CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI:} audience: ${CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE:} tlsskipverify: ${CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY:false} + outbound-http: + trust-all: false + trusted-ca-pem-paths: [] + default-connect-timeout-ms: 2000 + default-read-timeout-ms: 5000 + # proxy-url: + # proxy-username: + # proxy-password: clickhouse: url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer} username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java new file mode 100644 index 00000000..cd797c5f --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java @@ -0,0 +1,67 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApacheOutboundHttpClientFactoryIT { + + private WireMockServer wm; + private ApacheOutboundHttpClientFactory factory; + + @BeforeEach + void setUp() { + wm = new WireMockServer(WireMockConfiguration.options() + .httpDisabled(true).dynamicHttpsPort()); + wm.start(); + wm.stubFor(get("/ping").willReturn(ok("pong"))); + + OutboundHttpProperties props = new OutboundHttpProperties( + false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null); + factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder()); + } + + @AfterEach + void tearDown() { + wm.stop(); + } + + @Test + void trustAllBypassesWiremockSelfSignedCert() throws Exception { + CloseableHttpClient client = factory.clientFor( + new OutboundHttpRequestContext(TrustMode.TRUST_ALL, List.of(), null, null)); + try (var resp = client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) { + assertThat(resp.getCode()).isEqualTo(200); + assertThat(EntityUtils.toString(resp.getEntity())).isEqualTo("pong"); + } + } + + @Test + void systemDefaultRejectsSelfSignedCert() { + CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault()); + assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) + .hasMessageContaining("PKIX"); + } + + @Test + void clientsAreMemoizedByContext() { + CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault()); + CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault()); + assertThat(c1).isSameAs(c2); + } +} -- 2.49.1 From 0c9d12d8e07efcf7d143de78f4a7c940ea645990 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:08:43 +0200 Subject: [PATCH 07/22] test(http): tighten SSL-failure assertion + null-guard WireMock teardown Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/http/ApacheOutboundHttpClientFactoryIT.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java index cd797c5f..bc9fc7d3 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java @@ -38,7 +38,7 @@ class ApacheOutboundHttpClientFactoryIT { @AfterEach void tearDown() { - wm.stop(); + if (wm != null) wm.stop(); } @Test @@ -55,7 +55,7 @@ class ApacheOutboundHttpClientFactoryIT { void systemDefaultRejectsSelfSignedCert() { CloseableHttpClient client = factory.clientFor(OutboundHttpRequestContext.systemDefault()); assertThatThrownBy(() -> client.execute(new HttpGet("https://localhost:" + wm.httpsPort() + "/ping"))) - .hasMessageContaining("PKIX"); + .isInstanceOfAny(javax.net.ssl.SSLException.class, javax.net.ssl.SSLHandshakeException.class); } @Test -- 2.49.1 From 46b8f63fd1e10314f192c59dd31d9622affa854f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:10:17 +0200 Subject: [PATCH 08/22] feat(outbound): core domain records for outbound connections Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/core/outbound/OutboundAuth.java | 24 ++++++++++++ .../core/outbound/OutboundAuthKind.java | 3 ++ .../core/outbound/OutboundConnection.java | 39 +++++++++++++++++++ .../OutboundConnectionRepository.java | 13 +++++++ .../outbound/OutboundConnectionService.java | 13 +++++++ .../server/core/outbound/OutboundMethod.java | 3 ++ 6 files changed, 95 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java new file mode 100644 index 00000000..173e03ee --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java @@ -0,0 +1,24 @@ +package com.cameleer.server.core.outbound; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes({ + @JsonSubTypes.Type(OutboundAuth.None.class), + @JsonSubTypes.Type(OutboundAuth.Bearer.class), + @JsonSubTypes.Type(OutboundAuth.Basic.class), +}) +public sealed interface OutboundAuth permits OutboundAuth.None, OutboundAuth.Bearer, OutboundAuth.Basic { + OutboundAuthKind kind(); + + record None() implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.NONE; } + } + record Bearer(String tokenCiphertext) implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.BEARER; } + } + record Basic(String username, String passwordCiphertext) implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.BASIC; } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java new file mode 100644 index 00000000..eae12dec --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java @@ -0,0 +1,3 @@ +package com.cameleer.server.core.outbound; + +public enum OutboundAuthKind { NONE, BEARER, BASIC } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java new file mode 100644 index 00000000..1c281634 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java @@ -0,0 +1,39 @@ +package com.cameleer.server.core.outbound; + +import com.cameleer.server.core.http.TrustMode; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnection( + UUID id, + String tenantId, + String name, + String description, + String url, + OutboundMethod method, + Map defaultHeaders, + String defaultBodyTmpl, + TrustMode tlsTrustMode, + List tlsCaPemPaths, + String hmacSecretCiphertext, + OutboundAuth auth, + List allowedEnvironmentIds, + Instant createdAt, + UUID createdBy, + Instant updatedAt, + UUID updatedBy +) { + public OutboundConnection { + defaultHeaders = defaultHeaders == null ? Map.of() : Map.copyOf(defaultHeaders); + tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : List.copyOf(tlsCaPemPaths); + allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : List.copyOf(allowedEnvironmentIds); + if (auth == null) auth = new OutboundAuth.None(); + } + + public boolean isAllowedInEnvironment(UUID environmentId) { + return allowedEnvironmentIds.isEmpty() || allowedEnvironmentIds.contains(environmentId); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java new file mode 100644 index 00000000..5daac1da --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java @@ -0,0 +1,13 @@ +package com.cameleer.server.core.outbound; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OutboundConnectionRepository { + OutboundConnection save(OutboundConnection connection); + Optional findById(String tenantId, UUID id); + Optional findByName(String tenantId, String name); + List listByTenant(String tenantId); + void delete(String tenantId, UUID id); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java new file mode 100644 index 00000000..e0e22be6 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java @@ -0,0 +1,13 @@ +package com.cameleer.server.core.outbound; + +import java.util.List; +import java.util.UUID; + +public interface OutboundConnectionService { + OutboundConnection create(OutboundConnection draft, UUID actingUserId); + OutboundConnection update(UUID id, OutboundConnection draft, UUID actingUserId); + void delete(UUID id, UUID actingUserId); + OutboundConnection get(UUID id); + List list(); + List rulesReferencing(UUID id); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java new file mode 100644 index 00000000..5909382a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java @@ -0,0 +1,3 @@ +package com.cameleer.server.core.outbound; + +public enum OutboundMethod { POST, PUT, PATCH } -- 2.49.1 From b8565af039c4b73a456f37ee72f50eaf880d9a32 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:13:57 +0200 Subject: [PATCH 09/22] feat(outbound): SecretCipher - AES-GCM with JWT-derived key for at-rest secret encryption Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/outbound/crypto/SecretCipher.java | 62 +++++++++++++++++++ .../app/outbound/crypto/SecretCipherTest.java | 42 +++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java new file mode 100644 index 00000000..3f7e4eb9 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java @@ -0,0 +1,62 @@ +package com.cameleer.server.app.outbound.crypto; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** Symmetric cipher for small at-rest secrets; key derived from JWT secret. */ +public class SecretCipher { + private static final String KDF_LABEL = "cameleer-outbound-secret-v1"; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec aesKey; + private final SecureRandom random = new SecureRandom(); + + public SecretCipher(String jwtSecret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] keyBytes = mac.doFinal(KDF_LABEL.getBytes(StandardCharsets.UTF_8)); + this.aesKey = new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new IllegalStateException("Failed to derive outbound secret key", e); + } + } + + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length); + buf.put(iv).put(ct); + return Base64.getEncoder().encodeToString(buf.array()); + } catch (Exception e) { + throw new IllegalStateException("Encryption failed", e); + } + } + + public String decrypt(String ciphertextB64) { + try { + byte[] full = Base64.getDecoder().decode(ciphertextB64); + if (full.length < IV_BYTES + 16) throw new IllegalArgumentException("ciphertext too short"); + byte[] iv = new byte[IV_BYTES]; + System.arraycopy(full, 0, iv, 0, IV_BYTES); + byte[] ct = new byte[full.length - IV_BYTES]; + System.arraycopy(full, IV_BYTES, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(ct); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Decryption failed", e); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java new file mode 100644 index 00000000..0ef070e9 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java @@ -0,0 +1,42 @@ +package com.cameleer.server.app.outbound.crypto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecretCipherTest { + private final SecretCipher cipher = new SecretCipher("test-jwt-secret-must-be-long-enough-to-derive"); + + @Test + void roundTrips() { + String plaintext = "my-hmac-secret-12345"; + String ct = cipher.encrypt(plaintext); + assertThat(ct).isNotEqualTo(plaintext); + assertThat(cipher.decrypt(ct)).isEqualTo(plaintext); + } + + @Test + void differentCiphertextsForSamePlaintext() { + String a = cipher.encrypt("x"); + String b = cipher.encrypt("x"); + assertThat(a).isNotEqualTo(b); + assertThat(cipher.decrypt(a)).isEqualTo(cipher.decrypt(b)); + } + + @Test + void decryptRejectsTamperedCiphertext() { + String ct = cipher.encrypt("abc"); + String tampered = ct.substring(0, ct.length() - 2) + "00"; + assertThatThrownBy(() -> cipher.decrypt(tampered)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void decryptRejectsWrongKey() { + String ct = cipher.encrypt("abc"); + SecretCipher other = new SecretCipher("some-other-jwt-secret-that-is-long-enough"); + assertThatThrownBy(() -> other.decrypt(ct)) + .isInstanceOf(IllegalArgumentException.class); + } +} -- 2.49.1 From 380ccb102bd7cddc20471b58251dd1628881e24d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:18:12 +0200 Subject: [PATCH 10/22] fix(outbound): align user FK with users(user_id) TEXT schema V11 migration referenced users(id) as uuid, but V1 users table has user_id as TEXT primary key. Amending V11 and the OutboundConnection record before Task 7's integration tests catch this at Flyway startup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../resources/db/migration/V11__outbound_connections.sql | 4 ++-- .../cameleer/server/core/outbound/OutboundConnection.java | 4 ++-- .../server/core/outbound/OutboundConnectionService.java | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql index aa9576b1..48c803cd 100644 --- a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql +++ b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql @@ -21,9 +21,9 @@ CREATE TABLE outbound_connections ( auth_config jsonb NOT NULL DEFAULT '{}', allowed_environment_ids uuid[] NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), - created_by uuid NOT NULL REFERENCES users(id), + created_by text NOT NULL REFERENCES users(user_id), updated_at timestamptz NOT NULL DEFAULT now(), - updated_by uuid NOT NULL REFERENCES users(id), + updated_by text NOT NULL REFERENCES users(user_id), CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name) ); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java index 1c281634..b37c1c0a 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java @@ -22,9 +22,9 @@ public record OutboundConnection( OutboundAuth auth, List allowedEnvironmentIds, Instant createdAt, - UUID createdBy, + String createdBy, Instant updatedAt, - UUID updatedBy + String updatedBy ) { public OutboundConnection { defaultHeaders = defaultHeaders == null ? Map.of() : Map.copyOf(defaultHeaders); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java index e0e22be6..05576668 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java @@ -4,9 +4,9 @@ import java.util.List; import java.util.UUID; public interface OutboundConnectionService { - OutboundConnection create(OutboundConnection draft, UUID actingUserId); - OutboundConnection update(UUID id, OutboundConnection draft, UUID actingUserId); - void delete(UUID id, UUID actingUserId); + OutboundConnection create(OutboundConnection draft, String actingUserId); + OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId); + void delete(UUID id, String actingUserId); OutboundConnection get(UUID id); List list(); List rulesReferencing(UUID id); -- 2.49.1 From 642c040116a97826ef3c23819fce94ad46626bfd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:23:51 +0200 Subject: [PATCH 11/22] feat(outbound): Postgres repository for outbound_connections - PostgresOutboundConnectionRepository: JdbcTemplate impl of OutboundConnectionRepository; UUID arrays via ConnectionCallback, JSONB for headers/auth/ca-paths, enum casts for method/trust/auth-kind - OutboundBeanConfig: wires the repo + SecretCipher beans - PostgresOutboundConnectionRepositoryIT: 5 Testcontainers tests (save+read, unique-name, allowed-env-ids round-trip, tenant isolation, delete); validates V11 Flyway migration end-to-end - application-test.yml: add jwtsecret default so SecretCipher bean starts up in the Spring test context Co-Authored-By: Claude Sonnet 4.6 --- .../outbound/config/OutboundBeanConfig.java | 26 ++++ .../PostgresOutboundConnectionRepository.java | 147 ++++++++++++++++++ ...ostgresOutboundConnectionRepositoryIT.java | 111 +++++++++++++ .../src/test/resources/application-test.yml | 1 + 4 files changed, 285 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java 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 -- 2.49.1 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 12/22] 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); + } } -- 2.49.1 From a3c35c7df9f34aa77e3131a0493d9a5d5ca56139 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:37:00 +0200 Subject: [PATCH 13/22] feat(outbound): request + response + test-result DTOs with Bean Validation Co-Authored-By: Claude Opus 4.7 (1M context) --- .../outbound/dto/OutboundConnectionDto.java | 31 ++++++++++++ .../dto/OutboundConnectionRequest.java | 37 ++++++++++++++ .../dto/OutboundConnectionTestResult.java | 12 +++++ ...tboundConnectionRequestValidationTest.java | 49 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java new file mode 100644 index 00000000..07a0aa9f --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java @@ -0,0 +1,31 @@ +package com.cameleer.server.app.outbound.dto; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuthKind; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundMethod; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnectionDto( + UUID id, String name, String description, String url, + OutboundMethod method, Map defaultHeaders, String defaultBodyTmpl, + TrustMode tlsTrustMode, List tlsCaPemPaths, + boolean hmacSecretSet, + OutboundAuthKind authKind, + List allowedEnvironmentIds, + Instant createdAt, String createdBy, Instant updatedAt, String updatedBy +) { + public static OutboundConnectionDto from(OutboundConnection c) { + return new OutboundConnectionDto( + c.id(), c.name(), c.description(), c.url(), c.method(), + c.defaultHeaders(), c.defaultBodyTmpl(), + c.tlsTrustMode(), c.tlsCaPemPaths(), + c.hmacSecretCiphertext() != null, + c.auth().kind(), c.allowedEnvironmentIds(), + c.createdAt(), c.createdBy(), c.updatedAt(), c.updatedBy()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java new file mode 100644 index 00000000..2a5897b2 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java @@ -0,0 +1,37 @@ +package com.cameleer.server.app.outbound.dto; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundMethod; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnectionRequest( + @NotBlank @Size(max = 100) String name, + @Size(max = 2000) String description, + @NotBlank @Pattern(regexp = "^https://.+", message = "URL must be HTTPS") String url, + @NotNull OutboundMethod method, + Map defaultHeaders, + String defaultBodyTmpl, + @NotNull TrustMode tlsTrustMode, + List tlsCaPemPaths, + String hmacSecret, + @NotNull @Valid OutboundAuth auth, + List allowedEnvironmentIds +) { + public OutboundConnectionRequest { + defaultHeaders = defaultHeaders == null ? Map.of() : defaultHeaders; + tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : tlsCaPemPaths; + allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : allowedEnvironmentIds; + if (tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) { + throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS"); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java new file mode 100644 index 00000000..71527ae1 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java @@ -0,0 +1,12 @@ +package com.cameleer.server.app.outbound.dto; + +public record OutboundConnectionTestResult( + int status, + long latencyMs, + String responseSnippet, + String tlsProtocol, + String tlsCipherSuite, + String peerCertificateSubject, + Long peerCertificateExpiresAtEpochMs, + String error +) {} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java new file mode 100644 index 00000000..4ca4a8f8 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java @@ -0,0 +1,49 @@ +package com.cameleer.server.app.outbound; + +import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest; +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundMethod; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OutboundConnectionRequestValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void validRequestPasses() { + var r = new OutboundConnectionRequest("slack-ops", "desc", + "https://hooks.slack.com/x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), + null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).isEmpty(); + } + + @Test + void blankNameFails() { + var r = new OutboundConnectionRequest(" ", null, "https://x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("name")); + } + + @Test + void nonHttpsUrlFails() { + var r = new OutboundConnectionRequest("n", null, "http://x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("url")); + } + + @Test + void trustPathsRequiresNonEmptyCaList() { + assertThatThrownBy(() -> new OutboundConnectionRequest("n", null, "https://x", OutboundMethod.POST, + Map.of(), null, TrustMode.TRUST_PATHS, List.of(), null, new OutboundAuth.None(), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } +} -- 2.49.1 From ea4c56e7f653aeedf60e4df818eb038ed7246218 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:43:48 +0200 Subject: [PATCH 14/22] 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) --- .../OutboundConnectionAdminController.java | 117 +++++++++++++++ .../server/app/security/SecurityConfig.java | 5 +- .../OutboundConnectionAdminControllerIT.java | 135 ++++++++++++++++++ .../server/core/admin/AuditCategory.java | 3 +- 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java new file mode 100644 index 00000000..b2fb64f1 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java @@ -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 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 usage(@PathVariable UUID id) { + return service.rulesReferencing(id); + } + + @PostMapping + public ResponseEntity 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 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; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index 8dfb164c..65f8a7b6 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -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 diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java new file mode 100644 index 00000000..2a7a4381 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -0,0 +1,135 @@ +package com.cameleer.server.app.outbound.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + + private String adminJwt; + private String operatorJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + operatorJwt = securityHelper.operatorToken(); + viewerJwt = securityHelper.viewerToken(); + // Seed user rows matching the JWT subjects (users(user_id) is a FK target) + seedUser("test-admin"); + seedUser("test-operator"); + seedUser("test-viewer"); + jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'"); + } + + private void seedUser(String userId) { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + userId, userId + "@example.com", userId); + } + + private static final String CREATE_BODY = """ + {"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + + @Test + void adminCanCreate() throws Exception { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.path("name").asText()).isEqualTo("slack-ops"); + assertThat(body.path("hmacSecretSet").asBoolean()).isFalse(); + assertThat(body.path("id").asText()).isNotBlank(); + } + + @Test + void operatorCannotCreate() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void operatorCanList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCannotList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void nonHttpsUrlRejected() { + String body = """ + {"name":"bad","url":"http://x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void duplicateNameReturns409() { + restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + ResponseEntity dup = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void deleteRemoves() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity del = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + ResponseEntity get = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java index 968ee093..f76dff35 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java @@ -1,5 +1,6 @@ package com.cameleer.server.core.admin; public enum AuditCategory { - INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT + INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, + OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE } -- 2.49.1 From 87b8a71205d3466c16bd6846726cd0a54684e2c1 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:47:36 +0200 Subject: [PATCH 15/22] feat(outbound): admin test action for reachability + TLS summary POST /{id}/test issues a synthetic probe against the connection URL. TLS protocol/cipher/peer-cert details stubbed for now (Plan 02 follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../OutboundConnectionAdminController.java | 45 ++++++++++++++++++- .../OutboundConnectionAdminControllerIT.java | 38 ++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java index b2fb64f1..8697f22c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java @@ -3,14 +3,23 @@ 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.app.outbound.dto.OutboundConnectionTestResult; 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.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpRequestContext; 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.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -25,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.UUID; @@ -38,11 +48,14 @@ public class OutboundConnectionAdminController { private final OutboundConnectionService service; private final SecretCipher cipher; private final AuditService audit; + private final OutboundHttpClientFactory httpClientFactory; - public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, AuditService audit) { + public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, + AuditService audit, OutboundHttpClientFactory httpClientFactory) { this.service = service; this.cipher = cipher; this.audit = audit; + this.httpClientFactory = httpClientFactory; } @GetMapping @@ -94,6 +107,36 @@ public class OutboundConnectionAdminController { return ResponseEntity.noContent().build(); } + @PostMapping("/{id}/test") + public OutboundConnectionTestResult test(@PathVariable UUID id, HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection c = service.get(id); + long t0 = System.currentTimeMillis(); + try { + var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null); + CloseableHttpClient client = httpClientFactory.clientFor(ctx); + HttpPost request = new HttpPost(c.url()); + request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON)); + try (var resp = client.execute(request)) { + long latency = System.currentTimeMillis() - t0; + HttpEntity entity = resp.getEntity(); + String body = entity == null ? "" : EntityUtils.toString(entity, StandardCharsets.UTF_8); + String snippet = body.substring(0, Math.min(512, body.length())); + audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("status", resp.getCode(), "latencyMs", latency), + AuditResult.SUCCESS, httpRequest); + return new OutboundConnectionTestResult(resp.getCode(), latency, snippet, + "TLS", null, null, null, null); + } + } catch (Exception e) { + long latency = System.currentTimeMillis() - t0; + audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("error", e.getClass().getSimpleName(), "latencyMs", latency), + AuditResult.FAILURE, httpRequest); + return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage()); + } + } + private OutboundConnection toDraft(OutboundConnectionRequest req) { String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank()) ? null : cipher.encrypt(req.hmacSecret()); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java index 2a7a4381..62509b35 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -24,6 +24,12 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { private String adminJwt; private String operatorJwt; private String viewerJwt; + private com.github.tomakehurst.wiremock.WireMockServer wireMock; + + @org.junit.jupiter.api.AfterEach + void tearDownWireMock() { + if (wireMock != null) wireMock.stop(); + } @BeforeEach void setUp() { @@ -132,4 +138,36 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT { String.class); assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } + + @Test + void testActionReturnsStatusAndLatency() throws Exception { + wireMock = new com.github.tomakehurst.wiremock.WireMockServer( + com.github.tomakehurst.wiremock.core.WireMockConfiguration.options() + .httpDisabled(true).dynamicHttpsPort()); + wireMock.start(); + wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe") + .willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong"))); + + String createBody = """ + {"name":"probe-target","url":"https://localhost:%d/probe","method":"POST", + "tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort()); + + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity test = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(test.getBody()); + assertThat(body.path("status").asInt()).isEqualTo(200); + assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0); + assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS"); + assertThat(body.path("error").isNull()).isTrue(); + } } -- 2.49.1 From 3c903fc8dc3539e9e46da3bbcd292765ab4753b9 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:52:40 +0200 Subject: [PATCH 16/22] feat(ui): tanstack query hooks for outbound connections Types are hand-authored (matching codebase admin-query convention); schema.d.ts regeneration deferred until backend dev server is available. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/queries/admin/outboundConnections.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 ui/src/api/queries/admin/outboundConnections.ts diff --git a/ui/src/api/queries/admin/outboundConnections.ts b/ui/src/api/queries/admin/outboundConnections.ts new file mode 100644 index 00000000..cf6d1090 --- /dev/null +++ b/ui/src/api/queries/admin/outboundConnections.ts @@ -0,0 +1,128 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +// ── Types ────────────────────────────────────────────────────────────── + +export type OutboundMethod = 'POST' | 'PUT' | 'PATCH'; +export type OutboundAuthKind = 'NONE' | 'BEARER' | 'BASIC'; +export type TrustMode = 'SYSTEM_DEFAULT' | 'TRUST_ALL' | 'TRUST_PATHS'; + +// Jackson DEDUCTION mode picks subtype by which fields are present. +// None = {}, Bearer = { tokenCiphertext }, Basic = { username, passwordCiphertext }. +// Using a flat optional shape for pragmatic TypeScript compatibility. +export type OutboundAuth = { + tokenCiphertext?: string; + username?: string; + passwordCiphertext?: string; +}; + +// Server returns this shape (`OutboundConnectionDto` in Java) +export interface OutboundConnectionDto { + id: string; + name: string; + description: string | null; + url: string; + method: OutboundMethod; + defaultHeaders: Record; + defaultBodyTmpl: string | null; + tlsTrustMode: TrustMode; + tlsCaPemPaths: string[]; + hmacSecretSet: boolean; + authKind: OutboundAuthKind; + allowedEnvironmentIds: string[]; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} + +// Client sends this shape for create/update (`OutboundConnectionRequest` in Java) +export interface OutboundConnectionRequest { + name: string; + description?: string | null; + url: string; + method: OutboundMethod; + defaultHeaders?: Record; + defaultBodyTmpl?: string | null; + tlsTrustMode: TrustMode; + tlsCaPemPaths?: string[]; + hmacSecret?: string | null; + auth: OutboundAuth; + allowedEnvironmentIds?: string[]; +} + +// Server returns this from POST /{id}/test +export interface OutboundConnectionTestResult { + status: number; + latencyMs: number; + responseSnippet: string | null; + tlsProtocol: string | null; + tlsCipherSuite: string | null; + peerCertificateSubject: string | null; + peerCertificateExpiresAtEpochMs: number | null; + error: string | null; +} + +// ── Query Hooks ──────────────────────────────────────────────────────── + +export function useOutboundConnections() { + return useQuery({ + queryKey: ['admin', 'outbound-connections'], + queryFn: () => adminFetch('/outbound-connections'), + }); +} + +export function useOutboundConnection(id: string | undefined) { + return useQuery({ + queryKey: ['admin', 'outbound-connections', id], + queryFn: () => adminFetch(`/outbound-connections/${id}`), + enabled: !!id, + }); +} + +// ── Mutation Hooks ───────────────────────────────────────────────────── + +export function useCreateOutboundConnection() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: OutboundConnectionRequest) => + adminFetch('/outbound-connections', { + method: 'POST', + body: JSON.stringify(req), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }), + }); +} + +export function useUpdateOutboundConnection(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: OutboundConnectionRequest) => + adminFetch(`/outbound-connections/${id}`, { + method: 'PUT', + body: JSON.stringify(req), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }); + qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections', id] }); + }, + }); +} + +export function useDeleteOutboundConnection() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/outbound-connections/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }), + }); +} + +export function useTestOutboundConnection() { + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/outbound-connections/${id}/test`, { + method: 'POST', + }), + }); +} -- 2.49.1 From e7fbf5a7b234b00d69d206510eb3543fb4e98633 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:55:35 +0200 Subject: [PATCH 17/22] feat(ui): admin page for outbound connections list + navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OutboundConnectionsPage (list view with delete), lazy route at /admin/outbound-connections, and Outbound Connections nav node in the admin sidebar tree. No test file created — UI codebase has no existing test infrastructure to build on. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/components/sidebar-utils.ts | 1 + .../pages/Admin/OutboundConnectionsPage.tsx | 87 +++++++++++++++++++ ui/src/router.tsx | 2 + 3 files changed, 90 insertions(+) create mode 100644 ui/src/pages/Admin/OutboundConnectionsPage.tsx diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index 969b45b5..abfbe32d 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -107,6 +107,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean } ...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []), { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' }, { id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' }, { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, ]; diff --git a/ui/src/pages/Admin/OutboundConnectionsPage.tsx b/ui/src/pages/Admin/OutboundConnectionsPage.tsx new file mode 100644 index 00000000..03ec73f7 --- /dev/null +++ b/ui/src/pages/Admin/OutboundConnectionsPage.tsx @@ -0,0 +1,87 @@ +import { Link } from 'react-router-dom'; +import { Button, Badge, SectionHeader, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { + useOutboundConnections, + useDeleteOutboundConnection, + type OutboundConnectionDto, + type TrustMode, +} from '../../api/queries/admin/outboundConnections'; +import sectionStyles from '../../styles/section-card.module.css'; + +export default function OutboundConnectionsPage() { + const { data, isLoading, error } = useOutboundConnections(); + const deleteMut = useDeleteOutboundConnection(); + const { toast } = useToast(); + + if (isLoading) return ; + if (error) return
Failed to load outbound connections: {String(error)}
; + + const rows = data ?? []; + + const onDelete = (c: OutboundConnectionDto) => { + if (!confirm(`Delete outbound connection "${c.name}"?`)) return; + deleteMut.mutate(c.id, { + onSuccess: () => toast({ title: 'Deleted', description: c.name, variant: 'success' }), + onError: (e) => toast({ title: 'Delete failed', description: String(e), variant: 'error' }), + }); + }; + + return ( +
+
+ Outbound Connections + + + +
+
+ {rows.length === 0 ? ( +

No outbound connections yet. Create one to enable alerting webhooks or other outbound integrations.

+ ) : ( + + + + + + + + + + + + + + {rows.map((c) => ( + + + + + + + + + + ))} + +
NameHostMethodTrustAuthEnvs
{c.name}{safeHost(c.url)}{c.method}{c.authKind}{c.allowedEnvironmentIds.length > 0 ? c.allowedEnvironmentIds.length : 'all'} + +
+ )} +
+
+ ); +} + +function safeHost(url: string): string { + try { return new URL(url).host; } + catch { return url; } +} + +function TrustBadge({ mode }: { mode: TrustMode }) { + if (mode === 'TRUST_ALL') return ; + if (mode === 'TRUST_PATHS') return ; + return ; +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 4cddd9fa..7b2e8ed9 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -18,6 +18,7 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage')); const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage')); const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage')); const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage')); +const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage')); const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage')); const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab')); const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage')); @@ -84,6 +85,7 @@ export const router = createBrowserRouter([ { path: 'rbac', element: }, { path: 'audit', element: }, { path: 'oidc', element: }, + { path: 'outbound-connections', element: }, { path: 'sensitive-keys', element: }, { path: 'database', element: }, { path: 'clickhouse', element: }, -- 2.49.1 From 0c5f1b5740d14194c22fba11ae00011d6b5082f7 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:59:19 +0200 Subject: [PATCH 18/22] =?UTF-8?q?feat(ui):=20outbound=20connection=20edito?= =?UTF-8?q?r=20=E2=80=94=20TLS=20config,=20test=20action,=20env=20restrict?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/Admin/OutboundConnectionEditor.tsx | 470 ++++++++++++++++++ ui/src/router.tsx | 3 + 2 files changed, 473 insertions(+) create mode 100644 ui/src/pages/Admin/OutboundConnectionEditor.tsx diff --git a/ui/src/pages/Admin/OutboundConnectionEditor.tsx b/ui/src/pages/Admin/OutboundConnectionEditor.tsx new file mode 100644 index 00000000..92ebd3f2 --- /dev/null +++ b/ui/src/pages/Admin/OutboundConnectionEditor.tsx @@ -0,0 +1,470 @@ +import { useEffect, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Button, FormField, Input, Select, SectionHeader, Toggle, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { + useOutboundConnection, + useCreateOutboundConnection, + useUpdateOutboundConnection, + useTestOutboundConnection, + type OutboundConnectionDto, + type OutboundConnectionRequest, + type OutboundConnectionTestResult, + type OutboundMethod, + type OutboundAuthKind, + type TrustMode, +} from '../../api/queries/admin/outboundConnections'; +import { useEnvironments } from '../../api/queries/admin/environments'; +import sectionStyles from '../../styles/section-card.module.css'; + +// ── Form state ────────────────────────────────────────────────────────── + +interface FormState { + name: string; + description: string; + url: string; + method: OutboundMethod; + headers: Array<{ key: string; value: string }>; + defaultBodyTmpl: string; + tlsTrustMode: TrustMode; + tlsCaPemPaths: string[]; + hmacSecret: string; // empty = keep existing (edit) / no secret (create) + authKind: OutboundAuthKind; + bearerToken: string; + basicUsername: string; + basicPassword: string; + allowAllEnvs: boolean; + allowedEnvIds: string[]; +} + +function initialForm(existing?: OutboundConnectionDto): FormState { + return { + name: existing?.name ?? '', + description: existing?.description ?? '', + url: existing?.url ?? 'https://', + method: existing?.method ?? 'POST', + headers: existing + ? Object.entries(existing.defaultHeaders).map(([key, value]) => ({ key, value })) + : [], + defaultBodyTmpl: existing?.defaultBodyTmpl ?? '', + tlsTrustMode: existing?.tlsTrustMode ?? 'SYSTEM_DEFAULT', + tlsCaPemPaths: existing?.tlsCaPemPaths ?? [], + hmacSecret: '', + authKind: existing?.authKind ?? 'NONE', + bearerToken: '', + basicUsername: '', + basicPassword: '', + allowAllEnvs: !existing || existing.allowedEnvironmentIds.length === 0, + allowedEnvIds: existing?.allowedEnvironmentIds ?? [], + }; +} + +function toRequest(f: FormState): OutboundConnectionRequest { + const defaultHeaders = Object.fromEntries( + f.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]), + ); + const allowedEnvironmentIds = f.allowAllEnvs ? [] : f.allowedEnvIds; + const auth = + f.authKind === 'NONE' + ? {} + : f.authKind === 'BEARER' + ? { tokenCiphertext: f.bearerToken } + : { username: f.basicUsername, passwordCiphertext: f.basicPassword }; + return { + name: f.name, + description: f.description || null, + url: f.url, + method: f.method, + defaultHeaders, + defaultBodyTmpl: f.defaultBodyTmpl || null, + tlsTrustMode: f.tlsTrustMode, + tlsCaPemPaths: f.tlsCaPemPaths, + hmacSecret: f.hmacSecret ? f.hmacSecret : null, + auth, + allowedEnvironmentIds, + }; +} + +// ── Select option arrays ─────────────────────────────────────────────── + +const METHOD_OPTIONS: Array<{ value: OutboundMethod; label: string }> = [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'PATCH', label: 'PATCH' }, +]; + +const TRUST_OPTIONS: Array<{ value: TrustMode; label: string }> = [ + { value: 'SYSTEM_DEFAULT', label: 'System default (recommended)' }, + { value: 'TRUST_PATHS', label: 'Trust additional CA PEMs' }, + { value: 'TRUST_ALL', label: 'Trust all (INSECURE)' }, +]; + +const AUTH_OPTIONS: Array<{ value: OutboundAuthKind; label: string }> = [ + { value: 'NONE', label: 'None' }, + { value: 'BEARER', label: 'Bearer token' }, + { value: 'BASIC', label: 'Basic' }, +]; + +// ── Component ────────────────────────────────────────────────────────── + +export default function OutboundConnectionEditor() { + const { id } = useParams<{ id: string }>(); + const isNew = !id; + const navigate = useNavigate(); + const { toast } = useToast(); + + const existingQ = useOutboundConnection(isNew ? undefined : id); + const envQ = useEnvironments(); + const createMut = useCreateOutboundConnection(); + // Hooks must be called unconditionally; pass placeholder id when unknown. + const updateMut = useUpdateOutboundConnection(id ?? 'placeholder'); + const testMut = useTestOutboundConnection(); + + const [form, setForm] = useState(() => initialForm()); + const [initialized, setInitialized] = useState(isNew); + const [testResult, setTestResult] = useState(null); + const [showSecret, setShowSecret] = useState(false); + + useEffect(() => { + if (!initialized && existingQ.data) { + setForm(initialForm(existingQ.data)); + setInitialized(true); + } + }, [existingQ.data, initialized]); + + if (!isNew && existingQ.isLoading) return ; + + const isSaving = createMut.isPending || updateMut.isPending; + + const onSave = () => { + const payload = toRequest(form); + if (isNew) { + createMut.mutate(payload, { + onSuccess: () => { + toast({ title: 'Created', description: form.name, variant: 'success' }); + navigate('/admin/outbound-connections'); + }, + onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }), + }); + } else { + updateMut.mutate(payload, { + onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }), + onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }), + }); + } + }; + + const onTest = () => { + if (!id) return; + testMut.mutate(id, { + onSuccess: (r) => setTestResult(r), + onError: (e) => + setTestResult({ + status: 0, + latencyMs: 0, + responseSnippet: null, + tlsProtocol: null, + tlsCipherSuite: null, + peerCertificateSubject: null, + peerCertificateExpiresAtEpochMs: null, + error: String(e), + }), + }); + }; + + const envs = envQ.data ?? []; + + return ( +
+
+ + {isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`} + + + + +
+ +
+ + {/* Name */} + + setForm({ ...form, name: e.target.value })} + placeholder="slack-ops" + /> + + + {/* Description */} + +