This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch UNOMI-973-tenant-file-endpoints
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to
refs/heads/UNOMI-973-tenant-file-endpoints by this push:
new 6a012cd67 UNOMI-973: Refuse Camel property placeholders in file
endpoints
6a012cd67 is described below
commit 6a012cd6708febb2b91b583b4adc1aff2d438b9e
Author: Serge Huber <[email protected]>
AuthorDate: Sun Sep 27 11:15:56 2026 +0200
UNOMI-973: Refuse Camel property placeholders in file endpoints
Camel expands {{...}} placeholders over the whole endpoint URI before it
parses the endpoint, and the built-in env: and sys: functions accept an
inline default that is used verbatim when the variable is not set. So
file:///{baseDir}/{tenantId}/{{env:UNSET:../other}} passed containment
(the first parent segment is fused to the token and never seen by the
path walk) while Camel opened {baseDir}/other. EndpointValidator now
refuses any endpoint that carries the placeholder token, whatever its
scheme, and the manual says so.
Review follow-ups in the same change:
- the untenanted recordEndpointOutcome overload is private, so a route
builder cannot write the mark outside the configuration's tenant, and
the status a mark overwrites is logged since it is not restored;
- each route build logs one summary line when configurations were
refused, so a mass refusal after an upgrade is visible without reading
every line;
- the 3.0-to-3.1 migration guide explains the new {baseDir}/{tenantId}
layout and what to do with existing configurations;
- Javadoc on the new public setters, and the RAW{...} and '+' notes in
EndpointValidator now describe Camel 2.23.1 accurately;
- tests for placeholders, $simple{...}, a tenant id that is a path, a
non-system caller naming another tenant, a system caller naming one,
and the mark being written as the configuration's own tenant.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
.../apache/unomi/router/api/EndpointValidator.java | 26 +++++++-
.../router/core/context/RouterCamelContext.java | 10 +++
.../route/ProfileExportCollectRouteBuilder.java | 12 ++++
.../route/ProfileImportFromSourceRouteBuilder.java | 7 +++
.../core/route/RouterAbstractRouteBuilder.java | 12 +++-
.../core/route/FileEndpointContainmentTest.java | 73 ++++++++++++++++++++++
.../core/route/RefusedConfigurationStatusTest.java | 58 +++++++++++++++++
.../rest/ExportConfigurationServiceEndPoint.java | 10 +++
.../rest/ConfigurationEndpointValidationTest.java | 53 ++++++++++++++++
.../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 17 +++++
.../src/main/asciidoc/profile-import-export.adoc | 3 +-
11 files changed, 274 insertions(+), 7 deletions(-)
diff --git
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
index 04ef984e7..10ecf0cbb 100644
---
a/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
+++
b/extensions/router/router-api/src/main/java/org/apache/unomi/router/api/EndpointValidator.java
@@ -52,6 +52,13 @@ import java.util.regex.Pattern;
* instead of being cut in two, and the File Language expressions a
path-bearing option may carry are
* accounted for rather than taken literally.
*
+ * <p>One thing Camel does to a URI cannot be accounted for. A property
placeholder ({@code {{...}}})
+ * is expanded over the whole URI before the endpoint is parsed, and the
built-in {@code env:} and
+ * {@code sys:} functions take an inline default that is used verbatim when
the variable is not set:
+ * {@code {{env:UNSET:../elsewhere}}} is a parent segment that no path walk
sees, since the
+ * {@code ..} is fused to the token it sits in. An endpoint that carries the
placeholder token is
+ * therefore refused, whatever its scheme.
+ *
* <p>A path the file system cannot make sense of, and a path whose existing
part cannot be resolved,
* are both refusals: nothing is thrown out of this class, because one
malformed endpoint must not
* cost a deployment the routes of every other configuration.
@@ -113,6 +120,12 @@ public final class EndpointValidator {
/** Stands in for a token that expands to a name: one path component,
never a parent segment. */
private static final String NAME_PLACEHOLDER = "_";
+ /**
+ * Opens a Camel property placeholder. Camel expands these over the whole
URI before it parses the
+ * endpoint, so a placeholder can rewrite any part of a URI after this
class has looked at it.
+ */
+ private static final String PROPERTY_PLACEHOLDER_TOKEN = "{{";
+
/**
* A tenant id used as a directory name. The same shape the one-shot
import accepts, so a tenant
* cannot be {@code ..} or a path of its own and leave the directory this
class confines it to.
@@ -149,6 +162,10 @@ public final class EndpointValidator {
if (!containsIgnoreCase(split(allowedSchemes), scheme)) {
return "endpoint scheme '" + scheme + "' is not allowed";
}
+ if (endpointUri.contains(PROPERTY_PLACEHOLDER_TOKEN)) {
+ return "endpoint '" + endpointUri + "' carries a property
placeholder, which Camel expands "
+ + "before the endpoint is resolved, so it cannot be
validated";
+ }
try {
String refusal = validateLocalWorkDirectory(endpointUri,
permittedBaseDirs);
@@ -391,8 +408,9 @@ public final class EndpointValidator {
}
/**
- * {@code RAW(...)} and {@code RAW{...}} tell Camel not to decode a value;
the path it wraps is used
- * as it stands.
+ * {@code RAW(...)} tells Camel not to decode a value; the path it wraps
is used as it stands.
+ * {@code RAW{...}} is the same marker in later Camel versions; the
version in use does not know
+ * it, so honouring it here is stricter than Camel, never looser, and
stays right across an upgrade.
*/
private static String stripRaw(String value) {
if (value.startsWith("RAW(") && value.endsWith(")")) {
@@ -458,7 +476,9 @@ public final class EndpointValidator {
* Decodes the percent-encoding of a URI, so that containment is decided
on the path the file system
* will see. Unlike form decoding, {@code +} is left alone: it is a valid
character in a file name.
* Characters that are not escaped keep their own encoding, so a path that
mixes an escape with a
- * non-ASCII name is not corrupted into a different path.
+ * non-ASCII name is not corrupted into a different path. Camel
additionally folds {@code +} to a
+ * space in a value that is not {@code RAW()}; that turns one name into
another within the same
+ * directory, never into a parent segment, so it is not reproduced here.
*/
private static String decode(String value) {
if (value.indexOf('%') < 0) {
diff --git
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
index fcfebb6d8..5911de36b 100644
---
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
+++
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/context/RouterCamelContext.java
@@ -490,10 +490,20 @@ public class RouterCamelContext implements
IRouterCamelContext {
this.securityService = securityService;
}
+ /**
+ * Sets the comma-separated base directories an import {@code file}
endpoint may resolve into.
+ *
+ * @param permittedImportBaseDirs the permitted import base directories
+ */
public void setPermittedImportBaseDirs(String permittedImportBaseDirs) {
this.permittedImportBaseDirs = permittedImportBaseDirs;
}
+ /**
+ * Sets the comma-separated base directories an export {@code file}
endpoint may resolve into.
+ *
+ * @param permittedExportBaseDirs the permitted export base directories
+ */
public void setPermittedExportBaseDirs(String permittedExportBaseDirs) {
this.permittedExportBaseDirs = permittedExportBaseDirs;
}
diff --git
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
index 27480bbea..f74cab573 100644
---
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
+++
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileExportCollectRouteBuilder.java
@@ -101,6 +101,7 @@ public class ProfileExportCollectRouteBuilder extends
RouterAbstractRouteBuilder
collectProfileBean.setExecutionContextManager(executionContextManager);
//Loop on multiple export configuration
+ int refused = 0;
for (final ExportConfiguration exportConfiguration :
exportConfigurationList) {
if
(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(exportConfiguration.getConfigType())
&&
exportConfiguration.getProperties() != null &&
exportConfiguration.getProperties().size() > 0) {
@@ -130,6 +131,7 @@ public class ProfileExportCollectRouteBuilder extends
RouterAbstractRouteBuilder
prDef.to((String)
getEndpointURI(RouterConstants.DIRECTION_FROM,
RouterConstants.DIRECT_EXPORT_DEPOSIT_BUFFER));
}
} else {
+ refused++;
LOGGER.error("Destination endpoint is refused ({}),
route {} will be skipped.", refusal, exportConfiguration.getItemId());
}
} else {
@@ -139,6 +141,11 @@ public class ProfileExportCollectRouteBuilder extends
RouterAbstractRouteBuilder
LOGGER.warn("Export configuration incomplete, route {} will be
skipped!", exportConfiguration.getItemId());
}
}
+ if (refused > 0) {
+ LOGGER.warn("{} of {} export configuration(s) name a destination
that is refused and run no route; each is "
+ + "marked {} and logged above. After an upgrade, check
that their files sit under "
+ + "{baseDir}/{tenantId}.", refused,
exportConfigurationList.size(), RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT);
+ }
}
/**
@@ -151,6 +158,11 @@ public class ProfileExportCollectRouteBuilder extends
RouterAbstractRouteBuilder
this.permittedBaseDirs = permittedExportBaseDirs;
}
+ /**
+ * Sets the service a refused configuration is recorded with.
+ *
+ * @param exportConfigurationService the export configuration service
+ */
public void
setExportConfigurationService(ImportExportConfigurationService<ExportConfiguration>
exportConfigurationService) {
this.exportConfigurationService = exportConfigurationService;
}
diff --git
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
index c31713256..688b43b59 100644
---
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
+++
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/ProfileImportFromSourceRouteBuilder.java
@@ -119,6 +119,7 @@ public class ProfileImportFromSourceRouteBuilder extends
RouterAbstractRouteBuil
}
//Loop on multiple import configuration
+ int refused = 0;
for (final ImportConfiguration importConfiguration :
importConfigurationList) {
if
(RouterConstants.IMPORT_EXPORT_CONFIG_TYPE_RECURRENT.equals(importConfiguration.getConfigType())
&&
importConfiguration.getProperties() != null &&
importConfiguration.getProperties().size() > 0) {
@@ -183,10 +184,16 @@ public class ProfileImportFromSourceRouteBuilder extends
RouterAbstractRouteBuil
prDef.to((String)
getEndpointURI(RouterConstants.DIRECTION_FROM,
RouterConstants.DIRECT_IMPORT_DEPOSIT_BUFFER));
}
} else {
+ refused++;
LOGGER.error("Source endpoint is refused ({}), route {}
will be skipped.", refusal, importConfiguration.getItemId());
}
}
}
+ if (refused > 0) {
+ LOGGER.warn("{} of {} import configuration(s) name a source that
is refused and run no route; each is "
+ + "marked {} and logged above. After an upgrade, check
that their files sit under "
+ + "{baseDir}/{tenantId}.", refused,
importConfigurationList.size(), RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT);
+ }
}
/**
diff --git
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java
index 125c83419..e84d3a6d7 100644
---
a/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java
+++
b/extensions/router/router-core/src/main/java/org/apache/unomi/router/core/route/RouterAbstractRouteBuilder.java
@@ -119,7 +119,8 @@ public abstract class RouterAbstractRouteBuilder extends
RouteBuilder {
*
* <p>The other way round matters just as much: restoring the permitted
directories must bring the
* configuration back on its own, without anyone having to touch it. Only
the status this method
- * sets is cleared, so the record of a run that genuinely failed survives.
+ * sets is cleared, so the record of a run that genuinely failed survives.
The status the mark
+ * replaces is not restored afterwards, so it is logged when it is
overwritten.
*
* <p>The configuration is saved without asking for its running route to
be refreshed: the refresh
* would rebuild the route, refuse it again and save it again, without end.
@@ -128,9 +129,13 @@ public abstract class RouterAbstractRouteBuilder extends
RouteBuilder {
* @param service the service holding that kind of configuration
* @param refusal the reason the endpoint was refused, or {@code
null} if it can be honoured
*/
- protected <T extends ImportExportConfiguration> void recordEndpointOutcome(
+ private <T extends ImportExportConfiguration> void recordEndpointOutcome(
T configuration, ImportExportConfigurationService<T> service,
String refusal) {
if (refusal != null) {
+ if
(!RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT.equals(configuration.getStatus()))
{
+ LOGGER.warn("Configuration {} is marked {} (its status was
{}): {}", configuration.getItemId(),
+ RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT,
configuration.getStatus(), refusal);
+ }
configuration.setStatus(RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT);
saveQuietly(configuration, service);
} else if
(RouterConstants.CONFIG_STATUS_INVALID_ENDPOINT.equals(configuration.getStatus()))
{
@@ -163,7 +168,8 @@ public abstract class RouterAbstractRouteBuilder extends
RouteBuilder {
* <p>Startup builds every tenant's routes in one pass, and the
configuration service refuses a save
* whose current tenant is not the configuration's. Writing the mark from
the wrong tenant would
* either fail or store it in another tenant's index. When no context
manager is available the mark
- * is written as it stands, which is what the unit tests do.
+ * is written as it stands, which is what the unit tests do. This is the
only entry point: writing
+ * the mark outside the configuration's tenant is not something a route
builder may do.
*/
protected <T extends ImportExportConfiguration> void recordEndpointOutcome(
T configuration, ImportExportConfigurationService<T> service,
String refusal,
diff --git
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
index cdc6a5ddc..2a71b7052 100644
---
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
+++
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/FileEndpointContainmentTest.java
@@ -638,6 +638,79 @@ public class FileEndpointContainmentTest {
assertRouteBuilt("well-formed", "one malformed configuration must not
cost the deployment its other routes");
}
+ //
---------------------------------------------------------------------------------------------
+ // Property placeholders — expanded by Camel after validation, over the
whole URI
+ //
---------------------------------------------------------------------------------------------
+
+ @Test
+ public void
importRouteIsRefusedWhenSourceDirectoryCarriesAPropertyPlaceholder() throws
Exception {
+ // Camel expands {{env:NAME:default}} before it parses the endpoint,
and uses the default
+ // verbatim when NAME is not set: what reaches the file system is
acme/../beta. To a path walk
+ // the first parent segment is fused to the token and is not a parent
segment at all.
+ addImportRoutes(recurrentImport("placeholder-directory",
+ fileUri(permittedImportDir,
"/{{env:UNOMI_UNSET_FOR_TEST:../beta}}?fileName=profiles.csv")));
+
+ assertRouteRefused("placeholder-directory",
+ "a placeholder is expanded after validation, so nothing about
the path can be trusted");
+ }
+
+ @Test
+ public void
importRouteIsRefusedWhenAPathBearingOptionCarriesAPropertyPlaceholder() throws
Exception {
+ addImportRoutes(recurrentImport("placeholder-option",
+ fileUri(permittedImportDir,
"?fileName=profiles.csv&move={{sys:unomi.unset.for.test:../../beta}}")));
+
+ assertRouteRefused("placeholder-option", "the placeholder's default is
a parent segment the path walk never sees");
+ }
+
+ @Test
+ public void
importRouteIsRefusedWhenARemoteSourceCarriesAPropertyPlaceholder() throws
Exception {
+ // starts inside the tenant's directory, so that only the placeholder
takes it out of there
+ addImportRoutes(recurrentImport("placeholder-remote",
"sftp://sftp.example.com/profiles?fileName=profiles.csv"
+ + "&localWorkDirectory=" +
permittedImportDir.getAbsolutePath() +
"/{{env:UNOMI_UNSET_FOR_TEST:../../elsewhere}}"));
+
+ assertRouteRefused("placeholder-remote", "a placeholder can put the
staging directory anywhere, whatever the scheme");
+ }
+
+ @Test
+ public void
exportRouteIsRefusedWhenDestinationCarriesAPropertyPlaceholder() throws
Exception {
+ addExportRoutes(recurrentExport("placeholder-destination",
+ fileUri(permittedExportDir,
"?fileName={{env:UNOMI_UNSET_FOR_TEST:../../beta/profiles.csv}}")));
+
+ assertRouteRefused("placeholder-destination", "an export destination
is expanded by Camel the same way");
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // The other expression syntax, and a tenant id that is a path
+ //
---------------------------------------------------------------------------------------------
+
+ @Test
+ public void
importRouteIsRefusedWhenMoveOptionUsesTheAlternateExpressionSyntaxToLeavePermittedBaseDir()
throws Exception {
+ addImportRoutes(recurrentImport("simple-syntax",
+ fileUri(permittedImportDir,
"?fileName=profiles.csv&move=$simple{file:parent}/../../elsewhere")));
+
+ assertRouteRefused("simple-syntax", "$simple{...} is the same File
Language expression as ${...}");
+ }
+
+ @Test
+ public void
importRouteIsBuiltWhenMoveOptionUsesTheAlternateExpressionSyntaxAndStaysInside()
throws Exception {
+ addImportRoutes(recurrentImport("simple-syntax-inside",
+ fileUri(permittedImportDir,
"?fileName=profiles.csv&move=.done/$simple{file:name.noext}-$simple{date:now:yyyyMMdd}.csv")));
+
+ assertRouteBuilt("simple-syntax-inside", "name and date tokens
contribute one component each, whatever the syntax");
+ }
+
+ @Test
+ public void importRouteIsRefusedWhenTheTenantIdIsNotASingleDirectoryName()
throws Exception {
+ File otherTenant = new File(importRoot, "beta");
+ assertTrue(otherTenant.mkdirs());
+ ImportConfiguration configuration =
recurrentImport("traversing-tenant", fileUri(otherTenant,
"?fileName=profiles.csv"));
+ configuration.setTenantId("acme/../beta");
+
+ addImportRoutes(configuration);
+
+ assertRouteRefused("traversing-tenant", "a tenant id that is a path of
its own is not confined to a directory");
+ }
+
//
---------------------------------------------------------------------------------------------
// Fixtures
//
---------------------------------------------------------------------------------------------
diff --git
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java
index e6a24b59c..2df5c1cf7 100644
---
a/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java
+++
b/extensions/router/router-core/src/test/java/org/apache/unomi/router/core/route/RefusedConfigurationStatusTest.java
@@ -18,6 +18,7 @@ package org.apache.unomi.router.core.route;
import org.apache.camel.component.jackson.JacksonDataFormat;
import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.router.api.ExportConfiguration;
import org.apache.unomi.router.api.ImportConfiguration;
@@ -38,6 +39,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.Supplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -81,6 +83,9 @@ public class RefusedConfigurationStatusTest {
private RecordingConfigurationService<ImportConfiguration>
importConfigurations;
private RecordingConfigurationService<ExportConfiguration>
exportConfigurations;
+ /** The tenant the fake context manager is currently running as, or {@code
null} outside any. */
+ private String currentTenant;
+
@Before
public void setUp() throws Exception {
importRoot = tmp.newFolder("permitted-import");
@@ -126,6 +131,22 @@ public class RefusedConfigurationStatusTest {
exportConfigurations.contains("out-of-bounds"));
}
+ @Test
+ public void aRefusedConfigurationIsRecordedAsItsOwnTenant() throws
Exception {
+ // Start-up builds every tenant's routes in one pass; the store
refuses a save whose current
+ // tenant is not the configuration's, so the mark has to be written as
that tenant.
+ ImportConfiguration configuration =
recurrentImport(fileUri(arbitraryDir, "?fileName=profiles.csv"));
+ configuration.setTenantId("beta");
+ importConfigurations.currentTenant = () -> currentTenant;
+
+ addImportRoutes(recordingContextManager(), configuration);
+
+ assertTrue("the refusal should have been recorded",
importConfigurations.contains("out-of-bounds"));
+ assertEquals("the mark must be written as the configuration's tenant,
whatever the batch runs as",
+ "beta", importConfigurations.tenantAtLastSave);
+ assertNull("the batch's own context must be restored afterwards",
currentTenant);
+ }
+
@Test
public void
aStoreThatCannotRecordTheRefusalDoesNotCostTheBatchItsOtherRoutes() throws
Exception {
importConfigurations.unwritable = true;
@@ -252,6 +273,10 @@ public class RefusedConfigurationStatusTest {
}
private void addImportRoutes(ImportConfiguration... configurations) throws
Exception {
+ addImportRoutes(null, configurations);
+ }
+
+ private void addImportRoutes(ExecutionContextManager contextManager,
ImportConfiguration... configurations) throws Exception {
ProfileImportFromSourceRouteBuilder builder =
new ProfileImportFromSourceRouteBuilder(NO_KAFKA,
RouterConstants.CONFIG_TYPE_NOBROKER);
builder.setImportConfigurationList(java.util.Arrays.asList(configurations));
@@ -260,10 +285,37 @@ public class RefusedConfigurationStatusTest {
builder.setJacksonDataFormat(new
JacksonDataFormat(ProfileToImport.class));
builder.setAllowedEndpoints(DEFAULT_ALLOWED_ENDPOINTS);
builder.setPermittedImportBaseDirs(importRoot.getAbsolutePath());
+ builder.setExecutionContextManager(contextManager);
builder.setContext(camelContext);
camelContext.addRoutes(builder);
}
+ /**
+ * Runs an operation as the tenant it is asked to, and remembers which one
that is for as long as
+ * the operation lasts, the way the real manager swaps the thread's
context.
+ */
+ private ExecutionContextManager recordingContextManager() {
+ return (ExecutionContextManager) Proxy.newProxyInstance(
+ ExecutionContextManager.class.getClassLoader(),
+ new Class<?>[]{ExecutionContextManager.class},
+ (proxy, method, args) -> {
+ if (!"executeAsTenant".equals(method.getName())) {
+ return null;
+ }
+ String previous = currentTenant;
+ currentTenant = (String) args[0];
+ try {
+ if (args[1] instanceof Runnable) {
+ ((Runnable) args[1]).run();
+ return null;
+ }
+ return ((Supplier<?>) args[1]).get();
+ } finally {
+ currentTenant = previous;
+ }
+ });
+ }
+
private void addExportRoutes(ExportConfiguration... configurations) throws
Exception {
ProfileExportCollectRouteBuilder builder =
new ProfileExportCollectRouteBuilder(NO_KAFKA,
RouterConstants.CONFIG_TYPE_NOBROKER);
@@ -294,6 +346,11 @@ public class RefusedConfigurationStatusTest {
private boolean lastSaveAskedForARouteRefresh;
+ /** Where the store reads the current tenant from; the real one
refuses a save from another. */
+ private Supplier<String> currentTenant = () -> null;
+
+ private String tenantAtLastSave;
+
/** Stands in for a store that cannot be written to -- Elasticsearch
unreachable at start-up. */
private boolean unwritable;
@@ -317,6 +374,7 @@ public class RefusedConfigurationStatusTest {
throw new IllegalStateException("the store is unreachable");
}
lastSaveAskedForARouteRefresh = updateRunningRoute;
+ tenantAtLastSave = currentTenant.get();
stored.put(itemIdOf(configuration), configuration);
return configuration;
}
diff --git
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
index 01e909fba..18326a720 100644
---
a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
+++
b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
@@ -73,11 +73,21 @@ public class ExportConfigurationServiceEndPoint extends
AbstractConfigurationSer
configurationService = exportConfigurationService;
}
+ /**
+ * Sets the service the router publishes its allowed schemes and base
directories through.
+ *
+ * @param configSharingService the configuration sharing service
+ */
@Reference
public void setConfigSharingService(ConfigSharingService
configSharingService) {
this.configSharingService = configSharingService;
}
+ /**
+ * Sets the manager that tells which tenant, or the system, is calling.
+ *
+ * @param executionContextManager the execution context manager
+ */
@Reference
public void setExecutionContextManager(ExecutionContextManager
executionContextManager) {
this.executionContextManager = executionContextManager;
diff --git
a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
index 812bbb1c2..bcdbc66d0 100644
---
a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
+++
b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/ConfigurationEndpointValidationTest.java
@@ -220,6 +220,59 @@ public class ConfigurationEndpointValidationTest {
assertTrue(importConfigurations.contains("in-bounds"));
}
+ @Test
+ public void
savingARecurrentImportIsJudgedAgainstTheCallerTenantWhateverTenantTheBodyNames()
{
+ ExecutionContextManager contexts = mock(ExecutionContextManager.class);
+ when(contexts.getCurrentContext()).thenReturn(new
ExecutionContext(TENANT, null, null));
+ importEndpoint.setExecutionContextManager(contexts);
+ File otherTenant = new File(importRoot, "beta");
+ assertTrue(otherTenant.mkdirs());
+
+ ImportConfiguration configuration =
recurrentImport(fileUri(otherTenant, "?fileName=profiles.csv"));
+ configuration.setTenantId("beta");
+
+ assertRefused(() -> importEndpoint.saveConfiguration(configuration));
+ assertFalse("acme may not have a configuration judged against beta's
directory by naming beta",
+ importConfigurations.contains("in-bounds"));
+ }
+
+ @Test
+ public void
savingARecurrentImportAsTheSystemHonoursTheTenantTheBodyNames() {
+ ExecutionContextManager contexts = mock(ExecutionContextManager.class);
+ when(contexts.getCurrentContext()).thenReturn(new
ExecutionContext(ExecutionContext.SYSTEM_TENANT, null, null));
+ importEndpoint.setExecutionContextManager(contexts);
+ File otherTenant = new File(importRoot, "beta");
+ assertTrue(otherTenant.mkdirs());
+
+ ImportConfiguration configuration =
recurrentImport(fileUri(otherTenant, "?fileName=profiles.csv"));
+ configuration.setTenantId("beta");
+
+ importEndpoint.saveConfiguration(configuration);
+
+ assertTrue("the system may configure any tenant, judged against that
tenant's directory",
+ importConfigurations.contains("in-bounds"));
+ }
+
+ @Test
+ public void savingARecurrentImportWhoseTenantIdIsAPathIsRefused() {
+ File otherTenant = new File(importRoot, "beta");
+ assertTrue(otherTenant.mkdirs());
+ ImportConfiguration configuration =
recurrentImport(fileUri(otherTenant, "?fileName=profiles.csv"));
+ configuration.setTenantId("acme/../beta");
+
+ assertRefused(() -> importEndpoint.saveConfiguration(configuration));
+ assertFalse("a tenant id that is a path must not be resolved against
the shared base directory",
+ importConfigurations.contains("in-bounds"));
+ }
+
+ @Test
+ public void
savingARecurrentImportWhoseSourceCarriesAPropertyPlaceholderIsRefused() {
+ assertRefused(() -> importEndpoint.saveConfiguration(
+ recurrentImport(fileUri(permittedImportDir,
"/{{env:UNOMI_UNSET_FOR_TEST:../beta}}?fileName=profiles.csv"))));
+ assertFalse("Camel expands a placeholder after validation, so the
configuration cannot be stored",
+ importConfigurations.contains("in-bounds"));
+ }
+
@Test
public void savingAOneshotImportThatCarriesNoSourceStoresIt() {
ImportConfiguration configuration = new ImportConfiguration();
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index ad78c4149..5ab6d1d6d 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -276,6 +276,22 @@ When migrating to 3.1, you need to understand that:
- Each tenant operates in complete isolation with their own data space
- Tenant context must be established for every API operation
+==== Recurrent import and export files move under a tenant directory
+
+A recurrent import source or export destination that uses the `file` scheme
must now resolve inside the
+deployment's base directory for that direction
(`org.apache.unomi.router.config.import.baseDir` and
+`org.apache.unomi.router.config.export.baseDir`, defaulting to
`${karaf.data}/router/import/` and
+`${karaf.data}/router/export/`), under the subdirectory named after the
configuration's tenant
+(`{baseDir}/{tenantId}`). This applies to the endpoint directory, to its
path-bearing options (`fileName`,
+`move`, `moveFailed`, ...) and to `localWorkDirectory` on `ftp`, `sftp` and
`ftps` endpoints.
+
+Existing configurations are not migrated. After the upgrade, a configuration
whose endpoint resolves anywhere
+else builds no route and is marked `INVALID_ENDPOINT`; one error line is
logged per configuration, followed
+by a summary count at the end of each route build. To keep such a
configuration running, either move its
+files under `{baseDir}/{tenantId}` and update its `source` or `destination`
accordingly, or point the two
+base-directory properties at the directories already in use. The mark clears
on its own once the endpoint is
+accepted again. The "Profile import and export" chapter describes the rule in
full.
+
==== Migration Steps
1. **Understand Multi-Tenancy Impact**
@@ -394,6 +410,7 @@ Before starting the migration, please ensure that:
- You have a plan to update client applications to tenant API keys (or
temporary <<_v2_compatibility_mode,single-tenant compatibility mode>> only if
coming from 2.x)
- You have reviewed the <<_client_facing_hardening_3_1,client-facing
hardening>> notes (cookie-only public profile binding, HttpOnly default,
required passwords, privileged REST roles)
- You know how to obtain plaintext API keys after upgrade (regenerate via
`/cxs/tenants/{id}/apikeys`; create responses expose masked keys only)
+- Your recurrent import and export `file` endpoints resolve under
`{baseDir}/{tenantId}`, or the router's base-directory properties point at the
directories already in use (see above)
=== Migration Process
diff --git a/manual/src/main/asciidoc/profile-import-export.adoc
b/manual/src/main/asciidoc/profile-import-export.adoc
index d97f2f7a1..cdb92cb89 100644
--- a/manual/src/main/asciidoc/profile-import-export.adoc
+++ b/manual/src/main/asciidoc/profile-import-export.adoc
@@ -242,7 +242,8 @@ scheme is refused unless it resolves inside one of these
base directories, under
tenant (`{baseDir}/{tenantId}`), at any depth, and so is any of its options
that names a path (`fileName`, `move`,
`moveFailed`, ...). The same applies to `localWorkDirectory`, whatever the
scheme, since that one names a local
directory even on a remote endpoint. Each property accepts a comma-separated
list. A tenant cannot point a file
-endpoint at another tenant's directory.
+endpoint at another tenant's directory. An endpoint that carries a Camel
property placeholder (`{{...}}`) is refused
+whatever its scheme, since Camel expands it after the endpoint has been
validated.
#Base directories a file endpoint may resolve into
org.apache.unomi.router.config.import.baseDir=${karaf.data}/router/import/