Merge pull request 'feat(alerting): Plan 01 — outbound HTTP infra + admin-managed outbound connections' (#139) from feat/alerting-01-outbound-infra into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m53s
CI / docker (push) Successful in 36s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

Reviewed-on: #139
This commit was merged in pull request #139.
This commit is contained in:
2026-04-20 08:57:40 +02:00
43 changed files with 2425 additions and 5 deletions

View File

@@ -23,7 +23,7 @@ These paths intentionally stay flat (no `/environments/{envSlug}` prefix). Every
| `/api/v1/agents/register`, `/refresh`, `/{id}/heartbeat`, `/{id}/events` (SSE), `/{id}/deregister`, `/{id}/commands`, `/{id}/commands/{id}/ack`, `/{id}/replay` | Agent self-service; JWT-bound. |
| `/api/v1/agents/commands`, `/api/v1/agents/groups/{group}/commands` | Operator fan-out; target scope is explicit in query params. |
| `/api/v1/agents/config` | Agent-authoritative config read; JWT → registry → (app, env). |
| `/api/v1/admin/{users,roles,groups,oidc,license,audit,rbac/stats,claim-mappings,thresholds,sensitive-keys,usage,clickhouse,database,environments}` | Truly cross-env admin. Env CRUD URLs use `{envSlug}`, not UUID. |
| `/api/v1/admin/{users,roles,groups,oidc,license,audit,rbac/stats,claim-mappings,thresholds,sensitive-keys,usage,clickhouse,database,environments,outbound-connections}` | Truly cross-env admin. Env CRUD URLs use `{envSlug}`, not UUID. |
| `/api/v1/catalog`, `/api/v1/catalog/{applicationId}` | Cross-env discovery is the purpose. Env is an optional filter via `?environment=`. |
| `/api/v1/executions/{execId}`, `/processors/**` | Exchange IDs are globally unique; permalinks. |
| `/api/v1/diagrams/{contentHash}/render`, `POST /api/v1/diagrams/render` | Content-addressed or stateless. |
@@ -81,6 +81,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `RoleAdminController` — CRUD `/api/v1/admin/roles`.
- `GroupAdminController` — CRUD `/api/v1/admin/groups`.
- `OidcConfigAdminController` — GET/POST `/api/v1/admin/oidc`, POST `/test`.
- `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/usage ADMIN|OPERATOR; mutations + test ADMIN.
- `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
- `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`.
@@ -134,7 +135,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
## security/ — Spring Security
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional
- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only.
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
- `OidcAuthController` — /api/v1/auth/oidc (login-uri, token-exchange, logout)
@@ -151,6 +152,23 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `JarRetentionJob`@Scheduled 03:00 daily, per-environment retention, skips deployed versions
## http/ — Outbound HTTP client implementation
- `SslContextBuilder` — composes SSL context from `OutboundHttpProperties` + `OutboundHttpRequestContext`. Supports SYSTEM_DEFAULT (JDK roots + configured CA extras), TRUST_ALL (short-circuit no-op TrustManager), TRUST_PATHS (JDK roots + system extras + per-request extras). Throws `IllegalArgumentException("CA file not found: ...")` on missing PEM.
- `ApacheOutboundHttpClientFactory` — Apache HttpClient 5 impl of `OutboundHttpClientFactory`. Memoizes clients per `CacheKey(trustAll, caPaths, mode, connectTimeout, readTimeout)`. Applies `NoopHostnameVerifier` when trust-all is active.
- `config/OutboundHttpConfig``@ConfigurationProperties("cameleer.server.outbound-http")`. Exposes beans: `OutboundHttpProperties`, `SslContextBuilder`, `OutboundHttpClientFactory`. `@PostConstruct` logs WARN on trust-all and throws if configured CA paths don't exist.
## outbound/ — Admin-managed outbound connections (implementation)
- `crypto/SecretCipher` — AES-GCM symmetric cipher with key derived via HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). Ciphertext format: base64(IV(12 bytes) || GCM output with 128-bit tag). `encrypt` throws `IllegalStateException`; `decrypt` throws `IllegalArgumentException` on tamper/wrong-key/malformed.
- `storage/PostgresOutboundConnectionRepository` — JdbcTemplate impl. `save()` upserts by id; JSONB serialization via ObjectMapper; UUID arrays via `ConnectionCallback`. Reads `created_by`/`updated_by` as String (= users.user_id TEXT).
- `OutboundConnectionServiceImpl` — service layer. Tenant bound at construction via `cameleer.server.tenant.id` property. Uniqueness check via `findByName`. Narrowing-envs guard: rejects update that removes envs while rules reference the connection (rulesReferencing stubbed in Plan 01, wired in Plan 02). Delete guard: rejects if referenced by rules.
- `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Extracts acting user id from `SecurityContextHolder.authentication.name`, strips "user:" prefix. Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`.
- `dto/OutboundConnectionRequest` — Bean Validation: `@NotBlank` name, `@Pattern("^https://.+")` url, `@NotNull` method/tlsTrustMode/auth. Compact ctor throws `IllegalArgumentException` if TRUST_PATHS with empty paths list.
- `dto/OutboundConnectionDto` — response DTO. `hmacSecretSet: boolean` instead of the ciphertext; `authKind: OutboundAuthKind` instead of the full auth config.
- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).
- `config/OutboundBeanConfig` — registers `OutboundConnectionRepository`, `SecretCipher`, `OutboundConnectionService` beans.
## config/ — Spring beans
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor

View File

