|  | @@ -8,24 +8,34 @@
 | 
	
		
			
				|  |  |  package org.elasticsearch.xpack.remotecluster;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import org.elasticsearch.client.Request;
 | 
	
		
			
				|  |  | +import org.elasticsearch.client.RequestOptions;
 | 
	
		
			
				|  |  | +import org.elasticsearch.client.Response;
 | 
	
		
			
				|  |  |  import org.elasticsearch.client.ResponseException;
 | 
	
		
			
				|  |  |  import org.elasticsearch.client.RestClient;
 | 
	
		
			
				|  |  |  import org.elasticsearch.common.settings.SecureString;
 | 
	
		
			
				|  |  | +import org.elasticsearch.core.Nullable;
 | 
	
		
			
				|  |  |  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.elasticsearch.test.rest.ObjectPath;
 | 
	
		
			
				|  |  | +import org.junit.Before;
 | 
	
		
			
				|  |  |  import org.junit.ClassRule;
 | 
	
		
			
				|  |  |  import org.junit.rules.RuleChain;
 | 
	
		
			
				|  |  |  import org.junit.rules.TestRule;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import java.io.IOException;
 | 
	
		
			
				|  |  | +import java.util.HashMap;
 | 
	
		
			
				|  |  |  import java.util.Locale;
 | 
	
		
			
				|  |  | +import java.util.Map;
 | 
	
		
			
				|  |  | +import java.util.function.Consumer;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +import static org.hamcrest.Matchers.anyOf;
 | 
	
		
			
				|  |  |  import static org.hamcrest.Matchers.containsString;
 | 
	
		
			
				|  |  |  import static org.hamcrest.Matchers.equalTo;
 | 
	
		
			
				|  |  | +import static org.hamcrest.Matchers.is;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteClusterSecurityFailureStoreRestIT {
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -59,10 +69,190 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |      private static final String BACKING_FAILURE_STORE_INDEX_ACCESS = "backing_failure_store_index_access";
 | 
	
		
			
				|  |  |      private static final String BACKING_DATA_INDEX_ACCESS = "backing_data_index_access";
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * Maps usernames to their role descriptors. The usernames are also used as role names.
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private static final Map<String, String> usersAndRolesOnFulfillingCluster = Map.of(DATA_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", MANAGE_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["manage_failure_store", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", ALL_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["*"],
 | 
	
		
			
				|  |  | +              "privileges": ["all"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", ONLY_READ_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", BACKING_DATA_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": [".ds-test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", BACKING_FAILURE_STORE_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": [".fs-test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""");
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * The role must simply exist on query cluster, the actual access is irrelevant for user authorization.
 | 
	
		
			
				|  |  | +     * But we here grant access to the local and (for some also) remote indices to test mixed search
 | 
	
		
			
				|  |  | +     * and API key authorization.
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private static final Map<String, String> usersAndRolesOnQueryCluster = Map.of(DATA_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +             {
 | 
	
		
			
				|  |  | +              "names": ["local_index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read"]
 | 
	
		
			
				|  |  | +            },
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +             {
 | 
	
		
			
				|  |  | +              "names": ["local_index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read"]
 | 
	
		
			
				|  |  | +            },
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", MANAGE_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["local_index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read"]
 | 
	
		
			
				|  |  | +            },
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["manage_failure_store", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", ALL_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["all"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["*"],
 | 
	
		
			
				|  |  | +              "privileges": ["all"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", ONLY_READ_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | +              "privileges": ["read_failure_store"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", BACKING_DATA_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": [".ds-test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""", BACKING_FAILURE_STORE_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | +        {
 | 
	
		
			
				|  |  | +          "cluster": ["manage_security"],
 | 
	
		
			
				|  |  | +          "indices": [
 | 
	
		
			
				|  |  | +            {
 | 
	
		
			
				|  |  | +              "names": [".fs-test*"],
 | 
	
		
			
				|  |  | +              "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +          ]
 | 
	
		
			
				|  |  | +        }""");
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * Maps usernames to their API keys.
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private static Map<String, Tuple<String, String>> apiKeys = new HashMap<>();
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    @Before
 | 
	
		
			
				|  |  | +    public void resetApiKeys() {
 | 
	
		
			
				|  |  | +        apiKeys = new HashMap<>();
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static void setupRoleAndUserOnFulfillingCluster() throws IOException {
 | 
	
		
			
				|  |  | +        for (var userAndRole : usersAndRolesOnFulfillingCluster.entrySet()) {
 | 
	
		
			
				|  |  | +            String roleAndUsername = userAndRole.getKey();
 | 
	
		
			
				|  |  | +            String roleDescriptor = userAndRole.getValue();
 | 
	
		
			
				|  |  | +            createRoleAndUserOnFulfillingCluster(roleAndUsername, roleDescriptor);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static void setupUserAndRoleOnQueryCluster() throws IOException {
 | 
	
		
			
				|  |  | +        for (var userAndRole : usersAndRolesOnQueryCluster.entrySet()) {
 | 
	
		
			
				|  |  | +            String roleAndUsername = userAndRole.getKey();
 | 
	
		
			
				|  |  | +            String roleDescriptor = userAndRole.getValue();
 | 
	
		
			
				|  |  | +            createRoleOnQueryCluster(adminClient(), roleAndUsername, roleDescriptor);
 | 
	
		
			
				|  |  | +            createUserOnQueryCluster(adminClient(), roleAndUsername, PASS, roleAndUsername);
 | 
	
		
			
				|  |  | +            createAndStoreApiKeyOnQueryCluster(roleAndUsername, roleDescriptor);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    /**
 | 
	
		
			
				|  |  | +     * Index some documents on the query cluster to use them in a mixed-cluster search.
 | 
	
		
			
				|  |  | +     */
 | 
	
		
			
				|  |  | +    private static void setupLocalDataOnQueryCluster() throws IOException {
 | 
	
		
			
				|  |  | +        final var indexDocRequest = new Request("POST", "/local_index/_doc?refresh=true");
 | 
	
		
			
				|  |  | +        indexDocRequest.setJsonEntity("{\"local_foo\": \"local_bar\"}");
 | 
	
		
			
				|  |  | +        assertOK(client().performRequest(indexDocRequest));
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |      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 skipUnavailable = randomBoolean();
 | 
	
		
			
				|  |  |          final boolean ccsMinimizeRoundtrips = randomBoolean();
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          configureRemoteCluster(REMOTE_CLUSTER_ALIAS, fulfillingCluster, rcs1Security, isProxyMode, skipUnavailable);
 | 
	
	
		
			
				|  | @@ -86,11 +276,11 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          testCcsWithDataSelectorNotSupported(ccsMinimizeRoundtrips);
 | 
	
		
			
				|  |  |          testCcsWithFailuresSelectorNotSupported(ccsMinimizeRoundtrips);
 | 
	
		
			
				|  |  |          testCcsWithoutSelectorsSupported(backingDataIndexName, ccsMinimizeRoundtrips);
 | 
	
		
			
				|  |  | -        testSearchingUnauthorizedIndices(otherBackingFailureIndexName, otherBackingDataIndexName, ccsMinimizeRoundtrips);
 | 
	
		
			
				|  |  | +        testSearchingUnauthorizedIndices(otherBackingFailureIndexName, otherBackingDataIndexName, ccsMinimizeRoundtrips, skipUnavailable);
 | 
	
		
			
				|  |  |          testSearchingWithAccessToAllIndices(ccsMinimizeRoundtrips, backingDataIndexName, otherBackingDataIndexName);
 | 
	
		
			
				|  |  | -        testBackingFailureIndexAccess(ccsMinimizeRoundtrips, backingFailureIndexName);
 | 
	
		
			
				|  |  | +        testBackingFailureIndexAccess(ccsMinimizeRoundtrips, backingFailureIndexName, skipUnavailable);
 | 
	
		
			
				|  |  |          testBackingDataIndexAccess(ccsMinimizeRoundtrips, backingDataIndexName);
 | 
	
		
			
				|  |  | -        testSearchingNonExistingIndices(ccsMinimizeRoundtrips);
 | 
	
		
			
				|  |  | +        testSearchingNonExistingIndices(ccsMinimizeRoundtrips, skipUnavailable);
 | 
	
		
			
				|  |  |          testResolveRemoteClustersIsUnauthorized();
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -105,7 +295,7 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              )
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |          assertSearchResponseContainsIndices(
 | 
	
		
			
				|  |  | -            performRequestWithUser(BACKING_DATA_INDEX_ACCESS, dataIndexSearchRequest),
 | 
	
		
			
				|  |  | +            performRequestMaybeUsingApiKey(BACKING_DATA_INDEX_ACCESS, dataIndexSearchRequest),
 | 
	
		
			
				|  |  |              backingDataIndexName
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |      }
 | 
	
	
		
			
				|  | @@ -130,35 +320,37 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          final String[] expectedIndices = alsoSearchLocally
 | 
	
		
			
				|  |  |              ? new String[] { "local_index", backingDataIndexName, otherBackingDataIndexName }
 | 
	
		
			
				|  |  |              : new String[] { backingDataIndexName, otherBackingDataIndexName };
 | 
	
		
			
				|  |  | -        assertSearchResponseContainsIndices(performRequestWithUser(ALL_ACCESS, dataSearchRequest), expectedIndices);
 | 
	
		
			
				|  |  | +        assertSearchResponseContainsIndices(performRequestMaybeUsingApiKey(ALL_ACCESS, dataSearchRequest), expectedIndices);
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    private void testSearchingNonExistingIndices(boolean ccsMinimizeRoundtrips) {
 | 
	
		
			
				|  |  | +    private void testSearchingNonExistingIndices(boolean ccsMinimizeRoundtrips, boolean skipUnavailable) {
 | 
	
		
			
				|  |  |          // searching non-existing index without permissions should result in 403
 | 
	
		
			
				|  |  |          {
 | 
	
		
			
				|  |  | -            final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  | -                ResponseException.class,
 | 
	
		
			
				|  |  | -                () -> performRequestWithUser(
 | 
	
		
			
				|  |  | +            final String indexToSearch = "non-existing-no-privileges";
 | 
	
		
			
				|  |  | +            final String action = ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards";
 | 
	
		
			
				|  |  | +            executeAndAssert(
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(
 | 
	
		
			
				|  |  |                      FAILURE_STORE_ACCESS,
 | 
	
		
			
				|  |  |                      new Request(
 | 
	
		
			
				|  |  |                          "GET",
 | 
	
		
			
				|  |  |                          String.format(
 | 
	
		
			
				|  |  |                              Locale.ROOT,
 | 
	
		
			
				|  |  |                              "/my_remote_cluster:%s/_search?ccs_minimize_roundtrips=%s",
 | 
	
		
			
				|  |  | -                            "non-existing-no-privileges",
 | 
	
		
			
				|  |  | +                            indexToSearch,
 | 
	
		
			
				|  |  |                              ccsMinimizeRoundtrips
 | 
	
		
			
				|  |  |                          )
 | 
	
		
			
				|  |  |                      )
 | 
	
		
			
				|  |  | -                )
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                exception -> assertActionUnauthorized(exception, FAILURE_STORE_ACCESS, action, indexToSearch),
 | 
	
		
			
				|  |  | +                response -> assertActionUnauthorized(response, FAILURE_STORE_ACCESS, action, indexToSearch),
 | 
	
		
			
				|  |  | +                skipUnavailable
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  | -            final String action = ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards";
 | 
	
		
			
				|  |  | -            assertActionUnauthorized(exception, FAILURE_STORE_ACCESS, action, "non-existing-no-privileges");
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |          // searching non-existing index with permissions should result in 404
 | 
	
		
			
				|  |  |          {
 | 
	
		
			
				|  |  | -            final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  | -                ResponseException.class,
 | 
	
		
			
				|  |  | -                () -> performRequestWithUser(
 | 
	
		
			
				|  |  | +            final String indexToSearch = "non-existing-index";
 | 
	
		
			
				|  |  | +            executeAndAssert(
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(
 | 
	
		
			
				|  |  |                      FAILURE_STORE_ACCESS,
 | 
	
		
			
				|  |  |                      new Request(
 | 
	
		
			
				|  |  |                          "GET",
 | 
	
	
		
			
				|  | @@ -169,22 +361,30 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |                              ccsMinimizeRoundtrips
 | 
	
		
			
				|  |  |                          )
 | 
	
		
			
				|  |  |                      )
 | 
	
		
			
				|  |  | -                )
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                exception -> assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(404)),
 | 
	
		
			
				|  |  | +                response -> assertThat(
 | 
	
		
			
				|  |  | +                    ObjectPath.createFromResponse(response)
 | 
	
		
			
				|  |  | +                        .evaluate("_clusters.details.my_remote_cluster.failures.0.reason.reason")
 | 
	
		
			
				|  |  | +                        .toString(),
 | 
	
		
			
				|  |  | +                    containsString("no such index [" + indexToSearch + "]")
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                skipUnavailable
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  | -            assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(404));
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      private void testSearchingUnauthorizedIndices(
 | 
	
		
			
				|  |  |          String otherBackingFailureIndexName,
 | 
	
		
			
				|  |  |          String otherBackingDataIndexName,
 | 
	
		
			
				|  |  | -        boolean ccsMinimizeRoundtrips
 | 
	
		
			
				|  |  | +        boolean ccsMinimizeRoundtrips,
 | 
	
		
			
				|  |  | +        boolean skipUnavailable
 | 
	
		
			
				|  |  |      ) {
 | 
	
		
			
				|  |  |          // try searching remote index for which user has no access
 | 
	
		
			
				|  |  |          final String indexToSearch = randomFrom("other1", otherBackingFailureIndexName, otherBackingDataIndexName);
 | 
	
		
			
				|  |  | -        final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  | -            ResponseException.class,
 | 
	
		
			
				|  |  | -            () -> performRequestWithUser(
 | 
	
		
			
				|  |  | +        final String action = ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards";
 | 
	
		
			
				|  |  | +        executeAndAssert(
 | 
	
		
			
				|  |  | +            () -> performRequestMaybeUsingApiKey(
 | 
	
		
			
				|  |  |                  FAILURE_STORE_ACCESS,
 | 
	
		
			
				|  |  |                  new Request(
 | 
	
		
			
				|  |  |                      "GET",
 | 
	
	
		
			
				|  | @@ -195,13 +395,15 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |                          ccsMinimizeRoundtrips
 | 
	
		
			
				|  |  |                      )
 | 
	
		
			
				|  |  |                  )
 | 
	
		
			
				|  |  | -            )
 | 
	
		
			
				|  |  | +            ),
 | 
	
		
			
				|  |  | +            exception -> assertActionUnauthorized(exception, FAILURE_STORE_ACCESS, action, indexToSearch),
 | 
	
		
			
				|  |  | +            response -> assertActionUnauthorized(response, FAILURE_STORE_ACCESS, action, indexToSearch),
 | 
	
		
			
				|  |  | +            skipUnavailable
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  | -        final String action = ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards";
 | 
	
		
			
				|  |  | -        assertActionUnauthorized(exception, FAILURE_STORE_ACCESS, action, indexToSearch);
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    private void testBackingFailureIndexAccess(boolean ccsMinimizeRoundtrips, String backingFailureIndexName) throws IOException {
 | 
	
		
			
				|  |  | +    private void testBackingFailureIndexAccess(boolean ccsMinimizeRoundtrips, String backingFailureIndexName, boolean skipUnavailable)
 | 
	
		
			
				|  |  | +        throws IOException {
 | 
	
		
			
				|  |  |          // 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(
 | 
	
	
		
			
				|  | @@ -215,16 +417,17 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          // user with access to all should be able to search the backing failure index
 | 
	
		
			
				|  |  | -        assertSearchResponseContainsIndices(performRequestWithUser(ALL_ACCESS, failureIndexSearchRequest), backingFailureIndexName);
 | 
	
		
			
				|  |  | +        assertSearchResponseContainsIndices(performRequestMaybeUsingApiKey(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)
 | 
	
		
			
				|  |  | -            );
 | 
	
		
			
				|  |  |              final String action = ccsMinimizeRoundtrips ? "indices:data/read/search" : "indices:admin/search/search_shards";
 | 
	
		
			
				|  |  | -            assertActionUnauthorized(exception, DATA_ACCESS, action, backingFailureIndexName);
 | 
	
		
			
				|  |  | +            executeAndAssert(
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(DATA_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  | +                exception -> assertActionUnauthorized(exception, DATA_ACCESS, action, backingFailureIndexName),
 | 
	
		
			
				|  |  | +                response -> assertActionUnauthorized(response, DATA_ACCESS, action, backingFailureIndexName),
 | 
	
		
			
				|  |  | +                skipUnavailable
 | 
	
		
			
				|  |  | +            );
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          // for user with access to failure store, it depends on the underlying action that is being sent to the remote cluster
 | 
	
	
		
			
				|  | @@ -235,7 +438,7 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              // from a security perspective, this is a valid use case and there is no way to prevent this with RCS1 security model
 | 
	
		
			
				|  |  |              // since from the fulfilling cluster perspective this request is no different from any other local search request
 | 
	
		
			
				|  |  |              assertSearchResponseContainsIndices(
 | 
	
		
			
				|  |  | -                performRequestWithUser(FAILURE_STORE_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  | +                performRequestMaybeUsingApiKey(FAILURE_STORE_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  |                  backingFailureIndexName
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  |          } else {
 | 
	
	
		
			
				|  | @@ -244,21 +447,32 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              // which does not grant access to the indices:admin/search/search_shards action
 | 
	
		
			
				|  |  |              // this action is granted by read_cross_cluster privilege which is currently
 | 
	
		
			
				|  |  |              // not supporting the failure backing indices (only data backing indices)
 | 
	
		
			
				|  |  | -            final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  | -                ResponseException.class,
 | 
	
		
			
				|  |  | -                () -> performRequestWithUser(FAILURE_STORE_ACCESS, failureIndexSearchRequest)
 | 
	
		
			
				|  |  | +            executeAndAssert(
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(FAILURE_STORE_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  | +                exception -> assertActionUnauthorized(
 | 
	
		
			
				|  |  | +                    exception,
 | 
	
		
			
				|  |  | +                    FAILURE_STORE_ACCESS,
 | 
	
		
			
				|  |  | +                    "indices:admin/search/search_shards",
 | 
	
		
			
				|  |  | +                    backingFailureIndexName
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                response -> assertActionUnauthorized(
 | 
	
		
			
				|  |  | +                    response,
 | 
	
		
			
				|  |  | +                    FAILURE_STORE_ACCESS,
 | 
	
		
			
				|  |  | +                    "indices:admin/search/search_shards",
 | 
	
		
			
				|  |  | +                    backingFailureIndexName
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                skipUnavailable
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  | -            assertActionUnauthorized(exception, FAILURE_STORE_ACCESS, "indices:admin/search/search_shards", backingFailureIndexName);
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          // user with manage failure store access should be able to search the backing failure index
 | 
	
		
			
				|  |  |          assertSearchResponseContainsIndices(
 | 
	
		
			
				|  |  | -            performRequestWithUser(MANAGE_FAILURE_STORE_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  | +            performRequestMaybeUsingApiKey(MANAGE_FAILURE_STORE_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  |              backingFailureIndexName
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          assertSearchResponseContainsIndices(
 | 
	
		
			
				|  |  | -            performRequestWithUser(BACKING_FAILURE_STORE_INDEX_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  | +            performRequestMaybeUsingApiKey(BACKING_FAILURE_STORE_INDEX_ACCESS, failureIndexSearchRequest),
 | 
	
		
			
				|  |  |              backingFailureIndexName
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -282,7 +496,7 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              final String[] expectedIndices = alsoSearchLocally
 | 
	
		
			
				|  |  |                  ? new String[] { "local_index", backingDataIndexName }
 | 
	
		
			
				|  |  |                  : new String[] { backingDataIndexName };
 | 
	
		
			
				|  |  | -            assertSearchResponseContainsIndices(performRequestWithUser(user, dataSearchRequest), expectedIndices);
 | 
	
		
			
				|  |  | +            assertSearchResponseContainsIndices(performRequestMaybeUsingApiKey(user, dataSearchRequest), expectedIndices);
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -303,7 +517,7 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  |              final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  |                  ResponseException.class,
 | 
	
		
			
				|  |  | -                () -> performRequestWithUser(user, dataSearchRequest)
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(user, dataSearchRequest)
 | 
	
		
			
				|  |  |              );
 | 
	
		
			
				|  |  |              assertSelectorsNotSupported(exception);
 | 
	
		
			
				|  |  |          }
 | 
	
	
		
			
				|  | @@ -322,7 +536,7 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |              // query remote cluster using ::failures selector should fail (regardless of the user's permissions)
 | 
	
		
			
				|  |  |              final ResponseException exception = expectThrows(
 | 
	
		
			
				|  |  |                  ResponseException.class,
 | 
	
		
			
				|  |  | -                () -> performRequestWithUser(
 | 
	
		
			
				|  |  | +                () -> performRequestMaybeUsingApiKey(
 | 
	
		
			
				|  |  |                      user,
 | 
	
		
			
				|  |  |                      new Request(
 | 
	
		
			
				|  |  |                          "GET",
 | 
	
	
		
			
				|  | @@ -343,97 +557,40 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          // user with only read_failure_store access should not be able to resolve remote clusters
 | 
	
		
			
				|  |  |          var exc = expectThrows(
 | 
	
		
			
				|  |  |              ResponseException.class,
 | 
	
		
			
				|  |  | -            () -> performRequestWithUser(ONLY_READ_FAILURE_STORE_ACCESS, new Request("GET", "/_resolve/cluster/" + REMOTE_CLUSTER_ALIAS))
 | 
	
		
			
				|  |  | +            () -> performRequestMaybeUsingApiKey(ONLY_READ_FAILURE_STORE_ACCESS, new Request("GET", "/_resolve/cluster"))
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |          assertThat(exc.getResponse().getStatusLine().getStatusCode(), equalTo(403));
 | 
	
		
			
				|  |  |          assertThat(
 | 
	
		
			
				|  |  |              exc.getMessage(),
 | 
	
		
			
				|  |  | -            containsString(
 | 
	
		
			
				|  |  | -                "action ["
 | 
	
		
			
				|  |  | -                    + "indices:admin/resolve/cluster"
 | 
	
		
			
				|  |  | -                    + "] is unauthorized for user ["
 | 
	
		
			
				|  |  | -                    + ONLY_READ_FAILURE_STORE_ACCESS
 | 
	
		
			
				|  |  | -                    + "] "
 | 
	
		
			
				|  |  | -                    + "with effective roles ["
 | 
	
		
			
				|  |  | -                    + ONLY_READ_FAILURE_STORE_ACCESS
 | 
	
		
			
				|  |  | -                    + "]"
 | 
	
		
			
				|  |  | +            anyOf(
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action ["
 | 
	
		
			
				|  |  | +                        + "indices:admin/resolve/cluster"
 | 
	
		
			
				|  |  | +                        + "] is unauthorized for user ["
 | 
	
		
			
				|  |  | +                        + ONLY_READ_FAILURE_STORE_ACCESS
 | 
	
		
			
				|  |  | +                        + "] "
 | 
	
		
			
				|  |  | +                        + "with effective roles ["
 | 
	
		
			
				|  |  | +                        + ONLY_READ_FAILURE_STORE_ACCESS
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action [indices:admin/resolve/cluster] is unauthorized for API key id ["
 | 
	
		
			
				|  |  | +                        + apiKeys.get(ONLY_READ_FAILURE_STORE_ACCESS).v1()
 | 
	
		
			
				|  |  | +                        + "] of user ["
 | 
	
		
			
				|  |  | +                        + ONLY_READ_FAILURE_STORE_ACCESS
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  |              )
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    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);
 | 
	
		
			
				|  |  | -        createRole(adminClient(), MANAGE_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": ["local_index"],
 | 
	
		
			
				|  |  | -                  "privileges": ["read"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        createUser(adminClient(), MANAGE_FAILURE_STORE_ACCESS, PASS, MANAGE_FAILURE_STORE_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        createRole(adminClient(), ONLY_READ_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        createUser(adminClient(), ONLY_READ_FAILURE_STORE_ACCESS, PASS, ONLY_READ_FAILURE_STORE_ACCESS);
 | 
	
		
			
				|  |  | -        createRole(adminClient(), BACKING_FAILURE_STORE_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        createUser(adminClient(), BACKING_FAILURE_STORE_INDEX_ACCESS, PASS, BACKING_FAILURE_STORE_INDEX_ACCESS);
 | 
	
		
			
				|  |  | -        createRole(adminClient(), BACKING_DATA_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        createUser(adminClient(), BACKING_DATA_INDEX_ACCESS, PASS, BACKING_DATA_INDEX_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    private static void createRole(RestClient client, String role, String roleDescriptor) throws IOException {
 | 
	
		
			
				|  |  | +    private static void createRoleOnQueryCluster(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 {
 | 
	
		
			
				|  |  | +    private static void createUserOnQueryCluster(RestClient client, String user, SecureString password, String role) throws IOException {
 | 
	
		
			
				|  |  |          final Request putUserRequest = new Request("PUT", "/_security/user/" + user);
 | 
	
		
			
				|  |  |          putUserRequest.setJsonEntity(Strings.format("""
 | 
	
		
			
				|  |  |              {
 | 
	
	
		
			
				|  | @@ -443,83 +600,9 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          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*", "non-existing-index"],
 | 
	
		
			
				|  |  | -                  "privileges": ["read", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(FAILURE_STORE_ACCESS, FAILURE_STORE_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        putRoleOnFulfillingCluster(MANAGE_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | -                  "privileges": ["manage_failure_store", "read_cross_cluster", "read_failure_store"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(MANAGE_FAILURE_STORE_ACCESS, MANAGE_FAILURE_STORE_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        putRoleOnFulfillingCluster(ALL_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": ["*"],
 | 
	
		
			
				|  |  | -                  "privileges": ["all"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(ALL_ACCESS, ALL_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        putRoleOnFulfillingCluster(ONLY_READ_FAILURE_STORE_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": ["test*", "non-existing-index"],
 | 
	
		
			
				|  |  | -                  "privileges": ["read_failure_store"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(ONLY_READ_FAILURE_STORE_ACCESS, ONLY_READ_FAILURE_STORE_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        putRoleOnFulfillingCluster(BACKING_DATA_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": [".ds-test*"],
 | 
	
		
			
				|  |  | -                  "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(BACKING_DATA_INDEX_ACCESS, BACKING_DATA_INDEX_ACCESS);
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -        putRoleOnFulfillingCluster(BACKING_FAILURE_STORE_INDEX_ACCESS, """
 | 
	
		
			
				|  |  | -            {
 | 
	
		
			
				|  |  | -              "indices": [
 | 
	
		
			
				|  |  | -                {
 | 
	
		
			
				|  |  | -                  "names": [".fs-test*"],
 | 
	
		
			
				|  |  | -                  "privileges": ["read", "read_cross_cluster"]
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -              ]
 | 
	
		
			
				|  |  | -            }""");
 | 
	
		
			
				|  |  | -        putUserOnFulfillingCluster(BACKING_FAILURE_STORE_INDEX_ACCESS, BACKING_FAILURE_STORE_INDEX_ACCESS);
 | 
	
		
			
				|  |  | +    private static void createRoleAndUserOnFulfillingCluster(String userAndRoleName, String roleDescriptor) throws IOException {
 | 
	
		
			
				|  |  | +        putRoleOnFulfillingCluster(userAndRoleName, roleDescriptor);
 | 
	
		
			
				|  |  | +        putUserOnFulfillingCluster(userAndRoleName, userAndRoleName);
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      private static void putRoleOnFulfillingCluster(String roleName, String roleDescriptor) throws IOException {
 | 
	
	
		
			
				|  | @@ -547,18 +630,139 @@ public class RemoteClusterSecurityRCS1FailureStoreRestIT extends AbstractRemoteC
 | 
	
		
			
				|  |  |          assertThat(exception.getResponse().getStatusLine().getStatusCode(), equalTo(403));
 | 
	
		
			
				|  |  |          assertThat(
 | 
	
		
			
				|  |  |              exception.getMessage(),
 | 
	
		
			
				|  |  | -            containsString(
 | 
	
		
			
				|  |  | -                "action ["
 | 
	
		
			
				|  |  | -                    + action
 | 
	
		
			
				|  |  | -                    + "] is unauthorized for user ["
 | 
	
		
			
				|  |  | -                    + userAndRole
 | 
	
		
			
				|  |  | -                    + "] "
 | 
	
		
			
				|  |  | -                    + "with effective roles ["
 | 
	
		
			
				|  |  | -                    + userAndRole
 | 
	
		
			
				|  |  | -                    + "] on indices ["
 | 
	
		
			
				|  |  | -                    + backingFailureIndexName
 | 
	
		
			
				|  |  | -                    + "]"
 | 
	
		
			
				|  |  | +            anyOf(
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action ["
 | 
	
		
			
				|  |  | +                        + action
 | 
	
		
			
				|  |  | +                        + "] is unauthorized for user ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] "
 | 
	
		
			
				|  |  | +                        + "with effective roles ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] on indices ["
 | 
	
		
			
				|  |  | +                        + backingFailureIndexName
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action ["
 | 
	
		
			
				|  |  | +                        + action
 | 
	
		
			
				|  |  | +                        + "] is unauthorized for API key id ["
 | 
	
		
			
				|  |  | +                        + (apiKeys.containsKey(userAndRole)
 | 
	
		
			
				|  |  | +                            ? apiKeys.get(userAndRole).v1()
 | 
	
		
			
				|  |  | +                            : "<there is no test API key for this user - ignore this assertion>")
 | 
	
		
			
				|  |  | +                        + "] of user ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] on indices ["
 | 
	
		
			
				|  |  | +                        + backingFailureIndexName
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  |              )
 | 
	
		
			
				|  |  |          );
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static void assertActionUnauthorized(Response response, String userAndRole, String action, String backingFailureIndexName)
 | 
	
		
			
				|  |  | +        throws IOException {
 | 
	
		
			
				|  |  | +        assertThat(response.getStatusLine().getStatusCode(), equalTo(200));
 | 
	
		
			
				|  |  | +        assertThat(
 | 
	
		
			
				|  |  | +            ObjectPath.createFromResponse(response).evaluate("_clusters.details.my_remote_cluster.failures.0.reason.reason").toString(),
 | 
	
		
			
				|  |  | +            anyOf(
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action ["
 | 
	
		
			
				|  |  | +                        + action
 | 
	
		
			
				|  |  | +                        + "] is unauthorized for user ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] "
 | 
	
		
			
				|  |  | +                        + "with effective roles ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] on indices ["
 | 
	
		
			
				|  |  | +                        + backingFailureIndexName
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                ),
 | 
	
		
			
				|  |  | +                containsString(
 | 
	
		
			
				|  |  | +                    "action ["
 | 
	
		
			
				|  |  | +                        + action
 | 
	
		
			
				|  |  | +                        + "] is unauthorized for API key id ["
 | 
	
		
			
				|  |  | +                        + (apiKeys.containsKey(userAndRole)
 | 
	
		
			
				|  |  | +                            ? apiKeys.get(userAndRole).v1()
 | 
	
		
			
				|  |  | +                            : "<there is no test API key for this user - ignore this assertion>")
 | 
	
		
			
				|  |  | +                        + "] of user ["
 | 
	
		
			
				|  |  | +                        + userAndRole
 | 
	
		
			
				|  |  | +                        + "] on indices ["
 | 
	
		
			
				|  |  | +                        + backingFailureIndexName
 | 
	
		
			
				|  |  | +                        + "]"
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  | +            )
 | 
	
		
			
				|  |  | +        );
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    protected static void createAndStoreApiKeyOnQueryCluster(String user, @Nullable String roleDescriptors) throws IOException {
 | 
	
		
			
				|  |  | +        assertThat("API key already registered for user: " + user, apiKeys.containsKey(user), is(false));
 | 
	
		
			
				|  |  | +        apiKeys.put(user, createApiKeyOnQueryCluster(user, roleDescriptors));
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static Tuple<String, String> createApiKeyOnQueryCluster(String user, String roleDescriptors) throws IOException {
 | 
	
		
			
				|  |  | +        var request = new Request("POST", "/_security/api_key");
 | 
	
		
			
				|  |  | +        if (roleDescriptors == null) {
 | 
	
		
			
				|  |  | +            request.setJsonEntity("""
 | 
	
		
			
				|  |  | +                {
 | 
	
		
			
				|  |  | +                    "name": "test-api-key"
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +                """);
 | 
	
		
			
				|  |  | +        } else {
 | 
	
		
			
				|  |  | +            request.setJsonEntity(org.elasticsearch.common.Strings.format("""
 | 
	
		
			
				|  |  | +                {
 | 
	
		
			
				|  |  | +                    "name": "test-api-key",
 | 
	
		
			
				|  |  | +                    "role_descriptors": {
 | 
	
		
			
				|  |  | +                        "%s": %s
 | 
	
		
			
				|  |  | +                    }
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +                """, user, roleDescriptors));
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +        request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", basicAuthHeaderValue(user, PASS)).build());
 | 
	
		
			
				|  |  | +        Response response = client().performRequest(request);
 | 
	
		
			
				|  |  | +        assertOK(response);
 | 
	
		
			
				|  |  | +        Map<String, Object> responseAsMap = responseAsMap(response);
 | 
	
		
			
				|  |  | +        return new Tuple<>((String) responseAsMap.get("id"), (String) responseAsMap.get("encoded"));
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    protected static Response performRequestMaybeUsingApiKey(String user, Request request) throws IOException {
 | 
	
		
			
				|  |  | +        if (randomBoolean() && apiKeys.containsKey(user)) {
 | 
	
		
			
				|  |  | +            return performRequestWithApiKey(apiKeys.get(user).v2(), request);
 | 
	
		
			
				|  |  | +        } else {
 | 
	
		
			
				|  |  | +            return performRequestWithUser(user, request);
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static Response performRequestWithApiKey(String apiKey, Request request) throws IOException {
 | 
	
		
			
				|  |  | +        request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", "ApiKey " + apiKey).build());
 | 
	
		
			
				|  |  | +        return client().performRequest(request);
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    private static <T extends Throwable> void executeAndAssert(
 | 
	
		
			
				|  |  | +        ThrowableCommand<Response> requestCommand,
 | 
	
		
			
				|  |  | +        Consumer<ResponseException> exceptionAssertion,
 | 
	
		
			
				|  |  | +        ThrowableConsumer<Response> responseAssertion,
 | 
	
		
			
				|  |  | +        boolean expectResponse
 | 
	
		
			
				|  |  | +    ) {
 | 
	
		
			
				|  |  | +        if (expectResponse) {
 | 
	
		
			
				|  |  | +            try {
 | 
	
		
			
				|  |  | +                responseAssertion.accept(requestCommand.execute());
 | 
	
		
			
				|  |  | +            } catch (Exception e) {
 | 
	
		
			
				|  |  | +                fail(e, "Not expected exception to be thrown: " + e.getMessage());
 | 
	
		
			
				|  |  | +            }
 | 
	
		
			
				|  |  | +        } else {
 | 
	
		
			
				|  |  | +            exceptionAssertion.accept(expectThrows(ResponseException.class, requestCommand::execute));
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    @FunctionalInterface
 | 
	
		
			
				|  |  | +    private interface ThrowableCommand<T> {
 | 
	
		
			
				|  |  | +        T execute() throws Exception;
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    @FunctionalInterface
 | 
	
		
			
				|  |  | +    public interface ThrowableConsumer<T> {
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        void accept(T t) throws Exception;
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  |  }
 |