This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 5bcb48b79755 CAMEL-24751: camel-opa - unregister the OpaSecurityPolicy
health check when its routes stop (#26667)
5bcb48b79755 is described below
commit 5bcb48b797550cbf20b98d5a9c0466162a36bcce
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 21 18:40:44 2026 +0200
CAMEL-24751: camel-opa - unregister the OpaSecurityPolicy health check when
its routes stop (#26667)
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../component/opa/security/OpaSecurityPolicy.java | 48 +++++++-
.../opa/security/OpaSecurityProcessor.java | 15 +++
.../OpaSecurityPolicyHealthCheckLifecycleTest.java | 128 +++++++++++++++++++++
3 files changed, 185 insertions(+), 6 deletions(-)
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
index a8dfc21cb742..54054bad0361 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityPolicy.java
@@ -76,6 +76,8 @@ public class OpaSecurityPolicy implements AuthorizationPolicy
{
private volatile OpaSecurityPolicyHealthCheck healthCheck;
private volatile boolean ownsClient;
private volatile SSLContext sslContext;
+ private volatile CamelContext camelContext;
+ private int activeProcessors;
public OpaSecurityPolicy() {
}
@@ -87,6 +89,7 @@ public class OpaSecurityPolicy implements AuthorizationPolicy
{
@Override
public void beforeWrap(Route route, NamedNode definition) {
+ this.camelContext = route.getCamelContext();
if (evaluator == null) {
StringHelper.notEmpty(policyPath, "policyPath", this);
OpaHttpClient transport = null;
@@ -109,9 +112,9 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
throw new RuntimeCamelException("Could not register the
evaluator for policy " + policyPath, e);
}
}
- // after validation, so a policy that is missing its policyPath fails
without leaving a ".../null" check
- // behind in the registry
- registerHealthCheck(route);
+ // The health check is registered and unregistered from the wrapped
processors' lifecycle
+ // (onProcessorStart/onProcessorStop), not here: beforeWrap does not
run again when a route is merely
+ // restarted, so a check registered here would be left behind when the
guarded routes stop (CAMEL-24751).
}
/**
@@ -122,11 +125,12 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
* may never talk to - hence {@code ownsClient} rather than a null check
on {@code opaClient}, which by the time
* this runs is set either way.
*/
- private void registerHealthCheck(Route route) {
- if (!healthCheckEnabled || healthCheck != null || !ownsClient ||
ObjectHelper.isEmpty(serverUrl)) {
+ private synchronized void registerHealthCheck() {
+ if (!healthCheckEnabled || healthCheck != null || !ownsClient ||
ObjectHelper.isEmpty(serverUrl)
+ || camelContext == null) {
return;
}
- HealthCheckRegistry registry =
HealthCheckRegistry.get(route.getCamelContext());
+ HealthCheckRegistry registry = HealthCheckRegistry.get(camelContext);
if (registry == null) {
return;
}
@@ -134,6 +138,38 @@ public class OpaSecurityPolicy implements
AuthorizationPolicy {
registry.register(healthCheck);
}
+ private synchronized void unregisterHealthCheck() {
+ if (healthCheck == null || camelContext == null) {
+ return;
+ }
+ HealthCheckRegistry registry = HealthCheckRegistry.get(camelContext);
+ if (registry != null) {
+ registry.unregister(healthCheck);
+ }
+ healthCheck = null;
+ }
+
+ /**
+ * Called by {@link OpaSecurityProcessor} when a route this policy guards
starts. The readiness check is registered
+ * on the first start and, after a stop/restart cycle, restored here -
{@link #beforeWrap} does not run again when a
+ * route is merely restarted.
+ */
+ synchronized void onProcessorStart() {
+ activeProcessors++;
+ registerHealthCheck();
+ }
+
+ /**
+ * Called by {@link OpaSecurityProcessor} when a route this policy guards
stops. The check is unregistered once the
+ * last guarded route has gone, so a policy shared by several routes keeps
its check until all of them stop, and a
+ * route reload does not leave a check behind reporting on a policy that
no longer enforces anything (CAMEL-24751).
+ */
+ synchronized void onProcessorStop() {
+ if (activeProcessors > 0 && --activeProcessors == 0) {
+ unregisterHealthCheck();
+ }
+ }
+
@Override
public Processor wrap(Route route, final Processor processor) {
if (LOG.isDebugEnabled()) {
diff --git
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
index 8091bc091c1f..c0a0150cac78 100644
---
a/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
+++
b/components/camel-opa/src/main/java/org/apache/camel/component/opa/security/OpaSecurityProcessor.java
@@ -38,6 +38,21 @@ public class OpaSecurityProcessor extends
DelegateAsyncProcessor {
this.policy = policy;
}
+ @Override
+ protected void doStart() throws Exception {
+ super.doStart();
+ // drives the policy's readiness-check registration; a policy shared
by several routes counts them
+ policy.onProcessorStart();
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ // remove the readiness check once the last guarded route stops, so a
stopped or reloaded route does not
+ // leave a check behind reporting on a policy that is no longer
enforcing anything (CAMEL-24751)
+ policy.onProcessorStop();
+ super.doStop();
+ }
+
@Override
public boolean process(Exchange exchange, AsyncCallback callback) {
try {
diff --git
a/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
new file mode 100644
index 000000000000..5d38efbb13f2
--- /dev/null
+++
b/components/camel-opa/src/test/java/org/apache/camel/component/opa/security/OpaSecurityPolicyHealthCheckLifecycleTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.opa.security;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.health.HealthCheckRegistry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The readiness check registered by {@link OpaSecurityPolicy} must follow the
lifecycle of the routes it guards: it is
+ * kept while any guarded route runs, removed once the last one stops, and
restored when a route starts again. Otherwise
+ * a stopped or reloaded route leaves a check behind reporting on a policy
that is no longer enforcing anything
+ * (CAMEL-24751).
+ */
+public class OpaSecurityPolicyHealthCheckLifecycleTest extends
CamelTestSupport {
+
+ private static HttpServer server;
+ private static String serverUrl;
+
+ private final OpaSecurityPolicy policy = new OpaSecurityPolicy();
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ server = null;
+ }
+ }
+
+ private static String startHealthyServer() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
+ server.createContext("/health", exchange -> {
+ exchange.sendResponseHeaders(200, -1);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.flush();
+ }
+ });
+ server.start();
+ return "http://localhost:" + server.getAddress().getPort();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() throws Exception {
+ serverUrl = startHealthyServer();
+ policy.setPolicyPath("authz/allow");
+ policy.setServerUrl(serverUrl);
+ // two routes share the same policy instance, so the check is
deduplicated by id and its removal is
+ // ref-counted against the routes that are still running
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:one").routeId("guarded1").policy(policy).to("mock:result");
+
from("direct:two").routeId("guarded2").policy(policy).to("mock:result");
+ }
+ };
+ }
+
+ private long registeredChecks() {
+ HealthCheckRegistry registry = HealthCheckRegistry.get(context);
+ assertThat(registry).isNotNull();
+ return registry.stream()
+ .filter(hc -> hc.getId().startsWith("security-policy:opa-"))
+ .count();
+ }
+
+ @Test
+ void keepsTheCheckWhileAnyGuardedRouteRunsAndRestoresItOnRestart() throws
Exception {
+ assertThat(registeredChecks()).isEqualTo(1);
+
+ // stopping one of the two routes must not remove the check - the
other still enforces the policy
+ context.getRouteController().stopRoute("guarded1");
+ assertThat(registeredChecks()).isEqualTo(1);
+
+ // once the last guarded route stops, the check is gone
+ context.getRouteController().stopRoute("guarded2");
+ assertThat(registeredChecks()).isEqualTo(0);
+
+ // starting a route again restores it (beforeWrap does not run on a
plain restart, so this proves the
+ // registration is driven by the processor lifecycle rather than the
wrap)
+ context.getRouteController().startRoute("guarded1");
+ assertThat(registeredChecks()).isEqualTo(1);
+ }
+
+ @Test
+ void doesNotRegisterACheckWhenHealthCheckIsDisabled() throws Exception {
+ // the healthCheckEnabled guard now runs on processor start, not in
beforeWrap, so it has to be exercised
+ // through a started route. A distinct serverUrl keeps the would-be
check from deduplicating against the one
+ // the enabled routes share, so a missing guard would show up as a
second registration.
+ OpaSecurityPolicy disabled = new OpaSecurityPolicy();
+ disabled.setPolicyPath("authz/allow");
+ disabled.setServerUrl("http://disabled-unused:8181");
+ disabled.setHealthCheckEnabled(false);
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("direct:disabled").routeId("disabled").policy(disabled).to("mock:result");
+ }
+ });
+ context.getRouteController().startRoute("disabled");
+
+ // still only the one check the two enabled routes share; the disabled
policy registered nothing
+ assertThat(registeredChecks()).isEqualTo(1);
+ }
+}