@@ -78,7 +78,22 @@ paths:
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade
- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE`), `AuditRepository` — audit trail records and persistence
## http/ — Outbound HTTP primitives (cross-cutting)
- `OutboundHttpClientFactory` — interface: `clientFor(context)` returns memoized `CloseableHttpClient`
- `OutboundHttpProperties` — record: `trustAll, trustedCaPemPaths, defaultConnectTimeout, defaultReadTimeout, proxyUrl, proxyUsername, proxyPassword`
- `OutboundHttpRequestContext` — record of per-call TLS/timeout overrides; `systemDefault()` static factory
- `TrustMode` — enum: `SYSTEM_DEFAULT | TRUST_ALL | TRUST_PATHS`
## outbound/ — Admin-managed outbound connections
- `OutboundConnection` — record: id, tenantId, name, description, url, method, defaultHeaders, defaultBodyTmpl, tlsTrustMode, tlsCaPemPaths, hmacSecretCiphertext, auth, allowedEnvironmentIds, createdAt, createdBy (String user_id), updatedAt, updatedBy (String user_id). `isAllowedInEnvironment(envId)` returns true when allowed-envs list is empty OR contains the env.
- `OutboundAuth` — sealed interface + records: `None | Bearer(tokenCiphertext) | Basic(username, passwordCiphertext)`. Jackson `@JsonTypeInfo(use = DEDUCTION)` — wire shape has no discriminator, subtype inferred from fields.
- `OutboundAuthKind`, `OutboundMethod` — enums
- `OutboundConnectionRepository` — CRUD by (tenantId, id): save/findById/findByName/listByTenant/delete
- `OutboundConnectionService` — create/update/delete/get/list with uniqueness + narrow-envs + delete-if-referenced guards. `rulesReferencing(id)` stubbed in Plan 01 (returns `[]`); populated in Plan 02 against `AlertRuleRepository`.
## security/ — Auth

View File

@@ -144,6 +144,12 @@
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,94 @@
package com.cameleer.server.app.http;
import com.cameleer.server.core.http.OutboundHttpClientFactory;
import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.http.TrustMode;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
import org.apache.hc.core5.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;
public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory {
private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class);
private final OutboundHttpProperties systemProps;
private final SslContextBuilder sslBuilder;
private final ConcurrentMap<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);
boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL;
var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create()
.setSslContext(sslContext);
if (trustAll) {
sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE);
}
var sslFactory = sslFactoryBuilder.build();
var connMgr = PoolingHttpClientConnectionManagerBuilder.create()
.setSSLSocketFactory(sslFactory)
.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(
ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout()))
.setSocketTimeout(Timeout.of(
ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout()))
.build())
.build();
log.debug("Built outbound HTTP client: trustMode={}, caPaths={}",
ctx.trustMode(), ctx.trustedCaPemPaths());
return HttpClients.custom()
.setConnectionManager(connMgr)
.setDefaultRequestConfig(RequestConfig.custom().build())
.build();
} catch (Exception e) {
throw new IllegalStateException("Failed to build outbound HTTP client", e);
}
}
private record CacheKey(
boolean trustAll,
List<String> caPaths,
TrustMode mode,
Duration connect,
Duration read
) {
static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) {
List<String> mergedPaths = Stream.concat(
sp.trustedCaPemPaths().stream(),
ctx.trustedCaPemPaths().stream()
).toList();
return new CacheKey(
sp.trustAll(),
List.copyOf(mergedPaths),
ctx.trustMode(),
ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(),
ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout()
);
}
}
}

View File

@@ -0,0 +1,89 @@
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.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public class SslContextBuilder {
public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx)
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException,
CertificateException, IOException {
SSLContext sslContext = SSLContext.getInstance("TLS");
if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) {
sslContext.init(null, new TrustManager[]{new TrustAllManager()}, null);
return sslContext;
}
List<X509Certificate> extraCerts = new ArrayList<>();
// System-level extras are always merged; per-request paths apply only in TRUST_PATHS mode.
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]; }
}
}

View File

@@ -0,0 +1,76 @@
package com.cameleer.server.app.http.config;
import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory;
import com.cameleer.server.app.http.SslContextBuilder;
import com.cameleer.server.core.http.OutboundHttpClientFactory;
import com.cameleer.server.core.http.OutboundHttpProperties;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
@Configuration
@ConfigurationProperties(prefix = "cameleer.server.outbound-http")
public class OutboundHttpConfig {
private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class);
private boolean trustAll = false;
private List<String> trustedCaPemPaths = List.of();
private long defaultConnectTimeoutMs = 2000;
private long defaultReadTimeoutMs = 5000;
private String proxyUrl;
private String proxyUsername;
private String proxyPassword;
public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; }
public void setTrustedCaPemPaths(List<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);
}
}

View File

@@ -0,0 +1,105 @@
package com.cameleer.server.app.outbound;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public class OutboundConnectionServiceImpl implements OutboundConnectionService {
private final OutboundConnectionRepository repo;
private final String tenantId;
public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, String tenantId) {
this.repo = repo;
this.tenantId = tenantId;
}
@Override
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
assertNameUnique(draft.name(), null);
OutboundConnection c = new OutboundConnection(
UUID.randomUUID(), tenantId, draft.name(), draft.description(),
draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
draft.tlsTrustMode(), draft.tlsCaPemPaths(),
draft.hmacSecretCiphertext(),
draft.auth(), draft.allowedEnvironmentIds(),
Instant.now(), actingUserId, Instant.now(), actingUserId);
return repo.save(c);
}
@Override
public OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId) {
OutboundConnection existing = repo.findById(tenantId, id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!existing.name().equals(draft.name())) {
assertNameUnique(draft.name(), id);
}
// Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs,
// find any envs that existed before but are absent in the draft.
// Skip entirely if either side is empty (empty = "allowed in all envs").
if (!existing.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().isEmpty()) {
List<UUID> removed = existing.allowedEnvironmentIds().stream()
.filter(e -> !draft.allowedEnvironmentIds().contains(e))
.toList();
if (!removed.isEmpty()) {
List<UUID> refs = rulesReferencing(id); // Plan 01 stub
if (!refs.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Narrowing allowed environments while rules still reference this connection in removed envs: " + refs);
}
}
}
OutboundConnection updated = new OutboundConnection(
id, tenantId, draft.name(), draft.description(), draft.url(), draft.method(),
draft.defaultHeaders(), draft.defaultBodyTmpl(),
draft.tlsTrustMode(), draft.tlsCaPemPaths(),
// Retain existing secret if the draft omitted one (null = leave unchanged).
draft.hmacSecretCiphertext() != null ? draft.hmacSecretCiphertext() : existing.hmacSecretCiphertext(),
draft.auth(), draft.allowedEnvironmentIds(),
existing.createdAt(), existing.createdBy(), Instant.now(), actingUserId);
return repo.save(updated);
}
@Override
public void delete(UUID id, String actingUserId) {
List<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);
}
});
}
}

View File

@@ -0,0 +1,35 @@
package com.cameleer.server.app.outbound.config;
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.outbound.storage.PostgresOutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
public class OutboundBeanConfig {
@Bean
public OutboundConnectionRepository outboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
return new PostgresOutboundConnectionRepository(jdbc, mapper);
}
@Bean
public SecretCipher secretCipher(
@Value("${cameleer.server.security.jwtsecret:dev-default-jwt-secret-do-not-use-in-production}")
String jwtSecret) {
return new SecretCipher(jwtSecret);
}
@Bean
public OutboundConnectionService outboundConnectionService(
OutboundConnectionRepository repo,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
return new OutboundConnectionServiceImpl(repo, tenantId);
}
}

View File

@@ -0,0 +1,160 @@
package com.cameleer.server.app.outbound.controller;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.outbound.dto.OutboundConnectionDto;
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
import com.cameleer.server.app.outbound.dto.OutboundConnectionTestResult;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.http.OutboundHttpClientFactory;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/outbound-connections")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Outbound Connections Admin", description = "Admin-managed outbound HTTPS destinations")
public class OutboundConnectionAdminController {
private final OutboundConnectionService service;
private final SecretCipher cipher;
private final AuditService audit;
private final OutboundHttpClientFactory httpClientFactory;
public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher,
AuditService audit, OutboundHttpClientFactory httpClientFactory) {
this.service = service;
this.cipher = cipher;
this.audit = audit;
this.httpClientFactory = httpClientFactory;
}
@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));
}
@GetMapping("/{id}/usage")
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
public List<UUID> usage(@PathVariable UUID id) {
return service.rulesReferencing(id);
}
@PostMapping
public ResponseEntity<OutboundConnectionDto> create(@Valid @RequestBody OutboundConnectionRequest req,
HttpServletRequest httpRequest) {
String userId = currentUserId();
OutboundConnection draft = toDraft(req);
OutboundConnection saved = service.create(draft, userId);
audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved));
}
@PutMapping("/{id}")
public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req,
HttpServletRequest httpRequest) {
String userId = currentUserId();
OutboundConnection draft = toDraft(req);
OutboundConnection saved = service.update(id, draft, userId);
audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return OutboundConnectionDto.from(saved);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id, HttpServletRequest httpRequest) {
String userId = currentUserId();
service.delete(id, userId);
audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/test")
public OutboundConnectionTestResult test(@PathVariable UUID id, HttpServletRequest httpRequest) {
String userId = currentUserId();
OutboundConnection c = service.get(id);
long t0 = System.currentTimeMillis();
try {
var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null);
CloseableHttpClient client = httpClientFactory.clientFor(ctx);
HttpPost request = new HttpPost(c.url());
request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON));
try (var resp = client.execute(request)) {
long latency = System.currentTimeMillis() - t0;
HttpEntity entity = resp.getEntity();
String body = entity == null ? "" : EntityUtils.toString(entity, StandardCharsets.UTF_8);
String snippet = body.substring(0, Math.min(512, body.length()));
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
id.toString(), Map.of("status", resp.getCode(), "latencyMs", latency),
AuditResult.SUCCESS, httpRequest);
return new OutboundConnectionTestResult(resp.getCode(), latency, snippet,
"TLS", null, null, null, null);
}
} catch (Exception e) {
long latency = System.currentTimeMillis() - t0;
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
id.toString(), Map.of("error", e.getClass().getSimpleName(), "latencyMs", latency),
AuditResult.FAILURE, httpRequest);
return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage());
}
}
private OutboundConnection toDraft(OutboundConnectionRequest req) {
String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank())
? null : cipher.encrypt(req.hmacSecret());
// tenantId, id, timestamps, createdBy/updatedBy are filled by the service layer.
return new OutboundConnection(
null, null, req.name(), req.description(),
req.url(), req.method(), req.defaultHeaders(), req.defaultBodyTmpl(),
req.tlsTrustMode(), req.tlsCaPemPaths(),
cipherSecret, req.auth(), req.allowedEnvironmentIds(),
null, null, null, null);
}
private String currentUserId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
}
String name = auth.getName();
return name.startsWith("user:") ? name.substring(5) : name;
}
}

View File

@@ -0,0 +1,62 @@
package com.cameleer.server.app.outbound.crypto;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/** Symmetric cipher for small at-rest secrets; key derived from JWT secret. */
public class SecretCipher {
private static final String KDF_LABEL = "cameleer-outbound-secret-v1";
private static final int IV_BYTES = 12;
private static final int TAG_BITS = 128;
private final SecretKeySpec aesKey;
private final SecureRandom random = new SecureRandom();
public SecretCipher(String jwtSecret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] keyBytes = mac.doFinal(KDF_LABEL.getBytes(StandardCharsets.UTF_8));
this.aesKey = new SecretKeySpec(keyBytes, "AES");
} catch (Exception e) {
throw new IllegalStateException("Failed to derive outbound secret key", e);
}
}
public String encrypt(String plaintext) {
try {
byte[] iv = new byte[IV_BYTES];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv));
byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length);
buf.put(iv).put(ct);
return Base64.getEncoder().encodeToString(buf.array());
} catch (Exception e) {
throw new IllegalStateException("Encryption failed", e);
}
}
public String decrypt(String ciphertextB64) {
try {
byte[] full = Base64.getDecoder().decode(ciphertextB64);
if (full.length < IV_BYTES + 16) throw new IllegalArgumentException("ciphertext too short");
byte[] iv = new byte[IV_BYTES];
System.arraycopy(full, 0, iv, 0, IV_BYTES);
byte[] ct = new byte[full.length - IV_BYTES];
System.arraycopy(full, IV_BYTES, ct, 0, ct.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv));
byte[] pt = cipher.doFinal(ct);
return new String(pt, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalArgumentException("Decryption failed", e);
}
}
}

View File

@@ -0,0 +1,31 @@
package com.cameleer.server.app.outbound.dto;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuthKind;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundMethod;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record OutboundConnectionDto(
UUID id, String name, String description, String url,
OutboundMethod method, Map<String, String> defaultHeaders, String defaultBodyTmpl,
TrustMode tlsTrustMode, List<String> tlsCaPemPaths,
boolean hmacSecretSet,
OutboundAuthKind authKind,
List<UUID> allowedEnvironmentIds,
Instant createdAt, String createdBy, Instant updatedAt, String updatedBy
) {
public static OutboundConnectionDto from(OutboundConnection c) {
return new OutboundConnectionDto(
c.id(), c.name(), c.description(), c.url(), c.method(),
c.defaultHeaders(), c.defaultBodyTmpl(),
c.tlsTrustMode(), c.tlsCaPemPaths(),
c.hmacSecretCiphertext() != null,
c.auth().kind(), c.allowedEnvironmentIds(),
c.createdAt(), c.createdBy(), c.updatedAt(), c.updatedBy());
}
}

View File

@@ -0,0 +1,37 @@
package com.cameleer.server.app.outbound.dto;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundMethod;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record OutboundConnectionRequest(
@NotBlank @Size(max = 100) String name,
@Size(max = 2000) String description,
@NotBlank @Pattern(regexp = "^https://.+", message = "URL must be HTTPS") String url,
@NotNull OutboundMethod method,
Map<String, String> defaultHeaders,
String defaultBodyTmpl,
@NotNull TrustMode tlsTrustMode,
List<String> tlsCaPemPaths,
String hmacSecret,
@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;
if (tlsTrustMode != null && tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) {
throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS");
}
}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.outbound.dto;
public record OutboundConnectionTestResult(
int status,
long latencyMs,
String responseSnippet,
String tlsProtocol,
String tlsCipherSuite,
String peerCertificateSubject,
Long peerCertificateExpiresAtEpochMs,
String error
) {}

View File

@@ -0,0 +1,147 @@
package com.cameleer.server.app.outbound.storage;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundAuthKind;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundMethod;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Array;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository {
private final JdbcTemplate jdbc;
private final ObjectMapper mapper;
public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
this.jdbc = jdbc;
this.mapper = mapper;
}
@Override
public OutboundConnection save(OutboundConnection c) {
boolean exists = findById(c.tenantId(), c.id()).isPresent();
if (exists) {
jdbc.update("""
UPDATE outbound_connections
SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum,
default_headers = ?::jsonb, default_body_tmpl = ?,
tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb,
hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum,
auth_config = ?::jsonb, allowed_environment_ids = ?,
updated_at = now(), updated_by = ?
WHERE tenant_id = ? AND id = ?""",
c.name(), c.description(), c.url(), c.method().name(),
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
c.hmacSecretCiphertext(), c.auth().kind().name(),
writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()),
c.updatedBy(), c.tenantId(), c.id());
} else {
jdbc.update("""
INSERT INTO outbound_connections (
id, tenant_id, name, description, url, method,
default_headers, default_body_tmpl,
tls_trust_mode, tls_ca_pem_paths,
hmac_secret_ciphertext, auth_kind, auth_config,
allowed_environment_ids, created_by, updated_by)
VALUES (?,?,?,?,?,?::outbound_method_enum,
?::jsonb,?,
?::trust_mode_enum,?::jsonb,
?,?::outbound_auth_kind_enum,?::jsonb,
?,?,?)""",
c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(),
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()),
toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy());
}
return findById(c.tenantId(), c.id()).orElseThrow();
}
@Override
public Optional<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.getString("created_by"),
rs.getTimestamp("updated_at").toInstant(), rs.getString("updated_by"));
private String writeJson(Object v) {
try { return mapper.writeValueAsString(v); }
catch (Exception e) { throw new IllegalStateException(e); }
}
private Map<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) {
try {
return switch (kind) {
case NONE -> new OutboundAuth.None();
case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class);
case BASIC -> mapper.readValue(cfg, OutboundAuth.Basic.class);
};
} catch (Exception e) { throw new IllegalStateException(e); }
}
private Array toUuidArray(List<UUID> ids) {
return jdbc.execute((ConnectionCallback<Array>) conn ->
conn.createArrayOf("uuid", ids.toArray()));
}
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;
}
}

View File

@@ -161,7 +161,10 @@ public class SecurityConfig {
// Runtime management (OPERATOR+) — legacy flat shape
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
// Admin endpoints
// Outbound connections: list/get allow OPERATOR (method-level @PreAuthorize gates mutations)
.requestMatchers(HttpMethod.GET, "/api/v1/admin/outbound-connections", "/api/v1/admin/outbound-connections/**").hasAnyRole("OPERATOR", "ADMIN")
// Admin endpoints (catch-all)
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// Everything else requires authentication

View File

@@ -79,6 +79,14 @@ cameleer:
jwkseturi: ${CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI:}
audience: ${CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE:}
tlsskipverify: ${CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY:false}
outbound-http:
trust-all: false
trusted-ca-pem-paths: []
default-connect-timeout-ms: 2000
default-read-timeout-ms: 5000
# proxy-url:
# proxy-username:
# proxy-password:
clickhouse:
url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer}
username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default}

View File

@@ -0,0 +1,30 @@
-- V11 — Outbound connections (admin-managed HTTPS destinations)
-- See: docs/superpowers/specs/2026-04-19-alerting-design.md §6
CREATE TYPE trust_mode_enum AS ENUM ('SYSTEM_DEFAULT','TRUST_ALL','TRUST_PATHS');
CREATE TYPE outbound_method_enum AS ENUM ('POST','PUT','PATCH');
CREATE TYPE outbound_auth_kind_enum AS ENUM ('NONE','BEARER','BASIC');
CREATE TABLE outbound_connections (
id uuid PRIMARY KEY,
tenant_id varchar(64) NOT NULL,
name varchar(100) NOT NULL,
description text,
url text NOT NULL CHECK (url ~ '^https://'),
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 text NOT NULL REFERENCES users(user_id),
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by text NOT NULL REFERENCES users(user_id),
CONSTRAINT outbound_connections_name_unique_per_tenant UNIQUE (tenant_id, name)
);
CREATE INDEX outbound_connections_tenant_idx ON outbound_connections (tenant_id);

View File

@@ -0,0 +1,67 @@
package com.cameleer.server.app.http;
import com.cameleer.server.core.http.OutboundHttpProperties;
import com.cameleer.server.core.http.OutboundHttpRequestContext;
import com.cameleer.server.core.http.TrustMode;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ApacheOutboundHttpClientFactoryIT {
private WireMockServer wm;
private ApacheOutboundHttpClientFactory factory;
@BeforeEach
void setUp() {
wm = new WireMockServer(WireMockConfiguration.options()
.httpDisabled(true).dynamicHttpsPort());
wm.start();
wm.stubFor(get("/ping").willReturn(ok("pong")));
OutboundHttpProperties props = new OutboundHttpProperties(
false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null);
factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder());
}
@AfterEach
void tearDown() {
if (wm != null) 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")))
.isInstanceOfAny(javax.net.ssl.SSLException.class, javax.net.ssl.SSLHandshakeException.class);
}
@Test
void clientsAreMemoizedByContext() {
CloseableHttpClient c1 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
CloseableHttpClient c2 = factory.clientFor(OutboundHttpRequestContext.systemDefault());
assertThat(c1).isSameAs(c2);
}
}

View File

@@ -0,0 +1,62 @@
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(getClass().getClassLoader().getResource("test-ca.pem").toURI());
assertThat(pem).exists();
SSLContext ctx = builder.build(systemProps,
new OutboundHttpRequestContext(TrustMode.TRUST_PATHS, List.of(pem.toString()), null, null));
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");
}
@Test
void systemTrustAllShortCircuitsEvenWithSystemDefaultContext() throws Exception {
OutboundHttpProperties trustAllProps = new OutboundHttpProperties(
true, List.of(), Duration.ofMillis(2000), Duration.ofMillis(5000),
null, null, null);
SSLContext ctx = builder.build(trustAllProps, OutboundHttpRequestContext.systemDefault());
assertThat(ctx).isNotNull();
assertThat(ctx.getProtocol()).isEqualTo("TLS");
}
}

View File

@@ -0,0 +1,49 @@
package com.cameleer.server.app.outbound;
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundMethod;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OutboundConnectionRequestValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void validRequestPasses() {
var r = new OutboundConnectionRequest("slack-ops", "desc",
"https://hooks.slack.com/x", OutboundMethod.POST,
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
null, new OutboundAuth.None(), List.of());
assertThat(validator.validate(r)).isEmpty();
}
@Test
void blankNameFails() {
var r = new OutboundConnectionRequest(" ", null, "https://x", OutboundMethod.POST,
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of());
assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("name"));
}
@Test
void nonHttpsUrlFails() {
var r = new OutboundConnectionRequest("n", null, "http://x", OutboundMethod.POST,
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of());
assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("url"));
}
@Test
void trustPathsRequiresNonEmptyCaList() {
assertThatThrownBy(() -> new OutboundConnectionRequest("n", null, "https://x", OutboundMethod.POST,
Map.of(), null, TrustMode.TRUST_PATHS, List.of(), null, new OutboundAuth.None(), List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
}

View File

@@ -0,0 +1,194 @@
package com.cameleer.server.app.outbound.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
private String adminJwt;
private String operatorJwt;
private String viewerJwt;
private com.github.tomakehurst.wiremock.WireMockServer wireMock;
@org.junit.jupiter.api.AfterEach
void tearDownWireMock() {
if (wireMock != null) wireMock.stop();
}
@org.junit.jupiter.api.AfterEach
void cleanupRows() {
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin','test-operator','test-viewer')");
}
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken();
viewerJwt = securityHelper.viewerToken();
// Seed user rows matching the JWT subjects (users(user_id) is a FK target)
seedUser("test-admin");
seedUser("test-operator");
seedUser("test-viewer");
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
}
private void seedUser(String userId) {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
userId, userId + "@example.com", userId);
}
private static final String CREATE_BODY = """
{"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
@Test
void adminCanCreate() throws Exception {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.path("name").asText()).isEqualTo("slack-ops");
assertThat(body.path("hmacSecretSet").asBoolean()).isFalse();
assertThat(body.path("id").asText()).isNotBlank();
}
@Test
void operatorCannotCreate() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void operatorCanList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCannotList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void nonHttpsUrlRejected() {
String body = """
{"name":"bad","url":"http://x","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void duplicateNameReturns409() {
restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> dup = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
@Test
void deleteRemoves() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> del = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ResponseEntity<String> get = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id, HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void testActionReturnsStatusAndLatency() throws Exception {
wireMock = new com.github.tomakehurst.wiremock.WireMockServer(
com.github.tomakehurst.wiremock.core.WireMockConfiguration.options()
.httpDisabled(true).dynamicHttpsPort());
wireMock.start();
wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe")
.willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong")));
String createBody = """
{"name":"probe-target","url":"https://localhost:%d/probe","method":"POST",
"tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort());
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> test = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(test.getBody());
assertThat(body.path("status").asInt()).isEqualTo(200);
assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0);
assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS");
assertThat(body.path("error").isNull()).isTrue();
}
@Test
void operatorCannotTest() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> test = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(test.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}

View File

@@ -0,0 +1,42 @@
package com.cameleer.server.app.outbound.crypto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SecretCipherTest {
private final SecretCipher cipher = new SecretCipher("test-jwt-secret-must-be-long-enough-to-derive");
@Test
void roundTrips() {
String plaintext = "my-hmac-secret-12345";
String ct = cipher.encrypt(plaintext);
assertThat(ct).isNotEqualTo(plaintext);
assertThat(cipher.decrypt(ct)).isEqualTo(plaintext);
}
@Test
void differentCiphertextsForSamePlaintext() {
String a = cipher.encrypt("x");
String b = cipher.encrypt("x");
assertThat(a).isNotEqualTo(b);
assertThat(cipher.decrypt(a)).isEqualTo(cipher.decrypt(b));
}
@Test
void decryptRejectsTamperedCiphertext() {
String ct = cipher.encrypt("abc");
String tampered = ct.substring(0, ct.length() - 2) + "00";
assertThatThrownBy(() -> cipher.decrypt(tampered))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void decryptRejectsWrongKey() {
String ct = cipher.encrypt("abc");
SecretCipher other = new SecretCipher("some-other-jwt-secret-that-is-long-enough");
assertThatThrownBy(() -> other.decrypt(ct))
.isInstanceOf(IllegalArgumentException.class);
}
}

View File

@@ -0,0 +1,118 @@
package com.cameleer.server.app.outbound.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.cameleer.server.core.outbound.OutboundMethod;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT {
@Autowired
OutboundConnectionRepository repo;
private static final String TENANT = "default";
private static final String USER = "test-alice";
@BeforeEach
void seedUser() {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO NOTHING",
USER, "test", "alice@example.com", "Alice");
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = ?", TENANT);
}
@AfterEach
void cleanup() {
jdbcTemplate.update("DELETE FROM outbound_connections WHERE created_by = ? OR updated_by = ?", USER, USER);
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", USER);
}
private OutboundConnection draft(String name) {
return new OutboundConnection(
UUID.randomUUID(), TENANT, name, "desc",
"https://hooks.slack.com/services/T/B/X", OutboundMethod.POST,
Map.of("Content-Type", "application/json"), null,
TrustMode.SYSTEM_DEFAULT, List.of(),
null, new OutboundAuth.None(), List.of(),
Instant.now(), USER, Instant.now(), USER);
}
@Test
void saveAndRead() {
OutboundConnection c = draft("slack-ops");
repo.save(c);
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
assertThat(loaded.name()).isEqualTo("slack-ops");
assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json");
assertThat(loaded.method()).isEqualTo(OutboundMethod.POST);
assertThat(loaded.tlsTrustMode()).isEqualTo(TrustMode.SYSTEM_DEFAULT);
assertThat(loaded.auth()).isInstanceOf(OutboundAuth.None.class);
}
@Test
void uniqueNamePerTenant() {
OutboundConnection a = draft("slack-ops");
repo.save(a);
OutboundConnection b = draft("slack-ops");
assertThatThrownBy(() -> repo.save(b))
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
}
@Test
void allowedEnvironmentIdsRoundTrip() {
UUID env1 = UUID.randomUUID();
UUID env2 = UUID.randomUUID();
OutboundConnection c = new OutboundConnection(
UUID.randomUUID(), TENANT, "multi-env", null,
"https://example.com", OutboundMethod.POST,
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
null, new OutboundAuth.None(), List.of(env1, env2),
Instant.now(), USER, Instant.now(), USER);
repo.save(c);
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
assertThat(loaded.allowedEnvironmentIds()).containsExactly(env1, env2);
}
@Test
void listByTenantOnlyReturnsCurrentTenant() {
repo.save(draft("in-tenant"));
// The outbound_connections.tenant_id is a plain varchar, so we can insert for a different tenant
// without schema changes. Insert another tenant via the repo directly:
UUID otherId = UUID.randomUUID();
OutboundConnection other = new OutboundConnection(
otherId, "other-tenant", "other-tenant-conn", null,
"https://example.com", OutboundMethod.POST,
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
null, new OutboundAuth.None(), List.of(),
Instant.now(), USER, Instant.now(), USER);
repo.save(other);
try {
List<OutboundConnection> list = repo.listByTenant(TENANT);
assertThat(list).extracting(OutboundConnection::name).containsExactly("in-tenant");
} finally {
repo.delete("other-tenant", otherId);
}
}
@Test
void deleteRemovesRow() {
OutboundConnection c = draft("to-delete");
repo.save(c);
repo.delete(TENANT, c.id());
assertThat(repo.findById(TENANT, c.id())).isEmpty();
}
}

View File

@@ -16,3 +16,4 @@ cameleer:
bootstraptoken: test-bootstrap-token
bootstraptokenprevious: old-bootstrap-token
infrastructureendpoints: true
jwtsecret: test-jwt-secret-for-integration-tests-only

View File

@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDFzCCAf+gAwIBAgIUawEUnI7oAYTMsqYYJUBqGJhM8LMwDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQY2FtZWxlZXItdGVzdC1jYTAeFw0yNjA0MTkxMzUzMTNa
Fw0zNjA0MTYxMzUzMTNaMBsxGTAXBgNVBAMMEGNhbWVsZWVyLXRlc3QtY2EwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCalBl0hjPSXYZjFLImDT5awayX
nPmX7/TyduX2xSksLnGBfD1YcLYhIaOU3Bp6o+Wld8+nzNjwgMUcKdv1mWqDL8t0
nlf0JGAntNEQWGwxFUGSLcmzgVf8/d1s0mpZ1/mS6RDzoQ8i8rNq5mYPXkWFAvgD
G1FQpaBs71VC7vVcEgnJ4kK/8cNcQ/nvaI+T/Tk+sciu57XuMoa8AoJV/Oz/hdkc
fIoYeUpFp36pYn71yFyZ1N+1xCTi1ruBmMekYWKNaNY6/edk2z3iTFgv4Dk4QS1O
6GEdgUi9RQIgQwyYltQK2z+wLA8e982JK5HllsLYU0AcY6fvg+JlhEEpCsdnAgMB
AAGjUzBRMB0GA1UdDgQWBBQYRTifnA5YLxiqjIYMDcZLgDrtUDAfBgNVHSMEGDAW
gBQYRTifnA5YLxiqjIYMDcZLgDrtUDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQBpNfv6I9dsUflmslv6el5VvDOyZ3GWQIE/Zy0p+i0NYjeWMWtX
rFPw1mnfdhlyFMpNkP8ddyDaSoqnnFsC4pXqXibrey0NDYX7zujyVIaXixf6koU9
2/vNIb4evnJyVT9CcophDrobkR4NwU9SrMO7KAecb/U6shrfgmjIanUnCsfGdwsP
f85DQDbOd9UhnMfMB43I8tjn2Po155npmwGK7J4hZpWTUj/rbNt3fmy2mx6rqdkp
p61nLY3ba61bnl7QQ7VW7nhaIC7t5sO6NFDdv06MG8Cr5kcvoJDPe93iNeVT8iLp
Rxs85FvIVYIt58jMvr+RSEfTD8fIvXl0uRDy
-----END CERTIFICATE-----

View File

@@ -37,6 +37,10 @@
<artifactId>spring-security-core</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.core.admin;
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT,
OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE
}

View File

@@ -0,0 +1,8 @@
package com.cameleer.server.core.http;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
public interface OutboundHttpClientFactory {
/** Returns a memoized client configured for the given TLS/timeout context. */
CloseableHttpClient clientFor(OutboundHttpRequestContext context);
}

View File

@@ -0,0 +1,20 @@
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);
}
}

View File

@@ -0,0 +1,20 @@
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);
}
}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.core.http;
public enum TrustMode {
SYSTEM_DEFAULT,
TRUST_ALL,
TRUST_PATHS
}

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.core.outbound;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
@JsonSubTypes.Type(OutboundAuth.None.class),
@JsonSubTypes.Type(OutboundAuth.Bearer.class),
@JsonSubTypes.Type(OutboundAuth.Basic.class),
})
public sealed interface OutboundAuth permits OutboundAuth.None, OutboundAuth.Bearer, OutboundAuth.Basic {
OutboundAuthKind kind();
record None() implements OutboundAuth {
public OutboundAuthKind kind() { return OutboundAuthKind.NONE; }
}
record Bearer(String tokenCiphertext) implements OutboundAuth {
public OutboundAuthKind kind() { return OutboundAuthKind.BEARER; }
}
record Basic(String username, String passwordCiphertext) implements OutboundAuth {
public OutboundAuthKind kind() { return OutboundAuthKind.BASIC; }
}
}

View File

@@ -0,0 +1,3 @@
package com.cameleer.server.core.outbound;
public enum OutboundAuthKind { NONE, BEARER, BASIC }

View File

@@ -0,0 +1,39 @@
package com.cameleer.server.core.outbound;
import com.cameleer.server.core.http.TrustMode;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record OutboundConnection(
UUID id,
String tenantId,
String name,
String description,
String url,
OutboundMethod method,
Map<String, String> defaultHeaders,
String defaultBodyTmpl,
TrustMode tlsTrustMode,
List<String> tlsCaPemPaths,
String hmacSecretCiphertext,
OutboundAuth auth,
List<UUID> allowedEnvironmentIds,
Instant createdAt,
String createdBy,
Instant updatedAt,
String 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);
}
}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.core.outbound;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface OutboundConnectionRepository {
OutboundConnection save(OutboundConnection connection);
Optional<OutboundConnection> findById(String tenantId, UUID id);
Optional<OutboundConnection> findByName(String tenantId, String name);
List<OutboundConnection> listByTenant(String tenantId);
void delete(String tenantId, UUID id);
}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.core.outbound;
import java.util.List;
import java.util.UUID;
public interface OutboundConnectionService {
OutboundConnection create(OutboundConnection draft, String actingUserId);
OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId);
void delete(UUID id, String actingUserId);
OutboundConnection get(UUID id);
List<OutboundConnection> list();
List<UUID> rulesReferencing(UUID id);
}

View File

@@ -0,0 +1,3 @@
package com.cameleer.server.core.outbound;
public enum OutboundMethod { POST, PUT, PATCH }

View File

@@ -0,0 +1,94 @@
# Outbound Connections — Admin Guide
Outbound connections are admin-managed HTTPS destinations that Cameleer Server can POST to. They are the building block for future alerting webhooks (Plan 02) and for any other outbound integration. This page explains how to create one, how the TLS trust modes work, and what happens when you click *Test*.
## What is an outbound connection?
An outbound connection is a reusable HTTPS destination with:
- A **URL** (must be `https://`)
- A **method** (`POST`, `PUT`, or `PATCH`)
- Optional **default headers** and a **default body template** (Mustache-style placeholders like `{{message}}` will be supported by the alerting engine in Plan 02)
- A **TLS trust mode** — how the server validates the destination's certificate
- Optional **HMAC signing secret** — if set, the server HMAC-signs the request body and sends the signature in an `X-Cameleer-Signature` header (wiring lands in Plan 02)
- Optional **authentication** — Bearer token or Basic auth, stored encrypted at rest
- An optional **allowed-environments** restriction — if empty, the connection is usable from all environments; if non-empty, only rules in listed environments may use it
Connections are tenant-scoped. Names must be unique within a tenant.
## Creating a connection
Admin → *Outbound Connections**New connection*.
Fill in:
- **Name** — short identifier, unique per tenant (e.g. `slack-ops`, `pagerduty-primary`).
- **URL** — must start with `https://`. `http://` is rejected.
- **Method** — pick `POST` for most webhooks.
- **Default headers** — e.g. `Content-Type: application/json` for JSON webhooks. Per-rule headers may override these in Plan 02.
- **Default body template** — the request body used when a rule doesn't specify its own. Plan 02 will interpolate Mustache variables.
- **TLS trust mode** — see below.
- **HMAC secret** — optional. If supplied, it's encrypted at rest using a key derived from the server's JWT signing secret (AES-GCM + HMAC-SHA256 KDF). The plaintext never leaves the browser-to-server path.
- **Auth** — `None`, `Bearer`, or `Basic`. Credentials are encrypted at rest alongside the HMAC secret.
- **Allow in all environments** — unchecked lets you restrict usage to a subset.
Hit *Create*. The connection is now listed on the index page.
## TLS trust modes
| Mode | When to use |
|---|---|
| `SYSTEM_DEFAULT` | Standard public endpoints (Slack, PagerDuty, GitHub, etc.). The JVM's default trust store is used. |
| `TRUST_PATHS` | Self-hosted endpoints signed by a private CA. Provide one or more absolute paths to PEM-encoded CA certificates on the server filesystem. The JVM defaults are preserved; the configured CAs are added on top. |
| `TRUST_ALL` | Local dev and staging against self-signed certs. **Disables certificate validation entirely.** The UI shows an amber warning and does not recommend this for production. |
You can also configure tenant-wide extras via `cameleer.server.outbound-http.trusted-ca-pem-paths` in `application.yml` — those CAs are added to every outbound HTTPS call. If a path in that list doesn't exist on disk, the server refuses to start (fail-fast).
## Testing a connection
On the edit page of a saved connection, click *Test*. The server issues a synthetic `POST` with body `{"probe":true}` to the connection's URL using the connection's stored trust/auth configuration and returns:
- HTTP status code (0 if the request never completed)
- Latency in milliseconds
- First 512 characters of the response body
- TLS protocol (reported as `TLS`; full protocol/cipher/peer-cert extraction is deferred to a Plan 02 follow-up)
- Error message, if any
The probe is audit-logged under `OUTBOUND_CONNECTION_CHANGE` so you can trace who tested what.
Any test endpoint with a real handler should accept `{"probe":true}` as a harmless payload. If a downstream rejects the body shape, you still see the HTTP status, which is usually enough to diagnose routing / auth / TLS issues.
## Allowed environments
When a connection's *Allow in all environments* toggle is off and a set of environments is chosen, the connection is only usable by alerting rules (Plan 02) that fire in those environments. Two guards enforce this:
1. **Narrowing guard on update** — if you remove an environment from the list while existing rules reference this connection in that environment, the server returns `409 Conflict` and names the rules. (Plan 01 stubs out the rules check; it returns empty. Plan 02 wires it to the real alert-rules table.)
2. **Delete guard** — attempting to delete a connection still referenced by any rule returns `409 Conflict`. Drop the rules (or reassign them) first.
An empty list means "allowed everywhere" — the default.
## HMAC signing semantics (for consumers)
When Plan 02 starts firing webhook requests, connections with an HMAC secret set will sign each request body as follows:
```
X-Cameleer-Signature: v1=<hex(HMAC-SHA256(secret, body))>
X-Cameleer-Timestamp: <ISO-8601 UTC>
```
Consumers should compute the same HMAC over the raw body bytes using their copy of the shared secret and reject requests where the signatures don't match. Rotate secrets by updating the connection in the UI — the old secret continues to sign until the change is saved.
## RBAC
- **ADMIN** can create, update, delete, and test connections.
- **OPERATOR** can list and view connections (read-only). This mirrors the "operators run playbooks, admins configure systems" split used elsewhere in the product.
- **VIEWER** has no visibility into outbound connections.
All mutations are recorded in the audit log (`/admin/audit`) under categories `OUTBOUND_CONNECTION_CHANGE` (create/update/delete/test) and — reserved for future use — `OUTBOUND_HTTP_TRUST_CHANGE` for tenant-wide trust store edits.
## Operational notes
- Outbound clients are memoized per `(trust mode, CA paths, timeouts)` tuple inside `ApacheOutboundHttpClientFactory`. Changing a connection's trust mode evicts its cached client on next call.
- Default connect timeout is 2s; default read timeout is 5s. Override per-tenant in `cameleer.server.outbound-http.default-*-timeout-ms`.
- Proxy support (`cameleer.server.outbound-http.proxy-*` properties) is wired through the properties record but not yet applied by the factory — a Plan 02 follow-up.
- OIDC still uses its own Nimbus HTTP client. Retrofitting OIDC onto `OutboundHttpClientFactory` is deliberately deferred so Plan 01 doesn't touch proven-working auth code.

