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>
93 KiB
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_secretencryption 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
SslContextBuilderexists. 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— enumcameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java— record of system-wide configcameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java— record of per-call TLS/timeout overridescameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java— interfacecameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java— enumPOST|PUT|PATCHcameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java— enumNONE|BEARER|BASICcameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java— sealed interface + subtypescameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java— recordcameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java— interfacecameleer-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.javacameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.javacameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.javacameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.javacameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql
New files (UI)
ui/src/api/queries/admin/outboundConnections.tsui/src/pages/Admin/OutboundConnectionsPage.tsxui/src/pages/Admin/OutboundConnectionEditor.tsx
Test files
cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.javacameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.javacameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.javacameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.javacameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.javacameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.javaui/src/pages/Admin/OutboundConnectionsPage.test.tsxui/src/pages/Admin/OutboundConnectionEditor.test.tsx
Modified files
cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java— add two new categoriescameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java— path matchers for new admin endpointscameleer-server-app/src/main/resources/application.yml— defaultcameleer.server.outbound-http.*valuesui/src/router.tsx— route entry for admin outbound connectionsui/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,ClickHouseStatsStoreITfor 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 verifyfor fast feedback on changed modules. Full buildmvn 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
@Autowiredfield 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:
OutboundHttpClientFactoryinterface
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 defaultcameleer.server.outbound-httpsection) -
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
OutboundHttpConfigwith @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:
OutboundAuthsealed 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:
OutboundConnectionrecord
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:
OutboundConnectionRepositoryinterface
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:
OutboundConnectionServiceinterface
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.tsdiff 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.mdadd 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.mdadd 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/postvia 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-pathscontains 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 scanningalert_rules.webhooksJSONB foroutboundConnectionIdmatches.- The
testendpoint 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
OutboundHttpClientFactoryis deliberately not in this plan. Audit completed in Task 4; retrofit is a separate commit when someone has cycles.
Self-review
Spec coverage (against §6, §10 of the design):
- §6 Outbound connections — ✓ Tasks 5–12 cover schema, domain, persistence, service, REST, UI
- §10 Cross-cutting HTTP — ✓ Tasks 2–4 cover interfaces, impl, startup validation
- §20 #1 OIDC alignment — ✓ audit in Task 4, retrofit explicitly deferred
- §20 #2 secret encryption — ✓ Task 6 resolves with bespoke AES-GCM + KDF
Placeholders: None. All steps have concrete code. Known-incomplete section is explicit, not a placeholder.
Type consistency: OutboundConnection record fields match across service, DTO, repository, and SQL columns. TrustMode enum values are identical in core and DB enum type. OutboundMethod, OutboundAuthKind ditto.
Risks to flag to executor:
- Task 7 repo implementation has a small quirk:
toUuidArrayopens/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, theallowedEnvironmentIdsround-trip test). - The controller's
@AuthenticationPrincipal UserPrincipaltype is assumed — confirm against existing admin controllers before implementing. If wrong, adjust in allcreate/update/deletehandlers.