diff --git a/geode-docs/security/implementing_authorization.html.md.erb b/geode-docs/security/implementing_authorization.html.md.erb index 37dcb917394c..d30712d29370 100644 --- a/geode-docs/security/implementing_authorization.html.md.erb +++ b/geode-docs/security/implementing_authorization.html.md.erb @@ -154,7 +154,7 @@ This table classifies the permissions assigned for `gfsh` operations. | execute function | Defaults to DATA:WRITE. Override `Function.getRequiredPermissions` to change the permission. | | export cluster-configuration | CLUSTER:READ | | export config | CLUSTER:READ | -| export data | CLUSTER:READ | +| export data | DATA:READ:RegionName and CLUSTER:WRITE | | export logs | CLUSTER:READ | | export offline-disk-store | CLUSTER:READ | | export stack-traces | CLUSTER:READ | diff --git a/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb b/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb index 0fc1a76be7ad..4c360119ef7f 100644 --- a/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb +++ b/geode-docs/tools_modules/gfsh/command-pages/export.html.md.erb @@ -165,6 +165,22 @@ In this scenario, partitioned region data is exported simultaneously on all host | ‑‑dir | Directory to which the exported data is to be written. Required if ‑‑parallel is true. Cannot be specified at the same time as ‑‑file.| | ‑‑parallel | Export local data on each node to a directory on that machine. Available for partitioned regions only. | +**Export locations:** + +The snapshot is written by the member named in `--member`, on that member's host. A member writes +exports into its own working directory (and sub-directories of it). To export somewhere else, such +as a mounted backup location, set the `gemfire.export.data.dirs` system property on the member to +the additional directories, separated by the platform's path separator: + +``` pre +-Dgemfire.export.data.dirs=/mnt/backup/geode:/var/exports/geode +``` + +A path containing a `..` segment is not accepted, and a path that resolves outside the configured +directories is rejected by the member. + +**Required permission:** `DATA:READ` on the exported region, plus `CLUSTER:WRITE`. + **Example Commands:** ``` pre diff --git a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java index 80082f15f167..86e800d56058 100644 --- a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java +++ b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ExportDataIntegrationTest.java @@ -17,6 +17,7 @@ package org.apache.geode.management.internal.cli.commands; import static org.apache.geode.cache.Region.SEPARATOR; +import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertFalse; @@ -31,6 +32,7 @@ import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; +import org.junit.contrib.java.lang.system.RestoreSystemProperties; import org.junit.rules.TemporaryFolder; import org.apache.geode.DataSerializable; @@ -58,6 +60,9 @@ public class ExportDataIntegrationTest { @Rule public TemporaryFolder tempDir = new TemporaryFolder(); + @Rule + public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties(); + private Region region; private Path snapshotFile; private Path snapshotDir; @@ -87,6 +92,8 @@ public void setup() throws Exception { region = server.getCache().getRegion(TEST_REGION_NAME); loadRegion("value"); Path basePath = tempDir.getRoot().toPath(); + // configure the test's temporary folder as an export destination + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString()); snapshotFile = basePath.resolve(SNAPSHOT_FILE); snapshotDir = basePath.resolve(SNAPSHOT_DIR); } diff --git a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java index 63fb1461bf4c..2317ce847c91 100644 --- a/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java +++ b/geode-gfsh/src/integrationTest/java/org/apache/geode/management/internal/cli/commands/ImportDataIntegrationTest.java @@ -17,6 +17,7 @@ package org.apache.geode.management.internal.cli.commands; import static org.apache.geode.cache.Region.SEPARATOR; +import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; @@ -30,6 +31,7 @@ import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; +import org.junit.contrib.java.lang.system.RestoreSystemProperties; import org.junit.rules.TemporaryFolder; import org.apache.geode.cache.Region; @@ -55,6 +57,9 @@ public class ImportDataIntegrationTest { @Rule public TemporaryFolder tempDir = new TemporaryFolder(); + @Rule + public RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties(); + private Region region; private Path snapshotFile; private Path snapshotDir; @@ -65,6 +70,8 @@ public void setup() throws Exception { region = server.getCache().getRegion(TEST_REGION_NAME); loadRegion("value"); Path basePath = tempDir.getRoot().toPath(); + // configure the test's temporary folder as an export destination + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, basePath.toString()); snapshotFile = basePath.resolve(SNAPSHOT_FILE); snapshotDir = basePath.resolve(SNAPSHOT_DIR); } diff --git a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java index 9892ceef5f3d..385d2c3e5242 100644 --- a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java +++ b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/commands/ExportDataCommand.java @@ -16,6 +16,8 @@ package org.apache.geode.management.internal.cli.commands; import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.Optional; @@ -33,6 +35,7 @@ import org.apache.geode.management.internal.cli.result.model.ResultModel; import org.apache.geode.management.internal.functions.CliFunctionResult; import org.apache.geode.management.internal.i18n.CliStrings; +import org.apache.geode.security.ResourcePermission; import org.apache.geode.security.ResourcePermission.Operation; import org.apache.geode.security.ResourcePermission.Resource; @@ -54,6 +57,7 @@ public ResultModel exportData( help = CliStrings.EXPORT_DATA__PARALLEL_HELP) boolean parallel) { authorize(Resource.DATA, Operation.READ, regionName); + authorize(Resource.CLUSTER, Operation.WRITE, ResourcePermission.ALL); final DistributedMember targetMember = getMember(memberNameOrId); Optional validationResult = validatePath(filePath, dirPath, parallel); @@ -100,6 +104,28 @@ private Optional validatePath(String filePath, String dirPath, bool return Optional.of(ResultModel.createError(CliStrings.format( CliStrings.INVALID_FILE_EXTENSION, CliStrings.GEODE_DATA_FILE_EXTENSION))); } + + if (filePath != null && containsParentDirectorySegment(filePath)) { + return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__FILE, filePath)); + } + if (dirPath != null && containsParentDirectorySegment(dirPath)) { + return Optional.of(invalidPathError(CliStrings.EXPORT_DATA__DIR, dirPath)); + } + return Optional.empty(); } + + private static boolean containsParentDirectorySegment(String path) { + for (Path element : Paths.get(path)) { + if ("..".equals(element.toString())) { + return true; + } + } + return false; + } + + private static ResultModel invalidPathError(String option, String path) { + return ResultModel.createError(String.format( + "Option \"%s\" must not contain a \"..\" path segment: %s", option, path)); + } } diff --git a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java index 0c83d40a8ae6..2f0a18174721 100644 --- a/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java +++ b/geode-gfsh/src/main/java/org/apache/geode/management/internal/cli/functions/ExportDataFunction.java @@ -15,6 +15,9 @@ package org.apache.geode.management.internal.cli.functions; import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import org.apache.geode.cache.Cache; import org.apache.geode.cache.Region; @@ -27,12 +30,15 @@ import org.apache.geode.management.cli.CliFunction; import org.apache.geode.management.internal.functions.CliFunctionResult; import org.apache.geode.management.internal.i18n.CliStrings; +import org.apache.geode.util.internal.GeodeGlossary; /*** * Function which carries out the export of a region to a file on a member. Uses the * RegionSnapshotService to export the data * - * + *

+ * Export destinations are resolved to their canonical form and must be within the export + * directories configured for this member. */ public class ExportDataFunction extends CliFunction { private static final long serialVersionUID = 1L; @@ -40,6 +46,18 @@ public class ExportDataFunction extends CliFunction { private static final String ID = "org.apache.geode.management.internal.cli.functions.ExportDataFunction"; + /** + * System property naming additional directories this member writes {@code export data} snapshots + * into. Several directories may be listed, separated by {@link File#pathSeparator}. Exports into + * sub-directories of a configured directory are included. + * + *

+ * The member's working directory is always configured, since that is where a relative export + * path resolves to, so when this property is not set it is the only export destination. + */ + public static final String EXPORT_DATA_DIRS_PROPERTY = + GeodeGlossary.GEMFIRE_PREFIX + "export.data.dirs"; + @Override public String getId() { return ID; @@ -62,7 +80,7 @@ public CliFunctionResult executeFunction(FunctionContext context) thro String hostName = cache.getDistributedSystem().getDistributedMember().getHost(); if (region != null) { RegionSnapshotService snapshotService = region.getSnapshotService(); - final File exportFile = new File(fileName); + final File exportFile = resolveExportFile(fileName); if (parallel) { SnapshotOptions options = new SnapshotOptionsImpl<>().setParallelMode(true); snapshotService.save(exportFile, SnapshotFormat.GEODE, options); @@ -81,4 +99,42 @@ public CliFunctionResult executeFunction(FunctionContext context) thro return result; } + + /** + * Resolves the requested export path against the export directories configured for this member. + * + * @param fileName the path requested by the caller, which may be relative or absolute + * @return the canonical file to export to + * @throws IllegalArgumentException if the path is not within a configured export directory + */ + static File resolveExportFile(String fileName) throws IOException { + File exportFile = new File(fileName).getCanonicalFile(); + List exportDirs = configuredExportDirs(); + + for (File exportDir : exportDirs) { + if (exportFile.toPath().startsWith(exportDir.toPath())) { + return exportFile; + } + } + + throw new IllegalArgumentException(String.format( + "Cannot export to %s: the path is not within the export directories configured for this member (%s). Use the %s system property to configure additional directories.", + exportFile, exportDirs, EXPORT_DATA_DIRS_PROPERTY)); + } + + private static List configuredExportDirs() throws IOException { + List exportDirs = new ArrayList<>(); + exportDirs.add(new File(System.getProperty("user.dir")).getCanonicalFile()); + + String configuredDirs = System.getProperty(EXPORT_DATA_DIRS_PROPERTY); + if (configuredDirs != null) { + for (String configuredDir : configuredDirs.split(File.pathSeparator)) { + if (!configuredDir.trim().isEmpty()) { + exportDirs.add(new File(configuredDir.trim()).getCanonicalFile()); + } + } + } + + return exportDirs; + } } diff --git a/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandTest.java b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandTest.java index dd9a1f1e1370..61e8b655499e 100644 --- a/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandTest.java +++ b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/commands/ExportDataCommandTest.java @@ -15,10 +15,18 @@ package org.apache.geode.management.internal.cli.commands; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; +import org.apache.geode.distributed.DistributedMember; import org.apache.geode.test.junit.rules.GfshParserRule; @@ -34,6 +42,14 @@ public void setUp() { command = new ExportDataCommand(); } + /** A command whose option values are checked without contacting a member. */ + private ExportDataCommand commandWithMember() { + ExportDataCommand withMember = spy(ExportDataCommand.class); + doNothing().when(withMember).authorize(any(), any(), anyString()); + doReturn(mock(DistributedMember.class)).when(withMember).getMember(anyString()); + return withMember; + } + @Test public void missingMember() throws Exception { // Command parses successfully but fails during execution because cache is null @@ -41,4 +57,20 @@ public void missingMember() throws Exception { .statusIsError() .containsOutput("cache"); } + + @Test + public void fileOptionWithParentDirectorySegmentIsRejected() { + gfsh.executeAndAssertThat(commandWithMember(), + "export data --member=server1 --region=regionA --file=exports/../regionA.gfd") + .statusIsError() + .containsOutput("must not contain a \"..\" path segment"); + } + + @Test + public void dirOptionWithParentDirectorySegmentIsRejected() { + gfsh.executeAndAssertThat(commandWithMember(), + "export data --member=server1 --region=regionA --dir=exports/../elsewhere") + .statusIsError() + .containsOutput("must not contain a \"..\" path segment"); + } } diff --git a/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataDirectoryConfigTest.java b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataDirectoryConfigTest.java new file mode 100644 index 000000000000..29e790a4f452 --- /dev/null +++ b/geode-gfsh/src/test/java/org/apache/geode/management/internal/cli/functions/ExportDataDirectoryConfigTest.java @@ -0,0 +1,169 @@ +/* + * 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.geode.management.internal.cli.functions; + +import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests the export directories configured for a member, and which destinations resolve within + * them. + */ +public class ExportDataDirectoryConfigTest { + + private static final String SNAPSHOT = "testRegion.gfd"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private String originalProperty; + private Path configuredDir; + private Path otherDir; + + @Before + public void before() throws Exception { + originalProperty = System.getProperty(EXPORT_DATA_DIRS_PROPERTY); + configuredDir = temporaryFolder.newFolder("exports").toPath().toRealPath(); + otherDir = temporaryFolder.newFolder("elsewhere").toPath().toRealPath(); + configure(configuredDir); + } + + @After + public void after() { + if (originalProperty == null) { + System.clearProperty(EXPORT_DATA_DIRS_PROPERTY); + } else { + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, originalProperty); + } + } + + private void configure(Path... dirs) { + StringBuilder value = new StringBuilder(); + for (Path dir : dirs) { + if (value.length() > 0) { + value.append(File.pathSeparator); + } + value.append(dir); + } + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, value.toString()); + } + + @Test + public void exportIntoConfiguredDirectorySucceeds() throws Exception { + Path destination = configuredDir.resolve(SNAPSHOT); + + File resolved = ExportDataFunction.resolveExportFile(destination.toString()); + + assertThat(resolved.toPath()).isEqualTo(destination); + } + + @Test + public void exportIntoSubdirectoryOfConfiguredDirectorySucceeds() throws Exception { + Path destination = configuredDir.resolve("daily").resolve(SNAPSHOT); + + File resolved = ExportDataFunction.resolveExportFile(destination.toString()); + + assertThat(resolved.toPath()).isEqualTo(destination); + } + + @Test + public void exportOutsideConfiguredDirectoriesIsRejected() { + Path destination = otherDir.resolve(SNAPSHOT); + + assertThatThrownBy(() -> ExportDataFunction.resolveExportFile(destination.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not within the export directories configured") + .hasMessageContaining(EXPORT_DATA_DIRS_PROPERTY); + } + + @Test + public void directoryWithMatchingNamePrefixIsNotIncluded() throws Exception { + Path sibling = temporaryFolder.newFolder("exports-archive").toPath().toRealPath(); + Path destination = sibling.resolve(SNAPSHOT); + + assertThatThrownBy(() -> ExportDataFunction.resolveExportFile(destination.toString())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void severalDirectoriesCanBeConfigured() throws Exception { + configure(configuredDir, otherDir); + + assertThat(ExportDataFunction.resolveExportFile(configuredDir.resolve(SNAPSHOT).toString())) + .isNotNull(); + assertThat(ExportDataFunction.resolveExportFile(otherDir.resolve(SNAPSHOT).toString())) + .isNotNull(); + } + + @Test + public void emptyEntriesInThePropertyAreIgnored() throws Exception { + System.setProperty(EXPORT_DATA_DIRS_PROPERTY, + File.pathSeparator + configuredDir + File.pathSeparator + File.pathSeparator); + + File resolved = + ExportDataFunction.resolveExportFile(configuredDir.resolve(SNAPSHOT).toString()); + + assertThat(resolved.toPath()).isEqualTo(configuredDir.resolve(SNAPSHOT)); + } + + @Test + public void workingDirectoryIsUsedWhenThePropertyIsNotSet() throws Exception { + System.clearProperty(EXPORT_DATA_DIRS_PROPERTY); + Path workingDir = new File(System.getProperty("user.dir")).getCanonicalFile().toPath(); + + File resolved = ExportDataFunction.resolveExportFile(workingDir.resolve(SNAPSHOT).toString()); + + assertThat(resolved.toPath()).isEqualTo(workingDir.resolve(SNAPSHOT)); + assertThatThrownBy(() -> ExportDataFunction.resolveExportFile(otherDir.resolve(SNAPSHOT) + .toString())).isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void relativePathResolvesInsideTheWorkingDirectory() throws Exception { + System.clearProperty(EXPORT_DATA_DIRS_PROPERTY); + Path workingDir = new File(System.getProperty("user.dir")).getCanonicalFile().toPath(); + + File resolved = ExportDataFunction.resolveExportFile(SNAPSHOT); + + assertThat(resolved.toPath()).isEqualTo(workingDir.resolve(SNAPSHOT)); + } + + @Test + public void linkedDirectoryResolvesToItsTarget() throws Exception { + Path link = configuredDir.resolve("archive"); + try { + Files.createSymbolicLink(link, otherDir); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException("filesystem does not support links", e); + } + + assertThatThrownBy(() -> ExportDataFunction.resolveExportFile(link.resolve(SNAPSHOT) + .toString())).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java b/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java index ebbd8c950c43..0199985546af 100644 --- a/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java +++ b/geode-junit/src/main/java/org/apache/geode/management/internal/security/TestCommand.java @@ -135,7 +135,7 @@ private static void init() { // Data Commands createTestCommand("rebalance --include-region=RegionA", ResourcePermissions.DATA_MANAGE); createTestCommand("export data --region=RegionA --file=export.txt --member=exportMember", - regionARead); + regionARead, ResourcePermissions.CLUSTER_WRITE); createTestCommand("import data --region=RegionA --file=import.txt --member=importMember", regionAWrite); createTestCommand("put --key=key1 --value=value1 --region=RegionA", regionAWrite);