Files
cameleer-server/docs/superpowers/plans/2026-04-19-alerting-01-outbound-infra.md
hsiegeln 77a23c270b
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m57s
CI / docker (push) Successful in 1m6s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 44s
docs(alerting): Plan 01 — outbound HTTP infra + admin-managed outbound connections
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) <noreply@anthropic.com>
2026-04-19 15:26:00 +02:00

93 KiB
Raw Blame History

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

-- 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
psql -d cameleer -c "\d outbound_connections" -c "\dT+ trust_mode_enum"

Expected: table exists with all columns; enums defined.

  • Step 4: Commit
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

package com.cameleer.server.core.http;

public enum TrustMode {
    SYSTEM_DEFAULT,
    TRUST_ALL,
    TRUST_PATHS
}
  • Step 2: OutboundHttpProperties
package com.cameleer.server.core.http;

import java.time.Duration;
import java.util.List;

public record OutboundHttpProperties(
        boolean trustAll,
        List<String> 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
package com.cameleer.server.core.http;

import java.time.Duration;
import java.util.List;

public record OutboundHttpRequestContext(
        TrustMode trustMode,
        List<String> 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
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
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").

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
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
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<X509Certificate> extraCerts = new ArrayList<>();
        List<String> 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<? extends X509Certificate> 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<X509Certificate> certs = (Collection<X509Certificate>) 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
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.

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
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<CacheKey, CloseableHttpClient> 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<String> 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
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<String> 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<String> 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:

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
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

package com.cameleer.server.core.outbound;

public enum OutboundMethod { POST, PUT, PATCH }
package com.cameleer.server.core.outbound;

public enum OutboundAuthKind { NONE, BEARER, BASIC }
  • Step 2: OutboundAuth sealed interface
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
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<String, String> defaultHeaders,
        String defaultBodyTmpl,
        TrustMode tlsTrustMode,
        List<String> tlsCaPemPaths,
        String hmacSecretCiphertext,          // encrypted via SecretCipher
        OutboundAuth auth,
        List<UUID> 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
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<OutboundConnection> findById(String tenantId, UUID id);
    Optional<OutboundConnection> findByName(String tenantId, String name);
    List<OutboundConnection> listByTenant(String tenantId);
    void delete(String tenantId, UUID id);
}
  • Step 5: OutboundConnectionService interface
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<OutboundConnection> list();
    List<UUID> 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

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
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
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
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.

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
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<OutboundConnection> 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<OutboundConnection> 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<OutboundConnection> 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<OutboundConnection> 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<String, String> readMapString(String json) {
        try { return mapper.readValue(json, new TypeReference<>() {}); }
        catch (Exception e) { throw new IllegalStateException(e); }
    }
    private List<String> 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<UUID> 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<UUID> readUuidArray(Array arr) throws SQLException {
        if (arr == null) return List.of();
        Object[] raw = (Object[]) arr.getArray();
        List<UUID> 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:

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
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
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<UUID> removed = existing.allowedEnvironmentIds().stream()
                .filter(e -> !existing.allowedEnvironmentIds().isEmpty())
                .filter(e -> !draft.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().contains(e))
                .toList();
        if (!removed.isEmpty()) {
            List<UUID> 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<UUID> refs = rulesReferencing(id); // Plan 01 stub
        if (!refs.isEmpty()) {
            throw new ResponseStatusException(HttpStatus.CONFLICT,
                "Outbound connection is referenced by rules: " + refs);
        }
        repo.delete(tenantId, id);
    }

    @Override public OutboundConnection get(UUID id) {
        return repo.findById(tenantId, id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

    @Override public List<OutboundConnection> list() { return repo.listByTenant(tenantId); }

    @Override
    public List<UUID> rulesReferencing(UUID id) {
        // Plan 01 stub. Plan 02 will wire this to AlertRuleRepository.
        return List.of();
    }

    private void assertNameUnique(String name, UUID excludingId) {
        repo.findByName(tenantId, name).ifPresent(c -> {
            if (!c.id().equals(excludingId)) {
                throw new ResponseStatusException(HttpStatus.CONFLICT, "Connection name already exists: " + name);
            }
        });
    }
}
  • Step 2: Register the service bean

In OutboundBeanConfig.java, add:

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

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
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<String, String> defaultHeaders,
        String defaultBodyTmpl,
        @NotNull TrustMode tlsTrustMode,
        List<String> tlsCaPemPaths,
        String hmacSecret,            // plaintext in request; service encrypts before persist
        @NotNull @Valid OutboundAuth auth,
        List<UUID> 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");
        }
    }
}
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<String, String> defaultHeaders, String defaultBodyTmpl,
        TrustMode tlsTrustMode, List<String> tlsCaPemPaths,
        boolean hmacSecretSet,                     // never return the actual secret
        OutboundAuthKind authKind,                  // summarized; full auth cfg never returned
        List<UUID> 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());
    }
}
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
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)
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
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<OutboundConnectionDto> 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<OutboundConnectionDto> 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<Void> 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<UUID> 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
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)
@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
@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

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

