diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index ef0084a4..135f4f02 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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 diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index 3417e841..c8dbf304 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -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 diff --git a/cameleer-server-app/pom.xml b/cameleer-server-app/pom.xml index b828c850..9ee3c5b8 100644 --- a/cameleer-server-app/pom.xml +++ b/cameleer-server-app/pom.xml @@ -144,6 +144,12 @@ awaitility test + + org.wiremock + wiremock-standalone + 3.9.1 + test + diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java new file mode 100644 index 00000000..8b6811b6 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactory.java @@ -0,0 +1,94 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; + +public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory { + + private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class); + + private final OutboundHttpProperties systemProps; + private final SslContextBuilder sslBuilder; + private final ConcurrentMap clients = new ConcurrentHashMap<>(); + + public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) { + this.systemProps = systemProps; + this.sslBuilder = sslBuilder; + } + + @Override + public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) { + CacheKey key = CacheKey.of(systemProps, ctx); + return clients.computeIfAbsent(key, k -> build(ctx)); + } + + private CloseableHttpClient build(OutboundHttpRequestContext ctx) { + try { + var sslContext = sslBuilder.build(systemProps, ctx); + boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL; + var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create() + .setSslContext(sslContext); + if (trustAll) { + sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE); + } + var sslFactory = sslFactoryBuilder.build(); + var connMgr = PoolingHttpClientConnectionManagerBuilder.create() + .setSSLSocketFactory(sslFactory) + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.of( + ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout())) + .setSocketTimeout(Timeout.of( + ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout())) + .build()) + .build(); + log.debug("Built outbound HTTP client: trustMode={}, caPaths={}", + ctx.trustMode(), ctx.trustedCaPemPaths()); + return HttpClients.custom() + .setConnectionManager(connMgr) + .setDefaultRequestConfig(RequestConfig.custom().build()) + .build(); + } catch (Exception e) { + throw new IllegalStateException("Failed to build outbound HTTP client", e); + } + } + + private record CacheKey( + boolean trustAll, + List caPaths, + TrustMode mode, + Duration connect, + Duration read + ) { + static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) { + List mergedPaths = Stream.concat( + sp.trustedCaPemPaths().stream(), + ctx.trustedCaPemPaths().stream() + ).toList(); + return new CacheKey( + sp.trustAll(), + List.copyOf(mergedPaths), + ctx.trustMode(), + ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(), + ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout() + ); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java new file mode 100644 index 00000000..9207a609 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/SslContextBuilder.java @@ -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 extraCerts = new ArrayList<>(); + // System-level extras are always merged; per-request paths apply only in TRUST_PATHS mode. + List paths = new ArrayList<>(systemProps.trustedCaPemPaths()); + if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) { + paths.addAll(ctx.trustedCaPemPaths()); + } + for (String p : paths) { + extraCerts.addAll(loadPem(p)); + } + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + + // Load JDK default trust roots + TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + defaultTmf.init((KeyStore) null); + int i = 0; + for (TrustManager tm : defaultTmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509tm) { + for (X509Certificate cert : x509tm.getAcceptedIssuers()) { + ks.setCertificateEntry("default-" + (i++), cert); + } + } + } + // Add configured extras + for (X509Certificate cert : extraCerts) { + ks.setCertificateEntry("extra-" + (i++), cert); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } + + private Collection loadPem(String path) throws IOException, java.security.cert.CertificateException { + Path p = Path.of(path); + if (!Files.exists(p)) { + throw new IllegalArgumentException("CA file not found: " + path); + } + try (var in = Files.newInputStream(p)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + @SuppressWarnings("unchecked") + Collection certs = (Collection) cf.generateCertificates(in); + return certs; + } + } + + private static final class TrustAllManager implements X509TrustManager { + @Override public void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override public void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java new file mode 100644 index 00000000..4305d0c6 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/http/config/OutboundHttpConfig.java @@ -0,0 +1,76 @@ +package com.cameleer.server.app.http.config; + +import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory; +import com.cameleer.server.app.http.SslContextBuilder; +import com.cameleer.server.core.http.OutboundHttpClientFactory; +import com.cameleer.server.core.http.OutboundHttpProperties; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "cameleer.server.outbound-http") +public class OutboundHttpConfig { + + private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class); + + private boolean trustAll = false; + private List trustedCaPemPaths = List.of(); + private long defaultConnectTimeoutMs = 2000; + private long defaultReadTimeoutMs = 5000; + private String proxyUrl; + private String proxyUsername; + private String proxyPassword; + + public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; } + public void setTrustedCaPemPaths(List paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); } + public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; } + public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; } + public void setProxyUrl(String v) { this.proxyUrl = v; } + public void setProxyUsername(String v) { this.proxyUsername = v; } + public void setProxyPassword(String v) { this.proxyPassword = v; } + + @PostConstruct + void validate() { + if (trustAll) { + log.warn("cameleer.server.outbound-http.trust-all is ON - all outbound HTTPS cert validation is DISABLED. Do not use in production."); + } + for (String p : trustedCaPemPaths) { + if (!Files.exists(Path.of(p))) { + throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p); + } + log.info("Outbound HTTP: trusting additional CA from {}", p); + } + } + + @Bean + public OutboundHttpProperties outboundHttpProperties() { + return new OutboundHttpProperties( + trustAll, + trustedCaPemPaths, + Duration.ofMillis(defaultConnectTimeoutMs), + Duration.ofMillis(defaultReadTimeoutMs), + proxyUrl, + proxyUsername, + proxyPassword + ); + } + + @Bean + public SslContextBuilder sslContextBuilder() { + return new SslContextBuilder(); + } + + @Bean + public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) { + return new ApacheOutboundHttpClientFactory(props, builder); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java new file mode 100644 index 00000000..6ce204c2 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -0,0 +1,105 @@ +package com.cameleer.server.app.outbound; + +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundConnectionService; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public class OutboundConnectionServiceImpl implements OutboundConnectionService { + + private final OutboundConnectionRepository repo; + private final String tenantId; + + public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, String tenantId) { + this.repo = repo; + this.tenantId = tenantId; + } + + @Override + public OutboundConnection create(OutboundConnection draft, String actingUserId) { + assertNameUnique(draft.name(), null); + OutboundConnection c = new OutboundConnection( + UUID.randomUUID(), tenantId, draft.name(), draft.description(), + draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(), + draft.tlsTrustMode(), draft.tlsCaPemPaths(), + draft.hmacSecretCiphertext(), + draft.auth(), draft.allowedEnvironmentIds(), + Instant.now(), actingUserId, Instant.now(), actingUserId); + return repo.save(c); + } + + @Override + public OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId) { + OutboundConnection existing = repo.findById(tenantId, id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + if (!existing.name().equals(draft.name())) { + assertNameUnique(draft.name(), id); + } + + // Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs, + // find any envs that existed before but are absent in the draft. + // Skip entirely if either side is empty (empty = "allowed in all envs"). + if (!existing.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().isEmpty()) { + List removed = existing.allowedEnvironmentIds().stream() + .filter(e -> !draft.allowedEnvironmentIds().contains(e)) + .toList(); + if (!removed.isEmpty()) { + List refs = rulesReferencing(id); // Plan 01 stub + if (!refs.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Narrowing allowed environments while rules still reference this connection in removed envs: " + refs); + } + } + } + + OutboundConnection updated = new OutboundConnection( + id, tenantId, draft.name(), draft.description(), draft.url(), draft.method(), + draft.defaultHeaders(), draft.defaultBodyTmpl(), + draft.tlsTrustMode(), draft.tlsCaPemPaths(), + // Retain existing secret if the draft omitted one (null = leave unchanged). + draft.hmacSecretCiphertext() != null ? draft.hmacSecretCiphertext() : existing.hmacSecretCiphertext(), + draft.auth(), draft.allowedEnvironmentIds(), + existing.createdAt(), existing.createdBy(), Instant.now(), actingUserId); + return repo.save(updated); + } + + @Override + public void delete(UUID id, String actingUserId) { + List refs = rulesReferencing(id); // Plan 01 stub + if (!refs.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Outbound connection is referenced by rules: " + refs); + } + repo.delete(tenantId, id); + } + + @Override + public OutboundConnection get(UUID id) { + return repo.findById(tenantId, id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + } + + @Override + public List list() { + return repo.listByTenant(tenantId); + } + + @Override + public List rulesReferencing(UUID id) { + // Plan 01 stub. Plan 02 will wire this to AlertRuleRepository. + return List.of(); + } + + private void assertNameUnique(String name, UUID excludingId) { + repo.findByName(tenantId, name).ifPresent(c -> { + if (!c.id().equals(excludingId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Connection name already exists: " + name); + } + }); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java new file mode 100644 index 00000000..a4e9d8c8 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -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); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java new file mode 100644 index 00000000..8697f22c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminController.java @@ -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 list() { + return service.list().stream().map(OutboundConnectionDto::from).toList(); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public OutboundConnectionDto get(@PathVariable UUID id) { + return OutboundConnectionDto.from(service.get(id)); + } + + @GetMapping("/{id}/usage") + @PreAuthorize("hasAnyRole('ADMIN','OPERATOR')") + public List usage(@PathVariable UUID id) { + return service.rulesReferencing(id); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody OutboundConnectionRequest req, + HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection draft = toDraft(req); + OutboundConnection saved = service.create(draft, userId); + audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved)); + } + + @PutMapping("/{id}") + public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req, + HttpServletRequest httpRequest) { + String userId = currentUserId(); + OutboundConnection draft = toDraft(req); + OutboundConnection saved = service.update(id, draft, userId); + audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + return OutboundConnectionDto.from(saved); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id, HttpServletRequest httpRequest) { + String userId = currentUserId(); + service.delete(id, userId); + audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.noContent().build(); + } + + @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; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java new file mode 100644 index 00000000..3f7e4eb9 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/crypto/SecretCipher.java @@ -0,0 +1,62 @@ +package com.cameleer.server.app.outbound.crypto; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +/** Symmetric cipher for small at-rest secrets; key derived from JWT secret. */ +public class SecretCipher { + private static final String KDF_LABEL = "cameleer-outbound-secret-v1"; + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec aesKey; + private final SecureRandom random = new SecureRandom(); + + public SecretCipher(String jwtSecret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(jwtSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] keyBytes = mac.doFinal(KDF_LABEL.getBytes(StandardCharsets.UTF_8)); + this.aesKey = new SecretKeySpec(keyBytes, "AES"); + } catch (Exception e) { + throw new IllegalStateException("Failed to derive outbound secret key", e); + } + } + + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[IV_BYTES]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + ByteBuffer buf = ByteBuffer.allocate(iv.length + ct.length); + buf.put(iv).put(ct); + return Base64.getEncoder().encodeToString(buf.array()); + } catch (Exception e) { + throw new IllegalStateException("Encryption failed", e); + } + } + + public String decrypt(String ciphertextB64) { + try { + byte[] full = Base64.getDecoder().decode(ciphertextB64); + if (full.length < IV_BYTES + 16) throw new IllegalArgumentException("ciphertext too short"); + byte[] iv = new byte[IV_BYTES]; + System.arraycopy(full, 0, iv, 0, IV_BYTES); + byte[] ct = new byte[full.length - IV_BYTES]; + System.arraycopy(full, IV_BYTES, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(ct); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalArgumentException("Decryption failed", e); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java new file mode 100644 index 00000000..07a0aa9f --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionDto.java @@ -0,0 +1,31 @@ +package com.cameleer.server.app.outbound.dto; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuthKind; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundMethod; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnectionDto( + UUID id, String name, String description, String url, + OutboundMethod method, Map defaultHeaders, String defaultBodyTmpl, + TrustMode tlsTrustMode, List tlsCaPemPaths, + boolean hmacSecretSet, + OutboundAuthKind authKind, + List allowedEnvironmentIds, + Instant createdAt, String createdBy, Instant updatedAt, String updatedBy +) { + public static OutboundConnectionDto from(OutboundConnection c) { + return new OutboundConnectionDto( + c.id(), c.name(), c.description(), c.url(), c.method(), + c.defaultHeaders(), c.defaultBodyTmpl(), + c.tlsTrustMode(), c.tlsCaPemPaths(), + c.hmacSecretCiphertext() != null, + c.auth().kind(), c.allowedEnvironmentIds(), + c.createdAt(), c.createdBy(), c.updatedAt(), c.updatedBy()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java new file mode 100644 index 00000000..b12b6fff --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java @@ -0,0 +1,37 @@ +package com.cameleer.server.app.outbound.dto; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundMethod; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnectionRequest( + @NotBlank @Size(max = 100) String name, + @Size(max = 2000) String description, + @NotBlank @Pattern(regexp = "^https://.+", message = "URL must be HTTPS") String url, + @NotNull OutboundMethod method, + Map defaultHeaders, + String defaultBodyTmpl, + @NotNull TrustMode tlsTrustMode, + List tlsCaPemPaths, + String hmacSecret, + @NotNull @Valid OutboundAuth auth, + List allowedEnvironmentIds +) { + public OutboundConnectionRequest { + defaultHeaders = defaultHeaders == null ? Map.of() : defaultHeaders; + tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : tlsCaPemPaths; + allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : allowedEnvironmentIds; + if (tlsTrustMode != null && tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) { + throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS"); + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java new file mode 100644 index 00000000..71527ae1 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionTestResult.java @@ -0,0 +1,12 @@ +package com.cameleer.server.app.outbound.dto; + +public record OutboundConnectionTestResult( + int status, + long latencyMs, + String responseSnippet, + String tlsProtocol, + String tlsCipherSuite, + String peerCertificateSubject, + Long peerCertificateExpiresAtEpochMs, + String error +) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java new file mode 100644 index 00000000..02955bde --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepository.java @@ -0,0 +1,147 @@ +package com.cameleer.server.app.outbound.storage; + +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundAuthKind; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionRepository; +import com.cameleer.server.core.outbound.OutboundMethod; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Array; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository { + + private final JdbcTemplate jdbc; + private final ObjectMapper mapper; + + public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) { + this.jdbc = jdbc; + this.mapper = mapper; + } + + @Override + public OutboundConnection save(OutboundConnection c) { + boolean exists = findById(c.tenantId(), c.id()).isPresent(); + if (exists) { + jdbc.update(""" + UPDATE outbound_connections + SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum, + default_headers = ?::jsonb, default_body_tmpl = ?, + tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb, + hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum, + auth_config = ?::jsonb, allowed_environment_ids = ?, + updated_at = now(), updated_by = ? + WHERE tenant_id = ? AND id = ?""", + c.name(), c.description(), c.url(), c.method().name(), + writeJson(c.defaultHeaders()), c.defaultBodyTmpl(), + c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()), + c.hmacSecretCiphertext(), c.auth().kind().name(), + writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()), + c.updatedBy(), c.tenantId(), c.id()); + } else { + jdbc.update(""" + INSERT INTO outbound_connections ( + id, tenant_id, name, description, url, method, + default_headers, default_body_tmpl, + tls_trust_mode, tls_ca_pem_paths, + hmac_secret_ciphertext, auth_kind, auth_config, + allowed_environment_ids, created_by, updated_by) + VALUES (?,?,?,?,?,?::outbound_method_enum, + ?::jsonb,?, + ?::trust_mode_enum,?::jsonb, + ?,?::outbound_auth_kind_enum,?::jsonb, + ?,?,?)""", + c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(), + writeJson(c.defaultHeaders()), c.defaultBodyTmpl(), + c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()), + c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()), + toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy()); + } + return findById(c.tenantId(), c.id()).orElseThrow(); + } + + @Override + public Optional findById(String tenantId, UUID id) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND id = ?", + rowMapper, tenantId, id).stream().findFirst(); + } + + @Override + public Optional findByName(String tenantId, String name) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND name = ?", + rowMapper, tenantId, name).stream().findFirst(); + } + + @Override + public List listByTenant(String tenantId) { + return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? ORDER BY name", + rowMapper, tenantId); + } + + @Override + public void delete(String tenantId, UUID id) { + jdbc.update("DELETE FROM outbound_connections WHERE tenant_id = ? AND id = ?", tenantId, id); + } + + private final RowMapper rowMapper = (rs, i) -> new OutboundConnection( + rs.getObject("id", UUID.class), rs.getString("tenant_id"), + rs.getString("name"), rs.getString("description"), + rs.getString("url"), OutboundMethod.valueOf(rs.getString("method")), + readMapString(rs.getString("default_headers")), rs.getString("default_body_tmpl"), + TrustMode.valueOf(rs.getString("tls_trust_mode")), + readListString(rs.getString("tls_ca_pem_paths")), + rs.getString("hmac_secret_ciphertext"), + readAuth(OutboundAuthKind.valueOf(rs.getString("auth_kind")), rs.getString("auth_config")), + readUuidArray(rs.getArray("allowed_environment_ids")), + rs.getTimestamp("created_at").toInstant(), rs.getString("created_by"), + rs.getTimestamp("updated_at").toInstant(), rs.getString("updated_by")); + + private String writeJson(Object v) { + try { return mapper.writeValueAsString(v); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private Map readMapString(String json) { + try { return mapper.readValue(json, new TypeReference<>() {}); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private List readListString(String json) { + try { return mapper.readValue(json, new TypeReference<>() {}); } + catch (Exception e) { throw new IllegalStateException(e); } + } + + private OutboundAuth readAuth(OutboundAuthKind kind, String cfg) { + try { + return switch (kind) { + case NONE -> new OutboundAuth.None(); + case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class); + case BASIC -> mapper.readValue(cfg, OutboundAuth.Basic.class); + }; + } catch (Exception e) { throw new IllegalStateException(e); } + } + + private Array toUuidArray(List ids) { + return jdbc.execute((ConnectionCallback) conn -> + conn.createArrayOf("uuid", ids.toArray())); + } + + private List readUuidArray(Array arr) throws SQLException { + if (arr == null) return List.of(); + Object[] raw = (Object[]) arr.getArray(); + List out = new ArrayList<>(raw.length); + for (Object o : raw) out.add((UUID) o); + return out; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index 8dfb164c..65f8a7b6 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -161,7 +161,10 @@ public class SecurityConfig { // Runtime management (OPERATOR+) — legacy flat shape .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") - // Admin endpoints + // Outbound connections: list/get allow OPERATOR (method-level @PreAuthorize gates mutations) + .requestMatchers(HttpMethod.GET, "/api/v1/admin/outbound-connections", "/api/v1/admin/outbound-connections/**").hasAnyRole("OPERATOR", "ADMIN") + + // Admin endpoints (catch-all) .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // Everything else requires authentication diff --git a/cameleer-server-app/src/main/resources/application.yml b/cameleer-server-app/src/main/resources/application.yml index d0402871..7a73d3a3 100644 --- a/cameleer-server-app/src/main/resources/application.yml +++ b/cameleer-server-app/src/main/resources/application.yml @@ -79,6 +79,14 @@ cameleer: jwkseturi: ${CAMELEER_SERVER_SECURITY_OIDC_JWKSETURI:} audience: ${CAMELEER_SERVER_SECURITY_OIDC_AUDIENCE:} tlsskipverify: ${CAMELEER_SERVER_SECURITY_OIDC_TLSSKIPVERIFY:false} + outbound-http: + trust-all: false + trusted-ca-pem-paths: [] + default-connect-timeout-ms: 2000 + default-read-timeout-ms: 5000 + # proxy-url: + # proxy-username: + # proxy-password: clickhouse: url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer} username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default} diff --git a/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql new file mode 100644 index 00000000..48c803cd --- /dev/null +++ b/cameleer-server-app/src/main/resources/db/migration/V11__outbound_connections.sql @@ -0,0 +1,30 @@ +-- V11 — Outbound connections (admin-managed HTTPS destinations) +-- See: docs/superpowers/specs/2026-04-19-alerting-design.md §6 + +CREATE TYPE trust_mode_enum AS ENUM ('SYSTEM_DEFAULT','TRUST_ALL','TRUST_PATHS'); +CREATE TYPE outbound_method_enum AS ENUM ('POST','PUT','PATCH'); +CREATE TYPE outbound_auth_kind_enum AS ENUM ('NONE','BEARER','BASIC'); + +CREATE TABLE outbound_connections ( + id uuid PRIMARY KEY, + tenant_id varchar(64) NOT NULL, + name varchar(100) NOT NULL, + description text, + url text NOT NULL 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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java new file mode 100644 index 00000000..bc9fc7d3 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/ApacheOutboundHttpClientFactoryIT.java @@ -0,0 +1,67 @@ +package com.cameleer.server.app.http; + +import com.cameleer.server.core.http.OutboundHttpProperties; +import com.cameleer.server.core.http.OutboundHttpRequestContext; +import com.cameleer.server.core.http.TrustMode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApacheOutboundHttpClientFactoryIT { + + private WireMockServer wm; + private ApacheOutboundHttpClientFactory factory; + + @BeforeEach + void setUp() { + wm = new WireMockServer(WireMockConfiguration.options() + .httpDisabled(true).dynamicHttpsPort()); + wm.start(); + wm.stubFor(get("/ping").willReturn(ok("pong"))); + + OutboundHttpProperties props = new OutboundHttpProperties( + false, List.of(), Duration.ofSeconds(2), Duration.ofSeconds(5), null, null, null); + factory = new ApacheOutboundHttpClientFactory(props, new SslContextBuilder()); + } + + @AfterEach + void tearDown() { + 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java new file mode 100644 index 00000000..75693225 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/http/SslContextBuilderTest.java @@ -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"); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java new file mode 100644 index 00000000..4ca4a8f8 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionRequestValidationTest.java @@ -0,0 +1,49 @@ +package com.cameleer.server.app.outbound; + +import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest; +import com.cameleer.server.core.http.TrustMode; +import com.cameleer.server.core.outbound.OutboundAuth; +import com.cameleer.server.core.outbound.OutboundMethod; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OutboundConnectionRequestValidationTest { + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void validRequestPasses() { + var r = new OutboundConnectionRequest("slack-ops", "desc", + "https://hooks.slack.com/x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), + null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).isEmpty(); + } + + @Test + void blankNameFails() { + var r = new OutboundConnectionRequest(" ", null, "https://x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("name")); + } + + @Test + void nonHttpsUrlFails() { + var r = new OutboundConnectionRequest("n", null, "http://x", OutboundMethod.POST, + Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(), null, new OutboundAuth.None(), List.of()); + assertThat(validator.validate(r)).anySatisfy(v -> assertThat(v.getPropertyPath().toString()).isEqualTo("url")); + } + + @Test + void trustPathsRequiresNonEmptyCaList() { + assertThatThrownBy(() -> new OutboundConnectionRequest("n", null, "https://x", OutboundMethod.POST, + Map.of(), null, TrustMode.TRUST_PATHS, List.of(), null, new OutboundAuth.None(), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java new file mode 100644 index 00000000..00fd8158 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -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 resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.path("name").asText()).isEqualTo("slack-ops"); + assertThat(body.path("hmacSecretSet").asBoolean()).isFalse(); + assertThat(body.path("id").asText()).isNotBlank(); + } + + @Test + void operatorCannotCreate() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void operatorCanList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCannotList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void nonHttpsUrlRejected() { + String body = """ + {"name":"bad","url":"http://x","method":"POST", + "tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}"""; + ResponseEntity resp = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void duplicateNameReturns409() { + restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + ResponseEntity dup = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void deleteRemoves() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity del = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + ResponseEntity get = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id, HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void testActionReturnsStatusAndLatency() throws Exception { + wireMock = new com.github.tomakehurst.wiremock.WireMockServer( + com.github.tomakehurst.wiremock.core.WireMockConfiguration.options() + .httpDisabled(true).dynamicHttpsPort()); + wireMock.start(); + wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe") + .willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong"))); + + String createBody = """ + {"name":"probe-target","url":"https://localhost:%d/probe","method":"POST", + "tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort()); + + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity test = restTemplate.exchange( + "/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(test.getBody()); + assertThat(body.path("status").asInt()).isEqualTo(200); + assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0); + assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS"); + assertThat(body.path("error").isNull()).isTrue(); + } + + @Test + void operatorCannotTest() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/admin/outbound-connections", HttpMethod.POST, + new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java new file mode 100644 index 00000000..0ef070e9 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/crypto/SecretCipherTest.java @@ -0,0 +1,42 @@ +package com.cameleer.server.app.outbound.crypto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecretCipherTest { + private final SecretCipher cipher = new SecretCipher("test-jwt-secret-must-be-long-enough-to-derive"); + + @Test + void roundTrips() { + String plaintext = "my-hmac-secret-12345"; + String ct = cipher.encrypt(plaintext); + assertThat(ct).isNotEqualTo(plaintext); + assertThat(cipher.decrypt(ct)).isEqualTo(plaintext); + } + + @Test + void differentCiphertextsForSamePlaintext() { + String a = cipher.encrypt("x"); + String b = cipher.encrypt("x"); + assertThat(a).isNotEqualTo(b); + assertThat(cipher.decrypt(a)).isEqualTo(cipher.decrypt(b)); + } + + @Test + void decryptRejectsTamperedCiphertext() { + String ct = cipher.encrypt("abc"); + String tampered = ct.substring(0, ct.length() - 2) + "00"; + assertThatThrownBy(() -> cipher.decrypt(tampered)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void decryptRejectsWrongKey() { + String ct = cipher.encrypt("abc"); + SecretCipher other = new SecretCipher("some-other-jwt-secret-that-is-long-enough"); + assertThatThrownBy(() -> other.decrypt(ct)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java new file mode 100644 index 00000000..1f242685 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java @@ -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 list = repo.listByTenant(TENANT); + assertThat(list).extracting(OutboundConnection::name).containsExactly("in-tenant"); + } finally { + repo.delete("other-tenant", otherId); + } + } + + @Test + void deleteRemovesRow() { + OutboundConnection c = draft("to-delete"); + repo.save(c); + repo.delete(TENANT, c.id()); + assertThat(repo.findById(TENANT, c.id())).isEmpty(); + } +} diff --git a/cameleer-server-app/src/test/resources/application-test.yml b/cameleer-server-app/src/test/resources/application-test.yml index 82d07651..ce02814a 100644 --- a/cameleer-server-app/src/test/resources/application-test.yml +++ b/cameleer-server-app/src/test/resources/application-test.yml @@ -16,3 +16,4 @@ cameleer: bootstraptoken: test-bootstrap-token bootstraptokenprevious: old-bootstrap-token infrastructureendpoints: true + jwtsecret: test-jwt-secret-for-integration-tests-only diff --git a/cameleer-server-app/src/test/resources/test-ca.pem b/cameleer-server-app/src/test/resources/test-ca.pem new file mode 100644 index 00000000..37ced8f3 --- /dev/null +++ b/cameleer-server-app/src/test/resources/test-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUawEUnI7oAYTMsqYYJUBqGJhM8LMwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQY2FtZWxlZXItdGVzdC1jYTAeFw0yNjA0MTkxMzUzMTNa +Fw0zNjA0MTYxMzUzMTNaMBsxGTAXBgNVBAMMEGNhbWVsZWVyLXRlc3QtY2EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCalBl0hjPSXYZjFLImDT5awayX +nPmX7/TyduX2xSksLnGBfD1YcLYhIaOU3Bp6o+Wld8+nzNjwgMUcKdv1mWqDL8t0 +nlf0JGAntNEQWGwxFUGSLcmzgVf8/d1s0mpZ1/mS6RDzoQ8i8rNq5mYPXkWFAvgD +G1FQpaBs71VC7vVcEgnJ4kK/8cNcQ/nvaI+T/Tk+sciu57XuMoa8AoJV/Oz/hdkc +fIoYeUpFp36pYn71yFyZ1N+1xCTi1ruBmMekYWKNaNY6/edk2z3iTFgv4Dk4QS1O +6GEdgUi9RQIgQwyYltQK2z+wLA8e982JK5HllsLYU0AcY6fvg+JlhEEpCsdnAgMB +AAGjUzBRMB0GA1UdDgQWBBQYRTifnA5YLxiqjIYMDcZLgDrtUDAfBgNVHSMEGDAW +gBQYRTifnA5YLxiqjIYMDcZLgDrtUDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQBpNfv6I9dsUflmslv6el5VvDOyZ3GWQIE/Zy0p+i0NYjeWMWtX +rFPw1mnfdhlyFMpNkP8ddyDaSoqnnFsC4pXqXibrey0NDYX7zujyVIaXixf6koU9 +2/vNIb4evnJyVT9CcophDrobkR4NwU9SrMO7KAecb/U6shrfgmjIanUnCsfGdwsP +f85DQDbOd9UhnMfMB43I8tjn2Po155npmwGK7J4hZpWTUj/rbNt3fmy2mx6rqdkp +p61nLY3ba61bnl7QQ7VW7nhaIC7t5sO6NFDdv06MG8Cr5kcvoJDPe93iNeVT8iLp +Rxs85FvIVYIt58jMvr+RSEfTD8fIvXl0uRDy +-----END CERTIFICATE----- diff --git a/cameleer-server-core/pom.xml b/cameleer-server-core/pom.xml index 9c959ab3..b93977fd 100644 --- a/cameleer-server-core/pom.xml +++ b/cameleer-server-core/pom.xml @@ -37,6 +37,10 @@ spring-security-core provided + + org.apache.httpcomponents.client5 + httpclient5 + com.fasterxml.jackson.datatype jackson-datatype-jsr310 diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java index 968ee093..f76dff35 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java @@ -1,5 +1,6 @@ package com.cameleer.server.core.admin; public enum AuditCategory { - INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT + INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, + OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java new file mode 100644 index 00000000..64bf0f35 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpClientFactory.java @@ -0,0 +1,8 @@ +package com.cameleer.server.core.http; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; + +public interface OutboundHttpClientFactory { + /** Returns a memoized client configured for the given TLS/timeout context. */ + CloseableHttpClient clientFor(OutboundHttpRequestContext context); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java new file mode 100644 index 00000000..cb586ef9 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpProperties.java @@ -0,0 +1,20 @@ +package com.cameleer.server.core.http; + +import java.time.Duration; +import java.util.List; + +public record OutboundHttpProperties( + boolean trustAll, + List trustedCaPemPaths, + Duration defaultConnectTimeout, + Duration defaultReadTimeout, + String proxyUrl, + String proxyUsername, + String proxyPassword +) { + public OutboundHttpProperties { + trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths); + if (defaultConnectTimeout == null) defaultConnectTimeout = Duration.ofMillis(2000); + if (defaultReadTimeout == null) defaultReadTimeout = Duration.ofMillis(5000); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java new file mode 100644 index 00000000..a09ef217 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/OutboundHttpRequestContext.java @@ -0,0 +1,20 @@ +package com.cameleer.server.core.http; + +import java.time.Duration; +import java.util.List; + +public record OutboundHttpRequestContext( + TrustMode trustMode, + List trustedCaPemPaths, + Duration connectTimeout, + Duration readTimeout +) { + public OutboundHttpRequestContext { + if (trustMode == null) trustMode = TrustMode.SYSTEM_DEFAULT; + trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths); + } + + public static OutboundHttpRequestContext systemDefault() { + return new OutboundHttpRequestContext(TrustMode.SYSTEM_DEFAULT, List.of(), null, null); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java new file mode 100644 index 00000000..a1db2e8c --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/http/TrustMode.java @@ -0,0 +1,7 @@ +package com.cameleer.server.core.http; + +public enum TrustMode { + SYSTEM_DEFAULT, + TRUST_ALL, + TRUST_PATHS +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java new file mode 100644 index 00000000..173e03ee --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuth.java @@ -0,0 +1,24 @@ +package com.cameleer.server.core.outbound; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes({ + @JsonSubTypes.Type(OutboundAuth.None.class), + @JsonSubTypes.Type(OutboundAuth.Bearer.class), + @JsonSubTypes.Type(OutboundAuth.Basic.class), +}) +public sealed interface OutboundAuth permits OutboundAuth.None, OutboundAuth.Bearer, OutboundAuth.Basic { + OutboundAuthKind kind(); + + record None() implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.NONE; } + } + record Bearer(String tokenCiphertext) implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.BEARER; } + } + record Basic(String username, String passwordCiphertext) implements OutboundAuth { + public OutboundAuthKind kind() { return OutboundAuthKind.BASIC; } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java new file mode 100644 index 00000000..eae12dec --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundAuthKind.java @@ -0,0 +1,3 @@ +package com.cameleer.server.core.outbound; + +public enum OutboundAuthKind { NONE, BEARER, BASIC } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java new file mode 100644 index 00000000..b37c1c0a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnection.java @@ -0,0 +1,39 @@ +package com.cameleer.server.core.outbound; + +import com.cameleer.server.core.http.TrustMode; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public record OutboundConnection( + UUID id, + String tenantId, + String name, + String description, + String url, + OutboundMethod method, + Map defaultHeaders, + String defaultBodyTmpl, + TrustMode tlsTrustMode, + List tlsCaPemPaths, + String hmacSecretCiphertext, + OutboundAuth auth, + List allowedEnvironmentIds, + Instant createdAt, + 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); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java new file mode 100644 index 00000000..5daac1da --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionRepository.java @@ -0,0 +1,13 @@ +package com.cameleer.server.core.outbound; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OutboundConnectionRepository { + OutboundConnection save(OutboundConnection connection); + Optional findById(String tenantId, UUID id); + Optional findByName(String tenantId, String name); + List listByTenant(String tenantId); + void delete(String tenantId, UUID id); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java new file mode 100644 index 00000000..05576668 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundConnectionService.java @@ -0,0 +1,13 @@ +package com.cameleer.server.core.outbound; + +import java.util.List; +import java.util.UUID; + +public interface OutboundConnectionService { + OutboundConnection create(OutboundConnection draft, String actingUserId); + OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId); + void delete(UUID id, String actingUserId); + OutboundConnection get(UUID id); + List list(); + List rulesReferencing(UUID id); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java new file mode 100644 index 00000000..5909382a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/outbound/OutboundMethod.java @@ -0,0 +1,3 @@ +package com.cameleer.server.core.outbound; + +public enum OutboundMethod { POST, PUT, PATCH } diff --git a/docs/outbound-connections.md b/docs/outbound-connections.md new file mode 100644 index 00000000..007c3cbf --- /dev/null +++ b/docs/outbound-connections.md @@ -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= +X-Cameleer-Timestamp: +``` + +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. diff --git a/ui/src/api/queries/admin/outboundConnections.ts b/ui/src/api/queries/admin/outboundConnections.ts new file mode 100644 index 00000000..cf6d1090 --- /dev/null +++ b/ui/src/api/queries/admin/outboundConnections.ts @@ -0,0 +1,128 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminFetch } from './admin-api'; + +// ── Types ────────────────────────────────────────────────────────────── + +export type OutboundMethod = 'POST' | 'PUT' | 'PATCH'; +export type OutboundAuthKind = 'NONE' | 'BEARER' | 'BASIC'; +export type TrustMode = 'SYSTEM_DEFAULT' | 'TRUST_ALL' | 'TRUST_PATHS'; + +// Jackson DEDUCTION mode picks subtype by which fields are present. +// None = {}, Bearer = { tokenCiphertext }, Basic = { username, passwordCiphertext }. +// Using a flat optional shape for pragmatic TypeScript compatibility. +export type OutboundAuth = { + tokenCiphertext?: string; + username?: string; + passwordCiphertext?: string; +}; + +// Server returns this shape (`OutboundConnectionDto` in Java) +export interface OutboundConnectionDto { + id: string; + name: string; + description: string | null; + url: string; + method: OutboundMethod; + defaultHeaders: Record; + defaultBodyTmpl: string | null; + tlsTrustMode: TrustMode; + tlsCaPemPaths: string[]; + hmacSecretSet: boolean; + authKind: OutboundAuthKind; + allowedEnvironmentIds: string[]; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} + +// Client sends this shape for create/update (`OutboundConnectionRequest` in Java) +export interface OutboundConnectionRequest { + name: string; + description?: string | null; + url: string; + method: OutboundMethod; + defaultHeaders?: Record; + defaultBodyTmpl?: string | null; + tlsTrustMode: TrustMode; + tlsCaPemPaths?: string[]; + hmacSecret?: string | null; + auth: OutboundAuth; + allowedEnvironmentIds?: string[]; +} + +// Server returns this from POST /{id}/test +export interface OutboundConnectionTestResult { + status: number; + latencyMs: number; + responseSnippet: string | null; + tlsProtocol: string | null; + tlsCipherSuite: string | null; + peerCertificateSubject: string | null; + peerCertificateExpiresAtEpochMs: number | null; + error: string | null; +} + +// ── Query Hooks ──────────────────────────────────────────────────────── + +export function useOutboundConnections() { + return useQuery({ + queryKey: ['admin', 'outbound-connections'], + queryFn: () => adminFetch('/outbound-connections'), + }); +} + +export function useOutboundConnection(id: string | undefined) { + return useQuery({ + queryKey: ['admin', 'outbound-connections', id], + queryFn: () => adminFetch(`/outbound-connections/${id}`), + enabled: !!id, + }); +} + +// ── Mutation Hooks ───────────────────────────────────────────────────── + +export function useCreateOutboundConnection() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: OutboundConnectionRequest) => + adminFetch('/outbound-connections', { + method: 'POST', + body: JSON.stringify(req), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }), + }); +} + +export function useUpdateOutboundConnection(id: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (req: OutboundConnectionRequest) => + adminFetch(`/outbound-connections/${id}`, { + method: 'PUT', + body: JSON.stringify(req), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }); + qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections', id] }); + }, + }); +} + +export function useDeleteOutboundConnection() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/outbound-connections/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }), + }); +} + +export function useTestOutboundConnection() { + return useMutation({ + mutationFn: (id: string) => + adminFetch(`/outbound-connections/${id}/test`, { + method: 'POST', + }), + }); +} diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index 969b45b5..abfbe32d 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -107,6 +107,7 @@ export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean } ...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []), { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:outbound-connections', label: 'Outbound Connections', path: '/admin/outbound-connections' }, { id: 'admin:sensitive-keys', label: 'Sensitive Keys', path: '/admin/sensitive-keys' }, { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, ]; diff --git a/ui/src/pages/Admin/OutboundConnectionEditor.tsx b/ui/src/pages/Admin/OutboundConnectionEditor.tsx new file mode 100644 index 00000000..92ebd3f2 --- /dev/null +++ b/ui/src/pages/Admin/OutboundConnectionEditor.tsx @@ -0,0 +1,470 @@ +import { useEffect, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Button, FormField, Input, Select, SectionHeader, Toggle, useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; +import { + useOutboundConnection, + useCreateOutboundConnection, + useUpdateOutboundConnection, + useTestOutboundConnection, + type OutboundConnectionDto, + type OutboundConnectionRequest, + type OutboundConnectionTestResult, + type OutboundMethod, + type OutboundAuthKind, + type TrustMode, +} from '../../api/queries/admin/outboundConnections'; +import { useEnvironments } from '../../api/queries/admin/environments'; +import sectionStyles from '../../styles/section-card.module.css'; + +// ── Form state ────────────────────────────────────────────────────────── + +interface FormState { + name: string; + description: string; + url: string; + method: OutboundMethod; + headers: Array<{ key: string; value: string }>; + defaultBodyTmpl: string; + tlsTrustMode: TrustMode; + tlsCaPemPaths: string[]; + hmacSecret: string; // empty = keep existing (edit) / no secret (create) + authKind: OutboundAuthKind; + bearerToken: string; + basicUsername: string; + basicPassword: string; + allowAllEnvs: boolean; + allowedEnvIds: string[]; +} + +function initialForm(existing?: OutboundConnectionDto): FormState { + return { + name: existing?.name ?? '', + description: existing?.description ?? '', + url: existing?.url ?? 'https://', + method: existing?.method ?? 'POST', + headers: existing + ? Object.entries(existing.defaultHeaders).map(([key, value]) => ({ key, value })) + : [], + defaultBodyTmpl: existing?.defaultBodyTmpl ?? '', + tlsTrustMode: existing?.tlsTrustMode ?? 'SYSTEM_DEFAULT', + tlsCaPemPaths: existing?.tlsCaPemPaths ?? [], + hmacSecret: '', + authKind: existing?.authKind ?? 'NONE', + bearerToken: '', + basicUsername: '', + basicPassword: '', + allowAllEnvs: !existing || existing.allowedEnvironmentIds.length === 0, + allowedEnvIds: existing?.allowedEnvironmentIds ?? [], + }; +} + +function toRequest(f: FormState): OutboundConnectionRequest { + const defaultHeaders = Object.fromEntries( + f.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]), + ); + const allowedEnvironmentIds = f.allowAllEnvs ? [] : f.allowedEnvIds; + const auth = + f.authKind === 'NONE' + ? {} + : f.authKind === 'BEARER' + ? { tokenCiphertext: f.bearerToken } + : { username: f.basicUsername, passwordCiphertext: f.basicPassword }; + return { + name: f.name, + description: f.description || null, + url: f.url, + method: f.method, + defaultHeaders, + defaultBodyTmpl: f.defaultBodyTmpl || null, + tlsTrustMode: f.tlsTrustMode, + tlsCaPemPaths: f.tlsCaPemPaths, + hmacSecret: f.hmacSecret ? f.hmacSecret : null, + auth, + allowedEnvironmentIds, + }; +} + +// ── Select option arrays ─────────────────────────────────────────────── + +const METHOD_OPTIONS: Array<{ value: OutboundMethod; label: string }> = [ + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'PATCH', label: 'PATCH' }, +]; + +const TRUST_OPTIONS: Array<{ value: TrustMode; label: string }> = [ + { value: 'SYSTEM_DEFAULT', label: 'System default (recommended)' }, + { value: 'TRUST_PATHS', label: 'Trust additional CA PEMs' }, + { value: 'TRUST_ALL', label: 'Trust all (INSECURE)' }, +]; + +const AUTH_OPTIONS: Array<{ value: OutboundAuthKind; label: string }> = [ + { value: 'NONE', label: 'None' }, + { value: 'BEARER', label: 'Bearer token' }, + { value: 'BASIC', label: 'Basic' }, +]; + +// ── Component ────────────────────────────────────────────────────────── + +export default function OutboundConnectionEditor() { + const { id } = useParams<{ id: string }>(); + const isNew = !id; + const navigate = useNavigate(); + const { toast } = useToast(); + + const existingQ = useOutboundConnection(isNew ? undefined : id); + const envQ = useEnvironments(); + const createMut = useCreateOutboundConnection(); + // Hooks must be called unconditionally; pass placeholder id when unknown. + const updateMut = useUpdateOutboundConnection(id ?? 'placeholder'); + const testMut = useTestOutboundConnection(); + + const [form, setForm] = useState(() => initialForm()); + const [initialized, setInitialized] = useState(isNew); + const [testResult, setTestResult] = useState(null); + const [showSecret, setShowSecret] = useState(false); + + useEffect(() => { + if (!initialized && existingQ.data) { + setForm(initialForm(existingQ.data)); + setInitialized(true); + } + }, [existingQ.data, initialized]); + + if (!isNew && existingQ.isLoading) return ; + + const isSaving = createMut.isPending || updateMut.isPending; + + const onSave = () => { + const payload = toRequest(form); + if (isNew) { + createMut.mutate(payload, { + onSuccess: () => { + toast({ title: 'Created', description: form.name, variant: 'success' }); + navigate('/admin/outbound-connections'); + }, + onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }), + }); + } else { + updateMut.mutate(payload, { + onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }), + onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }), + }); + } + }; + + const onTest = () => { + if (!id) return; + testMut.mutate(id, { + onSuccess: (r) => setTestResult(r), + onError: (e) => + setTestResult({ + status: 0, + latencyMs: 0, + responseSnippet: null, + tlsProtocol: null, + tlsCipherSuite: null, + peerCertificateSubject: null, + peerCertificateExpiresAtEpochMs: null, + error: String(e), + }), + }); + }; + + const envs = envQ.data ?? []; + + return ( +
+
+ + {isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`} + + + + +
+ +
+ + {/* Name */} + + setForm({ ...form, name: e.target.value })} + placeholder="slack-ops" + /> + + + {/* Description */} + +