+ setForm({ ...form, allowAllEnvs: (e.target as HTMLInputElement).checked })}
+ label="Allow in all environments"
+ />
+ {!form.allowAllEnvs && (
+
+ {envs.map((env) => (
+
+ ))}
+ {envs.length === 0 && (
+ No environments found
+ )}
+
+ )}
+
+
+ {/* Actions */}
+
+
+ {!isNew && (
+
+ )}
+
+
+ {/* Test result */}
+ {testResult && (
+
+ Test result
+ {testResult.error ? (
+
+ Error: {testResult.error}
+
+ ) : (
+
+
Status
{testResult.status}
+
Latency
{testResult.latencyMs} ms
+
TLS protocol
{testResult.tlsProtocol ?? '—'}
+ {testResult.responseSnippet && (
+ <>
+
Response
+
{testResult.responseSnippet}
+ >
+ )}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/router.tsx b/ui/src/router.tsx
index 7b2e8ed9..3144e02f 100644
--- a/ui/src/router.tsx
+++ b/ui/src/router.tsx
@@ -19,6 +19,7 @@ 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'));
@@ -86,6 +87,8 @@ export const router = createBrowserRouter([
{ path: 'audit', element: },
{ path: 'oidc', element: },
{ path: 'outbound-connections', element: },
+ { path: 'outbound-connections/new', element: },
+ { path: 'outbound-connections/:id', element: },
{ path: 'sensitive-keys', element: },
{ path: 'database', element: },
{ path: 'clickhouse', element: },
From 1dd1f10c0e7f9864030d7f2a7d7e11455289fbaa Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:02:09 +0200
Subject: [PATCH 19/22] docs(rules): document http/ and outbound/ packages +
admin controller
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.claude/rules/app-classes.md | 22 ++++++++++++++++++++--
.claude/rules/core-classes.md | 17 ++++++++++++++++-
2 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md
index ef0084a4..135f4f02 100644
--- a/.claude/rules/app-classes.md
+++ b/.claude/rules/app-classes.md
@@ -23,7 +23,7 @@ These paths intentionally stay flat (no `/environments/{envSlug}` prefix). Every
| `/api/v1/agents/register`, `/refresh`, `/{id}/heartbeat`, `/{id}/events` (SSE), `/{id}/deregister`, `/{id}/commands`, `/{id}/commands/{id}/ack`, `/{id}/replay` | Agent self-service; JWT-bound. |
| `/api/v1/agents/commands`, `/api/v1/agents/groups/{group}/commands` | Operator fan-out; target scope is explicit in query params. |
| `/api/v1/agents/config` | Agent-authoritative config read; JWT → registry → (app, env). |
-| `/api/v1/admin/{users,roles,groups,oidc,license,audit,rbac/stats,claim-mappings,thresholds,sensitive-keys,usage,clickhouse,database,environments}` | Truly cross-env admin. Env CRUD URLs use `{envSlug}`, not UUID. |
+| `/api/v1/admin/{users,roles,groups,oidc,license,audit,rbac/stats,claim-mappings,thresholds,sensitive-keys,usage,clickhouse,database,environments,outbound-connections}` | Truly cross-env admin. Env CRUD URLs use `{envSlug}`, not UUID. |
| `/api/v1/catalog`, `/api/v1/catalog/{applicationId}` | Cross-env discovery is the purpose. Env is an optional filter via `?environment=`. |
| `/api/v1/executions/{execId}`, `/processors/**` | Exchange IDs are globally unique; permalinks. |
| `/api/v1/diagrams/{contentHash}/render`, `POST /api/v1/diagrams/render` | Content-addressed or stateless. |
@@ -81,6 +81,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `RoleAdminController` — CRUD `/api/v1/admin/roles`.
- `GroupAdminController` — CRUD `/api/v1/admin/groups`.
- `OidcConfigAdminController` — GET/POST `/api/v1/admin/oidc`, POST `/test`.
+- `OutboundConnectionAdminController` — `/api/v1/admin/outbound-connections`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/test` / GET `{id}/usage`. RBAC: list/get/usage ADMIN|OPERATOR; mutations + test ADMIN.
- `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
- `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`.
@@ -134,7 +135,7 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
## security/ — Spring Security
-- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional
+- `SecurityConfig` — WebSecurityFilterChain, JWT filter, CORS, OIDC conditional. `/api/v1/admin/outbound-connections/**` GETs permit OPERATOR in addition to ADMIN (defense-in-depth at controller level); mutations remain ADMIN-only.
- `JwtAuthenticationFilter` — OncePerRequestFilter, validates Bearer tokens
- `JwtServiceImpl` — HMAC-SHA256 JWT (Nimbus JOSE)
- `OidcAuthController` — /api/v1/auth/oidc (login-uri, token-exchange, logout)
@@ -151,6 +152,23 @@ ClickHouse is shared across tenants. Every ClickHouse query must filter by `tena
- `JarRetentionJob` — @Scheduled 03:00 daily, per-environment retention, skips deployed versions
+## http/ — Outbound HTTP client implementation
+
+- `SslContextBuilder` — composes SSL context from `OutboundHttpProperties` + `OutboundHttpRequestContext`. Supports SYSTEM_DEFAULT (JDK roots + configured CA extras), TRUST_ALL (short-circuit no-op TrustManager), TRUST_PATHS (JDK roots + system extras + per-request extras). Throws `IllegalArgumentException("CA file not found: ...")` on missing PEM.
+- `ApacheOutboundHttpClientFactory` — Apache HttpClient 5 impl of `OutboundHttpClientFactory`. Memoizes clients per `CacheKey(trustAll, caPaths, mode, connectTimeout, readTimeout)`. Applies `NoopHostnameVerifier` when trust-all is active.
+- `config/OutboundHttpConfig` — `@ConfigurationProperties("cameleer.server.outbound-http")`. Exposes beans: `OutboundHttpProperties`, `SslContextBuilder`, `OutboundHttpClientFactory`. `@PostConstruct` logs WARN on trust-all and throws if configured CA paths don't exist.
+
+## outbound/ — Admin-managed outbound connections (implementation)
+
+- `crypto/SecretCipher` — AES-GCM symmetric cipher with key derived via HMAC-SHA256(jwtSecret, "cameleer-outbound-secret-v1"). Ciphertext format: base64(IV(12 bytes) || GCM output with 128-bit tag). `encrypt` throws `IllegalStateException`; `decrypt` throws `IllegalArgumentException` on tamper/wrong-key/malformed.
+- `storage/PostgresOutboundConnectionRepository` — JdbcTemplate impl. `save()` upserts by id; JSONB serialization via ObjectMapper; UUID arrays via `ConnectionCallback`. Reads `created_by`/`updated_by` as String (= users.user_id TEXT).
+- `OutboundConnectionServiceImpl` — service layer. Tenant bound at construction via `cameleer.server.tenant.id` property. Uniqueness check via `findByName`. Narrowing-envs guard: rejects update that removes envs while rules reference the connection (rulesReferencing stubbed in Plan 01, wired in Plan 02). Delete guard: rejects if referenced by rules.
+- `controller/OutboundConnectionAdminController` — REST controller. Class-level `@PreAuthorize("hasRole('ADMIN')")` defaults; GETs relaxed to ADMIN|OPERATOR. Extracts acting user id from `SecurityContextHolder.authentication.name`, strips "user:" prefix. Audit via `AuditCategory.OUTBOUND_CONNECTION_CHANGE`.
+- `dto/OutboundConnectionRequest` — Bean Validation: `@NotBlank` name, `@Pattern("^https://.+")` url, `@NotNull` method/tlsTrustMode/auth. Compact ctor throws `IllegalArgumentException` if TRUST_PATHS with empty paths list.
+- `dto/OutboundConnectionDto` — response DTO. `hmacSecretSet: boolean` instead of the ciphertext; `authKind: OutboundAuthKind` instead of the full auth config.
+- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).
+- `config/OutboundBeanConfig` — registers `OutboundConnectionRepository`, `SecretCipher`, `OutboundConnectionService` beans.
+
## config/ — Spring beans
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor
diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md
index 3417e841..c8dbf304 100644
--- a/.claude/rules/core-classes.md
+++ b/.claude/rules/core-classes.md
@@ -78,7 +78,22 @@ paths:
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade
-- `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence
+- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE`), `AuditRepository` — audit trail records and persistence
+
+## http/ — Outbound HTTP primitives (cross-cutting)
+
+- `OutboundHttpClientFactory` — interface: `clientFor(context)` returns memoized `CloseableHttpClient`
+- `OutboundHttpProperties` — record: `trustAll, trustedCaPemPaths, defaultConnectTimeout, defaultReadTimeout, proxyUrl, proxyUsername, proxyPassword`
+- `OutboundHttpRequestContext` — record of per-call TLS/timeout overrides; `systemDefault()` static factory
+- `TrustMode` — enum: `SYSTEM_DEFAULT | TRUST_ALL | TRUST_PATHS`
+
+## outbound/ — Admin-managed outbound connections
+
+- `OutboundConnection` — record: id, tenantId, name, description, url, method, defaultHeaders, defaultBodyTmpl, tlsTrustMode, tlsCaPemPaths, hmacSecretCiphertext, auth, allowedEnvironmentIds, createdAt, createdBy (String user_id), updatedAt, updatedBy (String user_id). `isAllowedInEnvironment(envId)` returns true when allowed-envs list is empty OR contains the env.
+- `OutboundAuth` — sealed interface + records: `None | Bearer(tokenCiphertext) | Basic(username, passwordCiphertext)`. Jackson `@JsonTypeInfo(use = DEDUCTION)` — wire shape has no discriminator, subtype inferred from fields.
+- `OutboundAuthKind`, `OutboundMethod` — enums
+- `OutboundConnectionRepository` — CRUD by (tenantId, id): save/findById/findByName/listByTenant/delete
+- `OutboundConnectionService` — create/update/delete/get/list with uniqueness + narrow-envs + delete-if-referenced guards. `rulesReferencing(id)` stubbed in Plan 01 (returns `[]`); populated in Plan 02 against `AlertRuleRepository`.
## security/ — Auth
From 609a86dd033a9fdaa75b885e8f6f0037c051a5aa Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:03:18 +0200
Subject: [PATCH 20/22] docs: admin guide for outbound connections
Co-Authored-By: Claude Opus 4.7 (1M context)
---
docs/outbound-connections.md | 94 ++++++++++++++++++++++++++++++++++++
1 file changed, 94 insertions(+)
create mode 100644 docs/outbound-connections.md
diff --git a/docs/outbound-connections.md b/docs/outbound-connections.md
new file mode 100644
index 00000000..007c3cbf
--- /dev/null
+++ b/docs/outbound-connections.md
@@ -0,0 +1,94 @@
+# Outbound Connections — Admin Guide
+
+Outbound connections are admin-managed HTTPS destinations that Cameleer Server can POST to. They are the building block for future alerting webhooks (Plan 02) and for any other outbound integration. This page explains how to create one, how the TLS trust modes work, and what happens when you click *Test*.
+
+## What is an outbound connection?
+
+An outbound connection is a reusable HTTPS destination with:
+
+- A **URL** (must be `https://`)
+- A **method** (`POST`, `PUT`, or `PATCH`)
+- Optional **default headers** and a **default body template** (Mustache-style placeholders like `{{message}}` will be supported by the alerting engine in Plan 02)
+- A **TLS trust mode** — how the server validates the destination's certificate
+- Optional **HMAC signing secret** — if set, the server HMAC-signs the request body and sends the signature in an `X-Cameleer-Signature` header (wiring lands in Plan 02)
+- Optional **authentication** — Bearer token or Basic auth, stored encrypted at rest
+- An optional **allowed-environments** restriction — if empty, the connection is usable from all environments; if non-empty, only rules in listed environments may use it
+
+Connections are tenant-scoped. Names must be unique within a tenant.
+
+## Creating a connection
+
+Admin → *Outbound Connections* → *New connection*.
+
+Fill in:
+
+- **Name** — short identifier, unique per tenant (e.g. `slack-ops`, `pagerduty-primary`).
+- **URL** — must start with `https://`. `http://` is rejected.
+- **Method** — pick `POST` for most webhooks.
+- **Default headers** — e.g. `Content-Type: application/json` for JSON webhooks. Per-rule headers may override these in Plan 02.
+- **Default body template** — the request body used when a rule doesn't specify its own. Plan 02 will interpolate Mustache variables.
+- **TLS trust mode** — see below.
+- **HMAC secret** — optional. If supplied, it's encrypted at rest using a key derived from the server's JWT signing secret (AES-GCM + HMAC-SHA256 KDF). The plaintext never leaves the browser-to-server path.
+- **Auth** — `None`, `Bearer`, or `Basic`. Credentials are encrypted at rest alongside the HMAC secret.
+- **Allow in all environments** — unchecked lets you restrict usage to a subset.
+
+Hit *Create*. The connection is now listed on the index page.
+
+## TLS trust modes
+
+| Mode | When to use |
+|---|---|
+| `SYSTEM_DEFAULT` | Standard public endpoints (Slack, PagerDuty, GitHub, etc.). The JVM's default trust store is used. |
+| `TRUST_PATHS` | Self-hosted endpoints signed by a private CA. Provide one or more absolute paths to PEM-encoded CA certificates on the server filesystem. The JVM defaults are preserved; the configured CAs are added on top. |
+| `TRUST_ALL` | Local dev and staging against self-signed certs. **Disables certificate validation entirely.** The UI shows an amber warning and does not recommend this for production. |
+
+You can also configure tenant-wide extras via `cameleer.server.outbound-http.trusted-ca-pem-paths` in `application.yml` — those CAs are added to every outbound HTTPS call. If a path in that list doesn't exist on disk, the server refuses to start (fail-fast).
+
+## Testing a connection
+
+On the edit page of a saved connection, click *Test*. The server issues a synthetic `POST` with body `{"probe":true}` to the connection's URL using the connection's stored trust/auth configuration and returns:
+
+- HTTP status code (0 if the request never completed)
+- Latency in milliseconds
+- First 512 characters of the response body
+- TLS protocol (reported as `TLS`; full protocol/cipher/peer-cert extraction is deferred to a Plan 02 follow-up)
+- Error message, if any
+
+The probe is audit-logged under `OUTBOUND_CONNECTION_CHANGE` so you can trace who tested what.
+
+Any test endpoint with a real handler should accept `{"probe":true}` as a harmless payload. If a downstream rejects the body shape, you still see the HTTP status, which is usually enough to diagnose routing / auth / TLS issues.
+
+## Allowed environments
+
+When a connection's *Allow in all environments* toggle is off and a set of environments is chosen, the connection is only usable by alerting rules (Plan 02) that fire in those environments. Two guards enforce this:
+
+1. **Narrowing guard on update** — if you remove an environment from the list while existing rules reference this connection in that environment, the server returns `409 Conflict` and names the rules. (Plan 01 stubs out the rules check; it returns empty. Plan 02 wires it to the real alert-rules table.)
+2. **Delete guard** — attempting to delete a connection still referenced by any rule returns `409 Conflict`. Drop the rules (or reassign them) first.
+
+An empty list means "allowed everywhere" — the default.
+
+## HMAC signing semantics (for consumers)
+
+When Plan 02 starts firing webhook requests, connections with an HMAC secret set will sign each request body as follows:
+
+```
+X-Cameleer-Signature: v1=
+X-Cameleer-Timestamp:
+```
+
+Consumers should compute the same HMAC over the raw body bytes using their copy of the shared secret and reject requests where the signatures don't match. Rotate secrets by updating the connection in the UI — the old secret continues to sign until the change is saved.
+
+## RBAC
+
+- **ADMIN** can create, update, delete, and test connections.
+- **OPERATOR** can list and view connections (read-only). This mirrors the "operators run playbooks, admins configure systems" split used elsewhere in the product.
+- **VIEWER** has no visibility into outbound connections.
+
+All mutations are recorded in the audit log (`/admin/audit`) under categories `OUTBOUND_CONNECTION_CHANGE` (create/update/delete/test) and — reserved for future use — `OUTBOUND_HTTP_TRUST_CHANGE` for tenant-wide trust store edits.
+
+## Operational notes
+
+- Outbound clients are memoized per `(trust mode, CA paths, timeouts)` tuple inside `ApacheOutboundHttpClientFactory`. Changing a connection's trust mode evicts its cached client on next call.
+- Default connect timeout is 2s; default read timeout is 5s. Override per-tenant in `cameleer.server.outbound-http.default-*-timeout-ms`.
+- Proxy support (`cameleer.server.outbound-http.proxy-*` properties) is wired through the properties record but not yet applied by the factory — a Plan 02 follow-up.
+- OIDC still uses its own Nimbus HTTP client. Retrofitting OIDC onto `OutboundHttpClientFactory` is deliberately deferred so Plan 01 doesn't touch proven-working auth code.
From 7358555d56d7b851a06a501af2b26195cf13df46 Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:10:25 +0200
Subject: [PATCH 21/22] test(outbound): add @AfterEach cleanup to avoid leaking
user/connection rows
Shared Spring test context meant seeded test-admin/test-operator/test-viewer/test-alice
users persisted across IT classes, breaking FlywayMigrationIT's "users is empty" assertion.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../controller/OutboundConnectionAdminControllerIT.java | 6 ++++++
.../storage/PostgresOutboundConnectionRepositoryIT.java | 7 +++++++
2 files changed, 13 insertions(+)
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
index 62509b35..af8d2346 100644
--- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
@@ -31,6 +31,12 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
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();
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java
index ce762734..1f242685 100644
--- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/storage/PostgresOutboundConnectionRepositoryIT.java
@@ -6,6 +6,7 @@ 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;
@@ -34,6 +35,12 @@ class PostgresOutboundConnectionRepositoryIT extends AbstractPostgresIT {
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",
From cacedd3f161044da7a6f10f26b75777d68af3c2d Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sun, 19 Apr 2026 17:19:37 +0200
Subject: [PATCH 22/22] fix(outbound): null-guard TRUST_PATHS check; add RBAC
test for probe endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- OutboundConnectionRequest compact ctor: avoid NPE if tlsTrustMode is null
(defense-in-depth alongside @NotNull Bean Validation).
- Add operatorCannotTest IT case to lock the ADMIN-only contract on
POST /{id}/test — was previously untested.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../outbound/dto/OutboundConnectionRequest.java | 2 +-
.../OutboundConnectionAdminControllerIT.java | 15 +++++++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java
index 2a5897b2..b12b6fff 100644
--- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java
+++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/dto/OutboundConnectionRequest.java
@@ -30,7 +30,7 @@ public record OutboundConnectionRequest(
defaultHeaders = defaultHeaders == null ? Map.of() : defaultHeaders;
tlsCaPemPaths = tlsCaPemPaths == null ? List.of() : tlsCaPemPaths;
allowedEnvironmentIds = allowedEnvironmentIds == null ? List.of() : allowedEnvironmentIds;
- if (tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) {
+ if (tlsTrustMode != null && tlsTrustMode == TrustMode.TRUST_PATHS && tlsCaPemPaths.isEmpty()) {
throw new IllegalArgumentException("tlsCaPemPaths must not be empty when tlsTrustMode = TRUST_PATHS");
}
}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
index af8d2346..00fd8158 100644
--- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java
@@ -176,4 +176,19 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS");
assertThat(body.path("error").isNull()).isTrue();
}
+
+ @Test
+ void operatorCannotTest() throws Exception {
+ ResponseEntity create = restTemplate.exchange(
+ "/api/v1/admin/outbound-connections", HttpMethod.POST,
+ new HttpEntity<>(CREATE_BODY, securityHelper.authHeaders(adminJwt)),
+ String.class);
+ String id = objectMapper.readTree(create.getBody()).path("id").asText();
+
+ ResponseEntity test = restTemplate.exchange(
+ "/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
+ new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
+ String.class);
+ assertThat(test.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+ }
}