cd ui && npm run generate-api:live
  • Step 3: Verify ui/src/api/schema.d.ts diff contains the new endpoints
git diff ui/src/api/schema.d.ts | head -60
  • Step 4: Commit
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
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<OutboundConnection[]>('/outbound-connections'),
  });
}

export function useOutboundConnection(id: string | undefined) {
  return useQuery({
    queryKey: ['admin', 'outbound-connections', id],
    queryFn: () => adminFetch<OutboundConnection>(`/outbound-connections/${id}`),
    enabled: !!id,
  });
}

export function useCreateOutboundConnection() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (req: OutboundConnectionRequest) =>
      adminFetch<OutboundConnection>('/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<OutboundConnection>(`/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<void>(`/outbound-connections/${id}`, { method: 'DELETE' }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }),
  });
}

export function useTestOutboundConnection() {
  return useMutation({
    mutationFn: (id: string) =>
      adminFetch<OutboundConnectionTestResult>(`/outbound-connections/${id}/test`, { method: 'POST' }),
  });
}
  • Step 3: TypeScript check

Run: cd ui && npm run typecheck — expect clean.

  • Step 4: Commit
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

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).

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 <PageLoader />;
  if (error) return <div>Failed to load: {String(error)}</div>;

  return (
    <div>
      <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <h2>Outbound Connections</h2>
        <Link to="/admin/outbound-connections/new">
          <Button variant="primary">New connection</Button>
        </Link>
      </header>
      <table>
        <thead>
          <tr><th>Name</th><th>URL</th><th>Method</th><th>Trust</th><th>Auth</th><th>Envs</th><th></th></tr>
        </thead>
        <tbody>
          {(data ?? []).map(c => (
            <tr key={c.id}>
              <td><Link to={`/admin/outbound-connections/${c.id}`}>{c.name}</Link></td>
              <td><code>{new URL(c.url).host}</code></td>
              <td>{c.method}</td>
              <td><TrustBadge mode={c.tlsTrustMode} /></td>
              <td>{c.authKind}</td>
              <td>{c.allowedEnvironmentIds?.length ? c.allowedEnvironmentIds.length : 'all'}</td>
              <td>
                <Button variant="secondary" onClick={() => {
                  if (confirm(`Delete ${c.name}?`)) deleteMut.mutate(c.id!);
                }}>Delete</Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function TrustBadge({ mode }: { mode: string }) {
  if (mode === 'TRUST_ALL') return <Badge variant="error">Trust all</Badge>;
  if (mode === 'TRUST_PATHS') return <Badge variant="warning">Custom CA</Badge>;
  return <Badge variant="default">System</Badge>;
}
  • 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
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

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 <PageLoader />;

  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
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:

## 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:
- `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
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
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
mvn clean verify

Expected: BUILD SUCCESS.

  • Step 2: UI typecheck + tests
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 512 cover schema, domain, persistence, service, REST, UI
  • §10 Cross-cutting HTTP — ✓ Tasks 24 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.