View File

@@ -0,0 +1,128 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export type OutboundMethod = 'POST' | 'PUT' | 'PATCH';
export type OutboundAuthKind = 'NONE' | 'BEARER' | 'BASIC';
export type TrustMode = 'SYSTEM_DEFAULT' | 'TRUST_ALL' | 'TRUST_PATHS';
// Jackson DEDUCTION mode picks subtype by which fields are present.
// None = {}, Bearer = { tokenCiphertext }, Basic = { username, passwordCiphertext }.
// Using a flat optional shape for pragmatic TypeScript compatibility.
export type OutboundAuth = {
tokenCiphertext?: string;
username?: string;
passwordCiphertext?: string;
};
// Server returns this shape (`OutboundConnectionDto` in Java)
export interface OutboundConnectionDto {
id: string;
name: string;
description: string | null;
url: string;
method: OutboundMethod;
defaultHeaders: Record<string, string>;
defaultBodyTmpl: string | null;
tlsTrustMode: TrustMode;
tlsCaPemPaths: string[];
hmacSecretSet: boolean;
authKind: OutboundAuthKind;
allowedEnvironmentIds: string[];
createdAt: string;
createdBy: string;
updatedAt: string;
updatedBy: string;
}
// Client sends this shape for create/update (`OutboundConnectionRequest` in Java)
export interface OutboundConnectionRequest {
name: string;
description?: string | null;
url: string;
method: OutboundMethod;
defaultHeaders?: Record<string, string>;
defaultBodyTmpl?: string | null;
tlsTrustMode: TrustMode;
tlsCaPemPaths?: string[];
hmacSecret?: string | null;
auth: OutboundAuth;
allowedEnvironmentIds?: string[];
}
// Server returns this from POST /{id}/test
export interface OutboundConnectionTestResult {
status: number;
latencyMs: number;
responseSnippet: string | null;
tlsProtocol: string | null;
tlsCipherSuite: string | null;
peerCertificateSubject: string | null;
peerCertificateExpiresAtEpochMs: number | null;
error: string | null;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useOutboundConnections() {
return useQuery({
queryKey: ['admin', 'outbound-connections'],
queryFn: () => adminFetch<OutboundConnectionDto[]>('/outbound-connections'),
});
}
export function useOutboundConnection(id: string | undefined) {
return useQuery({
queryKey: ['admin', 'outbound-connections', id],
queryFn: () => adminFetch<OutboundConnectionDto>(`/outbound-connections/${id}`),
enabled: !!id,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useCreateOutboundConnection() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: OutboundConnectionRequest) =>
adminFetch<OutboundConnectionDto>('/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<OutboundConnectionDto>(`/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',
}),
});
}

