diff --git a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java index eaf9469d7d45a..f2d412d7baab9 100644 --- a/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java +++ b/server/src/main/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequest.java @@ -51,6 +51,9 @@ public class RestoreSnapshotRequest extends MasterNodeRequest source) { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(); + toXContentFragment(builder, params); + builder.endObject(); + return builder; + } + + private void toXContentFragment(XContentBuilder builder, Params params) throws IOException { builder.startArray("indices"); for (String index : indices) { builder.value(index); @@ -528,8 +545,6 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.value(ignoreIndexSetting); } builder.endArray(); - builder.endObject(); - return builder; } @Override @@ -554,13 +569,14 @@ public boolean equals(Object o) { Objects.equals(renameReplacement, that.renameReplacement) && Objects.equals(indexSettings, that.indexSettings) && Arrays.equals(ignoreIndexSettings, that.ignoreIndexSettings) && - Objects.equals(snapshotUuid, that.snapshotUuid); + Objects.equals(snapshotUuid, that.snapshotUuid) && + skipOperatorOnlyState == that.skipOperatorOnlyState; } @Override public int hashCode() { int result = Objects.hash(snapshot, repository, indicesOptions, renamePattern, renameReplacement, waitForCompletion, - includeGlobalState, partial, includeAliases, indexSettings, snapshotUuid); + includeGlobalState, partial, includeAliases, indexSettings, snapshotUuid, skipOperatorOnlyState); result = 31 * result + Arrays.hashCode(indices); result = 31 * result + Arrays.hashCode(ignoreIndexSettings); return result; @@ -568,6 +584,12 @@ public int hashCode() { @Override public String toString() { - return Strings.toString(this); + return Strings.toString((ToXContentObject) (builder, params) -> { + builder.startObject(); + toXContentFragment(builder, params); + builder.field("skipOperatorOnlyState", skipOperatorOnlyState); + builder.endObject(); + return builder; + }); } } diff --git a/server/src/main/java/org/elasticsearch/common/settings/Setting.java b/server/src/main/java/org/elasticsearch/common/settings/Setting.java index 22fbc4e24c920..af693628102ff 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/Setting.java +++ b/server/src/main/java/org/elasticsearch/common/settings/Setting.java @@ -87,6 +87,11 @@ public enum Property { */ Dynamic, + /** + * Operator only Dynamic setting + */ + OperatorDynamic, + /** * mark this setting as final, not updateable even when the context is not dynamic * ie. Setting this property on an index scoped setting will fail update when the index is closed @@ -157,9 +162,13 @@ private Setting(Key key, @Nullable Setting fallbackSetting, Function propertiesAsSet = EnumSet.copyOf(Arrays.asList(properties)); - if (propertiesAsSet.contains(Property.Dynamic) && propertiesAsSet.contains(Property.Final)) { + if ((propertiesAsSet.contains(Property.Dynamic) || propertiesAsSet.contains(Property.OperatorDynamic)) + && propertiesAsSet.contains(Property.Final)) { throw new IllegalArgumentException("final setting [" + key + "] cannot be dynamic"); } + if (propertiesAsSet.contains(Property.Dynamic) && propertiesAsSet.contains(Property.OperatorDynamic)) { + throw new IllegalArgumentException("setting [" + key + "] cannot be both dynamic and operator dynamic"); + } checkPropertyRequiresIndexScope(propertiesAsSet, Property.NotCopyableOnResize); checkPropertyRequiresIndexScope(propertiesAsSet, Property.InternalIndex); checkPropertyRequiresIndexScope(propertiesAsSet, Property.PrivateIndex); @@ -284,7 +293,14 @@ public final Key getRawKey() { * Returns true if this setting is dynamically updateable, otherwise false */ public final boolean isDynamic() { - return properties.contains(Property.Dynamic); + return properties.contains(Property.Dynamic) || properties.contains(Property.OperatorDynamic); + } + + /** + * Returns true if this setting is dynamically updateable by operators, otherwise false + */ + public final boolean isOperatorOnly() { + return properties.contains(Property.OperatorDynamic); } /** diff --git a/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java b/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java index e49ec294e5301..a73cc6d35e822 100644 --- a/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java +++ b/server/src/main/java/org/elasticsearch/snapshots/RestoreService.java @@ -55,6 +55,7 @@ import org.elasticsearch.common.lucene.Lucene; import org.elasticsearch.common.regex.Regex; import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.Index; import org.elasticsearch.index.IndexSettings; @@ -81,6 +82,7 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.Stream; import static java.util.Collections.unmodifiableSet; import static org.elasticsearch.cluster.metadata.IndexMetadata.SETTING_AUTO_EXPAND_REPLICAS; @@ -427,6 +429,22 @@ restoreUUID, snapshot, overallState(RestoreInProgress.State.INIT, shards), if (request.includeGlobalState()) { if (metadata.persistentSettings() != null) { Settings settings = metadata.persistentSettings(); + if (request.skipOperatorOnlyState()) { + // Skip any operator-only settings from the snapshot. This happens when operator privileges are enabled + Set operatorSettingKeys = Stream.concat( + settings.keySet().stream(), currentState.metadata().persistentSettings().keySet().stream()) + .filter(k -> { + final Setting setting = clusterSettings.get(k); + return setting != null && setting.isOperatorOnly(); + }) + .collect(Collectors.toSet()); + if (false == operatorSettingKeys.isEmpty()) { + settings = Settings.builder() + .put(settings.filter(k -> false == operatorSettingKeys.contains(k))) + .put(currentState.metadata().persistentSettings().filter(operatorSettingKeys::contains)) + .build(); + } + } clusterSettings.validateUpdate(settings); mdBuilder.persistentSettings(settings); } @@ -441,6 +459,7 @@ restoreUUID, snapshot, overallState(RestoreInProgress.State.INIT, shards), if (RepositoriesMetadata.TYPE.equals(cursor.key) == false && DataStreamMetadata.TYPE.equals(cursor.key) == false && cursor.value instanceof Metadata.NonRestorableCustom == false) { + // TODO: Check request.skipOperatorOnly for Autoscaling policies (NonRestorableCustom) // Don't restore repositories while we are working with them // TODO: Should we restore them at the end? // Also, don't restore data streams here, we already added them to the metadata builder above diff --git a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java index 47cbb44fa4889..a8a52931ad888 100644 --- a/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java +++ b/server/src/test/java/org/elasticsearch/action/admin/cluster/snapshots/restore/RestoreSnapshotRequestTests.java @@ -10,6 +10,7 @@ import org.elasticsearch.action.support.IndicesOptions; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.io.stream.BytesStreamOutput; import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.xcontent.NamedXContentRegistry; import org.elasticsearch.common.xcontent.ToXContent; @@ -29,6 +30,8 @@ import java.util.List; import java.util.Map; +import static org.hamcrest.Matchers.containsString; + public class RestoreSnapshotRequestTests extends AbstractWireSerializingTestCase { private RestoreSnapshotRequest randomState(RestoreSnapshotRequest instance) { if (randomBoolean()) { @@ -123,4 +126,37 @@ public void testSource() throws IOException { assertEquals(original, processed); } + + public void testSkipOperatorOnlyWillNotBeSerialised() throws IOException { + RestoreSnapshotRequest original = createTestInstance(); + assertFalse(original.skipOperatorOnlyState()); // default is false + if (randomBoolean()) { + original.skipOperatorOnlyState(true); + } + Map map = convertRequestToMap(original); + // It is not serialised as xcontent + assertFalse(map.containsKey("skip_operator_only")); + + // Xcontent is not affected by the value of skipOperatorOnlyState + original.skipOperatorOnlyState(original.skipOperatorOnlyState() == false); + assertEquals(map, convertRequestToMap(original)); + + // Nor does it serialise to streamInput + final BytesStreamOutput streamOutput = new BytesStreamOutput(); + original.writeTo(streamOutput); + final RestoreSnapshotRequest deserialized = new RestoreSnapshotRequest(streamOutput.bytes().streamInput()); + assertFalse(deserialized.skipOperatorOnlyState()); + } + + public void testToStringWillIncludeSkipOperatorOnlyState() { + RestoreSnapshotRequest original = createTestInstance(); + assertThat(original.toString(), containsString("skipOperatorOnlyState")); + } + + private Map convertRequestToMap(RestoreSnapshotRequest request) throws IOException { + XContentBuilder builder = request.toXContent(XContentFactory.jsonBuilder(), new ToXContent.MapParams(Collections.emptyMap())); + XContentParser parser = XContentType.JSON.xContent().createParser( + NamedXContentRegistry.EMPTY, null, BytesReference.bytes(builder).streamInput()); + return parser.mapOrdered(); + } } diff --git a/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java b/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java index 96bc3b8838780..4a3f1368045ae 100644 --- a/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java +++ b/server/src/test/java/org/elasticsearch/common/settings/SettingTests.java @@ -939,10 +939,16 @@ public void testRejectNullProperties() { public void testRejectConflictingDynamicAndFinalProperties() { IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, - () -> Setting.simpleString("foo.bar", Property.Final, Property.Dynamic)); + () -> Setting.simpleString("foo.bar", Property.Final, randomFrom(Property.Dynamic, Property.OperatorDynamic))); assertThat(ex.getMessage(), containsString("final setting [foo.bar] cannot be dynamic")); } + public void testRejectConflictingDynamicAndOperatorDynamicProperties() { + IllegalArgumentException ex = expectThrows(IllegalArgumentException.class, + () -> Setting.simpleString("foo.bar", Property.Dynamic, Property.OperatorDynamic)); + assertThat(ex.getMessage(), containsString("setting [foo.bar] cannot be both dynamic and operator dynamic")); + } + public void testRejectNonIndexScopedNotCopyableOnResizeSetting() { final IllegalArgumentException e = expectThrows( IllegalArgumentException.class, @@ -1240,4 +1246,11 @@ public boolean innerMatch(LogEvent event) { mockLogAppender.stop(); } } + + public void testDynamicTest() { + final Property property = randomFrom(Property.Dynamic, Property.OperatorDynamic); + final Setting setting = Setting.simpleString("foo.bar", property); + assertTrue(setting.isDynamic()); + assertEquals(setting.isOperatorOnly(), property == Property.OperatorDynamic); + } } diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java index 52c524ad63501..8a293a70c8d0b 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MachineLearning.java @@ -416,7 +416,7 @@ public Set getRoles() { public static final String MACHINE_MEMORY_NODE_ATTR = "ml.machine_memory"; public static final String MAX_JVM_SIZE_NODE_ATTR = "ml.max_jvm_size"; public static final Setting CONCURRENT_JOB_ALLOCATIONS = - Setting.intSetting("xpack.ml.node_concurrent_job_allocations", 2, 0, Property.Dynamic, Property.NodeScope); + Setting.intSetting("xpack.ml.node_concurrent_job_allocations", 2, 0, Property.OperatorDynamic, Property.NodeScope); /** * The amount of memory needed to load the ML native code shared libraries. The assumption is that the first * ML job to run on a given node will do this, and then subsequent ML jobs on the same node will reuse the @@ -430,7 +430,7 @@ public Set getRoles() { // controls the types of jobs that can be created, and each job alone is considerably smaller than what each node // can handle. public static final Setting MAX_MACHINE_MEMORY_PERCENT = - Setting.intSetting("xpack.ml.max_machine_memory_percent", 30, 5, 200, Property.Dynamic, Property.NodeScope); + Setting.intSetting("xpack.ml.max_machine_memory_percent", 30, 5, 200, Property.OperatorDynamic, Property.NodeScope); /** * This boolean value indicates if `max_machine_memory_percent` should be ignored and a automatic calculation is used instead. * @@ -446,10 +446,10 @@ public Set getRoles() { public static final Setting USE_AUTO_MACHINE_MEMORY_PERCENT = Setting.boolSetting( "xpack.ml.use_auto_machine_memory_percent", false, - Property.Dynamic, + Property.OperatorDynamic, Property.NodeScope); public static final Setting MAX_LAZY_ML_NODES = - Setting.intSetting("xpack.ml.max_lazy_ml_nodes", 0, 0, Property.Dynamic, Property.NodeScope); + Setting.intSetting("xpack.ml.max_lazy_ml_nodes", 0, 0, Property.OperatorDynamic, Property.NodeScope); // Before 8.0.0 this needs to match the max allowed value for xpack.ml.max_open_jobs, // as the current node could be running in a cluster where some nodes are still using @@ -464,7 +464,7 @@ public Set getRoles() { public static final Setting PROCESS_CONNECT_TIMEOUT = Setting.timeSetting("xpack.ml.process_connect_timeout", TimeValue.timeValueSeconds(10), - TimeValue.timeValueSeconds(5), Setting.Property.Dynamic, Setting.Property.NodeScope); + TimeValue.timeValueSeconds(5), Property.OperatorDynamic, Setting.Property.NodeScope); // Undocumented setting for integration test purposes public static final Setting MIN_DISK_SPACE_OFF_HEAP = @@ -483,7 +483,7 @@ public Set getRoles() { } return value; }, - Property.Dynamic, + Property.OperatorDynamic, Property.NodeScope ); @@ -496,7 +496,7 @@ public Set getRoles() { public static final Setting MAX_ML_NODE_SIZE = Setting.byteSizeSetting( "xpack.ml.max_ml_node_size", ByteSizeValue.ZERO, - Property.Dynamic, + Property.OperatorDynamic, Property.NodeScope); private static final Logger logger = LogManager.getLogger(MachineLearning.class); diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MlConfigMigrationEligibilityCheck.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MlConfigMigrationEligibilityCheck.java index f0c42ba85ecbb..9f3ceb39bfc0b 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MlConfigMigrationEligibilityCheck.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/MlConfigMigrationEligibilityCheck.java @@ -24,7 +24,7 @@ public class MlConfigMigrationEligibilityCheck { public static final Setting ENABLE_CONFIG_MIGRATION = Setting.boolSetting( - "xpack.ml.enable_config_migration", true, Setting.Property.Dynamic, Setting.Property.NodeScope); + "xpack.ml.enable_config_migration", true, Setting.Property.OperatorDynamic, Setting.Property.NodeScope); private volatile boolean isConfigMigrationEnabled; diff --git a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/utils/persistence/ResultsPersisterService.java b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/utils/persistence/ResultsPersisterService.java index 8cb5e2d687b23..3e816e9dcf235 100644 --- a/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/utils/persistence/ResultsPersisterService.java +++ b/x-pack/plugin/ml/src/main/java/org/elasticsearch/xpack/ml/utils/persistence/ResultsPersisterService.java @@ -77,7 +77,7 @@ public class ResultsPersisterService { 20, 0, 50, - Setting.Property.Dynamic, + Setting.Property.OperatorDynamic, Setting.Property.NodeScope); private static final int MAX_RETRY_SLEEP_MILLIS = (int)Duration.ofMinutes(15).toMillis(); private static final int MIN_RETRY_SLEEP_MILLIS = 50; diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle b/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle index fcbdfe6596740..8bc4d9543c5af 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle +++ b/x-pack/plugin/security/qa/operator-privileges-tests/build.gradle @@ -16,6 +16,13 @@ dependencies { javaRestTestImplementation project.sourceSets.main.runtimeClasspath } +File repoDir = file("$buildDir/testclusters/repo") + +javaRestTest { + /* To support taking snapshots, we have to set path.repo setting */ + systemProperty 'tests.path.repo', repoDir +} + testClusters.all { testDistribution = 'DEFAULT' numberOfNodes = 3 @@ -27,6 +34,7 @@ testClusters.all { setting 'xpack.security.enabled', 'true' setting 'xpack.security.http.ssl.enabled', 'false' setting 'xpack.security.operator_privileges.enabled', "true" + setting 'path.repo', repoDir.absolutePath user username: "test_admin", password: 'x-pack-test-password', role: "superuser" user username: "test_operator", password: 'x-pack-test-password', role: "limited_operator" diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java index b7280a283a17e..cce019b97ecdf 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesIT.java @@ -9,15 +9,20 @@ import org.elasticsearch.client.Request; import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.ResponseException; +import org.elasticsearch.common.Strings; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.json.JsonXContent; import org.elasticsearch.test.rest.ESRestTestCase; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -29,6 +34,9 @@ public class OperatorPrivilegesIT extends ESRestTestCase { + private static final String OPERATOR_AUTH_HEADER = "Basic " + + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8));; + @Override protected Settings restClientSettings() { String token = basicAuthHeaderValue("test_admin", new SecureString("x-pack-test-password".toCharArray())); @@ -47,17 +55,13 @@ public void testNonOperatorSuperuserWillFailToCallOperatorOnlyApiWhenOperatorPri public void testOperatorUserWillSucceedToCallOperatorOnlyApi() throws IOException { final Request postVotingConfigExclusionsRequest = new Request("POST", "_cluster/voting_config_exclusions?node_names=foo"); - final String authHeader = "Basic " - + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); - postVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + postVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", OPERATOR_AUTH_HEADER)); client().performRequest(postVotingConfigExclusionsRequest); } public void testOperatorUserWillFailToCallOperatorOnlyApiIfRbacFails() throws IOException { final Request deleteVotingConfigExclusionsRequest = new Request("DELETE", "_cluster/voting_config_exclusions"); - final String authHeader = "Basic " - + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); - deleteVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + deleteVotingConfigExclusionsRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", OPERATOR_AUTH_HEADER)); final ResponseException responseException = expectThrows( ResponseException.class, () -> client().performRequest(deleteVotingConfigExclusionsRequest) @@ -68,9 +72,7 @@ public void testOperatorUserWillFailToCallOperatorOnlyApiIfRbacFails() throws IO public void testOperatorUserCanCallNonOperatorOnlyApi() throws IOException { final Request mainRequest = new Request("GET", "/"); - final String authHeader = "Basic " - + Base64.getEncoder().encodeToString("test_operator:x-pack-test-password".getBytes(StandardCharsets.UTF_8)); - mainRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + mainRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", OPERATOR_AUTH_HEADER)); client().performRequest(mainRequest); } @@ -101,4 +103,141 @@ public void testOperatorPrivilegesXpackUsage() throws IOException { assertTrue((boolean) operatorPrivileges.get("available")); assertTrue((boolean) operatorPrivileges.get("enabled")); } + + public void testUpdateOperatorSettings() throws IOException { + final Map settings = new HashMap<>( + Map.of("xpack.security.http.filter.enabled", "false", "xpack.security.transport.filter.enabled", "false") + ); + final boolean extraSettings = randomBoolean(); + if (extraSettings) { + settings.put("search.allow_expensive_queries", false); + } + final ResponseException responseException = expectThrows(ResponseException.class, () -> updateSettings(settings, null)); + assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat(responseException.getMessage(), containsString("is unauthorized for user")); + assertTrue(getPersistentSettings().isEmpty()); + + updateSettings(settings, OPERATOR_AUTH_HEADER); + + Map persistentSettings = getPersistentSettings(); + assertThat(persistentSettings.get("xpack.security.http.filter.enabled"), equalTo("false")); + assertThat(persistentSettings.get("xpack.security.transport.filter.enabled"), equalTo("false")); + if (extraSettings) { + assertThat(persistentSettings.get("search.allow_expensive_queries"), equalTo("false")); + } + } + + public void testSnapshotRestoreBehaviourOfOperatorSettings() throws IOException { + final String repoName = "repo"; + final String snapshotName = "snap"; + createSnapshotRepo(repoName); + // Initial values + updateSettings( + Map.of( + "xpack.security.http.filter.enabled", + "false", + "xpack.security.http.filter.allow", + "example.com", + "search.default_keep_alive", + "10m" + ), + OPERATOR_AUTH_HEADER + ); + takeSnapshot(repoName, snapshotName); + // change to different values + deleteSettings(List.of("xpack.security.http.filter.enabled"), OPERATOR_AUTH_HEADER); + updateSettings( + Map.of( + "xpack.security.transport.filter.enabled", + "true", + "xpack.security.http.filter.allow", + "tutorial.com", + "search.default_keep_alive", + "1m", + "search.allow_expensive_queries", + "false" + ), + OPERATOR_AUTH_HEADER + ); + + // Restore with either operator or non-operator and the operator settings will not be touched + restoreSnapshot(repoName, snapshotName, randomFrom(OPERATOR_AUTH_HEADER, null)); + Map persistentSettings = getPersistentSettings(); + assertNull(persistentSettings.get("xpack.security.http.filter.enabled")); + assertThat(persistentSettings.get("xpack.security.transport.filter.enabled"), equalTo("true")); + assertThat(persistentSettings.get("xpack.security.http.filter.allow"), equalTo("tutorial.com")); + assertThat(persistentSettings.get("search.default_keep_alive"), equalTo("10m")); + assertNull(persistentSettings.get("search.allow_expensive_queries")); + } + + private void createSnapshotRepo(String repoName) throws IOException { + Request request = new Request("PUT", "/_snapshot/" + repoName); + request.setJsonEntity( + Strings.toString( + JsonXContent.contentBuilder() + .startObject() + .field("type", "fs") + .startObject("settings") + .field("location", System.getProperty("tests.path.repo")) + .endObject() + .endObject() + ) + ); + assertOK(client().performRequest(request)); + } + + private void updateSettings(Map settings, String authHeader) throws IOException { + final Request request = new Request("PUT", "/_cluster/settings"); + if (authHeader != null) { + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + } + request.setJsonEntity( + Strings.toString( + JsonXContent.contentBuilder().startObject().startObject("persistent").mapContents(settings).endObject().endObject() + ) + ); + assertOK(client().performRequest(request)); + } + + private void deleteSettings(Collection settingKeys, String authHeader) throws IOException { + final Request request = new Request("PUT", "/_cluster/settings"); + if (authHeader != null) { + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + } + final XContentBuilder builder = JsonXContent.contentBuilder().startObject().startObject("persistent"); + for (String k : settingKeys) { + builder.nullField(k); + } + builder.endObject().endObject(); + request.setJsonEntity(Strings.toString(builder)); + assertOK(client().performRequest(request)); + } + + private void takeSnapshot(String repoName, String snapshotName) throws IOException { + final Request request = new Request("POST", "/_snapshot/" + repoName + "/" + snapshotName); + request.addParameter("wait_for_completion", "true"); + request.setJsonEntity( + Strings.toString(JsonXContent.contentBuilder().startObject().field("include_global_state", true).endObject()) + ); + assertOK(client().performRequest(request)); + } + + private void restoreSnapshot(String repoName, String snapshotName, String authHeader) throws IOException { + final Request request = new Request("POST", "/_snapshot/" + repoName + "/" + snapshotName + "/_restore"); + if (authHeader != null) { + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", authHeader)); + } + request.addParameter("wait_for_completion", "true"); + request.setJsonEntity( + Strings.toString(JsonXContent.contentBuilder().startObject().field("include_global_state", true).endObject()) + ); + assertOK(client().performRequest(request)); + } + + @SuppressWarnings("unchecked") + private Map getPersistentSettings() throws IOException { + final Request getSettingsRequest = new Request("GET", "/_cluster/settings?flat_settings"); + Map response = entityAsMap(client().performRequest(getSettingsRequest)); + return (Map) response.get("persistent"); + } } diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml index ac6d3a00dacad..a63ebf977a61c 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/resources/roles.yml @@ -2,3 +2,5 @@ limited_operator: cluster: - "cluster:admin/voting_config/add_exclusions" - "monitor" + - "cluster:admin/settings/update" + - "cluster:admin/snapshot/restore" diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesDisabledIntegTestCase.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesDisabledIntegTestCase.java new file mode 100644 index 0000000000000..82b6f50b8bfc3 --- /dev/null +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesDisabledIntegTestCase.java @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.operator; + +import org.elasticsearch.snapshots.AbstractSnapshotIntegTestCase; + +public class OperatorPrivilegesDisabledIntegTestCase extends AbstractSnapshotIntegTestCase { +} diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java index 4b3e66287c662..657bca473590e 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesSingleNodeTests.java @@ -10,12 +10,15 @@ import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsAction; import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsRequest; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsAction; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; import org.elasticsearch.client.Client; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.SecuritySingleNodeTestCase; import org.elasticsearch.xpack.core.security.action.user.GetUsersAction; import org.elasticsearch.xpack.core.security.action.user.GetUsersRequest; +import org.elasticsearch.xpack.security.transport.filter.IPFilter; import java.util.Map; @@ -40,6 +43,7 @@ protected String configRoles() { + "limited_operator:\n" + " cluster:\n" + " - 'cluster:admin/voting_config/clear_exclusions'\n" + + " - 'cluster:admin/settings/update'\n" + " - 'monitor'\n"; } @@ -64,29 +68,67 @@ protected Settings nodeSettings() { return builder.build(); } - public void testOutcomeOfSuperuserPerformingOperatorOnlyActionWillDependOnWhetherFeatureIsEnabled() { - final Client client = client(); + public void testNormalSuperuserWillFailToCallOperatorOnlyAction() { final ClearVotingConfigExclusionsRequest clearVotingConfigExclusionsRequest = new ClearVotingConfigExclusionsRequest(); - final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, - () -> client.execute(ClearVotingConfigExclusionsAction.INSTANCE, clearVotingConfigExclusionsRequest).actionGet()); + final ElasticsearchSecurityException e = expectThrows( + ElasticsearchSecurityException.class, + () -> client().execute(ClearVotingConfigExclusionsAction.INSTANCE, clearVotingConfigExclusionsRequest).actionGet()); assertThat(e.getCause().getMessage(), containsString("Operator privileges are required for action")); } + public void testNormalSuperuserWillFailToSetOperatorOnlySettings() { + final Settings settings = Settings.builder().put(IPFilter.IP_FILTER_ENABLED_SETTING.getKey(), "null").build(); + final ClusterUpdateSettingsRequest clusterUpdateSettingsRequest = new ClusterUpdateSettingsRequest(); + if (randomBoolean()) { + clusterUpdateSettingsRequest.transientSettings(settings); + } else { + clusterUpdateSettingsRequest.persistentSettings(settings); + } + final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, + () -> client().execute(ClusterUpdateSettingsAction.INSTANCE, clusterUpdateSettingsRequest).actionGet()); + assertThat(e.getCause().getMessage(), containsString("Operator privileges are required for setting")); + } + public void testOperatorUserWillSucceedToCallOperatorOnlyAction() { - final Client client = client().filterWithHeader(Map.of( - "Authorization", - basicAuthHeaderValue(OPERATOR_USER_NAME, new SecureString(TEST_PASSWORD.toCharArray())))); + final Client client = createOperatorClient(); final ClearVotingConfigExclusionsRequest clearVotingConfigExclusionsRequest = new ClearVotingConfigExclusionsRequest(); client.execute(ClearVotingConfigExclusionsAction.INSTANCE, clearVotingConfigExclusionsRequest).actionGet(); } + public void testOperatorUserWillSucceedToSetOperatorOnlySettings() { + final Client client = createOperatorClient(); + final ClusterUpdateSettingsRequest clusterUpdateSettingsRequest = new ClusterUpdateSettingsRequest(); + final Settings settings = Settings.builder().put(IPFilter.IP_FILTER_ENABLED_SETTING.getKey(), false).build(); + final boolean useTransientSetting = randomBoolean(); + try { + if (useTransientSetting) { + clusterUpdateSettingsRequest.transientSettings(settings); + } else { + clusterUpdateSettingsRequest.persistentSettings(settings); + } + client.execute(ClusterUpdateSettingsAction.INSTANCE, clusterUpdateSettingsRequest).actionGet(); + } finally { + final ClusterUpdateSettingsRequest clearSettingsRequest = new ClusterUpdateSettingsRequest(); + final Settings clearSettings = Settings.builder().putNull(IPFilter.IP_FILTER_ENABLED_SETTING.getKey()).build(); + if (useTransientSetting) { + clearSettingsRequest.transientSettings(clearSettings); + } else { + clearSettingsRequest.persistentSettings(clearSettings); + } + client.execute(ClusterUpdateSettingsAction.INSTANCE, clearSettingsRequest).actionGet(); + } + } + public void testOperatorUserIsStillSubjectToRoleLimits() { - final Client client = client().filterWithHeader(Map.of( - "Authorization", - basicAuthHeaderValue(OPERATOR_USER_NAME, new SecureString(TEST_PASSWORD.toCharArray())))); + final Client client = createOperatorClient(); final GetUsersRequest getUsersRequest = new GetUsersRequest(); final ElasticsearchSecurityException e = expectThrows(ElasticsearchSecurityException.class, () -> client.execute(GetUsersAction.INSTANCE, getUsersRequest).actionGet()); assertThat(e.getMessage(), containsString("is unauthorized for user")); } + + private Client createOperatorClient() { + return client().filterWithHeader(Map.of("Authorization", + basicAuthHeaderValue(OPERATOR_USER_NAME, new SecureString(TEST_PASSWORD.toCharArray())))); + } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 70926ac207441..80d7e9ce5cc39 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -500,9 +500,11 @@ Collection createComponents(Client client, ThreadPool threadPool, Cluste final AuthenticationFailureHandler failureHandler = createAuthenticationFailureHandler(realms, extensionComponents); final OperatorPrivilegesService operatorPrivilegesService; - if (OPERATOR_PRIVILEGES_ENABLED.get(settings)) { + final boolean operatorPrivilegesEnabled = OPERATOR_PRIVILEGES_ENABLED.get(settings); + if (operatorPrivilegesEnabled) { operatorPrivilegesService = new OperatorPrivileges.DefaultOperatorPrivilegesService(getLicenseState(), - new FileOperatorUsersStore(environment, resourceWatcherService), new OperatorOnlyRegistry()); + new FileOperatorUsersStore(environment, resourceWatcherService), + new OperatorOnlyRegistry(clusterService.getClusterSettings())); } else { operatorPrivilegesService = OperatorPrivileges.NOOP_OPERATOR_PRIVILEGES_SERVICE; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index 567d37f787811..166dd60909d6e 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -214,6 +214,7 @@ public void authorize(final Authentication authentication, final String action, listener.onFailure(denialException(authentication, action, originalRequest, operatorException)); return; } + operatorPrivilegesService.maybeInterceptRequest(threadContext, originalRequest); if (SystemUser.is(authentication.getUser())) { // this never goes async so no need to wrap the listener diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java index b3a80b6809af0..6ec5ea7404e88 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistry.java @@ -9,11 +9,19 @@ import org.elasticsearch.action.admin.cluster.configuration.AddVotingConfigExclusionsAction; import org.elasticsearch.action.admin.cluster.configuration.ClearVotingConfigExclusionsAction; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsAction; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.Setting; import org.elasticsearch.license.DeleteLicenseAction; import org.elasticsearch.license.PutLicenseAction; import org.elasticsearch.transport.TransportRequest; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class OperatorOnlyRegistry { @@ -27,6 +35,12 @@ public class OperatorOnlyRegistry { "cluster:admin/autoscaling/get_autoscaling_policy", "cluster:admin/autoscaling/get_autoscaling_capacity"); + private final ClusterSettings clusterSettings; + + public OperatorOnlyRegistry(ClusterSettings clusterSettings) { + this.clusterSettings = clusterSettings; + } + /** * Check whether the given action and request qualify as operator-only. The method returns * null if the action+request is NOT operator-only. Other it returns a violation object @@ -35,6 +49,24 @@ public class OperatorOnlyRegistry { public OperatorPrivilegesViolation check(String action, TransportRequest request) { if (SIMPLE_ACTIONS.contains(action)) { return () -> "action [" + action + "]"; + } else if (ClusterUpdateSettingsAction.NAME.equals(action)) { + assert request instanceof ClusterUpdateSettingsRequest; + return checkClusterUpdateSettings((ClusterUpdateSettingsRequest) request); + } else { + return null; + } + } + + private OperatorPrivilegesViolation checkClusterUpdateSettings(ClusterUpdateSettingsRequest request) { + List operatorOnlySettingKeys = Stream.concat( + request.transientSettings().keySet().stream(), request.persistentSettings().keySet().stream() + ).filter(k -> { + final Setting setting = clusterSettings.get(k); + return setting != null && setting.isOperatorOnly(); + }).collect(Collectors.toList()); + if (false == operatorOnlySettingKeys.isEmpty()) { + return () -> (operatorOnlySettingKeys.size() == 1 ? "setting" : "settings") + + " [" + Strings.collectionToDelimitedString(operatorOnlySettingKeys, ",") + "]"; } else { return null; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java index e3a8f5506621d..bb8967d78c58c 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/OperatorPrivileges.java @@ -10,6 +10,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.license.XPackLicenseState; @@ -36,6 +37,11 @@ public interface OperatorPrivilegesService { * @return An exception if user is an non-operator and the request is operator-only. Otherwise returns null. */ ElasticsearchSecurityException check(String action, TransportRequest request, ThreadContext threadContext); + + /** + * Check the threadContext to see whether the current authenticating user is an operator user + */ + void maybeInterceptRequest(ThreadContext threadContext, TransportRequest request); } public static final class DefaultOperatorPrivilegesService implements OperatorPrivilegesService { @@ -79,6 +85,12 @@ public ElasticsearchSecurityException check(String action, TransportRequest requ return null; } + public void maybeInterceptRequest(ThreadContext threadContext, TransportRequest request) { + if (request instanceof RestoreSnapshotRequest) { + ((RestoreSnapshotRequest) request).skipOperatorOnlyState(shouldProcess()); + } + } + private boolean shouldProcess() { return licenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES); } @@ -93,5 +105,12 @@ public void maybeMarkOperatorUser(Authentication authentication, ThreadContext t public ElasticsearchSecurityException check(String action, TransportRequest request, ThreadContext threadContext) { return null; } + + @Override + public void maybeInterceptRequest(ThreadContext threadContext, TransportRequest request) { + if (request instanceof RestoreSnapshotRequest) { + ((RestoreSnapshotRequest) request).skipOperatorOnlyState(false); + } + } }; } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/filter/IPFilter.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/filter/IPFilter.java index bcce30249014a..395abbf341809 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/filter/IPFilter.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/transport/filter/IPFilter.java @@ -50,35 +50,35 @@ public class IPFilter { Setting.boolSetting(setting("filter.always_allow_bound_address"), true, Property.NodeScope); public static final Setting IP_FILTER_ENABLED_HTTP_SETTING = Setting.boolSetting(setting("http.filter.enabled"), - true, Property.Dynamic, Property.NodeScope); + true, Property.OperatorDynamic, Property.NodeScope); public static final Setting IP_FILTER_ENABLED_SETTING = Setting.boolSetting(setting("transport.filter.enabled"), - true, Property.Dynamic, Property.NodeScope); + true, Property.OperatorDynamic, Property.NodeScope); public static final Setting> TRANSPORT_FILTER_ALLOW_SETTING = Setting.listSetting(setting("transport.filter.allow"), - Collections.emptyList(), Function.identity(), Property.Dynamic, Property.NodeScope); + Collections.emptyList(), Function.identity(), Property.OperatorDynamic, Property.NodeScope); public static final Setting> TRANSPORT_FILTER_DENY_SETTING = Setting.listSetting(setting("transport.filter.deny"), - Collections.emptyList(), Function.identity(), Property.Dynamic, Property.NodeScope); + Collections.emptyList(), Function.identity(), Property.OperatorDynamic, Property.NodeScope); public static final Setting.AffixSetting> PROFILE_FILTER_DENY_SETTING = Setting.affixKeySetting("transport.profiles.", "xpack.security.filter.deny", key -> Setting.listSetting(key, Collections.emptyList(), Function.identity(), - Property.Dynamic, Property.NodeScope)); + Property.OperatorDynamic, Property.NodeScope)); public static final Setting.AffixSetting> PROFILE_FILTER_ALLOW_SETTING = Setting.affixKeySetting("transport.profiles.", "xpack.security.filter.allow", key -> Setting.listSetting(key, Collections.emptyList(), Function.identity(), - Property.Dynamic, Property.NodeScope)); + Property.OperatorDynamic, Property.NodeScope)); private static final Setting> HTTP_FILTER_ALLOW_FALLBACK = Setting.listSetting("transport.profiles.default.xpack.security.filter.allow", TRANSPORT_FILTER_ALLOW_SETTING, s -> s, Property.NodeScope); public static final Setting> HTTP_FILTER_ALLOW_SETTING = Setting.listSetting(setting("http.filter.allow"), - HTTP_FILTER_ALLOW_FALLBACK, Function.identity(), Property.Dynamic, Property.NodeScope); + HTTP_FILTER_ALLOW_FALLBACK, Function.identity(), Property.OperatorDynamic, Property.NodeScope); private static final Setting> HTTP_FILTER_DENY_FALLBACK = Setting.listSetting("transport.profiles.default.xpack.security.filter.deny", TRANSPORT_FILTER_DENY_SETTING, s -> s, Property.NodeScope); public static final Setting> HTTP_FILTER_DENY_SETTING = Setting.listSetting(setting("http.filter.deny"), - HTTP_FILTER_DENY_FALLBACK, Function.identity(), Property.Dynamic, Property.NodeScope); + HTTP_FILTER_DENY_FALLBACK, Function.identity(), Property.OperatorDynamic, Property.NodeScope); public static final Map DISABLED_USAGE_STATS = Map.of( "http", false, diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java index d8523b0f5e8bb..69618d33f2aa6 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorOnlyRegistryTests.java @@ -7,18 +7,54 @@ package org.elasticsearch.xpack.security.operator; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsAction; +import org.elasticsearch.action.admin.cluster.settings.ClusterUpdateSettingsRequest; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; import org.junit.Before; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.HTTP_FILTER_ALLOW_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.HTTP_FILTER_DENY_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.IP_FILTER_ENABLED_HTTP_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.IP_FILTER_ENABLED_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.PROFILE_FILTER_ALLOW_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.PROFILE_FILTER_DENY_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.TRANSPORT_FILTER_ALLOW_SETTING; +import static org.elasticsearch.xpack.security.transport.filter.IPFilter.TRANSPORT_FILTER_DENY_SETTING; import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class OperatorOnlyRegistryTests extends ESTestCase { + private static final Set> DYNAMIC_SETTINGS = ClusterSettings.BUILT_IN_CLUSTER_SETTINGS.stream() + .filter(Setting::isDynamic) + .filter(setting -> false == setting.isOperatorOnly()) + .collect(Collectors.toSet()); + private static final Set> IP_FILTER_SETTINGS = Set.of( + IP_FILTER_ENABLED_HTTP_SETTING, + IP_FILTER_ENABLED_SETTING, + TRANSPORT_FILTER_ALLOW_SETTING, + TRANSPORT_FILTER_DENY_SETTING, + PROFILE_FILTER_DENY_SETTING, + PROFILE_FILTER_ALLOW_SETTING, + HTTP_FILTER_ALLOW_SETTING, + HTTP_FILTER_DENY_SETTING); + private OperatorOnlyRegistry operatorOnlyRegistry; @Before public void init() { - operatorOnlyRegistry = new OperatorOnlyRegistry(); + final Set> settingsSet = new HashSet<>(IP_FILTER_SETTINGS); + settingsSet.addAll(DYNAMIC_SETTINGS); + operatorOnlyRegistry = new OperatorOnlyRegistry(new ClusterSettings(Settings.EMPTY, settingsSet)); } public void testSimpleOperatorOnlyApi() { @@ -35,6 +71,61 @@ public void testNonOperatorOnlyApi() { assertNull(operatorOnlyRegistry.check(actionName, null)); } - // TODO: not tests for settings yet since it's not settled whether it will be part of phase 1 + public void testOperatorOnlySettings() { + final ClusterUpdateSettingsRequest request; + final Setting transientSetting; + final Setting persistentSetting; + final OperatorOnlyRegistry.OperatorPrivilegesViolation violation; + + switch (randomIntBetween(0, 3)) { + case 0: + transientSetting = convertToConcreteSettingIfNecessary(randomFrom(IP_FILTER_SETTINGS)); + persistentSetting = convertToConcreteSettingIfNecessary( + randomValueOtherThan(transientSetting, () -> randomFrom(IP_FILTER_SETTINGS))); + request = prepareClusterUpdateSettingsRequest(transientSetting, persistentSetting); + violation = operatorOnlyRegistry.check(ClusterUpdateSettingsAction.NAME, request); + assertThat(violation.message(), containsString(String.format(Locale.ROOT, "settings [%s,%s]", + transientSetting.getKey(), persistentSetting.getKey()))); + break; + case 1: + transientSetting = convertToConcreteSettingIfNecessary(randomFrom(IP_FILTER_SETTINGS)); + persistentSetting = convertToConcreteSettingIfNecessary(randomFrom(DYNAMIC_SETTINGS)); + request = prepareClusterUpdateSettingsRequest(transientSetting, persistentSetting); + violation = operatorOnlyRegistry.check(ClusterUpdateSettingsAction.NAME, request); + assertThat(violation.message(), containsString(String.format(Locale.ROOT, "setting [%s]", + transientSetting.getKey()))); + break; + case 2: + transientSetting = convertToConcreteSettingIfNecessary(randomFrom(DYNAMIC_SETTINGS)); + persistentSetting = convertToConcreteSettingIfNecessary(randomFrom(IP_FILTER_SETTINGS)); + request = prepareClusterUpdateSettingsRequest(transientSetting, persistentSetting); + violation = operatorOnlyRegistry.check(ClusterUpdateSettingsAction.NAME, request); + assertThat(violation.message(), containsString(String.format(Locale.ROOT, "setting [%s]", + persistentSetting.getKey()))); + break; + case 3: + transientSetting = convertToConcreteSettingIfNecessary(randomFrom(DYNAMIC_SETTINGS)); + persistentSetting = convertToConcreteSettingIfNecessary(randomFrom(DYNAMIC_SETTINGS)); + request = prepareClusterUpdateSettingsRequest(transientSetting, persistentSetting); + assertNull(operatorOnlyRegistry.check(ClusterUpdateSettingsAction.NAME, request)); + break; + } + + } + + private Setting convertToConcreteSettingIfNecessary(Setting setting) { + if (setting instanceof Setting.AffixSetting) { + return ((Setting.AffixSetting) setting).getConcreteSettingForNamespace(randomAlphaOfLengthBetween(4, 8)); + } else { + return setting; + } + } + + private ClusterUpdateSettingsRequest prepareClusterUpdateSettingsRequest(Setting transientSetting, Setting persistentSetting) { + final ClusterUpdateSettingsRequest request = mock(ClusterUpdateSettingsRequest.class); + when(request.transientSettings()).thenReturn(Settings.builder().put(transientSetting.getKey(), "null").build()); + when(request.persistentSettings()).thenReturn(Settings.builder().put(persistentSetting.getKey(), "null").build()); + return request; + } } diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java index 1138711297eda..45512fce7796e 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/operator/OperatorPrivilegesTests.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.security.operator; import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.admin.cluster.snapshots.restore.RestoreSnapshotRequest; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.license.XPackLicenseState; @@ -25,6 +26,7 @@ import static org.mockito.Matchers.any; import static org.mockito.Matchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; @@ -33,12 +35,14 @@ public class OperatorPrivilegesTests extends ESTestCase { private XPackLicenseState xPackLicenseState; private FileOperatorUsersStore fileOperatorUsersStore; private OperatorOnlyRegistry operatorOnlyRegistry; + private OperatorPrivilegesService operatorPrivilegesService; @Before public void init() { xPackLicenseState = mock(XPackLicenseState.class); fileOperatorUsersStore = mock(FileOperatorUsersStore.class); operatorOnlyRegistry = mock(OperatorOnlyRegistry.class); + operatorPrivilegesService = new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); } public void testWillNotProcessWhenFeatureIsDisabledOrLicenseDoesNotSupport() { @@ -46,9 +50,6 @@ public void testWillNotProcessWhenFeatureIsDisabledOrLicenseDoesNotSupport() { .put("xpack.security.operator_privileges.enabled", randomBoolean()) .build(); when(xPackLicenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES)).thenReturn(false); - - final OperatorPrivilegesService operatorPrivilegesService = - new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); final ThreadContext threadContext = new ThreadContext(settings); operatorPrivilegesService.maybeMarkOperatorUser(mock(Authentication.class), threadContext); @@ -70,9 +71,6 @@ public void testMarkOperatorUser() { when(operatorAuth.getUser()).thenReturn(new User("operator_user")); when(fileOperatorUsersStore.isOperatorUser(operatorAuth)).thenReturn(true); when(fileOperatorUsersStore.isOperatorUser(nonOperatorAuth)).thenReturn(false); - - final OperatorPrivilegesService operatorPrivilegesService = - new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); ThreadContext threadContext = new ThreadContext(settings); operatorPrivilegesService.maybeMarkOperatorUser(operatorAuth, threadContext); @@ -95,11 +93,8 @@ public void testCheck() { final String message = "[" + operatorAction + "]"; when(operatorOnlyRegistry.check(eq(operatorAction), any())).thenReturn(() -> message); when(operatorOnlyRegistry.check(eq(nonOperatorAction), any())).thenReturn(null); - - final OperatorPrivilegesService operatorPrivilegesService = - new DefaultOperatorPrivilegesService(xPackLicenseState, fileOperatorUsersStore, operatorOnlyRegistry); - ThreadContext threadContext = new ThreadContext(settings); + if (randomBoolean()) { threadContext.putHeader(AuthenticationField.PRIVILEGE_CATEGORY_KEY, AuthenticationField.PRIVILEGE_CATEGORY_VALUE_OPERATOR); assertNull(operatorPrivilegesService.check(operatorAction, mock(TransportRequest.class), threadContext)); @@ -113,6 +108,22 @@ public void testCheck() { assertNull(operatorPrivilegesService.check(nonOperatorAction, mock(TransportRequest.class), threadContext)); } + public void testMaybeInterceptRequest() { + final boolean licensed = randomBoolean(); + when(xPackLicenseState.checkFeature(XPackLicenseState.Feature.OPERATOR_PRIVILEGES)).thenReturn(licensed); + + final RestoreSnapshotRequest restoreSnapshotRequest = mock(RestoreSnapshotRequest.class); + operatorPrivilegesService.maybeInterceptRequest(new ThreadContext(Settings.EMPTY), restoreSnapshotRequest); + + verify(restoreSnapshotRequest).skipOperatorOnlyState(licensed); + } + + public void testMaybeInterceptRequestWillNotInterceptRequestsOtherThanRestoreSnapshotRequest() { + final TransportRequest transportRequest = mock(TransportRequest.class); + operatorPrivilegesService.maybeInterceptRequest(new ThreadContext(Settings.EMPTY), transportRequest); + verifyZeroInteractions(xPackLicenseState); + } + public void testNoOpService() { final Authentication authentication = mock(Authentication.class); ThreadContext threadContext = new ThreadContext(Settings.EMPTY); @@ -126,4 +137,14 @@ public void testNoOpService() { verifyZeroInteractions(request); } + public void testNoOpServiceMaybeInterceptRequest() { + final RestoreSnapshotRequest restoreSnapshotRequest = mock(RestoreSnapshotRequest.class); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + NOOP_OPERATOR_PRIVILEGES_SERVICE.maybeInterceptRequest(threadContext, restoreSnapshotRequest); + verify(restoreSnapshotRequest).skipOperatorOnlyState(false); + + // The test just makes sure that other requests are also accepted without any error + NOOP_OPERATOR_PRIVILEGES_SERVICE.maybeInterceptRequest(threadContext, mock(TransportRequest.class)); + } + } diff --git a/x-pack/plugin/src/test/resources/rest-api-spec/test/snapshot/20_operator_privileges_disabled.yml b/x-pack/plugin/src/test/resources/rest-api-spec/test/snapshot/20_operator_privileges_disabled.yml new file mode 100644 index 0000000000000..70925dff2e765 --- /dev/null +++ b/x-pack/plugin/src/test/resources/rest-api-spec/test/snapshot/20_operator_privileges_disabled.yml @@ -0,0 +1,73 @@ +--- +setup: + + - do: + snapshot.create_repository: + repository: test_repo_restore_2 + body: + type: fs + settings: + location: "test_repo_restore_2_loc" + + - do: + cluster.health: + wait_for_status: green + +--- +teardown: + + - do: + snapshot.delete: + repository: test_repo_restore_2 + snapshot: test_snapshot_2 + - do: + snapshot.delete_repository: + repository: test_repo_restore_2 + +--- +"Operator only settings can be set and restored by non-operator user when operator privileges is disabled": + - skip: + features: ["allowed_warnings"] + + - do: + cluster.put_settings: + body: + persistent: + xpack.security.http.filter.deny: example.com + xpack.security.transport.filter.deny: example.com + + - do: + snapshot.create: + repository: test_repo_restore_2 + snapshot: test_snapshot_2 + wait_for_completion: true + body: | + { "include_global_state": true } + + - match: { snapshot.snapshot: test_snapshot_2 } + - match: { snapshot.state : SUCCESS } + - is_true: snapshot.include_global_state + - is_true: snapshot.version + - gt: { snapshot.version_id: 0} + + - do: + cluster.put_settings: + body: + persistent: + xpack.security.http.filter.deny: tutorial.com + xpack.security.transport.filter.deny: tutorial.com + + - do: + snapshot.restore: + repository: test_repo_restore_2 + snapshot: test_snapshot_2 + wait_for_completion: true + body: | + { "include_global_state": true } + + - do: + cluster.get_settings: {} + + - match: { persistent.xpack.security.http.filter.deny: "example.com" } + - match: { persistent.xpack.security.transport.filter.deny: "example.com" } +