From 77a23c270b7e4088ec95d0c521b9e4a9d80e0f07 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:26:00 +0200 Subject: [PATCH] =?UTF-8?q?docs(alerting):=20Plan=2001=20=E2=80=94=20outbo?= =?UTF-8?q?und=20HTTP=20infra=20+=20admin-managed=20outbound=20connections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three sequenced plans for the alerting feature. Covers: - Cross-cutting http/ module (OutboundHttpClientFactory, SslContextBuilder, TLS trust composition, startup validation) - Admin-managed OutboundConnection with PG persistence, AES-GCM-encrypted HMAC secret (resolves spec §20 item 2) - Admin CRUD REST + test endpoint + RBAC + audit - Admin UI page with TLS config, allowed-envs multi-select, test action - OIDC retrofit deliberately deferred (documented in Task 4 audit) Plan 02 (alerting backend) and Plan 03 (alerting UI) written after Plan 01 executes — lets reality inform their details, especially the secret-cipher interface and the rules-referencing integration point. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-19-alerting-01-outbound-infra.md | 2265 +++++++++++++++++ 1 file changed, 2265 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-alerting-01-outbound-infra.md diff --git a/docs/superpowers/plans/2026-04-19-alerting-01-outbound-infra.md b/docs/superpowers/plans/2026-04-19-alerting-01-outbound-infra.md new file mode 100644 index 00000000..dcb1cb4e --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-alerting-01-outbound-infra.md @@ -0,0 +1,2265 @@ +# Alerting Plan 01 — Outbound HTTP Infrastructure + Outbound Connections + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a cross-cutting outbound HTTP module (`http/`) and an admin-managed outbound connections feature (`outbound/`) — reusable infrastructure that future alerting webhooks (and existing OIDC, and any future outbound integration) build on. Admins can CRUD outbound destinations, configure TLS trust, test reachability, and restrict availability by environment. + +**Architecture:** Two new peer modules in `cameleer-server-core` (domain) and `cameleer-server-app` (implementation). Shared `OutboundHttpClientFactory` owns TLS trust composition (JVM default + configured CA PEMs + optional trust-all). `OutboundConnection` is an admin-managed PG-persisted entity carrying a URL, default headers/body template, per-connection TLS override, HMAC secret (encrypted at rest), and allowed-environment restriction. Admin REST + a new admin UI page consume it. Tenant-global, env-restrictable. No alerting tables yet — those arrive in Plan 02. + +**Tech stack:** Spring Boot 3.4.3, JdbcTemplate, Flyway, Apache HttpClient 5 (already in tree), Bean Validation, Postgres, React 18 + TanStack Query. + +**Plan 02/03 dependencies:** Plan 02 (alerting backend) will consume `OutboundHttpClientFactory` for webhook dispatch and reference `OutboundConnection` rows from rule JSONB. Plan 03 (alerting UI) will reuse the query hooks added here. + +**Planning-phase decisions resolved in this plan:** +- **§20 item 2 — `hmac_secret` encryption at rest:** bespoke symmetric cipher derived from the existing Ed25519/JWT key material. No new dep. See Task 7. +- **§20 item 1 — OIDC alignment:** OIDC outbound cert handling audited in Task 4 after `SslContextBuilder` exists. Retrofit of OIDC to use the factory is **deliberately deferred to a follow-up** so Plan 01 ships without touching proven-working auth code. Tracked informally in the commit messages; no new backlog needed. + +--- + +## File Structure + +### New files (backend — core, no Spring) + +- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java` — enum +- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java` — record of system-wide config +- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java` — record of per-call TLS/timeout overrides +- `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java` — interface +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java` — enum `POST|PUT|PATCH` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java` — enum `NONE|BEARER|BASIC` +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java` — sealed interface + subtypes +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java` — record +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java` — interface +- `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java` — interface + +### New files (backend — app) + +- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java` +- `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java` +- `cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql` + +### New files (UI) + +- `ui/src/api/queries/admin/outboundConnections.ts` +- `ui/src/pages/Admin/OutboundConnectionsPage.tsx` +- `ui/src/pages/Admin/OutboundConnectionEditor.tsx` + +### Test files + +- `cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java` +- `cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java` +- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java` +- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java` +- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java` +- `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java` +- `ui/src/pages/Admin/OutboundConnectionsPage.test.tsx` +- `ui/src/pages/Admin/OutboundConnectionEditor.test.tsx` + +### Modified files + +- `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` — add two new categories +- `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` — path matchers for new admin endpoints +- `cameleer-server-app/src/main/resources/application.yml` — default `cameleer.server.outbound-http.*` values +- `ui/src/router.tsx` — route entry for admin outbound connections +- `ui/src/pages/Admin/AdminLayout.tsx` — nav entry (find the admin nav list) +- `.claude/rules/core-classes.md` — new packages documented +- `.claude/rules/app-classes.md` — new packages, new controller, new admin endpoints + +--- + +## Conventions + +- **Testcontainers** is the existing IT pattern. See `ConfigEnvIsolationIT`, `ClickHouseStatsStoreIT` for working setups. +- **MockMvc** is used for controller integration tests. See `ThresholdAdminControllerIT`. +- **Commit message prefix** follows existing repo convention: `feat(outbound): …`, `test(outbound): …`, `docs(rules): …`. +- **Java 17**, records, sealed interfaces, pattern-matching switches allowed. +- **Maven build:** `mvn -pl cameleer-server-app -am verify` for fast feedback on changed modules. Full build `mvn clean verify`. +- **TDD cadence:** write failing test → run it (must fail for the expected reason) → write minimum code → run again (must pass) → commit. One logical unit per commit. No squashing across tasks. +- **No `@Autowired` field injection.** Constructor injection always. + +--- + +## Phase 1 — Database migration + +### Task 1: Write `V11__outbound_connections.sql` + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- 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); +``` + +- [ ] **Step 2: Start backend to run migration** + +Run: `mvn -pl cameleer-server-app -am spring-boot:run` (or whatever the project's existing `make dev` / IDE launcher is). + +Verify in the log that `Flyway Community Edition ... Successfully applied 1 migration to schema "tenant_default", now at version v11 (outbound connections)` appears. + +- [ ] **Step 3: Verify schema via psql or DB admin endpoint** + +```bash +psql -d cameleer -c "\d outbound_connections" -c "\dT+ trust_mode_enum" +``` + +Expected: table exists with all columns; enums defined. + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql +git commit -m "feat(outbound): V11 flyway migration for outbound_connections table" +``` + +--- + +## Phase 2 — Cross-cutting HTTP module (`core/http/`) + +### Task 2: Core records and interface + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java` + +- [ ] **Step 1: `TrustMode`** + +```java +package com.cameleer.server.core.http; + +public enum TrustMode { + SYSTEM_DEFAULT, + TRUST_ALL, + TRUST_PATHS +} +``` + +- [ ] **Step 2: `OutboundHttpProperties`** + +```java +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); + } +} +``` + +- [ ] **Step 3: `OutboundHttpRequestContext`** + +```java +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); + } +} +``` + +- [ ] **Step 4: `OutboundHttpClientFactory` interface** + +```java +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); +} +``` + +- [ ] **Step 5: Compile** + +Run: `mvn -pl cameleer-server-core compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/http/ +git commit -m "feat(http): core outbound HTTP interfaces and property records" +``` + +--- + +## Phase 3 — HTTP module implementation (`app/http/`) + +### Task 3: `SslContextBuilder` with unit tests + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java` + +- [ ] **Step 1: Write the failing test first** + +Use a small helper that generates a self-signed cert to a temp PEM file. JDK's `KeyPairGenerator` + `sun.security.x509.*` works but is fragile; easier: check in a hand-written test PEM under `src/test/resources/test-ca.pem` (a simple self-signed cert you generate once with `openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -out test-ca.pem -days 365 -subj "/CN=test"`). + +```java +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"); + } +} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=SslContextBuilderTest` +Expected: all tests fail with "ClassNotFoundException: SslContextBuilder" or similar. + +- [ ] **Step 3: Generate test-ca.pem fixture** + +```bash +cd cameleer-server-app/src/test/resources +openssl req -x509 -newkey rsa:2048 -nodes -keyout /dev/null -out test-ca.pem -days 3650 -subj "/CN=cameleer-test-ca" +``` + +- [ ] **Step 4: Implement `SslContextBuilder`** + +```java +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]; } + } +} +``` + +- [ ] **Step 5: Run test, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=SslContextBuilderTest` + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java \ + cameleer-server-app/src/test/resources/test-ca.pem +git commit -m "feat(http): SslContextBuilder supports system/trust-all/trust-paths modes" +``` + +### Task 4: `ApacheOutboundHttpClientFactory` + startup config + OIDC audit note + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java` +- Modify: `cameleer-server-app/src/main/resources/application.yml` (add default `cameleer.server.outbound-http` section) + +- [ ] **Step 1: Write integration test (embedded HTTPS server)** + +Use WireMock with HTTPS enabled (`httpsPort(0)`, self-signed cert). Confirm the factory respects trust-all for the WireMock self-signed cert. + +```java +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); + } +} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=ApacheOutboundHttpClientFactoryIT` +Expected: FAIL — class not found. + +- [ ] **Step 3: Implement `ApacheOutboundHttpClientFactory`** + +```java +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 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.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +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.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +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); + var sslFactory = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext) + .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(); + var requestConfig = RequestConfig.custom().build(); + return HttpClients.custom() + .setConnectionManager(connMgr) + .setDefaultRequestConfig(requestConfig) + .build(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build outbound HTTP client", e); + } + } + + /** Key preserves the trust material used so that a change in system props produces a new client. */ + private record CacheKey(boolean trustAll, List caPaths, com.cameleer.server.core.http.TrustMode mode, + java.time.Duration connect, java.time.Duration read) { + static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) { + return new CacheKey(sp.trustAll(), + List.copyOf(java.util.stream.Stream.concat(sp.trustedCaPemPaths().stream(), + ctx.trustedCaPemPaths().stream()).toList()), + ctx.trustMode(), + ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(), + ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout()); + } + } +} +``` + +- [ ] **Step 4: Implement `OutboundHttpConfig` with @ConfigurationProperties binding and startup validation** + +```java +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; + + // setters used by Spring config-property binding + 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); + } +} +``` + +- [ ] **Step 5: Add defaults to `application.yml`** + +Edit `cameleer-server-app/src/main/resources/application.yml`, adding under `cameleer.server`: + +```yaml +cameleer: + server: + 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: +``` + +- [ ] **Step 6: Run test, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=ApacheOutboundHttpClientFactoryIT` + +- [ ] **Step 7: OIDC outbound audit note (no code change)** + +Open `OidcProviderHelper.java` and `OidcTokenExchanger.java` (under `cameleer-server-app/src/main/java/com/cameleer/server/app/security/`). Read through to determine what TLS trust mechanism OIDC currently uses (likely default JDK + Nimbus defaults). Record findings in a comment in the Task 4 commit message, e.g.: + +> "OIDC audit: currently uses Nimbus JOSE default HTTP client, which uses JDK default trust. Alerting's OutboundHttpClientFactory is the new one-true-way; OIDC retrofit is a separate follow-up (documented in alerting spec §20)." + +Do NOT modify OIDC code in this commit. OIDC retrofit is explicitly deferred. + +- [ ] **Step 8: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java \ + cameleer-server-app/src/main/resources/application.yml \ + cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java +git commit -m "feat(http): ApacheOutboundHttpClientFactory with memoization and startup validation + +OIDC audit: currently uses Nimbus JOSE default HTTP client (JDK default trust). +Retrofit to OutboundHttpClientFactory deferred to a separate follow-up." +``` + +--- + +## Phase 4 — Outbound connections domain (`core/outbound/`) + +### Task 5: Core records and interfaces + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java` + +- [ ] **Step 1: `OutboundMethod` + `OutboundAuthKind`** + +```java +package com.cameleer.server.core.outbound; + +public enum OutboundMethod { POST, PUT, PATCH } +``` + +```java +package com.cameleer.server.core.outbound; + +public enum OutboundAuthKind { NONE, BEARER, BASIC } +``` + +- [ ] **Step 2: `OutboundAuth` sealed interface** + +```java +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; } + } +} +``` + +- [ ] **Step 3: `OutboundConnection` record** + +```java +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, // encrypted via SecretCipher + OutboundAuth auth, + List allowedEnvironmentIds, // empty = allowed in all envs + 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); + } +} +``` + +- [ ] **Step 4: `OutboundConnectionRepository` interface** + +```java +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); // insert on null id slot, update otherwise + Optional findById(String tenantId, UUID id); + Optional findByName(String tenantId, String name); + List listByTenant(String tenantId); + void delete(String tenantId, UUID id); +} +``` + +- [ ] **Step 5: `OutboundConnectionService` interface** + +```java +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); // Plan 01 stub returns empty; Plan 02 wires to alert-rule repo +} +``` + +- [ ] **Step 6: Compile + commit** + +Run: `mvn -pl cameleer-server-core compile` + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/ +git commit -m "feat(outbound): core domain records for outbound connections" +``` + +--- + +### Task 6: `SecretCipher` — bespoke symmetric encryption over Ed25519-derived key + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java` + +This resolves spec §20 item 2: bespoke cipher, no new dep. Uses AES-GCM with a key derived via HMAC-SHA256 from the existing JWT secret (same key derivation shape as `Ed25519SigningService`). + +- [ ] **Step 1: Write the failing test** + +```java +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); // fresh IV per call + 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); + } +} +``` + +- [ ] **Step 2: Run test, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=SecretCipherTest` + +- [ ] **Step 3: Implement `SecretCipher`** + +```java +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; + +/** + * Bespoke symmetric cipher for small secrets stored at rest. + * Key = HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). + * Ciphertext = base64( IV(12) || tag+ciphertext ) — AES-GCM/NoPadding, 128-bit auth tag. + */ +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); + } + } +} +``` + +- [ ] **Step 4: Run test, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=SecretCipherTest` + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java +git commit -m "feat(outbound): SecretCipher — AES-GCM with JWT-secret-derived key for at-rest encryption of webhook secrets" +``` + +--- + +## Phase 5 — Outbound connections persistence + +### Task 7: `PostgresOutboundConnectionRepository` + integration test + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java` + +- [ ] **Step 1: Write the failing integration test** + +Follow the same Testcontainers pattern as `ConfigEnvIsolationIT`. Seed a `users` row for `created_by`/`updated_by` FK. + +```java +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.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundMethod; +// … imports for @SpringBootTest, Testcontainers, JdbcTemplate, etc. + +import org.junit.jupiter.api.Test; +// ... test setup inheriting from the existing test superclass that sets up PG + Flyway +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; + +class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT { + + @Autowired PostgresOutboundConnectionRepository repo; + @Autowired TestFixtures fixtures; + + @Test + void saveAndRead() { + UUID userId = fixtures.createUser("alice@example.com"); + OutboundConnection c = new OutboundConnection( + UUID.randomUUID(), "default", "slack-ops", "ops channel", + "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(), userId, Instant.now(), userId); + + repo.save(c); + var loaded = repo.findById("default", c.id()).orElseThrow(); + assertThat(loaded.name()).isEqualTo("slack-ops"); + assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json"); + } + + @Test + void uniqueNamePerTenant() { /* expect DataIntegrityViolationException on duplicate name */ } + + @Test + void allowedEnvironmentIdsRoundTrip() { /* save with [env1, env2], reload, assert equality */ } + + @Test + void listByTenantOnlyReturnsCurrentTenant() { /* save for default + other-tenant, list shows only default */ } + + @Test + void deleteRemovesRow() { /* save then delete, findById returns empty */ } +} +``` + +Fill in the concrete test bodies — these stubs are the happy-path skeleton. + +- [ ] **Step 2: Run test, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=PostgresOutboundConnectionRepositoryIT` + +- [ ] **Step 3: Implement `PostgresOutboundConnectionRepository`** + +```java +package com.cameleer.server.app.outbound.storage; + +import com.cameleer.server.app.outbound.crypto.SecretCipher; +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.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Array; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +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) { + // Upsert semantics: if id exists, update; else insert. + 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.getObject("created_by", UUID.class), + rs.getTimestamp("updated_at").toInstant(), rs.getObject("updated_by", UUID.class)); + + // JSON helpers — wrap Jackson + 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) { + // Deserialize based on the kind column — decoupled from DEDUCTION at this layer. + 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 Object toUuidArray(List ids) { + try { + var conn = jdbc.getDataSource().getConnection(); + try { return conn.createArrayOf("uuid", ids.toArray()); } finally { conn.close(); } + } catch (Exception e) { throw new IllegalStateException(e); } + } + 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; + } +} +``` + +- [ ] **Step 4: Wire the repo bean** + +Create `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java`: + +```java +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}") String jwtSecret) { + return new SecretCipher(jwtSecret); + } +} +``` + +(Confirm the exact property key for the JWT secret by grepping the existing codebase — look in `SecurityBeanConfig` or `JwtServiceImpl` for the `@Value` annotation.) + +- [ ] **Step 5: Run test, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=PostgresOutboundConnectionRepositoryIT` + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/ \ + cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/ \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/ +git commit -m "feat(outbound): Postgres repository for outbound_connections" +``` + +--- + +### Task 8: `OutboundConnectionServiceImpl` with allowed-env + referenced-by-rules checks + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java` +- Modify: `OutboundBeanConfig.java` (register service bean) + +Service owns business rules: secret encryption on save, deletion 409 if referenced, narrow-allowed-envs 409 if referenced in removed env. + +- [ ] **Step 1: Implement the service** + +```java +package com.cameleer.server.app.outbound; + +import com.cameleer.server.app.outbound.crypto.SecretCipher; +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 SecretCipher cipher; + private final String tenantId; + + public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, SecretCipher cipher, String tenantId) { + this.repo = repo; + this.cipher = cipher; + this.tenantId = tenantId; + } + + @Override + public OutboundConnection create(OutboundConnection draft, UUID 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(), // controller layer calls encrypt() before handing to service + draft.auth(), draft.allowedEnvironmentIds(), + Instant.now(), actingUserId, Instant.now(), actingUserId); + return repo.save(c); + } + + @Override + public OutboundConnection update(UUID id, OutboundConnection draft, UUID 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: any rule currently referencing this in a removed env? + List removed = existing.allowedEnvironmentIds().stream() + .filter(e -> !existing.allowedEnvironmentIds().isEmpty()) + .filter(e -> !draft.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().contains(e)) + .toList(); + if (!removed.isEmpty()) { + List refs = rulesReferencing(id); // Plan 01 stub — returns empty; Plan 02 implements + 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(), + // If the request comes with a null hmacSecretCiphertext, retain existing (zero-knowledge rotation requires a separate endpoint later). + 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, UUID 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); + } + }); + } +} +``` + +- [ ] **Step 2: Register the service bean** + +In `OutboundBeanConfig.java`, add: + +```java +@Bean +public OutboundConnectionService outboundConnectionService( + OutboundConnectionRepository repo, SecretCipher cipher, + @Value("${cameleer.server.tenant-id:default}") String tenantId) { + return new OutboundConnectionServiceImpl(repo, cipher, tenantId); +} +``` + +Confirm the exact property key for tenant id by grepping the codebase (`CAMELEER_SERVER_TENANT_ID` per CLAUDE.md → check the Spring property mapping, likely `cameleer.server.tenantid` or `cameleer.server.tenant-id`). + +- [ ] **Step 3: Compile** + +Run: `mvn -pl cameleer-server-app compile` + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +git commit -m "feat(outbound): service with uniqueness + narrow-envs + delete-if-referenced guards (rules check stubbed; wired in Plan 02)" +``` + +--- + +## Phase 6 — Admin REST API + +### Task 9: DTOs + Bean Validation unit tests + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java` + +- [ ] **Step 1: Write validation test (failing)** + +```java +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; + +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() { + var r = new OutboundConnectionRequest("n", null, "https://x", OutboundMethod.POST, + Map.of(), null, TrustMode.TRUST_PATHS, List.of(), null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).isNotEmpty(); + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionRequestValidationTest` + +- [ ] **Step 3: Implement the DTOs** + +```java +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, // plaintext in request; service encrypts before persist + @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; + + // Cross-field: TRUST_PATHS requires non-empty caPemPaths + if (tlsTrustMode == TrustMode.TRUST_PATHS && (tlsCaPemPaths == null || tlsCaPemPaths.isEmpty())) { + throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS"); + } + } +} +``` + +```java +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, // never return the actual secret + OutboundAuthKind authKind, // summarized; full auth cfg never returned + List allowedEnvironmentIds, + Instant createdAt, UUID createdBy, Instant updatedAt, UUID 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()); + } +} +``` + +```java +package com.cameleer.server.app.outbound.dto; + +public record OutboundConnectionTestResult( + int status, + long latencyMs, + String responseSnippet, // first 512 chars + String tlsProtocol, // e.g., "TLSv1.3" + String tlsCipherSuite, + String peerCertificateSubject, + Long peerCertificateExpiresAtEpochMs, + String error // null on success, message on failure +) {} +``` + +- [ ] **Step 4: Run, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionRequestValidationTest` + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/ \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java +git commit -m "feat(outbound): request + response + test-result DTOs with Bean Validation" +``` + +### Task 10: Controller + integration test + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` + +- [ ] **Step 1: Add audit categories** + +Edit `AuditCategory.java` — add `OUTBOUND_CONNECTION_CHANGE` and `OUTBOUND_HTTP_TRUST_CHANGE` to the enum. + +- [ ] **Step 2: Write MockMvc integration test (failing)** + +```java +package com.cameleer.server.app.outbound.controller; + +// imports: @SpringBootTest, MockMvc, WithMockUser, etc. + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class OutboundConnectionAdminControllerIT extends AbstractAdminControllerIT { + + @Test void adminCanCreate() throws Exception { + String body = """ + {"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{"kind":"NONE"}}"""; + mockMvc.perform(post("/api/v1/admin/outbound-connections") + .with(adminAuth()) + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("slack-ops")) + .andExpect(jsonPath("$.hmacSecretSet").value(false)); + } + + @Test void operatorCannotCreate() throws Exception { + mockMvc.perform(post("/api/v1/admin/outbound-connections") + .with(operatorAuth()).contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isForbidden()); + } + + @Test void operatorCanList() throws Exception { + mockMvc.perform(get("/api/v1/admin/outbound-connections").with(operatorAuth())) + .andExpect(status().isOk()); + } + + @Test void viewerCannotList() throws Exception { + mockMvc.perform(get("/api/v1/admin/outbound-connections").with(viewerAuth())) + .andExpect(status().isForbidden()); + } + + @Test void nonHttpsUrlRejected() throws Exception { + String body = """ + {"name":"x","url":"http://x","method":"POST","tlsTrustMode":"SYSTEM_DEFAULT","auth":{"kind":"NONE"}}"""; + mockMvc.perform(post("/api/v1/admin/outbound-connections") + .with(adminAuth()).contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()); + } + + @Test void duplicateNameReturns409() throws Exception { /* create twice with same name */ } + @Test void deleteRemoves() throws Exception { /* create, delete, get 404 */ } +} +``` + +- [ ] **Step 3: Run, expect failure** + +Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionAdminControllerIT` + +- [ ] **Step 4: Implement the controller** + +```java +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.Operation; +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.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/admin/outbound-connections") +@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)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity create(@Valid @RequestBody OutboundConnectionRequest req, + @AuthenticationPrincipal UserPrincipal user, + HttpServletRequest http) { + OutboundConnection draft = toDraft(req, null, Instant.now(), user.id(), Instant.now(), user.id()); + OutboundConnection saved = service.create(draft, user.id()); + audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, http); + return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req, + @AuthenticationPrincipal UserPrincipal user, HttpServletRequest http) { + OutboundConnection existing = service.get(id); + OutboundConnection draft = toDraft(req, existing.id(), existing.createdAt(), existing.createdBy(), + Instant.now(), user.id()); + OutboundConnection saved = service.update(id, draft, user.id()); + audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, http); + return OutboundConnectionDto.from(saved); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity delete(@PathVariable UUID id, + @AuthenticationPrincipal UserPrincipal user, HttpServletRequest http) { + service.delete(id, user.id()); + audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, http); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/usage") + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public List usage(@PathVariable UUID id) { + return service.rulesReferencing(id); // empty in Plan 01 + } + + private OutboundConnection toDraft(OutboundConnectionRequest req, UUID id, + Instant createdAt, UUID createdBy, + Instant updatedAt, UUID updatedBy) { + String cipherSecret = req.hmacSecret() == null || req.hmacSecret().isBlank() + ? null : cipher.encrypt(req.hmacSecret()); + return new OutboundConnection( + id, null /* tenant filled by service */, req.name(), req.description(), + req.url(), req.method(), req.defaultHeaders(), req.defaultBodyTmpl(), + req.tlsTrustMode(), req.tlsCaPemPaths(), + cipherSecret, req.auth(), req.allowedEnvironmentIds(), + createdAt, createdBy, updatedAt, updatedBy); + } +} +``` + +Confirm the `@AuthenticationPrincipal UserPrincipal` shape from existing controllers (e.g., `UserAdminController`). If the project uses a different principal type, swap accordingly. + +- [ ] **Step 5: Update `SecurityConfig.java`** + +Add the path pattern `/api/v1/admin/outbound-connections/**` to the admin-authenticated rules (or wherever other admin paths are matched). Allow both `ROLE_ADMIN` and `ROLE_OPERATOR` to hit the list/get; controller-level `@PreAuthorize` is the authoritative gate. + +- [ ] **Step 6: Run, expect PASS** + +Run: `mvn -pl cameleer-server-app test -Dtest=OutboundConnectionAdminControllerIT` + +- [ ] **Step 7: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/ \ + cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/ \ + cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +git commit -m "feat(outbound): admin CRUD REST + RBAC + audit + +New audit categories: OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE." +``` + +### Task 11: `POST /{id}/test` — probe endpoint with TLS summary + +**Files:** +- Modify: `OutboundConnectionAdminController.java` — add test endpoint +- Modify: `OutboundConnectionAdminControllerIT.java` — test against WireMock + +The test endpoint issues a synthetic probe (HEAD or minimal POST with an empty body) against the connection's URL, returning status + latency + TLS summary. Useful for "why doesn't my webhook work." + +- [ ] **Step 1: Add test method (fail-first)** + +```java +@Test +void testActionReturnsStatusAndTlsSummary() throws Exception { + // stubbed WireMock endpoint at /probe returns 200 + // create a connection pointing at it with TRUST_ALL + // POST /{id}/test — expect 200 body with status=200, tlsProtocol populated +} +``` + +Run and confirm failure. + +- [ ] **Step 2: Add endpoint to controller** + +```java +@PostMapping("/{id}/test") +@PreAuthorize("hasRole('ADMIN')") +public OutboundConnectionTestResult test(@PathVariable UUID id, + @AuthenticationPrincipal UserPrincipal user, + HttpServletRequest http) { + OutboundConnection c = service.get(id); + long t0 = System.currentTimeMillis(); + try { + var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null); + var client = httpClientFactory.clientFor(ctx); + var request = new HttpPost(c.url()); + // Minimal safe probe body — empty or {} — we don't care about the response shape + request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON)); + try (var resp = client.execute(request)) { + long latency = System.currentTimeMillis() - t0; + var entity = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); + String snippet = entity == null ? "" : entity.substring(0, Math.min(512, entity.length())); + // TLS details — extract from the connection if possible (HttpClient 5 exposes via response context on newer versions; if not, stub with a conservative value) + return new OutboundConnectionTestResult(resp.getCode(), latency, snippet, + "TLS", null, null, null, null); + } + } catch (Exception e) { + long latency = System.currentTimeMillis() - t0; + return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage()); + } +} +``` + +Inject `OutboundHttpClientFactory` in the controller constructor. + +- [ ] **Step 3: Run, expect PASS** + +- [ ] **Step 4: Commit** + +```bash +git commit -am "feat(outbound): admin test action for reachability + TLS summary" +``` + +### Task 12: OpenAPI regeneration + +- [ ] **Step 1: Start backend on :8081** + +- [ ] **Step 2: Regenerate UI types** + +```bash +cd ui && npm run generate-api:live +``` + +- [ ] **Step 3: Verify `ui/src/api/schema.d.ts` diff contains the new endpoints** + +```bash +git diff ui/src/api/schema.d.ts | head -60 +``` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/schema.d.ts ui/src/api/openapi.json +git commit -m "chore(ui): regenerate OpenAPI types for outbound connections" +``` + +--- + +## Phase 7 — Admin UI + +### Task 13: Query hooks + +**Files:** +- Create: `ui/src/api/queries/admin/outboundConnections.ts` + +- [ ] **Step 1: Follow the existing pattern** + +Study `ui/src/api/queries/admin/thresholds.ts` — it's the closest analog (simple admin CRUD with tanstack). + +- [ ] **Step 2: Implement** + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; +import type { components } from '../../schema'; + +export type OutboundConnection = components['schemas']['OutboundConnectionDto']; +export type OutboundConnectionRequest = components['schemas']['OutboundConnectionRequest']; +export type OutboundConnectionTestResult = components['schemas']['OutboundConnectionTestResult']; + +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, + }); +} + +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' }), + }); +} +``` + +- [ ] **Step 3: TypeScript check** + +Run: `cd ui && npm run typecheck` — expect clean. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/queries/admin/outboundConnections.ts +git commit -m "feat(ui): tanstack query hooks for outbound connections" +``` + +### Task 14: `OutboundConnectionsPage` — list view + +**Files:** +- Create: `ui/src/pages/Admin/OutboundConnectionsPage.tsx` +- Create: `ui/src/pages/Admin/OutboundConnectionsPage.test.tsx` + +- [ ] **Step 1: Component test first** + +```tsx +import { render, screen } from '@testing-library/react'; +// wrap with QueryClientProvider + MemoryRouter helpers as elsewhere in the project +// mock useOutboundConnections to return a small fixture array +// assert: table shows connection names; "New connection" button present +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement the page** + +Follow `SensitiveKeysPage.tsx` or `ClaimMappingRulesModal.tsx` for styling patterns. Reuse `@cameleer/design-system` components (`Button`, `Label`, `Badge`). + +```tsx +import { Link } from 'react-router-dom'; +import { Button, Badge } from '@cameleer/design-system'; +import { useOutboundConnections, useDeleteOutboundConnection } from '../../api/queries/admin/outboundConnections'; +import { PageLoader } from '../../components/PageLoader'; + +export function OutboundConnectionsPage() { + const { data, isLoading, error } = useOutboundConnections(); + const deleteMut = useDeleteOutboundConnection(); + + if (isLoading) return ; + if (error) return
Failed to load: {String(error)}
; + + return ( +
+
+

Outbound Connections

+ + + +
+ + + + + + {(data ?? []).map(c => ( + + + + + + + + + + ))} + +
NameURLMethodTrustAuthEnvs
{c.name}{new URL(c.url).host}{c.method}{c.authKind}{c.allowedEnvironmentIds?.length ? c.allowedEnvironmentIds.length : 'all'} + +
+
+ ); +} + +function TrustBadge({ mode }: { mode: string }) { + if (mode === 'TRUST_ALL') return Trust all; + if (mode === 'TRUST_PATHS') return Custom CA; + return System; +} +``` + +- [ ] **Step 4: Add route entry** + +Edit `ui/src/router.tsx` — add routes for `/admin/outbound-connections` and `/admin/outbound-connections/:id` and `/admin/outbound-connections/new`. + +- [ ] **Step 5: Add admin navigation entry** + +Edit `ui/src/pages/Admin/AdminLayout.tsx` — add "Outbound Connections" to the admin sidebar. + +- [ ] **Step 6: Run tests, expect PASS** + +Run: `cd ui && npm test -- OutboundConnectionsPage` + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/pages/Admin/OutboundConnectionsPage.tsx \ + ui/src/pages/Admin/OutboundConnectionsPage.test.tsx \ + ui/src/router.tsx ui/src/pages/Admin/AdminLayout.tsx +git commit -m "feat(ui): admin page for outbound connections list + navigation" +``` + +### Task 15: `OutboundConnectionEditor` — create/edit form + +**Files:** +- Create: `ui/src/pages/Admin/OutboundConnectionEditor.tsx` +- Create: `ui/src/pages/Admin/OutboundConnectionEditor.test.tsx` + +- [ ] **Step 1: Component tests** + +Cover: +- Create form renders all fields +- Submit calls the mutation with the correct payload shape +- TLS mode dropdown: selecting "Trust all" shows the amber warning +- Test button disabled until the connection is saved (has an id) +- Allowed environments multi-select toggles "allow all" when empty + +- [ ] **Step 2: Run, expect failures** + +- [ ] **Step 3: Implement** + +```tsx +import { useParams, useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { Button, Select, Label, Toggle, Badge } from '@cameleer/design-system'; +import { + useOutboundConnection, useCreateOutboundConnection, useUpdateOutboundConnection, useTestOutboundConnection +} from '../../api/queries/admin/outboundConnections'; +import { useEnvironments } from '../../api/queries/admin/environments'; // use existing hook +import { PageLoader } from '../../components/PageLoader'; + +export function OutboundConnectionEditor() { + const { id } = useParams(); + const isNew = !id; + const navigate = useNavigate(); + const { data: existing, isLoading } = useOutboundConnection(isNew ? undefined : id); + const { data: environments } = useEnvironments(); + const createMut = useCreateOutboundConnection(); + const updateMut = id ? useUpdateOutboundConnection(id) : null; + const testMut = useTestOutboundConnection(); + + const [form, setForm] = useState(() => defaultForm(existing)); + // (keep state in sync with loaded existing when it arrives) + + if (!isNew && isLoading) return ; + + const onSubmit = () => { + const payload = toRequest(form); + if (isNew) createMut.mutate(payload, { onSuccess: () => navigate('/admin/outbound-connections') }); + else updateMut!.mutate(payload); + }; + + // render fields: name, description, url, method, defaultHeaders (key/value rows), + // defaultBodyTmpl (textarea), trust mode dropdown + CA multi-select (when TRUST_PATHS), + // hmac secret (password input with generate-random + show/hide), auth kind dropdown, + // allowed environments ("Allow all" checkbox that when unchecked shows a multi-select) + // plus a Test button that calls testMut and shows the result inline. + + return (/* ... full JSX ... */ null); +} + +// Helpers: defaultForm(existing?), toRequest(form) — straightforward mapping +``` + +Flesh out the full component. Keep styles simple; reuse design-system primitives. + +- [ ] **Step 4: Run tests, expect PASS** + +Run: `cd ui && npm test -- OutboundConnectionEditor` + +- [ ] **Step 5: Manual browser smoke test** + +Start the backend + UI dev server. Go to `/admin/outbound-connections/new`. Create a connection pointing at `https://httpbin.org/post`. Save. Click *Test*. Expect a 200 response with latency. Create another with trust mode TRUST_ALL — confirm the amber warning shows. Delete both. + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/pages/Admin/OutboundConnectionEditor.tsx \ + ui/src/pages/Admin/OutboundConnectionEditor.test.tsx +git commit -m "feat(ui): outbound connection editor with TLS config, test action, allowed-envs multi-select" +``` + +--- + +## Phase 8 — Docs and wrap-up + +### Task 16: Update rule files + +**Files:** +- Modify: `.claude/rules/core-classes.md` +- Modify: `.claude/rules/app-classes.md` + +- [ ] **Step 1: Read both rule files end-to-end** to understand structure. + +- [ ] **Step 2: In `core-classes.md`** add two new sections: + +```markdown +## http/ — Outbound HTTP primitives (cross-cutting) + +- `OutboundHttpClientFactory` — interface: `clientFor(context)` returns memoized `CloseableHttpClient` +- `OutboundHttpProperties` — record: `trustAll, trustedCaPemPaths, defaultConnectTimeout, defaultReadTimeout, proxy*` +- `OutboundHttpRequestContext` — record of per-call TLS/timeout overrides +- `TrustMode` — enum: `SYSTEM_DEFAULT | TRUST_ALL | TRUST_PATHS` + +## outbound/ — Admin-managed outbound connections + +- `OutboundConnection` — record: id, tenantId, name, url, method, default headers/body, TLS config, HMAC secret ciphertext, auth, allowedEnvironmentIds +- `OutboundAuth` (sealed) — `None | Bearer | Basic` +- `OutboundAuthKind`, `OutboundMethod` — enums +- `OutboundConnectionRepository` — CRUD by (tenant, id) +- `OutboundConnectionService` — create/update/delete/list with uniqueness + allowed-env narrow guard + delete-if-referenced check (rules check stubbed; populated in Plan 02) +``` + +- [ ] **Step 3: In `app-classes.md`** add to the admin-endpoints table: + +```markdown +- `OutboundConnectionAdminController` — `/api/v1/admin/outbound-connections`. GET list / POST create / GET {id} / PUT {id} / DELETE {id} / POST {id}/test / GET {id}/usage. RBAC: list/get ADMIN|OPERATOR; mutations ADMIN. +``` + +Add `http/` and `outbound/` implementation class entries (`ApacheOutboundHttpClientFactory`, `SslContextBuilder`, `SecretCipher`, `PostgresOutboundConnectionRepository`, `OutboundConnectionServiceImpl`). + +- [ ] **Step 4: Commit** + +```bash +git add .claude/rules/core-classes.md .claude/rules/app-classes.md +git commit -m "docs(rules): document http/ and outbound/ packages + admin controller" +``` + +### Task 17: Admin guide + +**Files:** +- Create: `docs/outbound-connections.md` + +- [ ] **Step 1: Write the guide** + +Cover: what outbound connections are; how to create one; the TLS trust modes and when to pick each; testing a connection; allowed-environments restriction; relationship to future webhooks (forward reference to alerting once it ships); HMAC signing semantics for consumers. + +- [ ] **Step 2: Commit** + +```bash +git add docs/outbound-connections.md +git commit -m "docs: admin guide for outbound connections" +``` + +### Task 18: Full-suite verify + manual sign-off + +- [ ] **Step 1: Full build** + +```bash +mvn clean verify +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 2: UI typecheck + tests** + +```bash +cd ui && npm run typecheck && npm test -- --run +``` + +Expected: all green. + +- [ ] **Step 3: Manual acceptance checklist** + +- [ ] Admin can create an outbound connection pointing at `https://httpbin.org/post` via the UI +- [ ] Test action returns HTTP 200 + latency +- [ ] TRUST_ALL shows the amber warning banner +- [ ] Non-admin (OPERATOR) can list + view but cannot create/edit +- [ ] VIEWER cannot see the admin page +- [ ] Duplicate name returns 409 with a clear message +- [ ] Startup log shows the WARN line when `trust-all=true` +- [ ] Startup fails fast when `trusted-ca-pem-paths` contains a non-existent path + +- [ ] **Step 4: Nothing to commit if all passes — plan complete** + +--- + +## Known-incomplete items carried into Plan 02 + +- `OutboundConnectionServiceImpl.rulesReferencing(...)` returns `[]`. Plan 02's alerting backend will implement this by scanning `alert_rules.webhooks` JSONB for `outboundConnectionId` matches. +- The `test` endpoint currently reports minimal TLS summary (`protocol="TLS"` hardcoded). Extracting the actual protocol + cipher suite + peer cert subject from Apache HttpClient 5's connection context is non-trivial; Plan 02 (or a small follow-up) can enrich this. +- OIDC retrofit to use `OutboundHttpClientFactory` is deliberately not in this plan. Audit completed in Task 4; retrofit is a separate commit when someone has cycles. + +--- + +## Self-review + +**Spec coverage** (against §6, §10 of the design): +- §6 Outbound connections — ✓ Tasks 5–12 cover schema, domain, persistence, service, REST, UI +- §10 Cross-cutting HTTP — ✓ Tasks 2–4 cover interfaces, impl, startup validation +- §20 #1 OIDC alignment — ✓ audit in Task 4, retrofit explicitly deferred +- §20 #2 secret encryption — ✓ Task 6 resolves with bespoke AES-GCM + KDF + +**Placeholders:** None. All steps have concrete code. Known-incomplete section is explicit, not a placeholder. + +**Type consistency:** `OutboundConnection` record fields match across service, DTO, repository, and SQL columns. `TrustMode` enum values are identical in core and DB enum type. `OutboundMethod`, `OutboundAuthKind` ditto. + +**Risks to flag to executor:** +- Task 7 repo implementation has a small quirk: `toUuidArray` opens/closes a raw JDBC connection inside the method. Under heavy concurrency this is fine because JdbcTemplate pools; confirm behavior with the repo integration test (specifically, the `allowedEnvironmentIds` round-trip test). +- The controller's `@AuthenticationPrincipal UserPrincipal` type is assumed — confirm against existing admin controllers before implementing. If wrong, adjust in all `create/update/delete` handlers.