View File

@@ -107,6 +107,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }
...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []),
{ id: 'admin:environments', label: 'Environments', path: '/admin/environments' },
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
{ id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' },
{ id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' },
{ id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' },
];

View File

@@ -0,0 +1,470 @@
import { useEffect, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Button, FormField, Input, Select, SectionHeader, Toggle, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import {
useOutboundConnection,
useCreateOutboundConnection,
useUpdateOutboundConnection,
useTestOutboundConnection,
type OutboundConnectionDto,
type OutboundConnectionRequest,
type OutboundConnectionTestResult,
type OutboundMethod,
type OutboundAuthKind,
type TrustMode,
} from '../../api/queries/admin/outboundConnections';
import { useEnvironments } from '../../api/queries/admin/environments';
import sectionStyles from '../../styles/section-card.module.css';
// ── Form state ──────────────────────────────────────────────────────────
interface FormState {
name: string;
description: string;
url: string;
method: OutboundMethod;
headers: Array<{ key: string; value: string }>;
defaultBodyTmpl: string;
tlsTrustMode: TrustMode;
tlsCaPemPaths: string[];
hmacSecret: string; // empty = keep existing (edit) / no secret (create)
authKind: OutboundAuthKind;
bearerToken: string;
basicUsername: string;
basicPassword: string;
allowAllEnvs: boolean;
allowedEnvIds: string[];
}
function initialForm(existing?: OutboundConnectionDto): FormState {
return {
name: existing?.name ?? '',
description: existing?.description ?? '',
url: existing?.url ?? 'https://',
method: existing?.method ?? 'POST',
headers: existing
? Object.entries(existing.defaultHeaders).map(([key, value]) => ({ key, value }))
: [],
defaultBodyTmpl: existing?.defaultBodyTmpl ?? '',
tlsTrustMode: existing?.tlsTrustMode ?? 'SYSTEM_DEFAULT',
tlsCaPemPaths: existing?.tlsCaPemPaths ?? [],
hmacSecret: '',
authKind: existing?.authKind ?? 'NONE',
bearerToken: '',
basicUsername: '',
basicPassword: '',
allowAllEnvs: !existing || existing.allowedEnvironmentIds.length === 0,
allowedEnvIds: existing?.allowedEnvironmentIds ?? [],
};
}
function toRequest(f: FormState): OutboundConnectionRequest {
const defaultHeaders = Object.fromEntries(
f.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]),
);
const allowedEnvironmentIds = f.allowAllEnvs ? [] : f.allowedEnvIds;
const auth =
f.authKind === 'NONE'
? {}
: f.authKind === 'BEARER'
? { tokenCiphertext: f.bearerToken }
: { username: f.basicUsername, passwordCiphertext: f.basicPassword };
return {
name: f.name,
description: f.description || null,
url: f.url,
method: f.method,
defaultHeaders,
defaultBodyTmpl: f.defaultBodyTmpl || null,
tlsTrustMode: f.tlsTrustMode,
tlsCaPemPaths: f.tlsCaPemPaths,
hmacSecret: f.hmacSecret ? f.hmacSecret : null,
auth,
allowedEnvironmentIds,
};
}
// ── Select option arrays ───────────────────────────────────────────────
const METHOD_OPTIONS: Array<{ value: OutboundMethod; label: string }> = [
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'PATCH', label: 'PATCH' },
];
const TRUST_OPTIONS: Array<{ value: TrustMode; label: string }> = [
{ value: 'SYSTEM_DEFAULT', label: 'System default (recommended)' },
{ value: 'TRUST_PATHS', label: 'Trust additional CA PEMs' },
{ value: 'TRUST_ALL', label: 'Trust all (INSECURE)' },
];
const AUTH_OPTIONS: Array<{ value: OutboundAuthKind; label: string }> = [
{ value: 'NONE', label: 'None' },
{ value: 'BEARER', label: 'Bearer token' },
{ value: 'BASIC', label: 'Basic' },
];
// ── Component ──────────────────────────────────────────────────────────
export default function OutboundConnectionEditor() {
const { id } = useParams<{ id: string }>();
const isNew = !id;
const navigate = useNavigate();
const { toast } = useToast();
const existingQ = useOutboundConnection(isNew ? undefined : id);
const envQ = useEnvironments();
const createMut = useCreateOutboundConnection();
// Hooks must be called unconditionally; pass placeholder id when unknown.
const updateMut = useUpdateOutboundConnection(id ?? 'placeholder');
const testMut = useTestOutboundConnection();
const [form, setForm] = useState<FormState>(() => initialForm());
const [initialized, setInitialized] = useState(isNew);
const [testResult, setTestResult] = useState<OutboundConnectionTestResult | null>(null);
const [showSecret, setShowSecret] = useState(false);
useEffect(() => {
if (!initialized && existingQ.data) {
setForm(initialForm(existingQ.data));
setInitialized(true);
}
}, [existingQ.data, initialized]);
if (!isNew && existingQ.isLoading) return <PageLoader />;
const isSaving = createMut.isPending || updateMut.isPending;
const onSave = () => {
const payload = toRequest(form);
if (isNew) {
createMut.mutate(payload, {
onSuccess: () => {
toast({ title: 'Created', description: form.name, variant: 'success' });
navigate('/admin/outbound-connections');
},
onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }),
});
} else {
updateMut.mutate(payload, {
onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }),
onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }),
});
}
};
const onTest = () => {
if (!id) return;
testMut.mutate(id, {
onSuccess: (r) => setTestResult(r),
onError: (e) =>
setTestResult({
status: 0,
latencyMs: 0,
responseSnippet: null,
tlsProtocol: null,
tlsCipherSuite: null,
peerCertificateSubject: null,
peerCertificateExpiresAtEpochMs: null,
error: String(e),
}),
});
};
const envs = envQ.data ?? [];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<SectionHeader>
{isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`}
</SectionHeader>
<Link to="/admin/outbound-connections">
<Button variant="secondary" size="sm">Back</Button>
</Link>
</div>
<section className={sectionStyles.section} style={{ display: 'grid', gap: 16 }}>
{/* Name */}
<FormField label="Name" htmlFor="oc-name">
<Input
id="oc-name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="slack-ops"
/>
</FormField>
{/* Description */}
<FormField label="Description" htmlFor="oc-description">
<textarea
id="oc-description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
style={{ width: '100%', boxSizing: 'border-box' }}
placeholder="Optional description"
/>
</FormField>
{/* URL */}
<FormField label="URL" htmlFor="oc-url">
<Input
id="oc-url"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://hooks.slack.com/services/..."
/>
</FormField>
{/* Method */}
<FormField label="Method" htmlFor="oc-method">
<Select
options={METHOD_OPTIONS}
value={form.method}
onChange={(e) => setForm({ ...form, method: e.target.value as OutboundMethod })}
/>
</FormField>
{/* Default headers */}
<div>
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>Default headers</div>
{form.headers.map((h, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Input
value={h.key}
onChange={(e) => {
const next = [...form.headers];
next[i] = { ...next[i], key: e.target.value };
setForm({ ...form, headers: next });
}}
placeholder="Header"
/>
<Input
value={h.value}
onChange={(e) => {
const next = [...form.headers];
next[i] = { ...next[i], value: e.target.value };
setForm({ ...form, headers: next });
}}
placeholder="Value"
/>
<Button
variant="secondary"
size="sm"
onClick={() => setForm({ ...form, headers: form.headers.filter((_, idx) => idx !== i) })}
>
Remove
</Button>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={() => setForm({ ...form, headers: [...form.headers, { key: '', value: '' }] })}
style={{ marginTop: 6 }}
>
Add header
</Button>
</div>
{/* Default body template */}
<FormField label="Default body template" htmlFor="oc-body">
<textarea
id="oc-body"
value={form.defaultBodyTmpl}
onChange={(e) => setForm({ ...form, defaultBodyTmpl: e.target.value })}
rows={4}
style={{ width: '100%', boxSizing: 'border-box', fontFamily: 'monospace', fontSize: '0.8125rem' }}
placeholder={'{"text": "{{message}}"}'}
/>
</FormField>
{/* TLS trust mode */}
<FormField label="TLS trust mode" htmlFor="oc-tls">
<Select
options={TRUST_OPTIONS}
value={form.tlsTrustMode}
onChange={(e) => setForm({ ...form, tlsTrustMode: e.target.value as TrustMode })}
/>
</FormField>
{form.tlsTrustMode === 'TRUST_ALL' && (
<div style={{ background: 'var(--amber-bg, #fef3c7)', color: 'var(--amber, #92400e)', padding: '8px 12px', borderRadius: 4, fontSize: '0.875rem' }}>
TLS certificate validation is DISABLED. Do not use TRUST_ALL in production.
</div>
)}
{form.tlsTrustMode === 'TRUST_PATHS' && (
<div>
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>CA PEM paths</div>
{form.tlsCaPemPaths.map((p, i) => (
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<Input
value={p}
onChange={(e) => {
const next = [...form.tlsCaPemPaths];
next[i] = e.target.value;
setForm({ ...form, tlsCaPemPaths: next });
}}
placeholder="/etc/ssl/certs/my-ca.pem"
/>
<Button
variant="secondary"
size="sm"
onClick={() =>
setForm({ ...form, tlsCaPemPaths: form.tlsCaPemPaths.filter((_, idx) => idx !== i) })
}
>
Remove
</Button>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={() => setForm({ ...form, tlsCaPemPaths: [...form.tlsCaPemPaths, ''] })}
style={{ marginTop: 6 }}
>
Add path
</Button>
</div>
)}
{/* HMAC secret */}
<FormField
label="HMAC secret"
htmlFor="oc-hmac"
hint={existingQ.data?.hmacSecretSet ? 'Leave blank to keep existing secret' : undefined}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Input
id="oc-hmac"
type={showSecret ? 'text' : 'password'}
value={form.hmacSecret}
onChange={(e) => setForm({ ...form, hmacSecret: e.target.value })}
placeholder={
existingQ.data?.hmacSecretSet
? '<secret set — leave blank to keep>'
: 'Optional signing secret'
}
/>
<Button variant="ghost" size="sm" onClick={() => setShowSecret((s) => !s)}>
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</FormField>
{/* Auth kind */}
<FormField label="Auth" htmlFor="oc-auth">
<Select
options={AUTH_OPTIONS}
value={form.authKind}
onChange={(e) => setForm({ ...form, authKind: e.target.value as OutboundAuthKind })}
/>
</FormField>
{form.authKind === 'BEARER' && (
<FormField label="Bearer token" htmlFor="oc-bearer">
<Input
id="oc-bearer"
value={form.bearerToken}
onChange={(e) => setForm({ ...form, bearerToken: e.target.value })}
placeholder="Token stored encrypted at rest"
/>
</FormField>
)}
{form.authKind === 'BASIC' && (
<>
<FormField label="Basic username" htmlFor="oc-basic-user">
<Input
id="oc-basic-user"
value={form.basicUsername}
onChange={(e) => setForm({ ...form, basicUsername: e.target.value })}
/>
</FormField>
<FormField label="Basic password" htmlFor="oc-basic-pass">
<Input
id="oc-basic-pass"
type="password"
value={form.basicPassword}
onChange={(e) => setForm({ ...form, basicPassword: e.target.value })}
/>
</FormField>
</>
)}
{/* Allowed environments */}
<div>
<Toggle
checked={form.allowAllEnvs}
onChange={(e) => setForm({ ...form, allowAllEnvs: (e.target as HTMLInputElement).checked })}
label="Allow in all environments"
/>
{!form.allowAllEnvs && (
<div style={{ display: 'grid', gap: 4, marginTop: 8, marginLeft: 4 }}>
{envs.map((env) => (
<label key={env.id} style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', fontSize: '0.875rem' }}>
<input
type="checkbox"
checked={form.allowedEnvIds.includes(env.id)}
onChange={(e) => {
const next = e.target.checked
? [...form.allowedEnvIds, env.id]
: form.allowedEnvIds.filter((x) => x !== env.id);
setForm({ ...form, allowedEnvIds: next });
}}
/>
{env.displayName}
</label>
))}
{envs.length === 0 && (
<span style={{ color: 'var(--text-muted, #6b7280)', fontSize: '0.875rem' }}>No environments found</span>
)}
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="primary" onClick={onSave} loading={isSaving} disabled={isSaving}>
{isNew ? 'Create' : 'Save'}
</Button>
{!isNew && (
<Button variant="secondary" onClick={onTest} loading={testMut.isPending} disabled={testMut.isPending}>
{testMut.isPending ? 'Testing...' : 'Test'}
</Button>
)}
</div>
{/* Test result */}
{testResult && (
<section className={sectionStyles.section}>
<SectionHeader>Test result</SectionHeader>
{testResult.error ? (
<div style={{ color: 'var(--error, #b91c1c)', fontSize: '0.875rem' }}>
Error: {testResult.error}
</div>
) : (
<dl style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px', fontSize: '0.875rem', margin: 0 }}>
<dt>Status</dt><dd style={{ margin: 0 }}>{testResult.status}</dd>
<dt>Latency</dt><dd style={{ margin: 0 }}>{testResult.latencyMs} ms</dd>
<dt>TLS protocol</dt><dd style={{ margin: 0 }}>{testResult.tlsProtocol ?? '—'}</dd>
{testResult.responseSnippet && (
<>
<dt>Response</dt>
<dd style={{ margin: 0 }}><code>{testResult.responseSnippet}</code></dd>
</>
)}
</dl>
)}
</section>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { Link } from 'react-router-dom';
import { Button, Badge, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import {
useOutboundConnections,
useDeleteOutboundConnection,
type OutboundConnectionDto,
type TrustMode,
} from '../../api/queries/admin/outboundConnections';
import sectionStyles from '../../styles/section-card.module.css';
export default function OutboundConnectionsPage() {
const { data, isLoading, error } = useOutboundConnections();
const deleteMut = useDeleteOutboundConnection();
const { toast } = useToast();
if (isLoading) return <PageLoader />;
if (error) return <div>Failed to load outbound connections: {String(error)}</div>;
const rows = data ?? [];
const onDelete = (c: OutboundConnectionDto) => {
if (!confirm(`Delete outbound connection "${c.name}"?`)) return;
deleteMut.mutate(c.id, {
onSuccess: () => toast({ title: 'Deleted', description: c.name, variant: 'success' }),
onError: (e) => toast({ title: 'Delete failed', description: String(e), variant: 'error' }),
});
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<SectionHeader>Outbound Connections</SectionHeader>
<Link to="/admin/outbound-connections/new">
<Button variant="primary">New connection</Button>
</Link>
</div>
<div className={sectionStyles.section}>
{rows.length === 0 ? (
<p>No outbound connections yet. Create one to enable alerting webhooks or other outbound integrations.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Name</th>
<th style={{ textAlign: 'left' }}>Host</th>
<th style={{ textAlign: 'left' }}>Method</th>
<th style={{ textAlign: 'left' }}>Trust</th>
<th style={{ textAlign: 'left' }}>Auth</th>
<th style={{ textAlign: 'left' }}>Envs</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((c) => (
<tr key={c.id}>
<td><Link to={`/admin/outbound-connections/${c.id}`}>{c.name}</Link></td>
<td><code>{safeHost(c.url)}</code></td>
<td>{c.method}</td>
<td><TrustBadge mode={c.tlsTrustMode} /></td>
<td>{c.authKind}</td>
<td>{c.allowedEnvironmentIds.length > 0 ? c.allowedEnvironmentIds.length : 'all'}</td>
<td>
<Button variant="secondary" onClick={() => onDelete(c)} disabled={deleteMut.isPending}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
function safeHost(url: string): string {
try { return new URL(url).host; }
catch { return url; }
}
function TrustBadge({ mode }: { mode: TrustMode }) {
if (mode === 'TRUST_ALL') return <Badge label="Trust all" color="error" variant="filled" />;
if (mode === 'TRUST_PATHS') return <Badge label="Custom CA" color="warning" variant="filled" />;
return <Badge label="System" color="auto" variant="filled" />;
}

View File

@@ -18,6 +18,8 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
@@ -84,6 +86,9 @@ export const router = createBrowserRouter([
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
{ path: 'outbound-connections', element: <SuspenseWrapper><OutboundConnectionsPage /></SuspenseWrapper> },
{ path: 'outbound-connections/new', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
{ path: 'outbound-connections/:id', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },