This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 7e20115f87cb [backport camel-4.22.x] CAMEL-25584: camel-lra - only 
join LRAs hosted by the configured LRA coordinator (#27769)
7e20115f87cb is described below

commit 7e20115f87cbec5adb58312de6235f9f50fe3049
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Oct 13 16:36:31 2026 +0200

    [backport camel-4.22.x] CAMEL-25584: camel-lra - only join LRAs hosted by 
the configured LRA coordinator (#27769)
    
    Backport of #27725.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../org/apache/camel/catalog/docs/main.adoc        |   3 +-
 .../org/apache/camel/catalog/docs/saga-eip.adoc    |   5 +
 .../main/camel-main-configuration-metadata.json    |   1 +
 .../service/lra/LRASagaServiceConfigurer.java      |   6 +
 .../apache/camel/service/lra/LRASagaService.java   | 123 +++++++++++++++++-
 .../camel/service/lra/LRAPropagatedSagaTest.java   | 129 +++++++++++++++++++
 .../lra/LRASagaServiceAllowedCoordinatorTest.java  | 141 +++++++++++++++++++++
 .../src/main/docs/modules/eips/pages/saga-eip.adoc |   5 +
 .../main/LraConfigurationPropertiesConfigurer.java |   7 +
 .../camel-main-configuration-metadata.json         |   1 +
 core/camel-main/src/main/docs/main.adoc            |   3 +-
 .../camel/main/LraConfigurationProperties.java     |  24 ++++
 12 files changed, 445 insertions(+), 3 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
index 43ca85abfd57..2ed417ded36c 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/main.adoc
@@ -725,11 +725,12 @@ The camel.resilience4j supports 25 options, which are 
listed below.
 
 
 === Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 5 options, which are listed below.
+The camel.lra supports 6 options, which are listed below.
 
 [width="100%",cols="2,5,^1,2",options="header"]
 |===
 | Name | Description | Default | Type
+| `camel.lra.allowedCoordinatorUrls` | Comma-separated URLs, including the 
context path, of further LRA coordinators whose LRAs may be joined, completed 
or compensated. By default, only an LRA hosted by the configured coordinator is 
accepted. Use it when participants reach the same coordinator under different 
URLs, for example with different host names. |  | String
 | `camel.lra.coordinatorContextPath` | The context-path for the LRA 
coordinator. Is default /lra-coordinator | /lra-coordinator | String
 | `camel.lra.coordinatorUrl` | The URL for the LRA coordinator service that 
orchestrates the transactions |  | String
 | `camel.lra.enabled` | To enable Saga LRA | false | boolean
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
index 124128079450..8e2ecd7671a0 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/saga-eip.adoc
@@ -93,6 +93,11 @@ Users can follow instructions present on the Narayana 
website to *start up a rem
 
 The URL of the LRA coordinator is a required parameter of the Camel LRA 
service. The Camel application and the LRA service communicate using the HTTP 
protocol.
 
+A saga started by another participant is joined when the message carries its 
LRA in the `Long-Running-Action` header.
+The LRA Saga Service only joins, completes or compensates an LRA hosted by the 
configured coordinator: a `Long-Running-Action` with any other URL fails the 
exchange.
+When the participants reach the coordinator under different URLs, for example 
with different host names, list the other coordinator URLs, including the 
context path, in the `allowedCoordinatorUrls` option 
(`camel.lra.allowedCoordinatorUrls` with Camel Main).
+This also applies to the LRAs the service starts itself: when the coordinator 
returns LRA URLs under another base URL than `coordinatorUrl`, for example 
behind a proxy, list that base URL as well.
+
 To use the LRA Saga service, maven users will need to add the following 
dependency to their `pom.xml`:
 
 [source,xml]
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
index 5268c0fa3730..dcba49de3313 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/camel-main-configuration-metadata.json
@@ -284,6 +284,7 @@
     { "name": "camel.health.producersEnabled", "required": false, 
"description": "Whether producers health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
     { "name": "camel.health.registryEnabled", "required": false, 
"description": "Whether registry health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.health.routesEnabled", "required": false, "description": 
"Whether routes health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
+    { "name": "camel.lra.allowedCoordinatorUrls", "required": false, 
"description": "Comma-separated URLs, including the context path, of further 
LRA coordinators whose LRAs may be joined, completed or compensated. By 
default, only an LRA hosted by the configured coordinator is accepted. Use it 
when participants reach the same coordinator under different URLs, for example 
with different host names.", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
     { "name": "camel.lra.coordinatorContextPath", "required": false, 
"description": "The context-path for the LRA coordinator. Is default 
\/lra-coordinator", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", 
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret": 
false },
     { "name": "camel.lra.coordinatorUrl", "required": false, "description": 
"The URL for the LRA coordinator service that orchestrates the transactions", 
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type": 
"string", "javaType": "java.lang.String", "secret": false },
     { "name": "camel.lra.enabled", "required": false, "description": "To 
enable Saga LRA", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git 
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
 
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
index 53d34137e6c8..cba17a6be24b 100644
--- 
a/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
+++ 
b/components/camel-lra/src/generated/java/org/apache/camel/service/lra/LRASagaServiceConfigurer.java
@@ -23,6 +23,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
     public boolean configure(CamelContext camelContext, Object obj, String 
name, Object value, boolean ignoreCase) {
         org.apache.camel.service.lra.LRASagaService target = 
(org.apache.camel.service.lra.LRASagaService) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": 
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class, 
value)); return true;
         case "camelcontext":
         case "camelContext": target.setCamelContext(property(camelContext, 
org.apache.camel.CamelContext.class, value)); return true;
         case "coordinatorcontextpath":
@@ -40,6 +42,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return java.lang.String.class;
         case "camelcontext":
         case "camelContext": return org.apache.camel.CamelContext.class;
         case "coordinatorcontextpath":
@@ -58,6 +62,8 @@ public class LRASagaServiceConfigurer extends 
org.apache.camel.support.component
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         org.apache.camel.service.lra.LRASagaService target = 
(org.apache.camel.service.lra.LRASagaService) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return 
target.getAllowedCoordinatorUrls();
         case "camelcontext":
         case "camelContext": return target.getCamelContext();
         case "coordinatorcontextpath":
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 841b365616aa..7ccc1bfdae08 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
@@ -16,10 +16,15 @@
  */
 package org.apache.camel.service.lra;
 
+import java.net.MalformedURLException;
 import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
@@ -38,6 +43,7 @@ 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;
+import org.apache.camel.util.ObjectHelper;
 
 /**
  * A Camel saga service based on Microprofile LRA 
(https://github.com/eclipse/microprofile-lra).
@@ -65,6 +71,8 @@ public class LRASagaService extends ServiceSupport implements 
StaticService, Cam
     private String localParticipantUrl;
     @Metadata
     private String localParticipantContextPath = 
LRAConstants.DEFAULT_LOCAL_PARTICIPANT_CONTEXT_PATH;
+    @Metadata(label = "security")
+    private String allowedCoordinatorUrls;
 
     public LRASagaService() {
     }
@@ -84,17 +92,107 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
         return true;
     }
 
+    /**
+     * Returns the coordinator of an LRA, which is joined, completed or 
compensated by calling its URL. Only an LRA
+     * hosted by the configured coordinator, or by one of the allowed 
coordinator URLs, is accepted.
+     */
     @Override
     public CompletableFuture<CamelSagaCoordinator> getSaga(String id) {
         CompletableFuture<CamelSagaCoordinator> coordinator;
         try {
-            coordinator = CompletableFuture.completedFuture(new 
LRASagaCoordinator(this, URI.create(id).toURL()));
+            URI lra = URI.create(id);
+            if (!isHostedByAllowedCoordinator(lra)) {
+                throw new IllegalArgumentException(
+                        "The Long-Running-Action " + id + " is not hosted by 
the LRA coordinator configured on the LRA"
+                                                   + " saga service. If it is 
a trusted coordinator, add its URL to"
+                                                   + " 
allowedCoordinatorUrls.");
+            }
+            coordinator = CompletableFuture.completedFuture(new 
LRASagaCoordinator(this, lra.toURL()));
         } catch (Exception ex) {
             coordinator = CompletableFuture.failedFuture(ex);
         }
         return coordinator;
     }
 
+    private boolean isHostedByAllowedCoordinator(URI lra) {
+        for (URI coordinator : allowedCoordinators()) {
+            if (isHostedBy(lra, coordinator)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * The URLs of the coordinators whose LRAs are accepted: the configured 
coordinator, and the allowed coordinator
+     * URLs.
+     */
+    private List<URI> allowedCoordinators() {
+        List<URI> coordinators = new ArrayList<>();
+        coordinators.add(URI.create(new 
LRAUrlBuilder().host(coordinatorUrl).path(coordinatorContextPath).build()));
+        coordinators.addAll(additionalCoordinators());
+        return coordinators;
+    }
+
+    private List<URI> additionalCoordinators() {
+        List<URI> coordinators = new ArrayList<>();
+        if (ObjectHelper.isNotEmpty(allowedCoordinatorUrls)) {
+            for (String url : allowedCoordinatorUrls.split(",")) {
+                if (!url.isBlank()) {
+                    coordinators.add(URI.create(url.trim()));
+                }
+            }
+        }
+        return coordinators;
+    }
+
+    /**
+     * Whether the LRA URL is under the coordinator URL: same scheme, host and 
port, no user info, and a path under the
+     * path of the coordinator.
+     */
+    private static boolean isHostedBy(URI lra, URI coordinator) {
+        // host and port are read through URL, which also takes host names 
that URI does not, such as lra_coordinator
+        URL lraUrl = toUrl(lra);
+        URL coordinatorUrl = toUrl(coordinator);
+        if (lraUrl == null || coordinatorUrl == null || lraUrl.getUserInfo() 
!= null || lraUrl.getHost().isEmpty()) {
+            return false;
+        }
+        String rawPath = lra.getRawPath();
+        if (rawPath == null || hasEncodedOrParameterSegment(rawPath)) {
+            return false;
+        }
+        String lraPath = lra.normalize().getRawPath();
+        String coordinatorPath = 
Objects.toString(coordinator.normalize().getRawPath(), "");
+        if (!coordinatorPath.endsWith("/")) {
+            coordinatorPath += "/";
+        }
+        return 
lraUrl.getProtocol().equalsIgnoreCase(coordinatorUrl.getProtocol())
+                && lraUrl.getHost().equalsIgnoreCase(coordinatorUrl.getHost())
+                && port(lraUrl) == port(coordinatorUrl)
+                && lraPath.startsWith(coordinatorPath);
+    }
+
+    /**
+     * Whether the path holds a path parameter, or an encoded dot, slash or 
backslash. A server may resolve them as dot
+     * segments after this check, so such a path could leave the path of the 
coordinator.
+     */
+    private static boolean hasEncodedOrParameterSegment(String rawPath) {
+        String path = rawPath.toLowerCase(Locale.ROOT);
+        return path.contains(";") || path.contains("%2e") || 
path.contains("%2f") || path.contains("%5c");
+    }
+
+    private static URL toUrl(URI uri) {
+        try {
+            return uri.isAbsolute() ? uri.toURL() : null;
+        } catch (MalformedURLException | IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private static int port(URL url) {
+        return url.getPort() != -1 ? url.getPort() : url.getDefaultPort();
+    }
+
     @Override
     public void registerStep(CamelSagaStep step) {
         
step.getCompensation().map(Endpoint::getEndpointUri).ifPresent(compensationURIs::add);
@@ -139,6 +237,15 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
         if (localParticipantUrl == null) {
             throw new IllegalStateException("localParticipantUrl must be 
configured on the LRA saga service");
         }
+        for (URI coordinator : additionalCoordinators()) {
+            URL url = toUrl(coordinator);
+            if (url == null || url.getHost().isEmpty()
+                    || !("http".equals(url.getProtocol()) || 
"https".equals(url.getProtocol()))) {
+                throw new IllegalArgumentException(
+                        "allowedCoordinatorUrls must hold http or https 
coordinator URLs with a host name, but no such"
+                                                   + " URL could be read from: 
" + coordinator);
+            }
+        }
 
         if (this.executorService == null) {
             this.executorService = camelContext.getExecutorServiceManager()
@@ -253,6 +360,20 @@ public class LRASagaService extends ServiceSupport 
implements StaticService, Cam
         this.localParticipantContextPath = localParticipantContextPath;
     }
 
+    @ManagedAttribute(description = "Allowed coordinator URLs")
+    public String getAllowedCoordinatorUrls() {
+        return allowedCoordinatorUrls;
+    }
+
+    /**
+     * Comma-separated URLs, including the context path, of further LRA 
coordinators whose LRAs may be joined, completed
+     * or compensated. By default, only an LRA hosted by the configured 
coordinator is accepted. Use it when
+     * participants reach the same coordinator under different URLs, for 
example with different host names.
+     */
+    public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+    }
+
     public Set<String> getRegisteredCompensationURIs() {
         return compensationURIs;
     }
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
new file mode 100644
index 000000000000..af1899f54075
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRAPropagatedSagaTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.net.URL;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.SagaPropagation;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Tests that a saga step joins an LRA propagated in the Long-Running-Action 
header only when the LRA is hosted by the
+ * configured coordinator.
+ */
+class LRAPropagatedSagaTest extends CamelTestSupport {
+
+    private static final String COORDINATOR_URL = 
"http://lra-coordinator:8080";;
+
+    @RegisterExtension
+    AvailablePortFinder.Port port = AvailablePortFinder.find();
+
+    private final LRAClient client = Mockito.mock(LRAClient.class);
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+
+        LRASagaService sagaService = new MockClientLRASagaService(client);
+        sagaService.setCoordinatorUrl(COORDINATOR_URL);
+        sagaService.setLocalParticipantUrl("http://localhost:"; + 
port.getPort());
+        context.addService(sagaService);
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().port(port.getPort());
+            }
+        });
+        return context;
+    }
+
+    @Override
+    protected RoutesBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:step")
+                        .saga().propagation(SagaPropagation.SUPPORTS)
+                        .compensation("mock:compensate")
+                        .to("mock:step");
+            }
+        };
+    }
+
+    @Test
+    void joinsAnLraOfTheConfiguredCoordinator() throws Exception {
+        Mockito.when(client.join(Mockito.any(), Mockito.any(), Mockito.any()))
+                .thenReturn(CompletableFuture.completedFuture(null));
+        MockEndpoint step = getMockEndpoint("mock:step");
+        step.expectedMessageCount(1);
+
+        Exchange exchange = send(COORDINATOR_URL + "/lra-coordinator/0_1");
+
+        assertNull(exchange.getException());
+        step.assertIsSatisfied();
+        ArgumentCaptor<URL> joined = ArgumentCaptor.forClass(URL.class);
+        Mockito.verify(client).join(joined.capture(), Mockito.any(), 
Mockito.any());
+        assertEquals(COORDINATOR_URL + "/lra-coordinator/0_1", 
joined.getValue().toString());
+    }
+
+    @Test
+    void doesNotJoinAnLraOfAnotherCoordinator() throws Exception {
+        MockEndpoint step = getMockEndpoint("mock:step");
+        step.expectedMessageCount(0);
+
+        Exchange exchange = send("http://elsewhere:8080/lra-coordinator/0_1";);
+
+        assertInstanceOf(IllegalArgumentException.class, 
exchange.getException());
+        step.assertIsSatisfied();
+        Mockito.verifyNoInteractions(client);
+    }
+
+    private Exchange send(String lra) {
+        return template.send("direct:step", e -> 
e.getIn().setHeader(Exchange.SAGA_LONG_RUNNING_ACTION, lra));
+    }
+
+    // a named class, as the thread pool of the service is named after its 
class
+    private static class MockClientLRASagaService extends LRASagaService {
+
+        private final LRAClient client;
+
+        MockClientLRASagaService(LRAClient client) {
+            this.client = client;
+        }
+
+        @Override
+        protected LRAClient createLRAClient() {
+            return client;
+        }
+    }
+}
diff --git 
a/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
new file mode 100644
index 000000000000..f8a1dbea5a78
--- /dev/null
+++ 
b/components/camel-lra/src/test/java/org/apache/camel/service/lra/LRASagaServiceAllowedCoordinatorTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.concurrent.CompletionException;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests which LRAs the LRA saga service accepts to join, complete or 
compensate.
+ */
+class LRASagaServiceAllowedCoordinatorTest {
+
+    private final LRASagaService sagaService = new LRASagaService();
+
+    @BeforeEach
+    void configure() {
+        sagaService.setCoordinatorUrl("http://lra-coordinator:8080";);
+        sagaService.setLocalParticipantUrl("http://participant:8080";);
+    }
+
+    @Test
+    void acceptsAnLraOfTheConfiguredCoordinator() {
+        
assertAccepted("http://lra-coordinator:8080/lra-coordinator/0_ffff0a1e1f05_1";);
+    }
+
+    @Test
+    void comparesSchemeAndHostIgnoringCase() {
+        assertAccepted("HTTP://LRA-Coordinator:8080/lra-coordinator/0_1");
+    }
+
+    @Test
+    void treatsTheDefaultPortLikeAnExplicitOne() {
+        sagaService.setCoordinatorUrl("http://lra-coordinator";);
+
+        assertAccepted("http://lra-coordinator:80/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void honoursTheCoordinatorContextPath() {
+        sagaService.setCoordinatorContextPath("/api/v1/lra-coordinator");
+
+        
assertAccepted("http://lra-coordinator:8080/api/v1/lra-coordinator/0_1";);
+        assertRejected("http://lra-coordinator:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void rejectsAnLraOfAnotherHost() {
+        assertRejected("http://elsewhere:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void rejectsAnLraOfAnotherPortOrScheme() {
+        assertRejected("http://lra-coordinator:9090/lra-coordinator/0_1";);
+        assertRejected("https://lra-coordinator:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void rejectsAPathOutsideTheCoordinatorContextPath() {
+        assertRejected("http://lra-coordinator:8080/other/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator-other/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/../other/0_1";);
+    }
+
+    @Test
+    void rejectsEncodedOrParameterDotSegments() {
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/%2e%2e/other/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/%2E%2E/other/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/..;/other/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/..%2fother/0_1";);
+        
assertRejected("http://lra-coordinator:8080/lra-coordinator/..%5Cother/0_1";);
+    }
+
+    @Test
+    void acceptsAnLraOfACoordinatorWithAnUnderscoreInItsHostName() {
+        sagaService.setCoordinatorUrl("http://lra_coordinator:8080";);
+
+        assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1";);
+        assertRejected("http://other_coordinator:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void rejectsAnLraWithUserInfo() {
+        assertRejected("http://user@lra-coordinator:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void acceptsAnLraOfAnAllowedCoordinatorUrl() {
+        sagaService.setAllowedCoordinatorUrls(
+                "http://lra-coordinator.lra.svc:8080/lra-coordinator, 
https://lra.example.com/coordinator,";
+                                              + " 
http://lra_coordinator:8080/lra-coordinator";);
+
+        
assertAccepted("http://lra-coordinator.lra.svc:8080/lra-coordinator/0_1";);
+        assertAccepted("https://lra.example.com/coordinator/0_1";);
+        assertAccepted("http://lra_coordinator:8080/lra-coordinator/0_1";);
+        assertRejected("http://elsewhere:8080/lra-coordinator/0_1";);
+    }
+
+    @Test
+    void failsToStartWithARelativeAllowedCoordinatorUrl() {
+        
sagaService.setAllowedCoordinatorUrls("lra-coordinator/lra-coordinator");
+
+        assertThrows(IllegalArgumentException.class, sagaService::doStart);
+    }
+
+    @Test
+    void failsToStartWithAnAllowedCoordinatorUrlThatIsNotHttp() {
+        
sagaService.setAllowedCoordinatorUrls("ftp://lra-coordinator/lra-coordinator";);
+
+        assertThrows(IllegalArgumentException.class, sagaService::doStart);
+    }
+
+    private void assertAccepted(String lra) {
+        assertNotNull(sagaService.getSaga(lra).join(), lra + " must be 
accepted");
+    }
+
+    private void assertRejected(String lra) {
+        CompletionException e = assertThrows(CompletionException.class, () -> 
sagaService.getSaga(lra).join(),
+                lra + " must be rejected");
+        assertInstanceOf(IllegalArgumentException.class, e.getCause());
+    }
+}
diff --git 
a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc 
b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
index 124128079450..8e2ecd7671a0 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/saga-eip.adoc
@@ -93,6 +93,11 @@ Users can follow instructions present on the Narayana 
website to *start up a rem
 
 The URL of the LRA coordinator is a required parameter of the Camel LRA 
service. The Camel application and the LRA service communicate using the HTTP 
protocol.
 
+A saga started by another participant is joined when the message carries its 
LRA in the `Long-Running-Action` header.
+The LRA Saga Service only joins, completes or compensates an LRA hosted by the 
configured coordinator: a `Long-Running-Action` with any other URL fails the 
exchange.
+When the participants reach the coordinator under different URLs, for example 
with different host names, list the other coordinator URLs, including the 
context path, in the `allowedCoordinatorUrls` option 
(`camel.lra.allowedCoordinatorUrls` with Camel Main).
+This also applies to the LRAs the service starts itself: when the coordinator 
returns LRA URLs under another base URL than `coordinatorUrl`, for example 
behind a proxy, list that base URL as well.
+
 To use the LRA Saga service, maven users will need to add the following 
dependency to their `pom.xml`:
 
 [source,xml]
diff --git 
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
 
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
index badb2b23ea60..3bdb1349e7c1 100644
--- 
a/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
+++ 
b/core/camel-main/src/generated/java/org/apache/camel/main/LraConfigurationPropertiesConfigurer.java
@@ -22,6 +22,7 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
     private static final Map<String, Object> ALL_OPTIONS;
     static {
         Map<String, Object> map = new CaseInsensitiveMap();
+        map.put("AllowedCoordinatorUrls", java.lang.String.class);
         map.put("CoordinatorContextPath", java.lang.String.class);
         map.put("CoordinatorUrl", java.lang.String.class);
         map.put("Enabled", boolean.class);
@@ -34,6 +35,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
     public boolean configure(CamelContext camelContext, Object obj, String 
name, Object value, boolean ignoreCase) {
         org.apache.camel.main.LraConfigurationProperties target = 
(org.apache.camel.main.LraConfigurationProperties) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": 
target.setAllowedCoordinatorUrls(property(camelContext, java.lang.String.class, 
value)); return true;
         case "coordinatorcontextpath":
         case "coordinatorContextPath": 
target.setCoordinatorContextPath(property(camelContext, java.lang.String.class, 
value)); return true;
         case "coordinatorurl":
@@ -55,6 +58,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
     @Override
     public Class<?> getOptionType(String name, boolean ignoreCase) {
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return java.lang.String.class;
         case "coordinatorcontextpath":
         case "coordinatorContextPath": return java.lang.String.class;
         case "coordinatorurl":
@@ -72,6 +77,8 @@ public class LraConfigurationPropertiesConfigurer extends 
org.apache.camel.suppo
     public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
         org.apache.camel.main.LraConfigurationProperties target = 
(org.apache.camel.main.LraConfigurationProperties) obj;
         switch (ignoreCase ? name.toLowerCase() : name) {
+        case "allowedcoordinatorurls":
+        case "allowedCoordinatorUrls": return 
target.getAllowedCoordinatorUrls();
         case "coordinatorcontextpath":
         case "coordinatorContextPath": return 
target.getCoordinatorContextPath();
         case "coordinatorurl":
diff --git 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
index 5268c0fa3730..dcba49de3313 100644
--- 
a/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
+++ 
b/core/camel-main/src/generated/resources/META-INF/camel-main-configuration-metadata.json
@@ -284,6 +284,7 @@
     { "name": "camel.health.producersEnabled", "required": false, 
"description": "Whether producers health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": false, "secret": false },
     { "name": "camel.health.registryEnabled", "required": false, 
"description": "Whether registry health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
     { "name": "camel.health.routesEnabled", "required": false, "description": 
"Whether routes health check is enabled", "sourceType": 
"org.apache.camel.main.HealthConfigurationProperties", "type": "boolean", 
"javaType": "java.lang.Boolean", "defaultValue": true, "secret": false },
+    { "name": "camel.lra.allowedCoordinatorUrls", "required": false, 
"description": "Comma-separated URLs, including the context path, of further 
LRA coordinators whose LRAs may be joined, completed or compensated. By 
default, only an LRA hosted by the configured coordinator is accepted. Use it 
when participants reach the same coordinator under different URLs, for example 
with different host names.", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", "jav [...]
     { "name": "camel.lra.coordinatorContextPath", "required": false, 
"description": "The context-path for the LRA coordinator. Is default 
\/lra-coordinator", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "string", 
"javaType": "java.lang.String", "defaultValue": "\/lra-coordinator", "secret": 
false },
     { "name": "camel.lra.coordinatorUrl", "required": false, "description": 
"The URL for the LRA coordinator service that orchestrates the transactions", 
"sourceType": "org.apache.camel.main.LraConfigurationProperties", "type": 
"string", "javaType": "java.lang.String", "secret": false },
     { "name": "camel.lra.enabled", "required": false, "description": "To 
enable Saga LRA", "sourceType": 
"org.apache.camel.main.LraConfigurationProperties", "type": "boolean", 
"javaType": "boolean", "defaultValue": false, "secret": false },
diff --git a/core/camel-main/src/main/docs/main.adoc 
b/core/camel-main/src/main/docs/main.adoc
index 43ca85abfd57..2ed417ded36c 100644
--- a/core/camel-main/src/main/docs/main.adoc
+++ b/core/camel-main/src/main/docs/main.adoc
@@ -725,11 +725,12 @@ The camel.resilience4j supports 25 options, which are 
listed below.
 
 
 === Camel Saga EIP (Long Running Actions) configurations
-The camel.lra supports 5 options, which are listed below.
+The camel.lra supports 6 options, which are listed below.
 
 [width="100%",cols="2,5,^1,2",options="header"]
 |===
 | Name | Description | Default | Type
+| `camel.lra.allowedCoordinatorUrls` | Comma-separated URLs, including the 
context path, of further LRA coordinators whose LRAs may be joined, completed 
or compensated. By default, only an LRA hosted by the configured coordinator is 
accepted. Use it when participants reach the same coordinator under different 
URLs, for example with different host names. |  | String
 | `camel.lra.coordinatorContextPath` | The context-path for the LRA 
coordinator. Is default /lra-coordinator | /lra-coordinator | String
 | `camel.lra.coordinatorUrl` | The URL for the LRA coordinator service that 
orchestrates the transactions |  | String
 | `camel.lra.enabled` | To enable Saga LRA | false | boolean
diff --git 
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
 
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
index 3c91843d31c7..6ae967f1a0a8 100644
--- 
a/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
+++ 
b/core/camel-main/src/main/java/org/apache/camel/main/LraConfigurationProperties.java
@@ -36,6 +36,8 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
     private String localParticipantUrl;
     @Metadata(defaultValue = "/lra-participant")
     private String localParticipantContextPath = "/lra-participant";
+    @Metadata(label = "security")
+    private String allowedCoordinatorUrls;
 
     public LraConfigurationProperties(MainConfigurationProperties parent) {
         this.parent = parent;
@@ -109,6 +111,19 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
         this.localParticipantContextPath = localParticipantContextPath;
     }
 
+    public String getAllowedCoordinatorUrls() {
+        return allowedCoordinatorUrls;
+    }
+
+    /**
+     * Comma-separated URLs, including the context path, of further LRA 
coordinators whose LRAs may be joined, completed
+     * or compensated. By default, only an LRA hosted by the configured 
coordinator is accepted. Use it when
+     * participants reach the same coordinator under different URLs, for 
example with different host names.
+     */
+    public void setAllowedCoordinatorUrls(String allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+    }
+
     /**
      * The URL for the LRA coordinator service that orchestrates the 
transactions
      */
@@ -149,4 +164,13 @@ public class LraConfigurationProperties implements 
BootstrapCloseable {
         return this;
     }
 
+    /**
+     * Comma-separated URLs, including the context path, of further LRA 
coordinators whose LRAs may be joined, completed
+     * or compensated.
+     */
+    public LraConfigurationProperties withAllowedCoordinatorUrls(String 
allowedCoordinatorUrls) {
+        this.allowedCoordinatorUrls = allowedCoordinatorUrls;
+        return this;
+    }
+
 }

Reply via email to