This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24192 in repository https://gitbox.apache.org/repos/asf/camel.git
commit b773865250ced652922b861980f93e19cfe08295 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jul 19 11:18:19 2026 +0200 CAMEL-24192: camel-lra - separate compensation/completion URI allowlists and prune on route removal Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/service/lra/LRASagaRoutes.java | 43 +++--- .../apache/camel/service/lra/LRASagaService.java | 71 +++++++++- .../service/lra/LRASagaServiceAllowlistTest.java | 148 +++++++++++++++++++++ .../java/org/apache/camel/reifier/SagaReifier.java | 1 + .../java/org/apache/camel/saga/CamelSagaStep.java | 10 ++ 5 files changed, 250 insertions(+), 23 deletions(-) diff --git a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java index b2538ac31c67..57ae40880e24 100644 --- a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java +++ b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaRoutes.java @@ -18,7 +18,10 @@ package org.apache.camel.service.lra; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.apache.camel.Exchange; @@ -44,7 +47,7 @@ public class LRASagaRoutes extends RouteBuilder { rest(sagaService.getLocalParticipantContextPath()) .put(PARTICIPANT_PATH_COMPENSATE).to("direct:lra-compensation"); from("direct:lra-compensation").routeId("lra-compensation") - .process(this::verifyRequest) + .process(this::verifyCompensationRequest) .choice() .when(header(URL_COMPENSATION_KEY).isNotNull()) .toD("${header." + URL_COMPENSATION_KEY + "}") @@ -53,7 +56,7 @@ public class LRASagaRoutes extends RouteBuilder { rest(sagaService.getLocalParticipantContextPath()) .put(PARTICIPANT_PATH_COMPLETE).to("direct:lra-completion"); from("direct:lra-completion").routeId("lra-completion") - .process(this::verifyRequest) + .process(this::verifyCompletionRequest) .choice() .when(header(URL_COMPLETION_KEY).isNotNull()) .toD("${header." + URL_COMPLETION_KEY + "}") @@ -93,47 +96,49 @@ public class LRASagaRoutes extends RouteBuilder { return URLDecoder.decode(encodedString, StandardCharsets.UTF_8); } + private void verifyCompensationRequest(Exchange exchange) { + verifyRequest(exchange, true); + } + + private void verifyCompletionRequest(Exchange exchange) { + verifyRequest(exchange, false); + } + /** - * Check if the request is pointing to an allowed URI to prevent unauthorized remote uri invocation + * Check if the request is pointing to an allowed URI to prevent unauthorized remote uri invocation. Validates only + * the URI relevant to the callback type against the corresponding allowlist. */ - private void verifyRequest(Exchange exchange) { + private void verifyRequest(Exchange exchange, boolean isCompensation) { if (exchange.getIn().getHeader(Exchange.SAGA_LONG_RUNNING_ACTION) == null) { throw new IllegalArgumentException("Missing " + Exchange.SAGA_LONG_RUNNING_ACTION + " header in received request"); } - Set<String> usedURIs = new HashSet<>(); String compensationURI = exchange.getIn().getHeader(URL_COMPENSATION_KEY, String.class); - if (compensationURI != null) { - usedURIs.add(compensationURI); - } String completionURI = exchange.getIn().getHeader(URL_COMPLETION_KEY, String.class); - if (completionURI != null) { - usedURIs.add(completionURI); - } // CAMEL-17751: Extract URIs from the CamelHttpQuery header - if (usedURIs.isEmpty()) { + if (compensationURI == null && completionURI == null) { Map<String, String> queryParams = parseQuery(exchange.getIn().getHeader(Exchange.HTTP_QUERY, String.class)); if (!queryParams.isEmpty()) { - if (queryParams.get(URL_COMPENSATION_KEY) != null) { compensationURI = queryParams.get(URL_COMPENSATION_KEY); - usedURIs.add(compensationURI); exchange.getIn().setHeader(URL_COMPENSATION_KEY, compensationURI); } - if (queryParams.get(URL_COMPLETION_KEY) != null) { completionURI = queryParams.get(URL_COMPLETION_KEY); - usedURIs.add(completionURI); exchange.getIn().setHeader(URL_COMPLETION_KEY, completionURI); } } } - for (String uri : usedURIs) { - if (!sagaService.getRegisteredURIs().contains(uri)) { + String uri = isCompensation ? compensationURI : completionURI; + if (uri != null) { + Set<String> allowedURIs = isCompensation + ? sagaService.getRegisteredCompensationURIs() + : sagaService.getRegisteredCompletionURIs(); + if (!allowedURIs.contains(uri)) { throw new IllegalArgumentException("URI " + uri + " is not allowed"); } } diff --git a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java index 73552c994ffb..451b7bbcb809 100644 --- a/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java +++ b/components/camel-lra/src/main/java/org/apache/camel/service/lra/LRASagaService.java @@ -17,9 +17,13 @@ package org.apache.camel.service.lra; import java.net.URI; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ScheduledExecutorService; import org.apache.camel.*; @@ -29,8 +33,10 @@ import org.apache.camel.saga.CamelSagaCoordinator; import org.apache.camel.saga.CamelSagaService; import org.apache.camel.saga.CamelSagaStep; import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.LifecycleStrategy; import org.apache.camel.spi.Metadata; import org.apache.camel.spi.annotations.JdkService; +import org.apache.camel.support.LifecycleStrategySupport; import org.apache.camel.support.service.ServiceSupport; /** @@ -45,7 +51,10 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam private ScheduledExecutorService executorService; private LRAClient client; private LRASagaRoutes routes; - private final Set<String> sagaURIs = ConcurrentHashMap.newKeySet(); + private LifecycleStrategy lifecycleStrategy; + private final Set<String> compensationURIs = ConcurrentHashMap.newKeySet(); + private final Set<String> completionURIs = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap<String, List<CamelSagaStep>> stepsByRouteId = new ConcurrentHashMap<>(); // we want to be able to configure these following options @Metadata @@ -79,8 +88,38 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam @Override public void registerStep(CamelSagaStep step) { - step.getCompensation().map(Endpoint::getEndpointUri).ifPresent(this.sagaURIs::add); - step.getCompletion().map(Endpoint::getEndpointUri).ifPresent(this.sagaURIs::add); + step.getCompensation().map(Endpoint::getEndpointUri).ifPresent(compensationURIs::add); + step.getCompletion().map(Endpoint::getEndpointUri).ifPresent(completionURIs::add); + if (step.getRouteId() != null) { + stepsByRouteId.computeIfAbsent(step.getRouteId(), k -> new CopyOnWriteArrayList<>()).add(step); + } + } + + void unregisterSteps(String routeId) { + List<CamelSagaStep> steps = stepsByRouteId.remove(routeId); + if (steps == null) { + return; + } + + // collect URIs still referenced by other routes + Set<String> retainedCompensation = new HashSet<>(); + Set<String> retainedCompletion = new HashSet<>(); + for (List<CamelSagaStep> remaining : stepsByRouteId.values()) { + for (CamelSagaStep s : remaining) { + s.getCompensation().map(Endpoint::getEndpointUri).ifPresent(retainedCompensation::add); + s.getCompletion().map(Endpoint::getEndpointUri).ifPresent(retainedCompletion::add); + } + } + + // remove only URIs no longer referenced + for (CamelSagaStep step : steps) { + step.getCompensation().map(Endpoint::getEndpointUri) + .filter(uri -> !retainedCompensation.contains(uri)) + .ifPresent(compensationURIs::remove); + step.getCompletion().map(Endpoint::getEndpointUri) + .filter(uri -> !retainedCompletion.contains(uri)) + .ifPresent(completionURIs::remove); + } } @Override @@ -121,6 +160,9 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam this.client.close(); this.client = null; } + compensationURIs.clear(); + completionURIs.clear(); + stepsByRouteId.clear(); } @Override @@ -136,6 +178,17 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam throw RuntimeCamelException.wrapRuntimeException(ex); } } + if (this.lifecycleStrategy == null) { + this.lifecycleStrategy = new LifecycleStrategySupport() { + @Override + public void onRoutesRemove(Collection<Route> routes) { + for (Route r : routes) { + unregisterSteps(r.getRouteId()); + } + } + }; + this.camelContext.addLifecycleStrategy(this.lifecycleStrategy); + } } @Override @@ -187,8 +240,18 @@ public class LRASagaService extends ServiceSupport implements StaticService, Cam this.localParticipantContextPath = localParticipantContextPath; } + public Set<String> getRegisteredCompensationURIs() { + return compensationURIs; + } + + public Set<String> getRegisteredCompletionURIs() { + return completionURIs; + } + public Set<String> getRegisteredURIs() { - return sagaURIs; + Set<String> all = new HashSet<>(compensationURIs); + all.addAll(completionURIs); + return all; } @Override diff --git a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java new file mode 100644 index 000000000000..905ed19416b6 --- /dev/null +++ b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowlistTest.java @@ -0,0 +1,148 @@ +/* + * 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.service.lra; + +import java.util.Collections; +import java.util.Set; + +import org.apache.camel.Endpoint; +import org.apache.camel.saga.CamelSagaStep; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class LRASagaServiceAllowlistTest extends CamelTestSupport { + + private LRASagaService sagaService; + + public LRASagaServiceAllowlistTest() { + testConfiguration().withUseRouteBuilder(false); + } + + @BeforeEach + void initService() { + sagaService = new LRASagaService(); + sagaService.setCoordinatorUrl("http://localhost:8080"); + sagaService.setLocalParticipantUrl("http://localhost:8081"); + sagaService.setLocalParticipantContextPath("/lra-participant"); + sagaService.setCoordinatorContextPath("/lra-coordinator"); + sagaService.setCamelContext(context()); + } + + @DisplayName("registerStep adds compensation URI only to the compensation allowlist") + @Test + void testCompensationUriSeparation() { + Endpoint compensation = context().getEndpoint("direct:compensate1"); + CamelSagaStep step = new CamelSagaStep(compensation, null, Collections.emptyMap(), null); + step.setRouteId("route1"); + + sagaService.registerStep(step); + + assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://compensate1")); + assertFalse(sagaService.getRegisteredCompletionURIs().contains("direct://compensate1")); + } + + @DisplayName("registerStep adds completion URI only to the completion allowlist") + @Test + void testCompletionUriSeparation() { + Endpoint completion = context().getEndpoint("direct:complete1"); + CamelSagaStep step = new CamelSagaStep(null, completion, Collections.emptyMap(), null); + step.setRouteId("route1"); + + sagaService.registerStep(step); + + assertTrue(sagaService.getRegisteredCompletionURIs().contains("direct://complete1")); + assertFalse(sagaService.getRegisteredCompensationURIs().contains("direct://complete1")); + } + + @DisplayName("getRegisteredURIs returns the union of both sets") + @Test + void testGetRegisteredURIsReturnsUnion() { + Endpoint compensation = context().getEndpoint("direct:compensate1"); + Endpoint completion = context().getEndpoint("direct:complete1"); + CamelSagaStep step = new CamelSagaStep(compensation, completion, Collections.emptyMap(), null); + step.setRouteId("route1"); + + sagaService.registerStep(step); + + Set<String> all = sagaService.getRegisteredURIs(); + assertEquals(2, all.size()); + assertTrue(all.contains("direct://compensate1")); + assertTrue(all.contains("direct://complete1")); + } + + @DisplayName("unregisterSteps prunes URIs from the allowlists") + @Test + void testUnregisterStepsPrunesURIs() { + Endpoint compensation = context().getEndpoint("direct:compensate1"); + Endpoint completion = context().getEndpoint("direct:complete1"); + CamelSagaStep step = new CamelSagaStep(compensation, completion, Collections.emptyMap(), null); + step.setRouteId("testRoute"); + + sagaService.registerStep(step); + assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://compensate1")); + assertTrue(sagaService.getRegisteredCompletionURIs().contains("direct://complete1")); + + sagaService.unregisterSteps("testRoute"); + + assertFalse(sagaService.getRegisteredCompensationURIs().contains("direct://compensate1")); + assertFalse(sagaService.getRegisteredCompletionURIs().contains("direct://complete1")); + assertTrue(sagaService.getRegisteredURIs().isEmpty()); + } + + @DisplayName("Shared URIs are only pruned when all referencing routes are removed") + @Test + void testSharedUriRetainedUntilAllRoutesRemoved() { + Endpoint sharedCompensation = context().getEndpoint("direct:sharedCompensate"); + + CamelSagaStep step1 = new CamelSagaStep(sharedCompensation, null, Collections.emptyMap(), null); + step1.setRouteId("routeA"); + sagaService.registerStep(step1); + + CamelSagaStep step2 = new CamelSagaStep(sharedCompensation, null, Collections.emptyMap(), null); + step2.setRouteId("routeB"); + sagaService.registerStep(step2); + + assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://sharedCompensate")); + + // remove routeA — shared URI should remain because routeB still references it + sagaService.unregisterSteps("routeA"); + assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://sharedCompensate")); + + // remove routeB — now the URI should be pruned + sagaService.unregisterSteps("routeB"); + assertFalse(sagaService.getRegisteredCompensationURIs().contains("direct://sharedCompensate")); + } + + @DisplayName("unregisterSteps for unknown routeId is a no-op") + @Test + void testUnregisterUnknownRouteIsNoOp() { + Endpoint compensation = context().getEndpoint("direct:compensate1"); + CamelSagaStep step = new CamelSagaStep(compensation, null, Collections.emptyMap(), null); + step.setRouteId("route1"); + sagaService.registerStep(step); + + sagaService.unregisterSteps("unknownRoute"); + + assertTrue(sagaService.getRegisteredCompensationURIs().contains("direct://compensate1")); + } +} diff --git a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SagaReifier.java b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SagaReifier.java index 2a3568b1ce9c..37d284c1e9ff 100644 --- a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SagaReifier.java +++ b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/SagaReifier.java @@ -95,6 +95,7 @@ public class SagaReifier extends ProcessorReifier<SagaDefinition> { CamelSagaStep step = new CamelSagaStep( compensationEndpoint, completionEndpoint, optionsMap, parseDuration(timeout)); + step.setRouteId(route.getRouteId()); SagaPropagation propagation = parse(SagaPropagation.class, definition.getPropagation()); if (propagation == null) { diff --git a/core/camel-support/src/main/java/org/apache/camel/saga/CamelSagaStep.java b/core/camel-support/src/main/java/org/apache/camel/saga/CamelSagaStep.java index 2fdd83847bd8..1b2a57091929 100644 --- a/core/camel-support/src/main/java/org/apache/camel/saga/CamelSagaStep.java +++ b/core/camel-support/src/main/java/org/apache/camel/saga/CamelSagaStep.java @@ -35,6 +35,8 @@ public class CamelSagaStep { private final Long timeoutInMilliseconds; + private String routeId; + public CamelSagaStep(Endpoint compensation, Endpoint completion, Map<String, Expression> options, Long timeoutInMilliseconds) { this.compensation = compensation; @@ -59,6 +61,14 @@ public class CamelSagaStep { return Optional.ofNullable(timeoutInMilliseconds); } + public String getRouteId() { + return routeId; + } + + public void setRouteId(String routeId) { + this.routeId = routeId; + } + public boolean isEmpty() { return compensation == null && completion == null && options.isEmpty() && timeoutInMilliseconds == null; }
