Merge pull request 'feat(alerting): Plan 01 — outbound HTTP infra + admin-managed outbound connections' (#139) from feat/alerting-01-outbound-infra into main
Reviewed-on: #139
This commit was merged in pull request #139.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>3.9.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.cameleer.server.app.http;
|
||||
|
||||
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import org.apache.hc.client5.http.config.ConnectionConfig;
|
||||
import org.apache.hc.client5.http.config.RequestConfig;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.client5.http.impl.classic.HttpClients;
|
||||
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
|
||||
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
|
||||
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactoryBuilder;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ApacheOutboundHttpClientFactory implements OutboundHttpClientFactory {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApacheOutboundHttpClientFactory.class);
|
||||
|
||||
private final OutboundHttpProperties systemProps;
|
||||
private final SslContextBuilder sslBuilder;
|
||||
private final ConcurrentMap<CacheKey, CloseableHttpClient> clients = new ConcurrentHashMap<>();
|
||||
|
||||
public ApacheOutboundHttpClientFactory(OutboundHttpProperties systemProps, SslContextBuilder sslBuilder) {
|
||||
this.systemProps = systemProps;
|
||||
this.sslBuilder = sslBuilder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CloseableHttpClient clientFor(OutboundHttpRequestContext ctx) {
|
||||
CacheKey key = CacheKey.of(systemProps, ctx);
|
||||
return clients.computeIfAbsent(key, k -> build(ctx));
|
||||
}
|
||||
|
||||
private CloseableHttpClient build(OutboundHttpRequestContext ctx) {
|
||||
try {
|
||||
var sslContext = sslBuilder.build(systemProps, ctx);
|
||||
boolean trustAll = systemProps.trustAll() || ctx.trustMode() == TrustMode.TRUST_ALL;
|
||||
var sslFactoryBuilder = SSLConnectionSocketFactoryBuilder.create()
|
||||
.setSslContext(sslContext);
|
||||
if (trustAll) {
|
||||
sslFactoryBuilder.setHostnameVerifier(NoopHostnameVerifier.INSTANCE);
|
||||
}
|
||||
var sslFactory = sslFactoryBuilder.build();
|
||||
var connMgr = PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setSSLSocketFactory(sslFactory)
|
||||
.setDefaultConnectionConfig(ConnectionConfig.custom()
|
||||
.setConnectTimeout(Timeout.of(
|
||||
ctx.connectTimeout() != null ? ctx.connectTimeout() : systemProps.defaultConnectTimeout()))
|
||||
.setSocketTimeout(Timeout.of(
|
||||
ctx.readTimeout() != null ? ctx.readTimeout() : systemProps.defaultReadTimeout()))
|
||||
.build())
|
||||
.build();
|
||||
log.debug("Built outbound HTTP client: trustMode={}, caPaths={}",
|
||||
ctx.trustMode(), ctx.trustedCaPemPaths());
|
||||
return HttpClients.custom()
|
||||
.setConnectionManager(connMgr)
|
||||
.setDefaultRequestConfig(RequestConfig.custom().build())
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to build outbound HTTP client", e);
|
||||
}
|
||||
}
|
||||
|
||||
private record CacheKey(
|
||||
boolean trustAll,
|
||||
List<String> caPaths,
|
||||
TrustMode mode,
|
||||
Duration connect,
|
||||
Duration read
|
||||
) {
|
||||
static CacheKey of(OutboundHttpProperties sp, OutboundHttpRequestContext ctx) {
|
||||
List<String> mergedPaths = Stream.concat(
|
||||
sp.trustedCaPemPaths().stream(),
|
||||
ctx.trustedCaPemPaths().stream()
|
||||
).toList();
|
||||
return new CacheKey(
|
||||
sp.trustAll(),
|
||||
List.copyOf(mergedPaths),
|
||||
ctx.trustMode(),
|
||||
ctx.connectTimeout() != null ? ctx.connectTimeout() : sp.defaultConnectTimeout(),
|
||||
ctx.readTimeout() != null ? ctx.readTimeout() : sp.defaultReadTimeout()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.cameleer.server.app.http;
|
||||
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class SslContextBuilder {
|
||||
|
||||
public SSLContext build(OutboundHttpProperties systemProps, OutboundHttpRequestContext ctx)
|
||||
throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException,
|
||||
CertificateException, IOException {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
|
||||
if (systemProps.trustAll() || ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_ALL) {
|
||||
sslContext.init(null, new TrustManager[]{new TrustAllManager()}, null);
|
||||
return sslContext;
|
||||
}
|
||||
|
||||
List<X509Certificate> extraCerts = new ArrayList<>();
|
||||
// System-level extras are always merged; per-request paths apply only in TRUST_PATHS mode.
|
||||
List<String> paths = new ArrayList<>(systemProps.trustedCaPemPaths());
|
||||
if (ctx.trustMode() == com.cameleer.server.core.http.TrustMode.TRUST_PATHS) {
|
||||
paths.addAll(ctx.trustedCaPemPaths());
|
||||
}
|
||||
for (String p : paths) {
|
||||
extraCerts.addAll(loadPem(p));
|
||||
}
|
||||
|
||||
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
ks.load(null, null);
|
||||
|
||||
// Load JDK default trust roots
|
||||
TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
defaultTmf.init((KeyStore) null);
|
||||
int i = 0;
|
||||
for (TrustManager tm : defaultTmf.getTrustManagers()) {
|
||||
if (tm instanceof X509TrustManager x509tm) {
|
||||
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
|
||||
ks.setCertificateEntry("default-" + (i++), cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add configured extras
|
||||
for (X509Certificate cert : extraCerts) {
|
||||
ks.setCertificateEntry("extra-" + (i++), cert);
|
||||
}
|
||||
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(ks);
|
||||
sslContext.init(null, tmf.getTrustManagers(), null);
|
||||
return sslContext;
|
||||
}
|
||||
|
||||
private Collection<? extends X509Certificate> loadPem(String path) throws IOException, java.security.cert.CertificateException {
|
||||
Path p = Path.of(path);
|
||||
if (!Files.exists(p)) {
|
||||
throw new IllegalArgumentException("CA file not found: " + path);
|
||||
}
|
||||
try (var in = Files.newInputStream(p)) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
@SuppressWarnings("unchecked")
|
||||
Collection<X509Certificate> certs = (Collection<X509Certificate>) cf.generateCertificates(in);
|
||||
return certs;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TrustAllManager implements X509TrustManager {
|
||||
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
|
||||
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) {}
|
||||
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.cameleer.server.app.http.config;
|
||||
|
||||
import com.cameleer.server.app.http.ApacheOutboundHttpClientFactory;
|
||||
import com.cameleer.server.app.http.SslContextBuilder;
|
||||
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||
import com.cameleer.server.core.http.OutboundHttpProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "cameleer.server.outbound-http")
|
||||
public class OutboundHttpConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OutboundHttpConfig.class);
|
||||
|
||||
private boolean trustAll = false;
|
||||
private List<String> trustedCaPemPaths = List.of();
|
||||
private long defaultConnectTimeoutMs = 2000;
|
||||
private long defaultReadTimeoutMs = 5000;
|
||||
private String proxyUrl;
|
||||
private String proxyUsername;
|
||||
private String proxyPassword;
|
||||
|
||||
public void setTrustAll(boolean trustAll) { this.trustAll = trustAll; }
|
||||
public void setTrustedCaPemPaths(List<String> paths) { this.trustedCaPemPaths = paths == null ? List.of() : List.copyOf(paths); }
|
||||
public void setDefaultConnectTimeoutMs(long v) { this.defaultConnectTimeoutMs = v; }
|
||||
public void setDefaultReadTimeoutMs(long v) { this.defaultReadTimeoutMs = v; }
|
||||
public void setProxyUrl(String v) { this.proxyUrl = v; }
|
||||
public void setProxyUsername(String v) { this.proxyUsername = v; }
|
||||
public void setProxyPassword(String v) { this.proxyPassword = v; }
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
if (trustAll) {
|
||||
log.warn("cameleer.server.outbound-http.trust-all is ON - all outbound HTTPS cert validation is DISABLED. Do not use in production.");
|
||||
}
|
||||
for (String p : trustedCaPemPaths) {
|
||||
if (!Files.exists(Path.of(p))) {
|
||||
throw new IllegalStateException("Configured trusted CA PEM path does not exist: " + p);
|
||||
}
|
||||
log.info("Outbound HTTP: trusting additional CA from {}", p);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OutboundHttpProperties outboundHttpProperties() {
|
||||
return new OutboundHttpProperties(
|
||||
trustAll,
|
||||
trustedCaPemPaths,
|
||||
Duration.ofMillis(defaultConnectTimeoutMs),
|
||||
Duration.ofMillis(defaultReadTimeoutMs),
|
||||
proxyUrl,
|
||||
proxyUsername,
|
||||
proxyPassword
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SslContextBuilder sslContextBuilder() {
|
||||
return new SslContextBuilder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OutboundHttpClientFactory outboundHttpClientFactory(OutboundHttpProperties props, SslContextBuilder builder) {
|
||||
return new ApacheOutboundHttpClientFactory(props, builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.cameleer.server.app.outbound;
|
||||
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class OutboundConnectionServiceImpl implements OutboundConnectionService {
|
||||
|
||||
private final OutboundConnectionRepository repo;
|
||||
private final String tenantId;
|
||||
|
||||
public OutboundConnectionServiceImpl(OutboundConnectionRepository repo, String tenantId) {
|
||||
this.repo = repo;
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
|
||||
assertNameUnique(draft.name(), null);
|
||||
OutboundConnection c = new OutboundConnection(
|
||||
UUID.randomUUID(), tenantId, draft.name(), draft.description(),
|
||||
draft.url(), draft.method(), draft.defaultHeaders(), draft.defaultBodyTmpl(),
|
||||
draft.tlsTrustMode(), draft.tlsCaPemPaths(),
|
||||
draft.hmacSecretCiphertext(),
|
||||
draft.auth(), draft.allowedEnvironmentIds(),
|
||||
Instant.now(), actingUserId, Instant.now(), actingUserId);
|
||||
return repo.save(c);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId) {
|
||||
OutboundConnection existing = repo.findById(tenantId, id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
if (!existing.name().equals(draft.name())) {
|
||||
assertNameUnique(draft.name(), id);
|
||||
}
|
||||
|
||||
// Narrowing allowed-envs guard: if the new draft restricts to a non-empty set of envs,
|
||||
// find any envs that existed before but are absent in the draft.
|
||||
// Skip entirely if either side is empty (empty = "allowed in all envs").
|
||||
if (!existing.allowedEnvironmentIds().isEmpty() && !draft.allowedEnvironmentIds().isEmpty()) {
|
||||
List<UUID> removed = existing.allowedEnvironmentIds().stream()
|
||||
.filter(e -> !draft.allowedEnvironmentIds().contains(e))
|
||||
.toList();
|
||||
if (!removed.isEmpty()) {
|
||||
List<UUID> refs = rulesReferencing(id); // Plan 01 stub
|
||||
if (!refs.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Narrowing allowed environments while rules still reference this connection in removed envs: " + refs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutboundConnection updated = new OutboundConnection(
|
||||
id, tenantId, draft.name(), draft.description(), draft.url(), draft.method(),
|
||||
draft.defaultHeaders(), draft.defaultBodyTmpl(),
|
||||
draft.tlsTrustMode(), draft.tlsCaPemPaths(),
|
||||
// Retain existing secret if the draft omitted one (null = leave unchanged).
|
||||
draft.hmacSecretCiphertext() != null ? draft.hmacSecretCiphertext() : existing.hmacSecretCiphertext(),
|
||||
draft.auth(), draft.allowedEnvironmentIds(),
|
||||
existing.createdAt(), existing.createdBy(), Instant.now(), actingUserId);
|
||||
return repo.save(updated);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UUID id, String actingUserId) {
|
||||
List<UUID> refs = rulesReferencing(id); // Plan 01 stub
|
||||
if (!refs.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Outbound connection is referenced by rules: " + refs);
|
||||
}
|
||||
repo.delete(tenantId, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboundConnection get(UUID id) {
|
||||
return repo.findById(tenantId, id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OutboundConnection> list() {
|
||||
return repo.listByTenant(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UUID> rulesReferencing(UUID id) {
|
||||
// Plan 01 stub. Plan 02 will wire this to AlertRuleRepository.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private void assertNameUnique(String name, UUID excludingId) {
|
||||
repo.findByName(tenantId, name).ifPresent(c -> {
|
||||
if (!c.id().equals(excludingId)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Connection name already exists: " + name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.cameleer.server.app.outbound.controller;
|
||||
|
||||
import com.cameleer.server.app.outbound.crypto.SecretCipher;
|
||||
import com.cameleer.server.app.outbound.dto.OutboundConnectionDto;
|
||||
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
|
||||
import com.cameleer.server.app.outbound.dto.OutboundConnectionTestResult;
|
||||
import com.cameleer.server.core.admin.AuditCategory;
|
||||
import com.cameleer.server.core.admin.AuditResult;
|
||||
import com.cameleer.server.core.admin.AuditService;
|
||||
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionService;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||
import org.apache.hc.core5.http.ContentType;
|
||||
import org.apache.hc.core5.http.HttpEntity;
|
||||
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/outbound-connections")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Outbound Connections Admin", description = "Admin-managed outbound HTTPS destinations")
|
||||
public class OutboundConnectionAdminController {
|
||||
|
||||
private final OutboundConnectionService service;
|
||||
private final SecretCipher cipher;
|
||||
private final AuditService audit;
|
||||
private final OutboundHttpClientFactory httpClientFactory;
|
||||
|
||||
public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher,
|
||||
AuditService audit, OutboundHttpClientFactory httpClientFactory) {
|
||||
this.service = service;
|
||||
this.cipher = cipher;
|
||||
this.audit = audit;
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public List<OutboundConnectionDto> list() {
|
||||
return service.list().stream().map(OutboundConnectionDto::from).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public OutboundConnectionDto get(@PathVariable UUID id) {
|
||||
return OutboundConnectionDto.from(service.get(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/usage")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','OPERATOR')")
|
||||
public List<UUID> usage(@PathVariable UUID id) {
|
||||
return service.rulesReferencing(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<OutboundConnectionDto> create(@Valid @RequestBody OutboundConnectionRequest req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = currentUserId();
|
||||
OutboundConnection draft = toDraft(req);
|
||||
OutboundConnection saved = service.create(draft, userId);
|
||||
audit.log("create_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||
saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(OutboundConnectionDto.from(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public OutboundConnectionDto update(@PathVariable UUID id, @Valid @RequestBody OutboundConnectionRequest req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = currentUserId();
|
||||
OutboundConnection draft = toDraft(req);
|
||||
OutboundConnection saved = service.update(id, draft, userId);
|
||||
audit.log("update_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||
id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
|
||||
return OutboundConnectionDto.from(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable UUID id, HttpServletRequest httpRequest) {
|
||||
String userId = currentUserId();
|
||||
service.delete(id, userId);
|
||||
audit.log("delete_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||
id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/test")
|
||||
public OutboundConnectionTestResult test(@PathVariable UUID id, HttpServletRequest httpRequest) {
|
||||
String userId = currentUserId();
|
||||
OutboundConnection c = service.get(id);
|
||||
long t0 = System.currentTimeMillis();
|
||||
try {
|
||||
var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null);
|
||||
CloseableHttpClient client = httpClientFactory.clientFor(ctx);
|
||||
HttpPost request = new HttpPost(c.url());
|
||||
request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON));
|
||||
try (var resp = client.execute(request)) {
|
||||
long latency = System.currentTimeMillis() - t0;
|
||||
HttpEntity entity = resp.getEntity();
|
||||
String body = entity == null ? "" : EntityUtils.toString(entity, StandardCharsets.UTF_8);
|
||||
String snippet = body.substring(0, Math.min(512, body.length()));
|
||||
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||
id.toString(), Map.of("status", resp.getCode(), "latencyMs", latency),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
return new OutboundConnectionTestResult(resp.getCode(), latency, snippet,
|
||||
"TLS", null, null, null, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
long latency = System.currentTimeMillis() - t0;
|
||||
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||
id.toString(), Map.of("error", e.getClass().getSimpleName(), "latencyMs", latency),
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private OutboundConnection toDraft(OutboundConnectionRequest req) {
|
||||
String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank())
|
||||
? null : cipher.encrypt(req.hmacSecret());
|
||||
// tenantId, id, timestamps, createdBy/updatedBy are filled by the service layer.
|
||||
return new OutboundConnection(
|
||||
null, null, req.name(), req.description(),
|
||||
req.url(), req.method(), req.defaultHeaders(), req.defaultBodyTmpl(),
|
||||
req.tlsTrustMode(), req.tlsCaPemPaths(),
|
||||
cipherSecret, req.auth(), req.allowedEnvironmentIds(),
|
||||
null, null, null, null);
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
|
||||
}
|
||||
String name = auth.getName();
|
||||
return name.startsWith("user:") ? name.substring(5) : name;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.cameleer.server.app.outbound.dto;
|
||||
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import com.cameleer.server.core.outbound.OutboundAuthKind;
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record OutboundConnectionDto(
|
||||
UUID id, String name, String description, String url,
|
||||
OutboundMethod method, Map<String, String> defaultHeaders, String defaultBodyTmpl,
|
||||
TrustMode tlsTrustMode, List<String> tlsCaPemPaths,
|
||||
boolean hmacSecretSet,
|
||||
OutboundAuthKind authKind,
|
||||
List<UUID> allowedEnvironmentIds,
|
||||
Instant createdAt, String createdBy, Instant updatedAt, String updatedBy
|
||||
) {
|
||||
public static OutboundConnectionDto from(OutboundConnection c) {
|
||||
return new OutboundConnectionDto(
|
||||
c.id(), c.name(), c.description(), c.url(), c.method(),
|
||||
c.defaultHeaders(), c.defaultBodyTmpl(),
|
||||
c.tlsTrustMode(), c.tlsCaPemPaths(),
|
||||
c.hmacSecretCiphertext() != null,
|
||||
c.auth().kind(), c.allowedEnvironmentIds(),
|
||||
c.createdAt(), c.createdBy(), c.updatedAt(), c.updatedBy());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cameleer.server.app.outbound.dto;
|
||||
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import com.cameleer.server.core.outbound.OutboundAuth;
|
||||
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record OutboundConnectionRequest(
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
@Size(max = 2000) String description,
|
||||
@NotBlank @Pattern(regexp = "^https://.+", message = "URL must be HTTPS") String url,
|
||||
@NotNull OutboundMethod method,
|
||||
Map<String, String> defaultHeaders,
|
||||
String defaultBodyTmpl,
|
||||
@NotNull TrustMode tlsTrustMode,
|
||||
List<String> tlsCaPemPaths,
|
||||
String hmacSecret,
|
||||
@NotNull @Valid OutboundAuth auth,
|
||||
List<UUID> allowedEnvironmentIds
|
||||
) {
|
||||
public OutboundConnectionRequest {
|
||||
defaultHeaders = defaultHeaders == null ? Map.of() : defaultHeaders;
|
||||
tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : tlsCaPemPaths;
|
||||
allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : allowedEnvironmentIds;
|
||||
if (tlsTrustMode != null && tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) {
|
||||
throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.cameleer.server.app.outbound.storage;
|
||||
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import com.cameleer.server.core.outbound.OutboundAuth;
|
||||
import com.cameleer.server.core.outbound.OutboundAuthKind;
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.sql.Array;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PostgresOutboundConnectionRepository implements OutboundConnectionRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public PostgresOutboundConnectionRepository(JdbcTemplate jdbc, ObjectMapper mapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboundConnection save(OutboundConnection c) {
|
||||
boolean exists = findById(c.tenantId(), c.id()).isPresent();
|
||||
if (exists) {
|
||||
jdbc.update("""
|
||||
UPDATE outbound_connections
|
||||
SET name = ?, description = ?, url = ?, method = ?::outbound_method_enum,
|
||||
default_headers = ?::jsonb, default_body_tmpl = ?,
|
||||
tls_trust_mode = ?::trust_mode_enum, tls_ca_pem_paths = ?::jsonb,
|
||||
hmac_secret_ciphertext = ?, auth_kind = ?::outbound_auth_kind_enum,
|
||||
auth_config = ?::jsonb, allowed_environment_ids = ?,
|
||||
updated_at = now(), updated_by = ?
|
||||
WHERE tenant_id = ? AND id = ?""",
|
||||
c.name(), c.description(), c.url(), c.method().name(),
|
||||
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
|
||||
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
|
||||
c.hmacSecretCiphertext(), c.auth().kind().name(),
|
||||
writeJson(c.auth()), toUuidArray(c.allowedEnvironmentIds()),
|
||||
c.updatedBy(), c.tenantId(), c.id());
|
||||
} else {
|
||||
jdbc.update("""
|
||||
INSERT INTO outbound_connections (
|
||||
id, tenant_id, name, description, url, method,
|
||||
default_headers, default_body_tmpl,
|
||||
tls_trust_mode, tls_ca_pem_paths,
|
||||
hmac_secret_ciphertext, auth_kind, auth_config,
|
||||
allowed_environment_ids, created_by, updated_by)
|
||||
VALUES (?,?,?,?,?,?::outbound_method_enum,
|
||||
?::jsonb,?,
|
||||
?::trust_mode_enum,?::jsonb,
|
||||
?,?::outbound_auth_kind_enum,?::jsonb,
|
||||
?,?,?)""",
|
||||
c.id(), c.tenantId(), c.name(), c.description(), c.url(), c.method().name(),
|
||||
writeJson(c.defaultHeaders()), c.defaultBodyTmpl(),
|
||||
c.tlsTrustMode().name(), writeJson(c.tlsCaPemPaths()),
|
||||
c.hmacSecretCiphertext(), c.auth().kind().name(), writeJson(c.auth()),
|
||||
toUuidArray(c.allowedEnvironmentIds()), c.createdBy(), c.updatedBy());
|
||||
}
|
||||
return findById(c.tenantId(), c.id()).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<OutboundConnection> findById(String tenantId, UUID id) {
|
||||
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND id = ?",
|
||||
rowMapper, tenantId, id).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<OutboundConnection> findByName(String tenantId, String name) {
|
||||
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? AND name = ?",
|
||||
rowMapper, tenantId, name).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<OutboundConnection> listByTenant(String tenantId) {
|
||||
return jdbc.query("SELECT * FROM outbound_connections WHERE tenant_id = ? ORDER BY name",
|
||||
rowMapper, tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String tenantId, UUID id) {
|
||||
jdbc.update("DELETE FROM outbound_connections WHERE tenant_id = ? AND id = ?", tenantId, id);
|
||||
}
|
||||
|
||||
private final RowMapper<OutboundConnection> rowMapper = (rs, i) -> new OutboundConnection(
|
||||
rs.getObject("id", UUID.class), rs.getString("tenant_id"),
|
||||
rs.getString("name"), rs.getString("description"),
|
||||
rs.getString("url"), OutboundMethod.valueOf(rs.getString("method")),
|
||||
readMapString(rs.getString("default_headers")), rs.getString("default_body_tmpl"),
|
||||
TrustMode.valueOf(rs.getString("tls_trust_mode")),
|
||||
readListString(rs.getString("tls_ca_pem_paths")),
|
||||
rs.getString("hmac_secret_ciphertext"),
|
||||
readAuth(OutboundAuthKind.valueOf(rs.getString("auth_kind")), rs.getString("auth_config")),
|
||||
readUuidArray(rs.getArray("allowed_environment_ids")),
|
||||
rs.getTimestamp("created_at").toInstant(), rs.getString("created_by"),
|
||||
rs.getTimestamp("updated_at").toInstant(), rs.getString("updated_by"));
|
||||
|
||||
private String writeJson(Object v) {
|
||||
try { return mapper.writeValueAsString(v); }
|
||||
catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private Map<String, String> readMapString(String json) {
|
||||
try { return mapper.readValue(json, new TypeReference<>() {}); }
|
||||
catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private List<String> readListString(String json) {
|
||||
try { return mapper.readValue(json, new TypeReference<>() {}); }
|
||||
catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private OutboundAuth readAuth(OutboundAuthKind kind, String cfg) {
|
||||
try {
|
||||
return switch (kind) {
|
||||
case NONE -> new OutboundAuth.None();
|
||||
case BEARER -> mapper.readValue(cfg, OutboundAuth.Bearer.class);
|
||||
case BASIC -> mapper.readValue(cfg, OutboundAuth.Basic.class);
|
||||
};
|
||||
} catch (Exception e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private Array toUuidArray(List<UUID> ids) {
|
||||
return jdbc.execute((ConnectionCallback<Array>) conn ->
|
||||
conn.createArrayOf("uuid", ids.toArray()));
|
||||
}
|
||||
|
||||
private List<UUID> readUuidArray(Array arr) throws SQLException {
|
||||
if (arr == null) return List.of();
|
||||
Object[] raw = (Object[]) arr.getArray();
|
||||
List<UUID> out = new ArrayList<>(raw.length);
|
||||
for (Object o : raw) out.add((UUID) o);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.cameleer.server.app.outbound.controller;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired private TestRestTemplate restTemplate;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
@Autowired private TestSecurityHelper securityHelper;
|
||||
|
||||
private String adminJwt;
|
||||
private String operatorJwt;
|
||||
private String viewerJwt;
|
||||
private com.github.tomakehurst.wiremock.WireMockServer wireMock;
|
||||
|
||||
@org.junit.jupiter.api.AfterEach
|
||||
void tearDownWireMock() {
|
||||
if (wireMock != null) wireMock.stop();
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.AfterEach
|
||||
void cleanupRows() {
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin','test-operator','test-viewer')");
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminJwt = securityHelper.adminToken();
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
viewerJwt = securityHelper.viewerToken();
|
||||
// Seed user rows matching the JWT subjects (users(user_id) is a FK target)
|
||||
seedUser("test-admin");
|
||||
seedUser("test-operator");
|
||||
seedUser("test-viewer");
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
|
||||
}
|
||||
|
||||
private void seedUser(String userId) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
userId, userId + "@example.com", userId);
|
||||
}
|
||||
|
||||
private static final String CREATE_BODY = """
|
||||
{"name":"slack-ops","url":"https://hooks.slack.com/x","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
|
||||
|
||||
@Test
|
||||
void adminCanCreate() throws Exception {
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
JsonNode body = objectMapper.readTree(resp.getBody());
|
||||
assertThat(body.path("name").asText()).isEqualTo("slack-ops");
|
||||
assertThat(body.path("hmacSecretSet").asBoolean()).isFalse();
|
||||
assertThat(body.path("id").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatorCannotCreate() {
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatorCanList() {
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerCannotList() {
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonHttpsUrlRejected() {
|
||||
String body = """
|
||||
{"name":"bad","url":"http://x","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
|
||||
ResponseEntity<String> resp = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateNameReturns409() {
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
ResponseEntity<String> dup = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(dup.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemoves() throws Exception {
|
||||
ResponseEntity<String> create = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
String id = objectMapper.readTree(create.getBody()).path("id").asText();
|
||||
|
||||
ResponseEntity<String> del = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections/" + id, HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
|
||||
ResponseEntity<String> get = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections/" + id, HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActionReturnsStatusAndLatency() throws Exception {
|
||||
wireMock = new com.github.tomakehurst.wiremock.WireMockServer(
|
||||
com.github.tomakehurst.wiremock.core.WireMockConfiguration.options()
|
||||
.httpDisabled(true).dynamicHttpsPort());
|
||||
wireMock.start();
|
||||
wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe")
|
||||
.willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong")));
|
||||
|
||||
String createBody = """
|
||||
{"name":"probe-target","url":"https://localhost:%d/probe","method":"POST",
|
||||
"tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort());
|
||||
|
||||
ResponseEntity<String> create = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
String id = objectMapper.readTree(create.getBody()).path("id").asText();
|
||||
|
||||
ResponseEntity<String> test = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
|
||||
new HttpEntity<>(securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(test.getBody());
|
||||
assertThat(body.path("status").asInt()).isEqualTo(200);
|
||||
assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0);
|
||||
assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS");
|
||||
assertThat(body.path("error").isNull()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void operatorCannotTest() throws Exception {
|
||||
ResponseEntity<String> create = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
String id = objectMapper.readTree(create.getBody()).path("id").asText();
|
||||
|
||||
ResponseEntity<String> test = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
|
||||
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
assertThat(test.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.cameleer.server.app.outbound.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
import com.cameleer.server.core.outbound.OutboundAuth;
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||
import com.cameleer.server.core.outbound.OutboundMethod;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
OutboundConnectionRepository repo;
|
||||
|
||||
private static final String TENANT = "default";
|
||||
private static final String USER = "test-alice";
|
||||
|
||||
@BeforeEach
|
||||
void seedUser() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, ?, ?, ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
USER, "test", "alice@example.com", "Alice");
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = ?", TENANT);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE created_by = ? OR updated_by = ?", USER, USER);
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", USER);
|
||||
}
|
||||
|
||||
private OutboundConnection draft(String name) {
|
||||
return new OutboundConnection(
|
||||
UUID.randomUUID(), TENANT, name, "desc",
|
||||
"https://hooks.slack.com/services/T/B/X", OutboundMethod.POST,
|
||||
Map.of("Content-Type", "application/json"), null,
|
||||
TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||
null, new OutboundAuth.None(), List.of(),
|
||||
Instant.now(), USER, Instant.now(), USER);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndRead() {
|
||||
OutboundConnection c = draft("slack-ops");
|
||||
repo.save(c);
|
||||
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
|
||||
assertThat(loaded.name()).isEqualTo("slack-ops");
|
||||
assertThat(loaded.defaultHeaders()).containsEntry("Content-Type", "application/json");
|
||||
assertThat(loaded.method()).isEqualTo(OutboundMethod.POST);
|
||||
assertThat(loaded.tlsTrustMode()).isEqualTo(TrustMode.SYSTEM_DEFAULT);
|
||||
assertThat(loaded.auth()).isInstanceOf(OutboundAuth.None.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uniqueNamePerTenant() {
|
||||
OutboundConnection a = draft("slack-ops");
|
||||
repo.save(a);
|
||||
OutboundConnection b = draft("slack-ops");
|
||||
assertThatThrownBy(() -> repo.save(b))
|
||||
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedEnvironmentIdsRoundTrip() {
|
||||
UUID env1 = UUID.randomUUID();
|
||||
UUID env2 = UUID.randomUUID();
|
||||
OutboundConnection c = new OutboundConnection(
|
||||
UUID.randomUUID(), TENANT, "multi-env", null,
|
||||
"https://example.com", OutboundMethod.POST,
|
||||
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||
null, new OutboundAuth.None(), List.of(env1, env2),
|
||||
Instant.now(), USER, Instant.now(), USER);
|
||||
repo.save(c);
|
||||
OutboundConnection loaded = repo.findById(TENANT, c.id()).orElseThrow();
|
||||
assertThat(loaded.allowedEnvironmentIds()).containsExactly(env1, env2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listByTenantOnlyReturnsCurrentTenant() {
|
||||
repo.save(draft("in-tenant"));
|
||||
// The outbound_connections.tenant_id is a plain varchar, so we can insert for a different tenant
|
||||
// without schema changes. Insert another tenant via the repo directly:
|
||||
UUID otherId = UUID.randomUUID();
|
||||
OutboundConnection other = new OutboundConnection(
|
||||
otherId, "other-tenant", "other-tenant-conn", null,
|
||||
"https://example.com", OutboundMethod.POST,
|
||||
Map.of(), null, TrustMode.SYSTEM_DEFAULT, List.of(),
|
||||
null, new OutboundAuth.None(), List.of(),
|
||||
Instant.now(), USER, Instant.now(), USER);
|
||||
repo.save(other);
|
||||
try {
|
||||
List<OutboundConnection> list = repo.listByTenant(TENANT);
|
||||
assertThat(list).extracting(OutboundConnection::name).containsExactly("in-tenant");
|
||||
} finally {
|
||||
repo.delete("other-tenant", otherId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRemovesRow() {
|
||||
OutboundConnection c = draft("to-delete");
|
||||
repo.save(c);
|
||||
repo.delete(TENANT, c.id());
|
||||
assertThat(repo.findById(TENANT, c.id())).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,4 @@ cameleer:
|
||||
bootstraptoken: test-bootstrap-token
|
||||
bootstraptokenprevious: old-bootstrap-token
|
||||
infrastructureendpoints: true
|
||||
jwtsecret: test-jwt-secret-for-integration-tests-only
|
||||
|
||||
19
cameleer-server-app/src/test/resources/test-ca.pem
Normal file
19
cameleer-server-app/src/test/resources/test-ca.pem
Normal file
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFzCCAf+gAwIBAgIUawEUnI7oAYTMsqYYJUBqGJhM8LMwDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQY2FtZWxlZXItdGVzdC1jYTAeFw0yNjA0MTkxMzUzMTNa
|
||||
Fw0zNjA0MTYxMzUzMTNaMBsxGTAXBgNVBAMMEGNhbWVsZWVyLXRlc3QtY2EwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCalBl0hjPSXYZjFLImDT5awayX
|
||||
nPmX7/TyduX2xSksLnGBfD1YcLYhIaOU3Bp6o+Wld8+nzNjwgMUcKdv1mWqDL8t0
|
||||
nlf0JGAntNEQWGwxFUGSLcmzgVf8/d1s0mpZ1/mS6RDzoQ8i8rNq5mYPXkWFAvgD
|
||||
G1FQpaBs71VC7vVcEgnJ4kK/8cNcQ/nvaI+T/Tk+sciu57XuMoa8AoJV/Oz/hdkc
|
||||
fIoYeUpFp36pYn71yFyZ1N+1xCTi1ruBmMekYWKNaNY6/edk2z3iTFgv4Dk4QS1O
|
||||
6GEdgUi9RQIgQwyYltQK2z+wLA8e982JK5HllsLYU0AcY6fvg+JlhEEpCsdnAgMB
|
||||
AAGjUzBRMB0GA1UdDgQWBBQYRTifnA5YLxiqjIYMDcZLgDrtUDAfBgNVHSMEGDAW
|
||||
gBQYRTifnA5YLxiqjIYMDcZLgDrtUDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQBpNfv6I9dsUflmslv6el5VvDOyZ3GWQIE/Zy0p+i0NYjeWMWtX
|
||||
rFPw1mnfdhlyFMpNkP8ddyDaSoqnnFsC4pXqXibrey0NDYX7zujyVIaXixf6koU9
|
||||
2/vNIb4evnJyVT9CcophDrobkR4NwU9SrMO7KAecb/U6shrfgmjIanUnCsfGdwsP
|
||||
f85DQDbOd9UhnMfMB43I8tjn2Po155npmwGK7J4hZpWTUj/rbNt3fmy2mx6rqdkp
|
||||
p61nLY3ba61bnl7QQ7VW7nhaIC7t5sO6NFDdv06MG8Cr5kcvoJDPe93iNeVT8iLp
|
||||
Rxs85FvIVYIt58jMvr+RSEfTD8fIvXl0uRDy
|
||||
-----END CERTIFICATE-----
|
||||
@@ -37,6 +37,10 @@
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cameleer.server.core.http;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public record OutboundHttpProperties(
|
||||
boolean trustAll,
|
||||
List<String> trustedCaPemPaths,
|
||||
Duration defaultConnectTimeout,
|
||||
Duration defaultReadTimeout,
|
||||
String proxyUrl,
|
||||
String proxyUsername,
|
||||
String proxyPassword
|
||||
) {
|
||||
public OutboundHttpProperties {
|
||||
trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths);
|
||||
if (defaultConnectTimeout == null) defaultConnectTimeout = Duration.ofMillis(2000);
|
||||
if (defaultReadTimeout == null) defaultReadTimeout = Duration.ofMillis(5000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cameleer.server.core.http;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public record OutboundHttpRequestContext(
|
||||
TrustMode trustMode,
|
||||
List<String> trustedCaPemPaths,
|
||||
Duration connectTimeout,
|
||||
Duration readTimeout
|
||||
) {
|
||||
public OutboundHttpRequestContext {
|
||||
if (trustMode == null) trustMode = TrustMode.SYSTEM_DEFAULT;
|
||||
trustedCaPemPaths = trustedCaPemPaths == null ? List.of() : List.copyOf(trustedCaPemPaths);
|
||||
}
|
||||
|
||||
public static OutboundHttpRequestContext systemDefault() {
|
||||
return new OutboundHttpRequestContext(TrustMode.SYSTEM_DEFAULT, List.of(), null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cameleer.server.core.http;
|
||||
|
||||
public enum TrustMode {
|
||||
SYSTEM_DEFAULT,
|
||||
TRUST_ALL,
|
||||
TRUST_PATHS
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer.server.core.outbound;
|
||||
|
||||
public enum OutboundAuthKind { NONE, BEARER, BASIC }
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.cameleer.server.core.outbound;
|
||||
|
||||
import com.cameleer.server.core.http.TrustMode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record OutboundConnection(
|
||||
UUID id,
|
||||
String tenantId,
|
||||
String name,
|
||||
String description,
|
||||
String url,
|
||||
OutboundMethod method,
|
||||
Map<String, String> defaultHeaders,
|
||||
String defaultBodyTmpl,
|
||||
TrustMode tlsTrustMode,
|
||||
List<String> tlsCaPemPaths,
|
||||
String hmacSecretCiphertext,
|
||||
OutboundAuth auth,
|
||||
List<UUID> allowedEnvironmentIds,
|
||||
Instant createdAt,
|
||||
String createdBy,
|
||||
Instant updatedAt,
|
||||
String updatedBy
|
||||
) {
|
||||
public OutboundConnection {
|
||||
defaultHeaders = defaultHeaders == null ? Map.of() : Map.copyOf(defaultHeaders);
|
||||
tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : List.copyOf(tlsCaPemPaths);
|
||||
allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : List.copyOf(allowedEnvironmentIds);
|
||||
if (auth == null) auth = new OutboundAuth.None();
|
||||
}
|
||||
|
||||
public boolean isAllowedInEnvironment(UUID environmentId) {
|
||||
return allowedEnvironmentIds.isEmpty() || allowedEnvironmentIds.contains(environmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer.server.core.outbound;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface OutboundConnectionRepository {
|
||||
OutboundConnection save(OutboundConnection connection);
|
||||
Optional<OutboundConnection> findById(String tenantId, UUID id);
|
||||
Optional<OutboundConnection> findByName(String tenantId, String name);
|
||||
List<OutboundConnection> listByTenant(String tenantId);
|
||||
void delete(String tenantId, UUID id);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer.server.core.outbound;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface OutboundConnectionService {
|
||||
OutboundConnection create(OutboundConnection draft, String actingUserId);
|
||||
OutboundConnection update(UUID id, OutboundConnection draft, String actingUserId);
|
||||
void delete(UUID id, String actingUserId);
|
||||
OutboundConnection get(UUID id);
|
||||
List<OutboundConnection> list();
|
||||
List<UUID> rulesReferencing(UUID id);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer.server.core.outbound;
|
||||
|
||||
public enum OutboundMethod { POST, PUT, PATCH }
|
||||
94
docs/outbound-connections.md
Normal file
94
docs/outbound-connections.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Outbound Connections — Admin Guide
|
||||
|
||||
Outbound connections are admin-managed HTTPS destinations that Cameleer Server can POST to. They are the building block for future alerting webhooks (Plan 02) and for any other outbound integration. This page explains how to create one, how the TLS trust modes work, and what happens when you click *Test*.
|
||||
|
||||
## What is an outbound connection?
|
||||
|
||||
An outbound connection is a reusable HTTPS destination with:
|
||||
|
||||
- A **URL** (must be `https://`)
|
||||
- A **method** (`POST`, `PUT`, or `PATCH`)
|
||||
- Optional **default headers** and a **default body template** (Mustache-style placeholders like `{{message}}` will be supported by the alerting engine in Plan 02)
|
||||
- A **TLS trust mode** — how the server validates the destination's certificate
|
||||
- Optional **HMAC signing secret** — if set, the server HMAC-signs the request body and sends the signature in an `X-Cameleer-Signature` header (wiring lands in Plan 02)
|
||||
- Optional **authentication** — Bearer token or Basic auth, stored encrypted at rest
|
||||
- An optional **allowed-environments** restriction — if empty, the connection is usable from all environments; if non-empty, only rules in listed environments may use it
|
||||
|
||||
Connections are tenant-scoped. Names must be unique within a tenant.
|
||||
|
||||
## Creating a connection
|
||||
|
||||
Admin → *Outbound Connections* → *New connection*.
|
||||
|
||||
Fill in:
|
||||
|
||||
- **Name** — short identifier, unique per tenant (e.g. `slack-ops`, `pagerduty-primary`).
|
||||
- **URL** — must start with `https://`. `http://` is rejected.
|
||||
- **Method** — pick `POST` for most webhooks.
|
||||
- **Default headers** — e.g. `Content-Type: application/json` for JSON webhooks. Per-rule headers may override these in Plan 02.
|
||||
- **Default body template** — the request body used when a rule doesn't specify its own. Plan 02 will interpolate Mustache variables.
|
||||
- **TLS trust mode** — see below.
|
||||
- **HMAC secret** — optional. If supplied, it's encrypted at rest using a key derived from the server's JWT signing secret (AES-GCM + HMAC-SHA256 KDF). The plaintext never leaves the browser-to-server path.
|
||||
- **Auth** — `None`, `Bearer`, or `Basic`. Credentials are encrypted at rest alongside the HMAC secret.
|
||||
- **Allow in all environments** — unchecked lets you restrict usage to a subset.
|
||||
|
||||
Hit *Create*. The connection is now listed on the index page.
|
||||
|
||||
## TLS trust modes
|
||||
|
||||
| Mode | When to use |
|
||||
|---|---|
|
||||
| `SYSTEM_DEFAULT` | Standard public endpoints (Slack, PagerDuty, GitHub, etc.). The JVM's default trust store is used. |
|
||||
| `TRUST_PATHS` | Self-hosted endpoints signed by a private CA. Provide one or more absolute paths to PEM-encoded CA certificates on the server filesystem. The JVM defaults are preserved; the configured CAs are added on top. |
|
||||
| `TRUST_ALL` | Local dev and staging against self-signed certs. **Disables certificate validation entirely.** The UI shows an amber warning and does not recommend this for production. |
|
||||
|
||||
You can also configure tenant-wide extras via `cameleer.server.outbound-http.trusted-ca-pem-paths` in `application.yml` — those CAs are added to every outbound HTTPS call. If a path in that list doesn't exist on disk, the server refuses to start (fail-fast).
|
||||
|
||||
## Testing a connection
|
||||
|
||||
On the edit page of a saved connection, click *Test*. The server issues a synthetic `POST` with body `{"probe":true}` to the connection's URL using the connection's stored trust/auth configuration and returns:
|
||||
|
||||
- HTTP status code (0 if the request never completed)
|
||||
- Latency in milliseconds
|
||||
- First 512 characters of the response body
|
||||
- TLS protocol (reported as `TLS`; full protocol/cipher/peer-cert extraction is deferred to a Plan 02 follow-up)
|
||||
- Error message, if any
|
||||
|
||||
The probe is audit-logged under `OUTBOUND_CONNECTION_CHANGE` so you can trace who tested what.
|
||||
|
||||
Any test endpoint with a real handler should accept `{"probe":true}` as a harmless payload. If a downstream rejects the body shape, you still see the HTTP status, which is usually enough to diagnose routing / auth / TLS issues.
|
||||
|
||||
## Allowed environments
|
||||
|
||||
When a connection's *Allow in all environments* toggle is off and a set of environments is chosen, the connection is only usable by alerting rules (Plan 02) that fire in those environments. Two guards enforce this:
|
||||
|
||||
1. **Narrowing guard on update** — if you remove an environment from the list while existing rules reference this connection in that environment, the server returns `409 Conflict` and names the rules. (Plan 01 stubs out the rules check; it returns empty. Plan 02 wires it to the real alert-rules table.)
|
||||
2. **Delete guard** — attempting to delete a connection still referenced by any rule returns `409 Conflict`. Drop the rules (or reassign them) first.
|
||||
|
||||
An empty list means "allowed everywhere" — the default.
|
||||
|
||||
## HMAC signing semantics (for consumers)
|
||||
|
||||
When Plan 02 starts firing webhook requests, connections with an HMAC secret set will sign each request body as follows:
|
||||
|
||||
```
|
||||
X-Cameleer-Signature: v1=<hex(HMAC-SHA256(secret, body))>
|
||||
X-Cameleer-Timestamp: <ISO-8601 UTC>
|
||||
```
|
||||
|
||||
Consumers should compute the same HMAC over the raw body bytes using their copy of the shared secret and reject requests where the signatures don't match. Rotate secrets by updating the connection in the UI — the old secret continues to sign until the change is saved.
|
||||
|
||||
## RBAC
|
||||
|
||||
- **ADMIN** can create, update, delete, and test connections.
|
||||
- **OPERATOR** can list and view connections (read-only). This mirrors the "operators run playbooks, admins configure systems" split used elsewhere in the product.
|
||||
- **VIEWER** has no visibility into outbound connections.
|
||||
|
||||
All mutations are recorded in the audit log (`/admin/audit`) under categories `OUTBOUND_CONNECTION_CHANGE` (create/update/delete/test) and — reserved for future use — `OUTBOUND_HTTP_TRUST_CHANGE` for tenant-wide trust store edits.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Outbound clients are memoized per `(trust mode, CA paths, timeouts)` tuple inside `ApacheOutboundHttpClientFactory`. Changing a connection's trust mode evicts its cached client on next call.
|
||||
- Default connect timeout is 2s; default read timeout is 5s. Override per-tenant in `cameleer.server.outbound-http.default-*-timeout-ms`.
|
||||
- Proxy support (`cameleer.server.outbound-http.proxy-*` properties) is wired through the properties record but not yet applied by the factory — a Plan 02 follow-up.
|
||||
- OIDC still uses its own Nimbus HTTP client. Retrofitting OIDC onto `OutboundHttpClientFactory` is deliberately deferred so Plan 01 doesn't touch proven-working auth code.
|
||||
128
ui/src/api/queries/admin/outboundConnections.ts
Normal file
128
ui/src/api/queries/admin/outboundConnections.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminFetch } from './admin-api';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type OutboundMethod = 'POST' | 'PUT' | 'PATCH';
|
||||
export type OutboundAuthKind = 'NONE' | 'BEARER' | 'BASIC';
|
||||
export type TrustMode = 'SYSTEM_DEFAULT' | 'TRUST_ALL' | 'TRUST_PATHS';
|
||||
|
||||
// Jackson DEDUCTION mode picks subtype by which fields are present.
|
||||
// None = {}, Bearer = { tokenCiphertext }, Basic = { username, passwordCiphertext }.
|
||||
// Using a flat optional shape for pragmatic TypeScript compatibility.
|
||||
export type OutboundAuth = {
|
||||
tokenCiphertext?: string;
|
||||
username?: string;
|
||||
passwordCiphertext?: string;
|
||||
};
|
||||
|
||||
// Server returns this shape (`OutboundConnectionDto` in Java)
|
||||
export interface OutboundConnectionDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
method: OutboundMethod;
|
||||
defaultHeaders: Record<string, string>;
|
||||
defaultBodyTmpl: string | null;
|
||||
tlsTrustMode: TrustMode;
|
||||
tlsCaPemPaths: string[];
|
||||
hmacSecretSet: boolean;
|
||||
authKind: OutboundAuthKind;
|
||||
allowedEnvironmentIds: string[];
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
// Client sends this shape for create/update (`OutboundConnectionRequest` in Java)
|
||||
export interface OutboundConnectionRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
url: string;
|
||||
method: OutboundMethod;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
defaultBodyTmpl?: string | null;
|
||||
tlsTrustMode: TrustMode;
|
||||
tlsCaPemPaths?: string[];
|
||||
hmacSecret?: string | null;
|
||||
auth: OutboundAuth;
|
||||
allowedEnvironmentIds?: string[];
|
||||
}
|
||||
|
||||
// Server returns this from POST /{id}/test
|
||||
export interface OutboundConnectionTestResult {
|
||||
status: number;
|
||||
latencyMs: number;
|
||||
responseSnippet: string | null;
|
||||
tlsProtocol: string | null;
|
||||
tlsCipherSuite: string | null;
|
||||
peerCertificateSubject: string | null;
|
||||
peerCertificateExpiresAtEpochMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ── Query Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useOutboundConnections() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'outbound-connections'],
|
||||
queryFn: () => adminFetch<OutboundConnectionDto[]>('/outbound-connections'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOutboundConnection(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'outbound-connections', id],
|
||||
queryFn: () => adminFetch<OutboundConnectionDto>(`/outbound-connections/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutation Hooks ─────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateOutboundConnection() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: OutboundConnectionRequest) =>
|
||||
adminFetch<OutboundConnectionDto>('/outbound-connections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOutboundConnection(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (req: OutboundConnectionRequest) =>
|
||||
adminFetch<OutboundConnectionDto>(`/outbound-connections/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections', id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOutboundConnection() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
adminFetch<void>(`/outbound-connections/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'outbound-connections'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestOutboundConnection() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
adminFetch<OutboundConnectionTestResult>(`/outbound-connections/${id}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
470
ui/src/pages/Admin/OutboundConnectionEditor.tsx
Normal file
470
ui/src/pages/Admin/OutboundConnectionEditor.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, FormField, Input, Select, SectionHeader, Toggle, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import {
|
||||
useOutboundConnection,
|
||||
useCreateOutboundConnection,
|
||||
useUpdateOutboundConnection,
|
||||
useTestOutboundConnection,
|
||||
type OutboundConnectionDto,
|
||||
type OutboundConnectionRequest,
|
||||
type OutboundConnectionTestResult,
|
||||
type OutboundMethod,
|
||||
type OutboundAuthKind,
|
||||
type TrustMode,
|
||||
} from '../../api/queries/admin/outboundConnections';
|
||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
|
||||
// ── Form state ──────────────────────────────────────────────────────────
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
method: OutboundMethod;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
defaultBodyTmpl: string;
|
||||
tlsTrustMode: TrustMode;
|
||||
tlsCaPemPaths: string[];
|
||||
hmacSecret: string; // empty = keep existing (edit) / no secret (create)
|
||||
authKind: OutboundAuthKind;
|
||||
bearerToken: string;
|
||||
basicUsername: string;
|
||||
basicPassword: string;
|
||||
allowAllEnvs: boolean;
|
||||
allowedEnvIds: string[];
|
||||
}
|
||||
|
||||
function initialForm(existing?: OutboundConnectionDto): FormState {
|
||||
return {
|
||||
name: existing?.name ?? '',
|
||||
description: existing?.description ?? '',
|
||||
url: existing?.url ?? 'https://',
|
||||
method: existing?.method ?? 'POST',
|
||||
headers: existing
|
||||
? Object.entries(existing.defaultHeaders).map(([key, value]) => ({ key, value }))
|
||||
: [],
|
||||
defaultBodyTmpl: existing?.defaultBodyTmpl ?? '',
|
||||
tlsTrustMode: existing?.tlsTrustMode ?? 'SYSTEM_DEFAULT',
|
||||
tlsCaPemPaths: existing?.tlsCaPemPaths ?? [],
|
||||
hmacSecret: '',
|
||||
authKind: existing?.authKind ?? 'NONE',
|
||||
bearerToken: '',
|
||||
basicUsername: '',
|
||||
basicPassword: '',
|
||||
allowAllEnvs: !existing || existing.allowedEnvironmentIds.length === 0,
|
||||
allowedEnvIds: existing?.allowedEnvironmentIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function toRequest(f: FormState): OutboundConnectionRequest {
|
||||
const defaultHeaders = Object.fromEntries(
|
||||
f.headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]),
|
||||
);
|
||||
const allowedEnvironmentIds = f.allowAllEnvs ? [] : f.allowedEnvIds;
|
||||
const auth =
|
||||
f.authKind === 'NONE'
|
||||
? {}
|
||||
: f.authKind === 'BEARER'
|
||||
? { tokenCiphertext: f.bearerToken }
|
||||
: { username: f.basicUsername, passwordCiphertext: f.basicPassword };
|
||||
return {
|
||||
name: f.name,
|
||||
description: f.description || null,
|
||||
url: f.url,
|
||||
method: f.method,
|
||||
defaultHeaders,
|
||||
defaultBodyTmpl: f.defaultBodyTmpl || null,
|
||||
tlsTrustMode: f.tlsTrustMode,
|
||||
tlsCaPemPaths: f.tlsCaPemPaths,
|
||||
hmacSecret: f.hmacSecret ? f.hmacSecret : null,
|
||||
auth,
|
||||
allowedEnvironmentIds,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Select option arrays ───────────────────────────────────────────────
|
||||
|
||||
const METHOD_OPTIONS: Array<{ value: OutboundMethod; label: string }> = [
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'PUT', label: 'PUT' },
|
||||
{ value: 'PATCH', label: 'PATCH' },
|
||||
];
|
||||
|
||||
const TRUST_OPTIONS: Array<{ value: TrustMode; label: string }> = [
|
||||
{ value: 'SYSTEM_DEFAULT', label: 'System default (recommended)' },
|
||||
{ value: 'TRUST_PATHS', label: 'Trust additional CA PEMs' },
|
||||
{ value: 'TRUST_ALL', label: 'Trust all (INSECURE)' },
|
||||
];
|
||||
|
||||
const AUTH_OPTIONS: Array<{ value: OutboundAuthKind; label: string }> = [
|
||||
{ value: 'NONE', label: 'None' },
|
||||
{ value: 'BEARER', label: 'Bearer token' },
|
||||
{ value: 'BASIC', label: 'Basic' },
|
||||
];
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function OutboundConnectionEditor() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isNew = !id;
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const existingQ = useOutboundConnection(isNew ? undefined : id);
|
||||
const envQ = useEnvironments();
|
||||
const createMut = useCreateOutboundConnection();
|
||||
// Hooks must be called unconditionally; pass placeholder id when unknown.
|
||||
const updateMut = useUpdateOutboundConnection(id ?? 'placeholder');
|
||||
const testMut = useTestOutboundConnection();
|
||||
|
||||
const [form, setForm] = useState<FormState>(() => initialForm());
|
||||
const [initialized, setInitialized] = useState(isNew);
|
||||
const [testResult, setTestResult] = useState<OutboundConnectionTestResult | null>(null);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && existingQ.data) {
|
||||
setForm(initialForm(existingQ.data));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [existingQ.data, initialized]);
|
||||
|
||||
if (!isNew && existingQ.isLoading) return <PageLoader />;
|
||||
|
||||
const isSaving = createMut.isPending || updateMut.isPending;
|
||||
|
||||
const onSave = () => {
|
||||
const payload = toRequest(form);
|
||||
if (isNew) {
|
||||
createMut.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Created', description: form.name, variant: 'success' });
|
||||
navigate('/admin/outbound-connections');
|
||||
},
|
||||
onError: (e) => toast({ title: 'Create failed', description: String(e), variant: 'error' }),
|
||||
});
|
||||
} else {
|
||||
updateMut.mutate(payload, {
|
||||
onSuccess: () => toast({ title: 'Updated', description: form.name, variant: 'success' }),
|
||||
onError: (e) => toast({ title: 'Update failed', description: String(e), variant: 'error' }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = () => {
|
||||
if (!id) return;
|
||||
testMut.mutate(id, {
|
||||
onSuccess: (r) => setTestResult(r),
|
||||
onError: (e) =>
|
||||
setTestResult({
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
responseSnippet: null,
|
||||
tlsProtocol: null,
|
||||
tlsCipherSuite: null,
|
||||
peerCertificateSubject: null,
|
||||
peerCertificateExpiresAtEpochMs: null,
|
||||
error: String(e),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const envs = envQ.data ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<SectionHeader>
|
||||
{isNew ? 'New Outbound Connection' : `Edit: ${existingQ.data?.name ?? ''}`}
|
||||
</SectionHeader>
|
||||
<Link to="/admin/outbound-connections">
|
||||
<Button variant="secondary" size="sm">Back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className={sectionStyles.section} style={{ display: 'grid', gap: 16 }}>
|
||||
|
||||
{/* Name */}
|
||||
<FormField label="Name" htmlFor="oc-name">
|
||||
<Input
|
||||
id="oc-name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="slack-ops"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Description */}
|
||||
<FormField label="Description" htmlFor="oc-description">
|
||||
<textarea
|
||||
id="oc-description"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={2}
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* URL */}
|
||||
<FormField label="URL" htmlFor="oc-url">
|
||||
<Input
|
||||
id="oc-url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Method */}
|
||||
<FormField label="Method" htmlFor="oc-method">
|
||||
<Select
|
||||
options={METHOD_OPTIONS}
|
||||
value={form.method}
|
||||
onChange={(e) => setForm({ ...form, method: e.target.value as OutboundMethod })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Default headers */}
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>Default headers</div>
|
||||
{form.headers.map((h, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
value={h.key}
|
||||
onChange={(e) => {
|
||||
const next = [...form.headers];
|
||||
next[i] = { ...next[i], key: e.target.value };
|
||||
setForm({ ...form, headers: next });
|
||||
}}
|
||||
placeholder="Header"
|
||||
/>
|
||||
<Input
|
||||
value={h.value}
|
||||
onChange={(e) => {
|
||||
const next = [...form.headers];
|
||||
next[i] = { ...next[i], value: e.target.value };
|
||||
setForm({ ...form, headers: next });
|
||||
}}
|
||||
placeholder="Value"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, headers: form.headers.filter((_, idx) => idx !== i) })}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, headers: [...form.headers, { key: '', value: '' }] })}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Default body template */}
|
||||
<FormField label="Default body template" htmlFor="oc-body">
|
||||
<textarea
|
||||
id="oc-body"
|
||||
value={form.defaultBodyTmpl}
|
||||
onChange={(e) => setForm({ ...form, defaultBodyTmpl: e.target.value })}
|
||||
rows={4}
|
||||
style={{ width: '100%', boxSizing: 'border-box', fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
placeholder={'{"text": "{{message}}"}'}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* TLS trust mode */}
|
||||
<FormField label="TLS trust mode" htmlFor="oc-tls">
|
||||
<Select
|
||||
options={TRUST_OPTIONS}
|
||||
value={form.tlsTrustMode}
|
||||
onChange={(e) => setForm({ ...form, tlsTrustMode: e.target.value as TrustMode })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{form.tlsTrustMode === 'TRUST_ALL' && (
|
||||
<div style={{ background: 'var(--amber-bg, #fef3c7)', color: 'var(--amber, #92400e)', padding: '8px 12px', borderRadius: 4, fontSize: '0.875rem' }}>
|
||||
TLS certificate validation is DISABLED. Do not use TRUST_ALL in production.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.tlsTrustMode === 'TRUST_PATHS' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>CA PEM paths</div>
|
||||
{form.tlsCaPemPaths.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<Input
|
||||
value={p}
|
||||
onChange={(e) => {
|
||||
const next = [...form.tlsCaPemPaths];
|
||||
next[i] = e.target.value;
|
||||
setForm({ ...form, tlsCaPemPaths: next });
|
||||
}}
|
||||
placeholder="/etc/ssl/certs/my-ca.pem"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setForm({ ...form, tlsCaPemPaths: form.tlsCaPemPaths.filter((_, idx) => idx !== i) })
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setForm({ ...form, tlsCaPemPaths: [...form.tlsCaPemPaths, ''] })}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
Add path
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HMAC secret */}
|
||||
<FormField
|
||||
label="HMAC secret"
|
||||
htmlFor="oc-hmac"
|
||||
hint={existingQ.data?.hmacSecretSet ? 'Leave blank to keep existing secret' : undefined}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
id="oc-hmac"
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={form.hmacSecret}
|
||||
onChange={(e) => setForm({ ...form, hmacSecret: e.target.value })}
|
||||
placeholder={
|
||||
existingQ.data?.hmacSecretSet
|
||||
? '<secret set — leave blank to keep>'
|
||||
: 'Optional signing secret'
|
||||
}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowSecret((s) => !s)}>
|
||||
{showSecret ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{/* Auth kind */}
|
||||
<FormField label="Auth" htmlFor="oc-auth">
|
||||
<Select
|
||||
options={AUTH_OPTIONS}
|
||||
value={form.authKind}
|
||||
onChange={(e) => setForm({ ...form, authKind: e.target.value as OutboundAuthKind })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{form.authKind === 'BEARER' && (
|
||||
<FormField label="Bearer token" htmlFor="oc-bearer">
|
||||
<Input
|
||||
id="oc-bearer"
|
||||
value={form.bearerToken}
|
||||
onChange={(e) => setForm({ ...form, bearerToken: e.target.value })}
|
||||
placeholder="Token stored encrypted at rest"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{form.authKind === 'BASIC' && (
|
||||
<>
|
||||
<FormField label="Basic username" htmlFor="oc-basic-user">
|
||||
<Input
|
||||
id="oc-basic-user"
|
||||
value={form.basicUsername}
|
||||
onChange={(e) => setForm({ ...form, basicUsername: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Basic password" htmlFor="oc-basic-pass">
|
||||
<Input
|
||||
id="oc-basic-pass"
|
||||
type="password"
|
||||
value={form.basicPassword}
|
||||
onChange={(e) => setForm({ ...form, basicPassword: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed environments */}
|
||||
<div>
|
||||
<Toggle
|
||||
checked={form.allowAllEnvs}
|
||||
onChange={(e) => setForm({ ...form, allowAllEnvs: (e.target as HTMLInputElement).checked })}
|
||||
label="Allow in all environments"
|
||||
/>
|
||||
{!form.allowAllEnvs && (
|
||||
<div style={{ display: 'grid', gap: 4, marginTop: 8, marginLeft: 4 }}>
|
||||
{envs.map((env) => (
|
||||
<label key={env.id} style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer', fontSize: '0.875rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.allowedEnvIds.includes(env.id)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...form.allowedEnvIds, env.id]
|
||||
: form.allowedEnvIds.filter((x) => x !== env.id);
|
||||
setForm({ ...form, allowedEnvIds: next });
|
||||
}}
|
||||
/>
|
||||
{env.displayName}
|
||||
</label>
|
||||
))}
|
||||
{envs.length === 0 && (
|
||||
<span style={{ color: 'var(--text-muted, #6b7280)', fontSize: '0.875rem' }}>No environments found</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<Button variant="primary" onClick={onSave} loading={isSaving} disabled={isSaving}>
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
{!isNew && (
|
||||
<Button variant="secondary" onClick={onTest} loading={testMut.isPending} disabled={testMut.isPending}>
|
||||
{testMut.isPending ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<section className={sectionStyles.section}>
|
||||
<SectionHeader>Test result</SectionHeader>
|
||||
{testResult.error ? (
|
||||
<div style={{ color: 'var(--error, #b91c1c)', fontSize: '0.875rem' }}>
|
||||
Error: {testResult.error}
|
||||
</div>
|
||||
) : (
|
||||
<dl style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr', gap: '4px 16px', fontSize: '0.875rem', margin: 0 }}>
|
||||
<dt>Status</dt><dd style={{ margin: 0 }}>{testResult.status}</dd>
|
||||
<dt>Latency</dt><dd style={{ margin: 0 }}>{testResult.latencyMs} ms</dd>
|
||||
<dt>TLS protocol</dt><dd style={{ margin: 0 }}>{testResult.tlsProtocol ?? '—'}</dd>
|
||||
{testResult.responseSnippet && (
|
||||
<>
|
||||
<dt>Response</dt>
|
||||
<dd style={{ margin: 0 }}><code>{testResult.responseSnippet}</code></dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
ui/src/pages/Admin/OutboundConnectionsPage.tsx
Normal file
87
ui/src/pages/Admin/OutboundConnectionsPage.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Badge, SectionHeader, useToast } from '@cameleer/design-system';
|
||||
import { PageLoader } from '../../components/PageLoader';
|
||||
import {
|
||||
useOutboundConnections,
|
||||
useDeleteOutboundConnection,
|
||||
type OutboundConnectionDto,
|
||||
type TrustMode,
|
||||
} from '../../api/queries/admin/outboundConnections';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
|
||||
export default function OutboundConnectionsPage() {
|
||||
const { data, isLoading, error } = useOutboundConnections();
|
||||
const deleteMut = useDeleteOutboundConnection();
|
||||
const { toast } = useToast();
|
||||
|
||||
if (isLoading) return <PageLoader />;
|
||||
if (error) return <div>Failed to load outbound connections: {String(error)}</div>;
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const onDelete = (c: OutboundConnectionDto) => {
|
||||
if (!confirm(`Delete outbound connection "${c.name}"?`)) return;
|
||||
deleteMut.mutate(c.id, {
|
||||
onSuccess: () => toast({ title: 'Deleted', description: c.name, variant: 'success' }),
|
||||
onError: (e) => toast({ title: 'Delete failed', description: String(e), variant: 'error' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<SectionHeader>Outbound Connections</SectionHeader>
|
||||
<Link to="/admin/outbound-connections/new">
|
||||
<Button variant="primary">New connection</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={sectionStyles.section}>
|
||||
{rows.length === 0 ? (
|
||||
<p>No outbound connections yet. Create one to enable alerting webhooks or other outbound integrations.</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Name</th>
|
||||
<th style={{ textAlign: 'left' }}>Host</th>
|
||||
<th style={{ textAlign: 'left' }}>Method</th>
|
||||
<th style={{ textAlign: 'left' }}>Trust</th>
|
||||
<th style={{ textAlign: 'left' }}>Auth</th>
|
||||
<th style={{ textAlign: 'left' }}>Envs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td><Link to={`/admin/outbound-connections/${c.id}`}>{c.name}</Link></td>
|
||||
<td><code>{safeHost(c.url)}</code></td>
|
||||
<td>{c.method}</td>
|
||||
<td><TrustBadge mode={c.tlsTrustMode} /></td>
|
||||
<td>{c.authKind}</td>
|
||||
<td>{c.allowedEnvironmentIds.length > 0 ? c.allowedEnvironmentIds.length : 'all'}</td>
|
||||
<td>
|
||||
<Button variant="secondary" onClick={() => onDelete(c)} disabled={deleteMut.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeHost(url: string): string {
|
||||
try { return new URL(url).host; }
|
||||
catch { return url; }
|
||||
}
|
||||
|
||||
function TrustBadge({ mode }: { mode: TrustMode }) {
|
||||
if (mode === 'TRUST_ALL') return <Badge label="Trust all" color="error" variant="filled" />;
|
||||
if (mode === 'TRUST_PATHS') return <Badge label="Custom CA" color="warning" variant="filled" />;
|
||||
return <Badge label="System" color="auto" variant="filled" />;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||
const EnvironmentsPage = lazy(() => import('./pages/Admin/EnvironmentsPage'));
|
||||
const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnectionsPage'));
|
||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
@@ -84,6 +86,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections', element: <SuspenseWrapper><OutboundConnectionsPage /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections/new', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
|
||||
{ path: 'outbound-connections/:id', element: <SuspenseWrapper><OutboundConnectionEditor /></SuspenseWrapper> },
|
||||
{ path: 'sensitive-keys', element: <SuspenseWrapper><SensitiveKeysPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'clickhouse', element: <SuspenseWrapper><ClickHouseAdminPage /></SuspenseWrapper> },
|
||||
|
||||
Reference in New Issue
Block a user