|
| 1 | +/* |
| 2 | + * Licensed to the Apache Software Foundation (ASF) under one or more contributor license |
| 3 | + * agreements. See the NOTICE file distributed with this work for additional information regarding |
| 4 | + * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the |
| 5 | + * "License"); you may not use this file except in compliance with the License. You may obtain a |
| 6 | + * copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software distributed under the License |
| 11 | + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express |
| 12 | + * or implied. See the License for the specific language governing permissions and limitations under |
| 13 | + * the License. |
| 14 | + */ |
| 15 | + |
| 16 | +package org.apache.geode.management.internal.cli.commands; |
| 17 | + |
| 18 | +import static org.apache.geode.distributed.ConfigurationProperties.SECURITY_MANAGER; |
| 19 | +import static org.apache.geode.management.internal.cli.functions.ExportDataFunction.EXPORT_DATA_DIRS_PROPERTY; |
| 20 | +import static org.assertj.core.api.Assertions.assertThat; |
| 21 | + |
| 22 | +import java.io.Serializable; |
| 23 | +import java.nio.charset.StandardCharsets; |
| 24 | +import java.nio.file.Files; |
| 25 | +import java.nio.file.Path; |
| 26 | +import java.util.Properties; |
| 27 | + |
| 28 | +import org.junit.After; |
| 29 | +import org.junit.Before; |
| 30 | +import org.junit.BeforeClass; |
| 31 | +import org.junit.ClassRule; |
| 32 | +import org.junit.Rule; |
| 33 | +import org.junit.Test; |
| 34 | +import org.junit.experimental.categories.Category; |
| 35 | +import org.junit.rules.TemporaryFolder; |
| 36 | + |
| 37 | +import org.apache.geode.cache.RegionShortcut; |
| 38 | +import org.apache.geode.examples.SimpleSecurityManager; |
| 39 | +import org.apache.geode.internal.cache.InternalCache; |
| 40 | +import org.apache.geode.management.internal.security.ResourceConstants; |
| 41 | +import org.apache.geode.test.dunit.IgnoredException; |
| 42 | +import org.apache.geode.test.dunit.rules.ClusterStartupRule; |
| 43 | +import org.apache.geode.test.dunit.rules.MemberVM; |
| 44 | +import org.apache.geode.test.junit.categories.SecurityTest; |
| 45 | +import org.apache.geode.test.junit.rules.GfshCommandRule; |
| 46 | + |
| 47 | +/** |
| 48 | + * Tests which principals may run {@code export data} in a secured cluster. |
| 49 | + * |
| 50 | + * <p> |
| 51 | + * {@link SimpleSecurityManager} authorizes a user for exactly those permissions whose string form |
| 52 | + * starts with the user name, and treats a comma separated user name as a set of roles. So |
| 53 | + * "dataRead" holds DATA:READ alone, while "dataRead,clusterWrite" is the operator {@code |
| 54 | + * export data} requires. |
| 55 | + */ |
| 56 | +@Category(SecurityTest.class) |
| 57 | +public class ExportDataCommandSecurityTest implements Serializable { |
| 58 | + |
| 59 | + private static final String REGION_NAME = "testRegion"; |
| 60 | + private static final String READ_ONLY_USER = "dataRead"; |
| 61 | + private static final String EXPORT_OPERATOR = "dataRead,clusterWrite"; |
| 62 | + |
| 63 | + @ClassRule |
| 64 | + public static ClusterStartupRule cluster = new ClusterStartupRule(); |
| 65 | + |
| 66 | + @Rule |
| 67 | + public GfshCommandRule gfsh = new GfshCommandRule(); |
| 68 | + |
| 69 | + @Rule |
| 70 | + public TemporaryFolder temporaryFolder = new TemporaryFolder(); |
| 71 | + |
| 72 | + private static MemberVM locator; |
| 73 | + private static MemberVM server; |
| 74 | + |
| 75 | + /** The directory the server has been configured to permit exports into. */ |
| 76 | + private Path permittedDir; |
| 77 | + |
| 78 | + /** Any other location on the server host. */ |
| 79 | + private Path otherDir; |
| 80 | + |
| 81 | + @BeforeClass |
| 82 | + public static void beforeClass() { |
| 83 | + Properties locatorProps = new Properties(); |
| 84 | + locatorProps.setProperty(SECURITY_MANAGER, SimpleSecurityManager.class.getName()); |
| 85 | + locator = cluster.startLocatorVM(0, locatorProps); |
| 86 | + |
| 87 | + Properties serverProps = new Properties(); |
| 88 | + serverProps.setProperty(ResourceConstants.USER_NAME, "clusterManage"); |
| 89 | + serverProps.setProperty(ResourceConstants.PASSWORD, "clusterManage"); |
| 90 | + server = cluster.startServerVM(1, serverProps, locator.getPort()); |
| 91 | + |
| 92 | + server.invoke(() -> { |
| 93 | + InternalCache cache = ClusterStartupRule.getCache(); |
| 94 | + assertThat(cache).isNotNull(); |
| 95 | + cache.createRegionFactory(RegionShortcut.REPLICATE).create(REGION_NAME).put("key", "value"); |
| 96 | + }); |
| 97 | + } |
| 98 | + |
| 99 | + @Before |
| 100 | + public void configurePermittedExportDirectory() throws Exception { |
| 101 | + // Refusing an export is logged at error level on the member; that is the expected outcome of |
| 102 | + // most of these tests, not a symptom of one going wrong. |
| 103 | + IgnoredException.addIgnoredException("Cannot export to"); |
| 104 | + |
| 105 | + permittedDir = temporaryFolder.newFolder("permitted").toPath(); |
| 106 | + otherDir = temporaryFolder.newFolder("other").toPath(); |
| 107 | + |
| 108 | + String permitted = permittedDir.toString(); |
| 109 | + server.invoke(() -> System.setProperty(EXPORT_DATA_DIRS_PROPERTY, permitted)); |
| 110 | + } |
| 111 | + |
| 112 | + @After |
| 113 | + public void clearPermittedExportDirectory() { |
| 114 | + server.invoke(() -> System.clearProperty(EXPORT_DATA_DIRS_PROPERTY)); |
| 115 | + } |
| 116 | + |
| 117 | + private void connectAs(String user) throws Exception { |
| 118 | + gfsh.secureConnectAndVerify(locator.getPort(), GfshCommandRule.PortType.locator, user, user); |
| 119 | + } |
| 120 | + |
| 121 | + private String exportTo(String option, Path path) { |
| 122 | + return "export data --member=" + server.getName() + " --region=" + REGION_NAME + " --" |
| 123 | + + option + "=" + path; |
| 124 | + } |
| 125 | + |
| 126 | + /** |
| 127 | + * Read access to region data on its own does not permit an export. |
| 128 | + */ |
| 129 | + @Test |
| 130 | + public void dataReadUserCannotExport() throws Exception { |
| 131 | + connectAs(READ_ONLY_USER); |
| 132 | + Path target = permittedDir.resolve("snapshot.gfd"); |
| 133 | + |
| 134 | + gfsh.executeAndAssertThat(exportTo("file", target)) |
| 135 | + .statusIsError() |
| 136 | + .containsOutput("not authorized for CLUSTER:WRITE"); |
| 137 | + |
| 138 | + assertThat(target).doesNotExist(); |
| 139 | + } |
| 140 | + |
| 141 | + /** |
| 142 | + * Permissions are checked before the path, so the target directory makes no difference. |
| 143 | + */ |
| 144 | + @Test |
| 145 | + public void dataReadUserIsRefusedForAnyDirectory() throws Exception { |
| 146 | + connectAs(READ_ONLY_USER); |
| 147 | + Path target = otherDir.resolve("snapshot.gfd"); |
| 148 | + |
| 149 | + gfsh.executeAndAssertThat(exportTo("file", target)) |
| 150 | + .statusIsError() |
| 151 | + .containsOutput("not authorized for CLUSTER:WRITE"); |
| 152 | + |
| 153 | + assertThat(target).doesNotExist(); |
| 154 | + } |
| 155 | + |
| 156 | + /** |
| 157 | + * The command works for a principal holding both permissions. |
| 158 | + */ |
| 159 | + @Test |
| 160 | + public void operatorWithClusterWriteCanExportIntoThePermittedDirectory() throws Exception { |
| 161 | + connectAs(EXPORT_OPERATOR); |
| 162 | + Path target = permittedDir.resolve("snapshot.gfd"); |
| 163 | + |
| 164 | + gfsh.executeAndAssertThat(exportTo("file", target)) |
| 165 | + .statusIsSuccess() |
| 166 | + .containsOutput("Data successfully exported"); |
| 167 | + |
| 168 | + assertThat(target).exists(); |
| 169 | + } |
| 170 | + |
| 171 | + /** |
| 172 | + * The directory restriction applies independently of the permission: even a permitted operator |
| 173 | + * cannot place the snapshot anywhere it likes. |
| 174 | + */ |
| 175 | + @Test |
| 176 | + public void operatorCannotExportOutsideThePermittedDirectory() throws Exception { |
| 177 | + connectAs(EXPORT_OPERATOR); |
| 178 | + Path target = otherDir.resolve("snapshot.gfd"); |
| 179 | + |
| 180 | + gfsh.executeAndAssertThat(exportTo("file", target)).statusIsError(); |
| 181 | + |
| 182 | + assertThat(target).doesNotExist(); |
| 183 | + } |
| 184 | + |
| 185 | + /** |
| 186 | + * Nor can the operator leave the permitted directory with "../". |
| 187 | + */ |
| 188 | + @Test |
| 189 | + public void operatorCannotLeavePermittedDirectoryWithParentReference() throws Exception { |
| 190 | + connectAs(EXPORT_OPERATOR); |
| 191 | + Path withParentReference = permittedDir.resolve("..").resolve("other"); |
| 192 | + |
| 193 | + gfsh.executeAndAssertThat(exportTo("dir", withParentReference)).statusIsError(); |
| 194 | + |
| 195 | + assertThat(otherDir.resolve(REGION_NAME + ".gfd")).doesNotExist(); |
| 196 | + } |
| 197 | + |
| 198 | + /** |
| 199 | + * An existing file outside the permitted directory survives an export aimed at it. |
| 200 | + */ |
| 201 | + @Test |
| 202 | + public void existingFileOutsideThePermittedDirectoryIsNotOverwritten() throws Exception { |
| 203 | + connectAs(EXPORT_OPERATOR); |
| 204 | + Path existingFile = otherDir.resolve("existing.gfd"); |
| 205 | + String originalContent = "existing content"; |
| 206 | + Files.write(existingFile, originalContent.getBytes(StandardCharsets.UTF_8)); |
| 207 | + |
| 208 | + gfsh.executeAndAssertThat(exportTo("file", existingFile)).statusIsError(); |
| 209 | + |
| 210 | + assertThat(new String(Files.readAllBytes(existingFile), StandardCharsets.UTF_8)) |
| 211 | + .isEqualTo(originalContent); |
| 212 | + } |
| 213 | + |
| 214 | + /** |
| 215 | + * Confirms the read only grant really is read only, so the refusals above are the permission |
| 216 | + * check taking effect rather than a misconfigured principal. |
| 217 | + */ |
| 218 | + @Test |
| 219 | + public void readOnlyUserCanStillReadData() throws Exception { |
| 220 | + connectAs(READ_ONLY_USER); |
| 221 | + |
| 222 | + gfsh.executeAndAssertThat("get --region=" + REGION_NAME + " --key=key").statusIsSuccess(); |
| 223 | + gfsh.executeAndAssertThat("put --region=" + REGION_NAME + " --key=k --value=v") |
| 224 | + .statusIsError() |
| 225 | + .containsOutput("dataRead not authorized for DATA:WRITE"); |
| 226 | + } |
| 227 | +} |
0 commit comments