diff --git a/server/src/test/java/org/elasticsearch/cluster/metadata/SelectorResolverTests.java b/server/src/test/java/org/elasticsearch/cluster/metadata/SelectorResolverTests.java index b796cdfbca335..28bf91156c9b8 100644 --- a/server/src/test/java/org/elasticsearch/cluster/metadata/SelectorResolverTests.java +++ b/server/src/test/java/org/elasticsearch/cluster/metadata/SelectorResolverTests.java @@ -72,16 +72,32 @@ public void testResolveExpression() { // === Corner Cases // Empty index name is not necessarily disallowed, but will be filtered out in the next steps of resolution assertThat(resolve(selectorsAllowed, "::data"), equalTo(new ResolvedExpression("", DATA))); + assertThat(resolve(selectorsAllowed, "::failures"), equalTo(new ResolvedExpression("", FAILURES))); // Remote cluster syntax is respected, even if code higher up the call stack is likely to already have handled it already assertThat(resolve(selectorsAllowed, "cluster:index::data"), equalTo(new ResolvedExpression("cluster:index", DATA))); // CCS with an empty index name is not necessarily disallowed, though other code in the resolution logic will likely throw assertThat(resolve(selectorsAllowed, "cluster:::data"), equalTo(new ResolvedExpression("cluster:", DATA))); // Same for empty cluster and index names assertThat(resolve(selectorsAllowed, ":::data"), equalTo(new ResolvedExpression(":", DATA))); + assertThat(resolve(selectorsAllowed, ":::failures"), equalTo(new ResolvedExpression(":", FAILURES))); // Any more prefix colon characters will trigger the multiple separators error logic expectThrows(InvalidIndexNameException.class, () -> resolve(selectorsAllowed, "::::data")); + expectThrows(InvalidIndexNameException.class, () -> resolve(selectorsAllowed, "::::failures")); + expectThrows(InvalidIndexNameException.class, () -> resolve(selectorsAllowed, ":::::failures")); // Suffix case is not supported because there is no component named with the empty string expectThrows(InvalidIndexNameException.class, () -> resolve(selectorsAllowed, "index::")); + + assertThat(resolve(selectorsAllowed, "cluster:index::failures"), equalTo(new ResolvedExpression("cluster:index", FAILURES))); + expectThrows(IllegalArgumentException.class, () -> resolve(noSelectors, "cluster:index::failures")); + assertThat(resolve(selectorsAllowed, "cluster-*:index::failures"), equalTo(new ResolvedExpression("cluster-*:index", FAILURES))); + assertThat( + resolve(selectorsAllowed, "cluster-*:index-*::failures"), + equalTo(new ResolvedExpression("cluster-*:index-*", FAILURES)) + ); + assertThat(resolve(selectorsAllowed, "cluster-*:*::failures"), equalTo(new ResolvedExpression("cluster-*:*", FAILURES))); + assertThat(resolve(selectorsAllowed, "*:index-*::failures"), equalTo(new ResolvedExpression("*:index-*", FAILURES))); + assertThat(resolve(selectorsAllowed, "*:*::failures"), equalTo(new ResolvedExpression("*:*", FAILURES))); + assertThat(resolve(selectorsAllowed, "cluster:::failures"), equalTo(new ResolvedExpression("cluster:", FAILURES))); } public void testResolveMatchAllToSelectors() { diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java index 95ad898140c21..d22ed149e87cf 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/CrossClusterApiKeyRoleDescriptorBuilder.java @@ -31,7 +31,11 @@ public class CrossClusterApiKeyRoleDescriptorBuilder { Arrays.stream(CCS_CLUSTER_PRIVILEGE_NAMES), Arrays.stream(CCR_CLUSTER_PRIVILEGE_NAMES) ).toArray(String[]::new); - public static final String[] CCS_INDICES_PRIVILEGE_NAMES = { "read", "read_cross_cluster", "view_index_metadata" }; + public static final String[] CCS_INDICES_PRIVILEGE_NAMES = { + "read", + "read_cross_cluster", + "view_index_metadata", + "read_failure_store" }; public static final String[] CCR_INDICES_PRIVILEGE_NAMES = { "cross_cluster_replication", "cross_cluster_replication_internal" }; public static final String ROLE_DESCRIPTOR_NAME = "cross_cluster"; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java index 761c2258d3d3f..f695d7da9eeaf 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/role/RoleDescriptorRequestValidator.java @@ -11,7 +11,6 @@ import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilege; import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver; -import org.elasticsearch.xpack.core.security.authz.privilege.IndexComponentSelectorPredicate; import org.elasticsearch.xpack.core.security.authz.privilege.IndexPrivilege; import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver; import org.elasticsearch.xpack.core.security.support.MetadataUtils; @@ -61,13 +60,7 @@ public static ActionRequestValidationException validate( validationException = addValidationError("remote index cluster alias cannot be an empty string", validationException); } try { - Set privileges = IndexPrivilege.resolveBySelectorAccess(Set.of(ridp.indicesPrivileges().getPrivileges())); - if (privileges.stream().anyMatch(p -> p.getSelectorPredicate() == IndexComponentSelectorPredicate.FAILURES)) { - validationException = addValidationError( - "remote index privileges cannot contain privileges that grant access to the failure store", - validationException - ); - } + IndexPrivilege.resolveBySelectorAccess(Set.of(ridp.indicesPrivileges().getPrivileges())); } catch (IllegalArgumentException ile) { validationException = addValidationError(ile.getMessage(), validationException); } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java index 5da75d5717edf..24fa45fb887dc 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/IndexPrivilege.java @@ -188,7 +188,11 @@ public final class IndexPrivilege extends Privilege { public static final IndexPrivilege NONE = new IndexPrivilege("none", Automatons.EMPTY); public static final IndexPrivilege ALL = new IndexPrivilege("all", ALL_AUTOMATON, IndexComponentSelectorPredicate.ALL); public static final IndexPrivilege READ = new IndexPrivilege("read", READ_AUTOMATON); - public static final IndexPrivilege READ_CROSS_CLUSTER = new IndexPrivilege("read_cross_cluster", READ_CROSS_CLUSTER_AUTOMATON); + public static final IndexPrivilege READ_CROSS_CLUSTER = new IndexPrivilege( + "read_cross_cluster", + READ_CROSS_CLUSTER_AUTOMATON, + IndexComponentSelectorPredicate.ALL + ); public static final IndexPrivilege CREATE = new IndexPrivilege("create", CREATE_AUTOMATON); public static final IndexPrivilege INDEX = new IndexPrivilege("index", INDEX_AUTOMATON); public static final IndexPrivilege DELETE = new IndexPrivilege("delete", DELETE_AUTOMATON); @@ -383,6 +387,9 @@ private static Set resolve(Set name) { dataSelectorAccessPrivileges.add(indexPrivilege); } else if (indexPrivilege.selectorPredicate == IndexComponentSelectorPredicate.FAILURES) { failuresSelectorAccessPrivileges.add(indexPrivilege); + } else if (indexPrivilege.selectorPredicate == IndexComponentSelectorPredicate.ALL) { + failuresSelectorAccessPrivileges.add(indexPrivilege); + dataSelectorAccessPrivileges.add(indexPrivilege); } else { String errorMessage = "unexpected selector [" + indexPrivilege.selectorPredicate + "]"; assert false : errorMessage; diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/AbstractRemoteClusterSecurityFailureStoreRestIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/AbstractRemoteClusterSecurityFailureStoreRestIT.java new file mode 100644 index 0000000000000..b4265d2b9e3ad --- /dev/null +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/AbstractRemoteClusterSecurityFailureStoreRestIT.java @@ -0,0 +1,139 @@ +/* + * 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.remotecluster; + +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.Response; +import org.elasticsearch.common.xcontent.support.XContentMapValues; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchResponseUtils; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; + +abstract class AbstractRemoteClusterSecurityFailureStoreRestIT extends AbstractRemoteClusterSecurityTestCase { + + protected void assertSearchResponseContainsIndices(Response response, String... expectedIndices) throws IOException { + assertOK(response); + final SearchResponse searchResponse = SearchResponseUtils.parseSearchResponse(responseAsParser(response)); + try { + final List actualIndices = Arrays.stream(searchResponse.getHits().getHits()) + .map(SearchHit::getIndex) + .collect(Collectors.toList()); + assertThat(actualIndices, containsInAnyOrder(expectedIndices)); + } finally { + searchResponse.decRef(); + } + } + + protected void setupTestDataStreamOnFulfillingCluster() throws IOException { + // Create data stream and index some documents + final Request createComponentTemplate = new Request("PUT", "/_component_template/component1"); + createComponentTemplate.setJsonEntity(""" + { + "template": { + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "age": { + "type": "integer" + }, + "email": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "data_stream_options": { + "failure_store": { + "enabled": true + } + } + } + }"""); + assertOK(performRequestAgainstFulfillingCluster(createComponentTemplate)); + + final Request createTemplate = new Request("PUT", "/_index_template/template1"); + createTemplate.setJsonEntity(""" + { + "index_patterns": ["test*"], + "data_stream": {}, + "priority": 500, + "composed_of": ["component1"] + }"""); + assertOK(performRequestAgainstFulfillingCluster(createTemplate)); + + final Request createDoc1 = new Request("PUT", "/test1/_doc/1?refresh=true&op_type=create"); + createDoc1.setJsonEntity(""" + { + "@timestamp": 1, + "age" : 1, + "name" : "jack", + "email" : "jack@example.com" + }"""); + assertOK(performRequestAgainstFulfillingCluster(createDoc1)); + + final Request createDoc2 = new Request("PUT", "/test1/_doc/2?refresh=true&op_type=create"); + createDoc2.setJsonEntity(""" + { + "@timestamp": 2, + "age" : "this should be an int", + "name" : "jack", + "email" : "jack@example.com" + }"""); + assertOK(performRequestAgainstFulfillingCluster(createDoc2)); + } + + protected Response performRequestWithRemoteSearchUser(final Request request) throws IOException { + request.setOptions( + RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerFromRandomAuthMethod(REMOTE_SEARCH_USER, PASS)) + ); + return client().performRequest(request); + } + + protected Response performRequestWithUser(final String user, final Request request) throws IOException { + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", headerFromRandomAuthMethod(user, PASS))); + return client().performRequest(request); + } + + @SuppressWarnings("unchecked") + protected Tuple, List> getDataAndFailureIndices(String dataStreamName) throws IOException { + Request dataStream = new Request("GET", "/_data_stream/" + dataStreamName); + Response response = performRequestAgainstFulfillingCluster(dataStream); + Map dataStreams = entityAsMap(response); + assertEquals(Collections.singletonList("test1"), XContentMapValues.extractValue("data_streams.name", dataStreams)); + List dataIndexNames = (List) XContentMapValues.extractValue("data_streams.indices.index_name", dataStreams); + List failureIndexNames = (List) XContentMapValues.extractValue( + "data_streams.failure_store.indices.index_name", + dataStreams + ); + return new Tuple<>(dataIndexNames, failureIndexNames); + } + + protected Tuple getSingleDataAndFailureIndices(String dataStreamName) throws IOException { + Tuple, List> indices = getDataAndFailureIndices(dataStreamName); + assertThat(indices.v1().size(), equalTo(1)); + assertThat(indices.v2().size(), equalTo(1)); + return new Tuple<>(indices.v1().get(0), indices.v2().get(0)); + } + +} diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1FailureStoreRestIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1FailureStoreRestIT.java new file mode 100644 index 0000000000000..d545758fbeac5 --- /dev/null +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS1FailureStoreRestIT.java @@ -0,0 +1,272 @@ +/* + * 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.remotecluster; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.ResponseException; +import org.elasticsearch.client.RestClient; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.Strings; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.cluster.FeatureFlag; +import org.elasticsearch.test.cluster.local.distribution.DistributionType; +import org.elasticsearch.test.cluster.util.resource.Resource; +import org.junit.ClassRule; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; + +import java.io.IOException; +import java.util.Locale; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteClusterSecurityFailureStoreRestIT { + + static { + fulfillingCluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .name("fulfilling-cluster") + .nodes(3) + .apply(commonClusterConfig) + .feature(FeatureFlag.FAILURE_STORE_ENABLED) + .rolesFile(Resource.fromClasspath("roles.yml")) + .build(); + + queryCluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .name("query-cluster") + .apply(commonClusterConfig) + .feature(FeatureFlag.FAILURE_STORE_ENABLED) + .rolesFile(Resource.fromClasspath("roles.yml")) + .build(); + } + + @ClassRule + public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster); + + private static final String ALL_ACCESS = "all_access"; + private static final String DATA_ACCESS = "data_access"; + private static final String FAILURE_STORE_ACCESS = "failure_store_access"; + + public void testRCS1CrossClusterSearch() throws Exception { + final boolean rcs1Security = true; + final boolean isProxyMode = randomBoolean(); + final boolean skipUnavailable = false; // we want to get actual failures and not skip and get empty results + final boolean ccsMinimizeRoundtrips = randomBoolean(); + + configureRemoteCluster(REMOTE_CLUSTER_ALIAS, fulfillingCluster, rcs1Security, isProxyMode, skipUnavailable); + + // fulfilling cluster setup + setupRoleAndUserOnFulfillingCluster(); + setupTestDataStreamOnFulfillingCluster(); + + // query cluster setup + setupLocalDataOnQueryCluster(); + setupUserAndRoleOnQueryCluster(); + + final Tuple backingIndices = getSingleDataAndFailureIndices("test1"); + final String backingDataIndexName = backingIndices.v1(); + final String backingFailureIndexName = backingIndices.v2(); + + final String[] users = { FAILURE_STORE_ACCESS, DATA_ACCESS, ALL_ACCESS }; + for (String user : users) { + // query remote cluster using ::data selector should succeed + final boolean alsoSearchLocally = randomBoolean(); + final Request dataSearchRequest = new Request( + "GET", + String.format( + Locale.ROOT, + "/%s%s:%s/_search?ccs_minimize_roundtrips=%s", + alsoSearchLocally ? "local_index," : "", + randomFrom("my_remote_cluster", "*", "my_remote_*"), + randomFrom("test1::data", "test1", "test*", "test*::data", "*", "*::data", backingDataIndexName), + ccsMinimizeRoundtrips + ) + ); + final String[] expectedIndices = alsoSearchLocally + ? new String[] { "local_index", backingDataIndexName } + : new String[] { backingDataIndexName }; + assertSearchResponseContainsIndices(performRequestWithUser(user, dataSearchRequest), expectedIndices); + } + for (String user : new String[] { FAILURE_STORE_ACCESS, ALL_ACCESS }) { + // query remote cluster using ::failures selector should fail (regardless of the user's permissions) + assertSearchResponseContainsIndices( + performRequestWithUser( + user, + new Request( + "GET", + String.format( + Locale.ROOT, + "/my_remote_cluster:%s/_search?ccs_minimize_roundtrips=%s&ignore_unavailable=true", + randomFrom("test1::failures", "test*::failures", "*::failures"), + ccsMinimizeRoundtrips + ) + ) + ), + backingFailureIndexName + ); + } + { + // direct access to backing failure index is subject to the user's permissions + // it might fail in some cases and work in others + Request failureIndexSearchRequest = new Request( + "GET", + String.format( + Locale.ROOT, + "/my_remote_cluster:%s/_search?ccs_minimize_roundtrips=%s", + backingFailureIndexName, + ccsMinimizeRoundtrips + ) + ); + + // user with access to all should be able to search the backing failure index + assertSearchResponseContainsIndices(performRequestWithUser(ALL_ACCESS, failureIndexSearchRequest), backingFailureIndexName); + + // user with data only access should not be able to search the backing failure index + { + final ResponseException exception = expectThrows( + ResponseException.class, + () -> performRequestWithUser(DATA_ACCESS, failureIndexSearchRequest) + ); + assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(403)); + assertThat( + exception.getMessage(), + containsString( + "action [" + + (ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards") + + "] is unauthorized for user [" + + DATA_ACCESS + + "] " + + "with effective roles [" + + DATA_ACCESS + + "] on indices [" + + backingFailureIndexName + + "], this action is granted by the index privileges [view_index_metadata,manage,read_cross_cluster,all]" + + ) + ); + } + assertSearchResponseContainsIndices( + performRequestWithUser(FAILURE_STORE_ACCESS, failureIndexSearchRequest), + backingFailureIndexName + ); + } + } + + private static void setupLocalDataOnQueryCluster() throws IOException { + // Index some documents, to use them in a mixed-cluster search + final var indexDocRequest = new Request("POST", "/local_index/_doc?refresh=true"); + indexDocRequest.setJsonEntity("{\"local_foo\": \"local_bar\"}"); + assertOK(client().performRequest(indexDocRequest)); + } + + private static void setupUserAndRoleOnQueryCluster() throws IOException { + createRole(adminClient(), ALL_ACCESS, """ + { + "indices": [ + { + "names": ["*"], + "privileges": ["all"] + } + ] + }"""); + createUser(adminClient(), ALL_ACCESS, PASS, ALL_ACCESS); + // the role must simply exist on query cluster, the access is irrelevant, + // but we here grant the access to local_index only to test mixed search + createRole(adminClient(), FAILURE_STORE_ACCESS, """ + { + "indices": [ + { + "names": ["local_index"], + "privileges": ["read"] + } + ] + }"""); + createUser(adminClient(), FAILURE_STORE_ACCESS, PASS, FAILURE_STORE_ACCESS); + createRole(adminClient(), DATA_ACCESS, """ + { + "indices": [ + { + "names": ["local_index"], + "privileges": ["read"] + } + ] + }"""); + createUser(adminClient(), DATA_ACCESS, PASS, DATA_ACCESS); + } + + private static void createRole(RestClient client, String role, String roleDescriptor) throws IOException { + final Request putRoleRequest = new Request("PUT", "/_security/role/" + role); + putRoleRequest.setJsonEntity(roleDescriptor); + assertOK(client.performRequest(putRoleRequest)); + } + + private static void createUser(RestClient client, String user, SecureString password, String role) throws IOException { + final Request putUserRequest = new Request("PUT", "/_security/user/" + user); + putUserRequest.setJsonEntity(Strings.format(""" + { + "password": "%s", + "roles" : ["%s"] + }""", password.toString(), role)); + assertOK(client.performRequest(putUserRequest)); + } + + private static void setupRoleAndUserOnFulfillingCluster() throws IOException { + putRoleOnFulfillingCluster(DATA_ACCESS, """ + { + "indices": [ + { + "names": ["test*"], + "privileges": ["read", "read_cross_cluster"] + } + ] + }"""); + putUserOnFulfillingCluster(DATA_ACCESS, DATA_ACCESS); + + putRoleOnFulfillingCluster(FAILURE_STORE_ACCESS, """ + { + "indices": [ + { + "names": ["test*"], + "privileges": ["read", "read_cross_cluster", "read_failure_store"] + } + ] + }"""); + putUserOnFulfillingCluster(FAILURE_STORE_ACCESS, FAILURE_STORE_ACCESS); + + putRoleOnFulfillingCluster(ALL_ACCESS, """ + { + "indices": [ + { + "names": ["*"], + "privileges": ["all"] + } + ] + }"""); + putUserOnFulfillingCluster(ALL_ACCESS, ALL_ACCESS); + } + + private static void putRoleOnFulfillingCluster(String roleName, String roleDescriptor) throws IOException { + Request request = new Request("PUT", "/_security/role/" + roleName); + request.setJsonEntity(roleDescriptor); + assertOK(performRequestAgainstFulfillingCluster(request)); + } + + private static void putUserOnFulfillingCluster(String user, String role) throws IOException { + Request request = new Request("PUT", "/_security/user/" + user); + request.setJsonEntity(Strings.format(""" + { + "password": "%s", + "roles" : ["%s"] + }""", PASS.toString(), role)); + assertOK(performRequestAgainstFulfillingCluster(request)); + } + +} diff --git a/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2FailureStoreRestIT.java b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2FailureStoreRestIT.java new file mode 100644 index 0000000000000..d0969b5401e67 --- /dev/null +++ b/x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityRCS2FailureStoreRestIT.java @@ -0,0 +1,173 @@ +/* + * 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.remotecluster; + +import org.elasticsearch.client.Request; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.test.cluster.ElasticsearchCluster; +import org.elasticsearch.test.cluster.FeatureFlag; +import org.elasticsearch.test.cluster.local.distribution.DistributionType; +import org.elasticsearch.test.cluster.util.resource.Resource; +import org.junit.ClassRule; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; + +import java.io.IOException; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public class RemoteClusterSecurityRCS2FailureStoreRestIT extends AbstractRemoteClusterSecurityFailureStoreRestIT { + + private static final AtomicReference> API_KEY_MAP_REF = new AtomicReference<>(); + + static { + fulfillingCluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .name("fulfilling-cluster") + .apply(commonClusterConfig) + .feature(FeatureFlag.FAILURE_STORE_ENABLED) + .setting("remote_cluster_server.enabled", "true") + .setting("remote_cluster.port", "0") + .setting("xpack.security.remote_cluster_server.ssl.enabled", "true") + .setting("xpack.security.remote_cluster_server.ssl.key", "remote-cluster.key") + .setting("xpack.security.remote_cluster_server.ssl.certificate", "remote-cluster.crt") + .setting("xpack.security.authc.token.enabled", "true") + .keystore("xpack.security.remote_cluster_server.ssl.secure_key_passphrase", "remote-cluster-password") + .build(); + + queryCluster = ElasticsearchCluster.local() + .distribution(DistributionType.DEFAULT) + .name("query-cluster") + .apply(commonClusterConfig) + .feature(FeatureFlag.FAILURE_STORE_ENABLED) + .setting("xpack.security.remote_cluster_client.ssl.enabled", "true") + .setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt") + .setting("xpack.security.authc.token.enabled", "true") + .keystore("cluster.remote.my_remote_cluster.credentials", () -> { + API_KEY_MAP_REF.compareAndSet(null, createCrossClusterAccessApiKey(""" + { + "search": [ + { + "names": ["test*"] + } + ] + }""")); + return (String) API_KEY_MAP_REF.get().get("encoded"); + }) + .rolesFile(Resource.fromClasspath("roles.yml")) + .build(); + } + + @ClassRule + // Use a RuleChain to ensure that fulfilling cluster is started before query cluster + public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster); + + public void testRCS2CrossClusterSearch() throws Exception { + // configure remote cluster using API Key-based authentication + configureRemoteCluster(); + final String crossClusterAccessApiKeyId = (String) API_KEY_MAP_REF.get().get("id"); + final boolean ccsMinimizeRoundtrips = randomBoolean(); + + // fulfilling cluster setup + setupTestDataStreamOnFulfillingCluster(); + + // query cluster setup + setupLocalDataOnQueryCluster(); + setupUserAndRoleOnQueryCluster(); + + final Tuple backingIndices = getSingleDataAndFailureIndices("test1"); + final String backingDataIndexName = backingIndices.v1(); + final String backingFailureIndexName = backingIndices.v2(); + { + // query remote cluster using ::data selector should succeed + final boolean alsoSearchLocally = randomBoolean(); + final Request dataSearchRequest = new Request( + "GET", + String.format( + Locale.ROOT, + "/%s%s:%s/_search?ccs_minimize_roundtrips=%s&ignore_unavailable=false", + alsoSearchLocally ? "local_index," : "", + randomFrom("my_remote_cluster", "*", "my_remote_*"), + randomFrom("test1::data", "test1", "test*", "test*::data", "*", "*::data", backingDataIndexName), + ccsMinimizeRoundtrips + ) + ); + final String[] expectedIndices = alsoSearchLocally + ? new String[] { "local_index", backingDataIndexName } + : new String[] { backingDataIndexName }; + assertSearchResponseContainsIndices(performRequestWithRemoteSearchUser(dataSearchRequest), expectedIndices); + } + { + // query remote cluster using ::failures selector should succeed + assertSearchResponseContainsIndices( + performRequestWithRemoteSearchUser( + new Request( + "GET", + String.format( + Locale.ROOT, + "/my_remote_cluster:%s/_search?ccs_minimize_roundtrips=%s", + randomFrom("test1::failures", "test*::failures", "*::failures"), + ccsMinimizeRoundtrips + ) + ) + ), + backingFailureIndexName + ); + } + { + Request failureIndexSearchRequest = new Request( + "GET", + String.format( + Locale.ROOT, + "/my_remote_cluster:%s/_search?ccs_minimize_roundtrips=%s", + backingFailureIndexName, + ccsMinimizeRoundtrips + ) + ); + assertSearchResponseContainsIndices(performRequestWithRemoteSearchUser(failureIndexSearchRequest), backingFailureIndexName); + } + } + + private static void setupLocalDataOnQueryCluster() throws IOException { + // Index some documents, to use them in a mixed-cluster search + final var indexDocRequest = new Request("POST", "/local_index/_doc?refresh=true"); + indexDocRequest.setJsonEntity("{\"local_foo\": \"local_bar\"}"); + assertOK(client().performRequest(indexDocRequest)); + } + + private static void setupUserAndRoleOnQueryCluster() throws IOException { + final var putRoleRequest = new Request("PUT", "/_security/role/" + REMOTE_SEARCH_ROLE); + putRoleRequest.setJsonEntity(""" + { + "description": "Role with privileges for remote and local indices.", + "indices": [ + { + "names": ["local_index"], + "privileges": ["read"] + } + ], + "remote_indices": [ + { + "names": ["test*"], + "privileges": ["read", "read_cross_cluster", "read_failure_store"], + "clusters": ["my_remote_cluster"] + } + ] + }"""); + assertOK(adminClient().performRequest(putRoleRequest)); + final var putUserRequest = new Request("PUT", "/_security/user/" + REMOTE_SEARCH_USER); + putUserRequest.setJsonEntity(""" + { + "password": "x-pack-test-password", + "roles" : ["remote_search"] + }"""); + assertOK(adminClient().performRequest(putUserRequest)); + } + +}