Browse Source

[Backport] Make reserved built-in roles queryable (#118794)

* [Backport] Make reserved built-in roles queryable

* fix compilation errors due to missing isMixedVersionCluster
Slobodan Adamović 10 months ago
parent
commit
6ff46da309
23 changed files with 1597 additions and 21 deletions
  1. 5 0
      docs/changelog/117581.yaml
  2. 1 1
      test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java
  3. 12 0
      x-pack/plugin/security/qa/security-basic/build.gradle
  4. 1 1
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryRoleIT.java
  5. 354 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryableReservedRolesIT.java
  6. 6 0
      x-pack/plugin/security/qa/security-basic/src/main/java/module-info.java
  7. 22 0
      x-pack/plugin/security/qa/security-basic/src/main/java/org/elasticsearch/xpack/security/role/QueryableBuiltInRolesTestPlugin.java
  8. 2 0
      x-pack/plugin/security/src/main/java/module-info.java
  9. 22 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  10. 7 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java
  11. 5 7
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportGetRolesAction.java
  12. 9 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/FileRolesStore.java
  13. 41 9
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java
  14. 3 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/role/RestQueryRoleAction.java
  15. 1 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FeatureNotEnabledException.java
  16. 52 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRoles.java
  17. 23 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesProviderFactory.java
  18. 546 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesSynchronizer.java
  19. 101 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesUtils.java
  20. 56 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableReservedRolesProvider.java
  21. 1 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java
  22. 296 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesUtilsTests.java
  23. 31 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/QueryableReservedRolesProviderTests.java

+ 5 - 0
docs/changelog/117581.yaml

@@ -0,0 +1,5 @@
+pr: 117581
+summary: Make reserved built-in roles queryable
+area: Authorization
+type: enhancement
+issues: []

+ 1 - 1
test/framework/src/main/java/org/elasticsearch/test/rest/ESRestTestCase.java

@@ -1197,7 +1197,7 @@ public abstract class ESRestTestCase extends ESTestCase {
         }
     }
 
-    private static boolean ignoreSystemIndexAccessWarnings(List<String> warnings) {
+    protected static boolean ignoreSystemIndexAccessWarnings(List<String> warnings) {
         for (String warning : warnings) {
             if (warning.startsWith("this request accesses system indices:")) {
                 SUITE_LOGGER.warn("Ignoring system index access warning during test cleanup: {}", warning);

+ 12 - 0
x-pack/plugin/security/qa/security-basic/build.gradle

@@ -1,17 +1,29 @@
 
+apply plugin: 'elasticsearch.base-internal-es-plugin'
 apply plugin: 'elasticsearch.internal-java-rest-test'
 
 import org.elasticsearch.gradle.internal.info.BuildParams
 
+esplugin {
+  name 'queryable-reserved-roles-test'
+  description 'A test plugin for testing that changes to reserved roles are made queryable'
+  classname 'org.elasticsearch.xpack.security.role.QueryableBuiltInRolesTestPlugin'
+  extendedPlugins = ['x-pack-core', 'x-pack-security']
+}
+
 dependencies {
   javaRestTestImplementation(testArtifact(project(xpackModule('security'))))
   javaRestTestImplementation(testArtifact(project(xpackModule('core'))))
+  compileOnly project(':x-pack:plugin:core')
+  compileOnly project(':x-pack:plugin:security')
+  clusterPlugins project(':x-pack:plugin:security:qa:security-basic')
 }
 
 tasks.named('javaRestTest') {
   usesDefaultDistribution()
 }
 
+tasks.named("javadoc").configure { enabled = false }
 
 if (buildParams.inFipsJvm){
   // This test cluster is using a BASIC license and FIPS 140 mode is not supported in BASIC

+ 1 - 1
x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryRoleIT.java

@@ -496,7 +496,7 @@ public final class QueryRoleIT extends SecurityInBasicRestTestCase {
         );
     }
 
-    private void assertQuery(String body, int total, Consumer<List<Map<String, Object>>> roleVerifier) throws IOException {
+    static void assertQuery(String body, int total, Consumer<List<Map<String, Object>>> roleVerifier) throws IOException {
         assertQuery(client(), body, total, roleVerifier);
     }
 

+ 354 - 0
x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryableReservedRolesIT.java

@@ -0,0 +1,354 @@
+/*
+ * 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;
+
+import com.carrotsearch.randomizedtesting.annotations.TestCaseOrdering;
+
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.RequestOptions;
+import org.elasticsearch.client.Response;
+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.xcontent.XContentHelper;
+import org.elasticsearch.test.AnnotationTestOrdering;
+import org.elasticsearch.test.AnnotationTestOrdering.Order;
+import org.elasticsearch.test.cluster.ElasticsearchCluster;
+import org.elasticsearch.test.cluster.MutableSettingsProvider;
+import org.elasticsearch.test.cluster.local.distribution.DistributionType;
+import org.elasticsearch.test.cluster.local.model.User;
+import org.elasticsearch.test.cluster.util.resource.Resource;
+import org.elasticsearch.test.rest.ESRestTestCase;
+import org.elasticsearch.test.rest.ObjectPath;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+import org.elasticsearch.xpack.core.security.test.TestRestrictedIndices;
+import org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer;
+import org.elasticsearch.xpack.security.support.SecurityMigrations;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.elasticsearch.xpack.core.security.test.TestRestrictedIndices.INTERNAL_SECURITY_MAIN_INDEX_7;
+import static org.elasticsearch.xpack.security.QueryRoleIT.assertQuery;
+import static org.elasticsearch.xpack.security.QueryRoleIT.waitForMigrationCompletion;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.iterableWithSize;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.oneOf;
+
+@TestCaseOrdering(AnnotationTestOrdering.class)
+public class QueryableReservedRolesIT extends ESRestTestCase {
+
+    protected static final String REST_USER = "security_test_user";
+    private static final SecureString REST_PASSWORD = new SecureString("security-test-password".toCharArray());
+    private static final String ADMIN_USER = "admin_user";
+    private static final SecureString ADMIN_PASSWORD = new SecureString("admin-password".toCharArray());
+    protected static final String READ_SECURITY_USER = "read_security_user";
+    private static final SecureString READ_SECURITY_PASSWORD = new SecureString("read-security-password".toCharArray());
+
+    @BeforeClass
+    public static void setup() {
+        new ReservedRolesStore();
+    }
+
+    @Override
+    protected boolean preserveClusterUponCompletion() {
+        return true;
+    }
+
+    private static MutableSettingsProvider clusterSettings = new MutableSettingsProvider() {
+        {
+            put("xpack.license.self_generated.type", "basic");
+            put("xpack.security.enabled", "true");
+            put("xpack.security.http.ssl.enabled", "false");
+            put("xpack.security.transport.ssl.enabled", "false");
+        }
+    };
+
+    @ClassRule
+    public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
+        .distribution(DistributionType.DEFAULT)
+        .nodes(2)
+        .settings(clusterSettings)
+        .rolesFile(Resource.fromClasspath("roles.yml"))
+        .user(ADMIN_USER, ADMIN_PASSWORD.toString(), User.ROOT_USER_ROLE, true)
+        .user(REST_USER, REST_PASSWORD.toString(), "security_test_role", false)
+        .user(READ_SECURITY_USER, READ_SECURITY_PASSWORD.toString(), "read_security_user_role", false)
+        .systemProperty("es.queryable_built_in_roles_enabled", "true")
+        .plugin("queryable-reserved-roles-test")
+        .build();
+
+    private static Set<String> PREVIOUS_RESERVED_ROLES;
+    private static Set<String> CONFIGURED_RESERVED_ROLES;
+
+    @Override
+    protected String getTestRestCluster() {
+        return cluster.getHttpAddresses();
+    }
+
+    @Override
+    protected Settings restAdminSettings() {
+        String token = basicAuthHeaderValue(ADMIN_USER, ADMIN_PASSWORD);
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    @Override
+    protected Settings restClientSettings() {
+        String token = basicAuthHeaderValue(REST_USER, REST_PASSWORD);
+        return Settings.builder().put(ThreadContext.PREFIX + ".Authorization", token).build();
+    }
+
+    @Order(10)
+    public void testQueryDeleteOrUpdateReservedRoles() throws Exception {
+        waitForMigrationCompletion(adminClient(), SecurityMigrations.ROLE_METADATA_FLATTENED_MIGRATION_VERSION);
+
+        final String[] allReservedRoles = ReservedRolesStore.names().toArray(new String[0]);
+        assertQuery(client(), """
+            { "query": { "bool": { "must": { "term": { "metadata._reserved": true } } } }, "size": 100 }
+            """, allReservedRoles.length, roles -> {
+            assertThat(roles, iterableWithSize(allReservedRoles.length));
+            for (var role : roles) {
+                assertThat((String) role.get("name"), is(oneOf(allReservedRoles)));
+            }
+        });
+
+        final String roleName = randomFrom(allReservedRoles);
+        assertQuery(client(), String.format("""
+            { "query": { "bool": { "must": { "term": { "name": "%s" } } } } }
+            """, roleName), 1, roles -> {
+            assertThat(roles, iterableWithSize(1));
+            assertThat((String) roles.get(0).get("name"), equalTo(roleName));
+        });
+
+        assertCannotDeleteReservedRoles();
+        assertCannotCreateOrUpdateReservedRole(roleName);
+    }
+
+    @Order(11)
+    public void testGetReservedRoles() throws Exception {
+        final String[] allReservedRoles = ReservedRolesStore.names().toArray(new String[0]);
+        final String roleName = randomFrom(allReservedRoles);
+        Request request = new Request("GET", "/_security/role/" + roleName);
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+        var responseMap = responseAsMap(response);
+        assertThat(responseMap.size(), equalTo(1));
+        assertThat(responseMap.containsKey(roleName), is(true));
+    }
+
+    @Order(20)
+    public void testRestartForConfiguringReservedRoles() throws Exception {
+        configureReservedRoles(List.of("editor", "viewer", "kibana_system", "apm_system", "beats_system", "logstash_system"));
+        cluster.restart(false);
+        closeClients();
+    }
+
+    @Order(30)
+    public void testConfiguredReservedRoles() throws Exception {
+        assert CONFIGURED_RESERVED_ROLES != null;
+
+        // Test query roles API
+        assertBusy(() -> {
+            assertQuery(client(), """
+                { "query": { "bool": { "must": { "term": { "metadata._reserved": true } } } }, "size": 100 }
+                """, CONFIGURED_RESERVED_ROLES.size(), roles -> {
+                assertThat(roles, iterableWithSize(CONFIGURED_RESERVED_ROLES.size()));
+                for (var role : roles) {
+                    assertThat((String) role.get("name"), is(oneOf(CONFIGURED_RESERVED_ROLES.toArray(new String[0]))));
+                }
+            });
+        }, 30, TimeUnit.SECONDS);
+
+        // Test get roles API
+        assertBusy(() -> {
+            final Response response = adminClient().performRequest(new Request("GET", "/_security/role"));
+            assertOK(response);
+            final Map<String, Object> responseMap = responseAsMap(response);
+            assertThat(responseMap.keySet(), equalTo(CONFIGURED_RESERVED_ROLES));
+        });
+    }
+
+    @Order(40)
+    public void testRestartForConfiguringReservedRolesAndClosingIndex() throws Exception {
+        configureReservedRoles(List.of("editor", "viewer"));
+        closeSecurityIndex();
+        cluster.restart(false);
+        closeClients();
+    }
+
+    @Order(50)
+    public void testConfiguredReservedRolesAfterClosingAndOpeningIndex() throws Exception {
+        assert CONFIGURED_RESERVED_ROLES != null;
+        assert PREVIOUS_RESERVED_ROLES != null;
+        assertThat(PREVIOUS_RESERVED_ROLES, is(not(equalTo(CONFIGURED_RESERVED_ROLES))));
+
+        // Test configured roles did not get updated because the security index is closed
+        assertMetadataContainsBuiltInRoles(PREVIOUS_RESERVED_ROLES);
+
+        // Open the security index
+        openSecurityIndex();
+
+        // Test that the roles are now updated after index got opened
+        assertBusy(() -> {
+            assertQuery(client(), """
+                { "query": { "bool": { "must": { "term": { "metadata._reserved": true } } } }, "size": 100 }
+                """, CONFIGURED_RESERVED_ROLES.size(), roles -> {
+                assertThat(roles, iterableWithSize(CONFIGURED_RESERVED_ROLES.size()));
+                for (var role : roles) {
+                    assertThat((String) role.get("name"), is(oneOf(CONFIGURED_RESERVED_ROLES.toArray(new String[0]))));
+                }
+            });
+        }, 30, TimeUnit.SECONDS);
+
+    }
+
+    @Order(60)
+    public void testDeletingAndCreatingSecurityIndexTriggersSynchronization() throws Exception {
+        deleteSecurityIndex();
+
+        assertBusy(this::assertSecurityIndexDeleted, 30, TimeUnit.SECONDS);
+
+        // Creating a user will trigger .security index creation
+        createUser("superman", "superman", "superuser");
+
+        // Test that the roles are now updated after index got created
+        assertBusy(() -> {
+            assertQuery(client(), """
+                { "query": { "bool": { "must": { "term": { "metadata._reserved": true } } } }, "size": 100 }
+                """, CONFIGURED_RESERVED_ROLES.size(), roles -> {
+                assertThat(roles, iterableWithSize(CONFIGURED_RESERVED_ROLES.size()));
+                for (var role : roles) {
+                    assertThat((String) role.get("name"), is(oneOf(CONFIGURED_RESERVED_ROLES.toArray(new String[0]))));
+                }
+            });
+        }, 30, TimeUnit.SECONDS);
+    }
+
+    private void createUser(String name, String password, String role) throws IOException {
+        Request request = new Request("PUT", "/_security/user/" + name);
+        request.setJsonEntity("{ \"password\": \"" + password + "\", \"roles\": [ \"" + role + "\"] }");
+        assertOK(adminClient().performRequest(request));
+    }
+
+    private void deleteSecurityIndex() throws IOException {
+        final Request deleteRequest = new Request("DELETE", INTERNAL_SECURITY_MAIN_INDEX_7);
+        deleteRequest.setOptions(RequestOptions.DEFAULT.toBuilder().setWarningsHandler(ESRestTestCase::ignoreSystemIndexAccessWarnings));
+        final Response response = adminClient().performRequest(deleteRequest);
+        try (InputStream is = response.getEntity().getContent()) {
+            assertTrue((boolean) XContentHelper.convertToMap(XContentType.JSON.xContent(), is, true).get("acknowledged"));
+        }
+    }
+
+    private void assertMetadataContainsBuiltInRoles(Set<String> builtInRoles) throws IOException {
+        final Request request = new Request("GET", "_cluster/state/metadata/" + INTERNAL_SECURITY_MAIN_INDEX_7);
+        final Response response = adminClient().performRequest(request);
+        assertOK(response);
+        final Map<String, String> builtInRolesDigests = ObjectPath.createFromResponse(response)
+            .evaluate("metadata.indices.\\.security-7." + QueryableBuiltInRolesSynchronizer.METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY);
+        assertThat(builtInRolesDigests.keySet(), equalTo(builtInRoles));
+    }
+
+    private void assertSecurityIndexDeleted() throws IOException {
+        final Request request = new Request("GET", "_cluster/state/metadata/" + INTERNAL_SECURITY_MAIN_INDEX_7);
+        final Response response = adminClient().performRequest(request);
+        assertOK(response);
+        final Map<String, String> securityIndexMetadata = ObjectPath.createFromResponse(response)
+            .evaluate("metadata.indices.\\.security-7");
+        assertThat(securityIndexMetadata, is(nullValue()));
+    }
+
+    private void configureReservedRoles(List<String> reservedRoles) throws Exception {
+        PREVIOUS_RESERVED_ROLES = CONFIGURED_RESERVED_ROLES;
+        CONFIGURED_RESERVED_ROLES = new HashSet<>();
+        CONFIGURED_RESERVED_ROLES.add("superuser"); // superuser must always be included
+        CONFIGURED_RESERVED_ROLES.addAll(reservedRoles);
+        clusterSettings.put("xpack.security.reserved_roles.include", Strings.collectionToCommaDelimitedString(CONFIGURED_RESERVED_ROLES));
+    }
+
+    private void closeSecurityIndex() throws Exception {
+        Request request = new Request("POST", "/" + TestRestrictedIndices.INTERNAL_SECURITY_MAIN_INDEX_7 + "/_close");
+        request.setOptions(
+            expectWarnings(
+                "this request accesses system indices: [.security-7], but in a future major version, "
+                    + "direct access to system indices will be prevented by default"
+            )
+        );
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+    }
+
+    private void openSecurityIndex() throws Exception {
+        Request request = new Request("POST", "/" + TestRestrictedIndices.INTERNAL_SECURITY_MAIN_INDEX_7 + "/_open");
+        request.setOptions(
+            expectWarnings(
+                "this request accesses system indices: [.security-7], but in a future major version, "
+                    + "direct access to system indices will be prevented by default"
+            )
+        );
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+    }
+
+    private void assertCannotDeleteReservedRoles() throws Exception {
+        {
+            String roleName = randomFrom(ReservedRolesStore.names());
+            Request request = new Request("DELETE", "/_security/role/" + roleName);
+            var e = expectThrows(ResponseException.class, () -> adminClient().performRequest(request));
+            assertThat(e.getMessage(), containsString("role [" + roleName + "] is reserved and cannot be deleted"));
+        }
+        {
+            Request request = new Request("DELETE", "/_security/role/");
+            request.setJsonEntity(
+                """
+                    {
+                      "names": [%s]
+                    }
+                    """.formatted(
+                    ReservedRolesStore.names().stream().map(name -> "\"" + name + "\"").reduce((a, b) -> a + ", " + b).orElse("")
+                )
+            );
+            Response response = adminClient().performRequest(request);
+            assertOK(response);
+            String responseAsString = responseAsMap(response).toString();
+            for (String roleName : ReservedRolesStore.names()) {
+                assertThat(responseAsString, containsString("role [" + roleName + "] is reserved and cannot be deleted"));
+            }
+        }
+    }
+
+    private void assertCannotCreateOrUpdateReservedRole(String roleName) throws Exception {
+        Request request = new Request(randomBoolean() ? "PUT" : "POST", "/_security/role/" + roleName);
+        request.setJsonEntity("""
+            {
+              "cluster": ["all"],
+              "indices": [
+                {
+                  "names": ["*"],
+                  "privileges": ["all"]
+                }
+              ]
+            }
+            """);
+        var e = expectThrows(ResponseException.class, () -> adminClient().performRequest(request));
+        assertThat(e.getMessage(), containsString("Role [" + roleName + "] is reserved and may not be used."));
+    }
+
+}

+ 6 - 0
x-pack/plugin/security/qa/security-basic/src/main/java/module-info.java

@@ -0,0 +1,6 @@
+module org.elasticsearch.internal.security {
+    requires org.elasticsearch.base;
+    requires org.elasticsearch.server;
+    requires org.elasticsearch.xcore;
+    requires org.elasticsearch.security;
+}

+ 22 - 0
x-pack/plugin/security/qa/security-basic/src/main/java/org/elasticsearch/xpack/security/role/QueryableBuiltInRolesTestPlugin.java

@@ -0,0 +1,22 @@
+/*
+ * 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.role;
+
+import org.elasticsearch.common.settings.Setting;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+
+import java.util.List;
+
+public class QueryableBuiltInRolesTestPlugin extends Plugin {
+
+    @Override
+    public List<Setting<?>> getSettings() {
+        return List.of(ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING);
+    }
+}

+ 2 - 0
x-pack/plugin/security/src/main/java/module-info.java

@@ -70,6 +70,8 @@ module org.elasticsearch.security {
     exports org.elasticsearch.xpack.security.slowlog to org.elasticsearch.server;
     exports org.elasticsearch.xpack.security.authc.support to org.elasticsearch.internal.security;
     exports org.elasticsearch.xpack.security.rest.action.apikey to org.elasticsearch.internal.security;
+    exports org.elasticsearch.xpack.security.support to org.elasticsearch.internal.security;
+    exports org.elasticsearch.xpack.security.authz.store to org.elasticsearch.internal.security;
 
     provides org.elasticsearch.index.SlowLogFieldProvider with org.elasticsearch.xpack.security.slowlog.SecuritySlowLogFieldProvider;
 

+ 22 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -411,6 +411,8 @@ import org.elasticsearch.xpack.security.rest.action.user.RestQueryUserAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestSetEnabledAction;
 import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
 import org.elasticsearch.xpack.security.support.ExtensionComponents;
+import org.elasticsearch.xpack.security.support.QueryableBuiltInRolesProviderFactory;
+import org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer;
 import org.elasticsearch.xpack.security.support.ReloadableSecurityComponent;
 import org.elasticsearch.xpack.security.support.SecurityIndexManager;
 import org.elasticsearch.xpack.security.support.SecurityMigrationExecutor;
@@ -461,6 +463,7 @@ import static org.elasticsearch.xpack.core.XPackSettings.HTTP_SSL_ENABLED;
 import static org.elasticsearch.xpack.core.security.SecurityField.FIELD_LEVEL_SECURITY_FEATURE;
 import static org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore.INCLUDED_RESERVED_ROLES_SETTING;
 import static org.elasticsearch.xpack.security.operator.OperatorPrivileges.OPERATOR_PRIVILEGES_ENABLED;
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer.QUERYABLE_BUILT_IN_ROLES_ENABLED;
 import static org.elasticsearch.xpack.security.transport.SSLEngineUtils.extractClientCertificates;
 
 public class Security extends Plugin
@@ -631,7 +634,7 @@ public class Security extends Plugin
     private final SetOnce<ReservedRoleNameChecker.Factory> reservedRoleNameCheckerFactory = new SetOnce<>();
     private final SetOnce<FileRoleValidator> fileRoleValidator = new SetOnce<>();
     private final SetOnce<SecondaryAuthActions> secondaryAuthActions = new SetOnce<>();
-
+    private final SetOnce<QueryableBuiltInRolesProviderFactory> queryableRolesProviderFactory = new SetOnce<>();
     private final SetOnce<SecurityMigrationExecutor> securityMigrationExecutor = new SetOnce<>();
 
     // Node local retry count for migration jobs that's checked only on the master node to make sure
@@ -1206,6 +1209,23 @@ public class Security extends Plugin
 
         reservedRoleMappingAction.set(new ReservedRoleMappingAction());
 
+        if (QUERYABLE_BUILT_IN_ROLES_ENABLED) {
+            if (queryableRolesProviderFactory.get() == null) {
+                queryableRolesProviderFactory.set(new QueryableBuiltInRolesProviderFactory.Default());
+            }
+            components.add(
+                new QueryableBuiltInRolesSynchronizer(
+                    clusterService,
+                    featureService,
+                    queryableRolesProviderFactory.get(),
+                    nativeRolesStore,
+                    reservedRolesStore,
+                    fileRolesStore.get(),
+                    threadPool
+                )
+            );
+        }
+
         cacheInvalidatorRegistry.validate();
 
         final List<ReloadableSecurityComponent> reloadableComponents = new ArrayList<>();
@@ -2321,6 +2341,7 @@ public class Security extends Plugin
         loadSingletonExtensionAndSetOnce(loader, grantApiKeyRequestTranslator, RestGrantApiKeyAction.RequestTranslator.class);
         loadSingletonExtensionAndSetOnce(loader, fileRoleValidator, FileRoleValidator.class);
         loadSingletonExtensionAndSetOnce(loader, secondaryAuthActions, SecondaryAuthActions.class);
+        loadSingletonExtensionAndSetOnce(loader, queryableRolesProviderFactory, QueryableBuiltInRolesProviderFactory.class);
     }
 
     private <T> void loadSingletonExtensionAndSetOnce(ExtensionLoader loader, SetOnce<T> setOnce, Class<T> clazz) {

+ 7 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/SecurityFeatures.java

@@ -14,6 +14,7 @@ import org.elasticsearch.features.NodeFeature;
 import java.util.Map;
 import java.util.Set;
 
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesSynchronizer.QUERYABLE_BUILT_IN_ROLES_FEATURE;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MIGRATION_FRAMEWORK;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_PROFILE_ORIGIN_FEATURE;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_ROLES_METADATA_FLATTENED;
@@ -24,7 +25,12 @@ public class SecurityFeatures implements FeatureSpecification {
 
     @Override
     public Set<NodeFeature> getFeatures() {
-        return Set.of(SECURITY_ROLE_MAPPING_CLEANUP, SECURITY_ROLES_METADATA_FLATTENED, SECURITY_MIGRATION_FRAMEWORK);
+        return Set.of(
+            SECURITY_ROLE_MAPPING_CLEANUP,
+            SECURITY_ROLES_METADATA_FLATTENED,
+            SECURITY_MIGRATION_FRAMEWORK,
+            QUERYABLE_BUILT_IN_ROLES_FEATURE
+        );
     }
 
     @Override

+ 5 - 7
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/role/TransportGetRolesAction.java

@@ -20,11 +20,9 @@ import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
 import org.elasticsearch.xpack.security.authz.store.NativeRolesStore;
 
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
+import java.util.LinkedHashSet;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -51,8 +49,8 @@ public class TransportGetRolesAction extends TransportAction<GetRolesRequest, Ge
             return;
         }
 
-        final Set<String> rolesToSearchFor = new HashSet<>();
-        final List<RoleDescriptor> reservedRoles = new ArrayList<>();
+        final Set<String> rolesToSearchFor = new LinkedHashSet<>();
+        final Set<RoleDescriptor> reservedRoles = new LinkedHashSet<>();
         if (specificRolesRequested) {
             for (String role : requestedRoles) {
                 if (ReservedRolesStore.isReserved(role)) {
@@ -80,10 +78,10 @@ public class TransportGetRolesAction extends TransportAction<GetRolesRequest, Ge
     }
 
     private void getNativeRoles(Set<String> rolesToSearchFor, ActionListener<GetRolesResponse> listener) {
-        getNativeRoles(rolesToSearchFor, new ArrayList<>(), listener);
+        getNativeRoles(rolesToSearchFor, new LinkedHashSet<>(), listener);
     }
 
-    private void getNativeRoles(Set<String> rolesToSearchFor, List<RoleDescriptor> foundRoles, ActionListener<GetRolesResponse> listener) {
+    private void getNativeRoles(Set<String> rolesToSearchFor, Set<RoleDescriptor> foundRoles, ActionListener<GetRolesResponse> listener) {
         nativeRolesStore.getRoleDescriptors(rolesToSearchFor, ActionListener.wrap((retrievalResult) -> {
             if (retrievalResult.isSuccess()) {
                 foundRoles.addAll(retrievalResult.getDescriptors());

+ 9 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/FileRolesStore.java

@@ -44,6 +44,7 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -173,6 +174,14 @@ public class FileRolesStore implements BiConsumer<Set<String>, ActionListener<Ro
         return file;
     }
 
+    /**
+     * @return a map of all file role definitions. The returned map is unmodifiable.
+     */
+    public Map<String, RoleDescriptor> getAllRoleDescriptors() {
+        final Map<String, RoleDescriptor> localPermissions = permissions;
+        return Collections.unmodifiableMap(localPermissions);
+    }
+
     // package private for testing
     Set<String> getAllRoleNames() {
         return permissions.keySet();

+ 41 - 9
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/store/NativeRolesStore.java

@@ -63,13 +63,13 @@ import org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermi
 import org.elasticsearch.xpack.core.security.authz.privilege.ConfigurableClusterPrivileges;
 import org.elasticsearch.xpack.core.security.authz.store.RoleRetrievalResult;
 import org.elasticsearch.xpack.core.security.authz.support.DLSRoleQueryValidator;
-import org.elasticsearch.xpack.core.security.support.NativeRealmValidationUtil;
 import org.elasticsearch.xpack.security.authz.ReservedRoleNameChecker;
 import org.elasticsearch.xpack.security.support.SecurityIndexManager;
 
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -169,6 +169,10 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         this.enabled = settings.getAsBoolean(NATIVE_ROLES_ENABLED, true);
     }
 
+    public boolean isEnabled() {
+        return enabled;
+    }
+
     @Override
     public void accept(Set<String> names, ActionListener<RoleRetrievalResult> listener) {
         getRoleDescriptors(names, listener);
@@ -263,6 +267,10 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
     }
 
     public void queryRoleDescriptors(SearchSourceBuilder searchSourceBuilder, ActionListener<QueryRoleResult> listener) {
+        if (enabled == false) {
+            listener.onResponse(QueryRoleResult.EMPTY);
+            return;
+        }
         SearchRequest searchRequest = new SearchRequest(new String[] { SECURITY_MAIN_ALIAS }, searchSourceBuilder);
         SecurityIndexManager frozenSecurityIndex = securityIndex.defensiveCopy();
         if (frozenSecurityIndex.indexExists() == false) {
@@ -345,6 +353,15 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         final List<String> roleNames,
         WriteRequest.RefreshPolicy refreshPolicy,
         final ActionListener<BulkRolesResponse> listener
+    ) {
+        deleteRoles(roleNames, refreshPolicy, true, listener);
+    }
+
+    public void deleteRoles(
+        final Collection<String> roleNames,
+        WriteRequest.RefreshPolicy refreshPolicy,
+        boolean validateRoleNames,
+        final ActionListener<BulkRolesResponse> listener
     ) {
         if (enabled == false) {
             listener.onFailure(new IllegalStateException("Native role management is disabled"));
@@ -355,7 +372,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         Map<String, Exception> validationErrorByRoleName = new HashMap<>();
 
         for (String roleName : roleNames) {
-            if (reservedRoleNameChecker.isReserved(roleName)) {
+            if (validateRoleNames && reservedRoleNameChecker.isReserved(roleName)) {
                 validationErrorByRoleName.put(
                     roleName,
                     new IllegalArgumentException("role [" + roleName + "] is reserved and cannot be deleted")
@@ -402,7 +419,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
     }
 
     private void bulkResponseAndRefreshRolesCache(
-        List<String> roleNames,
+        Collection<String> roleNames,
         BulkResponse bulkResponse,
         Map<String, Exception> validationErrorByRoleName,
         ActionListener<BulkRolesResponse> listener
@@ -430,7 +447,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
     }
 
     private void bulkResponseWithOnlyValidationErrors(
-        List<String> roleNames,
+        Collection<String> roleNames,
         Map<String, Exception> validationErrorByRoleName,
         ActionListener<BulkRolesResponse> listener
     ) {
@@ -542,7 +559,16 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
 
     public void putRoles(
         final WriteRequest.RefreshPolicy refreshPolicy,
-        final List<RoleDescriptor> roles,
+        final Collection<RoleDescriptor> roles,
+        final ActionListener<BulkRolesResponse> listener
+    ) {
+        putRoles(refreshPolicy, roles, true, listener);
+    }
+
+    public void putRoles(
+        final WriteRequest.RefreshPolicy refreshPolicy,
+        final Collection<RoleDescriptor> roles,
+        boolean validateRoleDescriptors,
         final ActionListener<BulkRolesResponse> listener
     ) {
         if (enabled == false) {
@@ -555,7 +581,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
         for (RoleDescriptor role : roles) {
             Exception validationException;
             try {
-                validationException = validateRoleDescriptor(role);
+                validationException = validateRoleDescriptors ? validateRoleDescriptor(role) : null;
             } catch (Exception e) {
                 validationException = e;
             }
@@ -621,8 +647,6 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
 
     // Package private for testing
     XContentBuilder createRoleXContentBuilder(RoleDescriptor role) throws IOException {
-        assert NativeRealmValidationUtil.validateRoleName(role.getName(), false) == null
-            : "Role name was invalid or reserved: " + role.getName();
         assert false == role.hasRestriction() : "restriction is not supported for native roles";
 
         XContentBuilder builder = jsonBuilder().startObject();
@@ -671,7 +695,11 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                     client.prepareMultiSearch()
                         .add(
                             client.prepareSearch(SECURITY_MAIN_ALIAS)
-                                .setQuery(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                .setQuery(
+                                    QueryBuilders.boolQuery()
+                                        .must(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                        .mustNot(QueryBuilders.termQuery("metadata_flattened._reserved", true))
+                                )
                                 .setTrackTotalHits(true)
                                 .setSize(0)
                         )
@@ -680,6 +708,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                                 .setQuery(
                                     QueryBuilders.boolQuery()
                                         .must(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                        .mustNot(QueryBuilders.termQuery("metadata_flattened._reserved", true))
                                         .must(
                                             QueryBuilders.boolQuery()
                                                 .should(existsQuery("indices.field_security.grant"))
@@ -697,6 +726,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                                 .setQuery(
                                     QueryBuilders.boolQuery()
                                         .must(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                        .mustNot(QueryBuilders.termQuery("metadata_flattened._reserved", true))
                                         .filter(existsQuery("indices.query"))
                                 )
                                 .setTrackTotalHits(true)
@@ -708,6 +738,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                                 .setQuery(
                                     QueryBuilders.boolQuery()
                                         .must(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                        .mustNot(QueryBuilders.termQuery("metadata_flattened._reserved", true))
                                         .filter(existsQuery("remote_indices"))
                                 )
                                 .setTrackTotalHits(true)
@@ -718,6 +749,7 @@ public class NativeRolesStore implements BiConsumer<Set<String>, ActionListener<
                                 .setQuery(
                                     QueryBuilders.boolQuery()
                                         .must(QueryBuilders.termQuery(RoleDescriptor.Fields.TYPE.getPreferredName(), ROLE_TYPE))
+                                        .mustNot(QueryBuilders.termQuery("metadata_flattened._reserved", true))
                                         .filter(existsQuery("remote_cluster"))
                                 )
                                 .setTrackTotalHits(true)

+ 3 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/role/RestQueryRoleAction.java

@@ -14,6 +14,8 @@ import org.elasticsearch.core.Nullable;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.license.XPackLicenseState;
 import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.Scope;
+import org.elasticsearch.rest.ServerlessScope;
 import org.elasticsearch.rest.action.RestToXContentListener;
 import org.elasticsearch.search.searchafter.SearchAfterBuilder;
 import org.elasticsearch.search.sort.FieldSortBuilder;
@@ -32,6 +34,7 @@ import static org.elasticsearch.rest.RestRequest.Method.GET;
 import static org.elasticsearch.rest.RestRequest.Method.POST;
 import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
 
+@ServerlessScope(Scope.INTERNAL)
 public final class RestQueryRoleAction extends NativeRoleBaseRestHandler {
 
     @SuppressWarnings("unchecked")

+ 1 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/FeatureNotEnabledException.java

@@ -29,6 +29,7 @@ public class FeatureNotEnabledException extends ElasticsearchException {
         }
     }
 
+    @SuppressWarnings("this-escape")
     public FeatureNotEnabledException(Feature feature, String message, Object... args) {
         super(message, args);
         addMetadata(DISABLED_FEATURE_METADATA, feature.featureName);

+ 52 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRoles.java

@@ -0,0 +1,52 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * A class that holds the built-in roles and their hash digests.
+ */
+public record QueryableBuiltInRoles(Map<String, String> rolesDigest, Collection<RoleDescriptor> roleDescriptors) {
+
+    /**
+     * A listener that is notified when the built-in roles change.
+     */
+    public interface Listener {
+
+        /**
+         * Called when the built-in roles change.
+         *
+         * @param roles the new built-in roles.
+         */
+        void onRolesChanged(QueryableBuiltInRoles roles);
+
+    }
+
+    /**
+     * A provider that provides the built-in roles and can notify subscribed listeners when the built-in roles change.
+     */
+    public interface Provider {
+
+        /**
+         * @return the built-in roles.
+         */
+        QueryableBuiltInRoles getRoles();
+
+        /**
+         * Adds a listener to be notified when the built-in roles change.
+         *
+         * @param listener the listener to add.
+         */
+        void addListener(Listener listener);
+
+    }
+}

+ 23 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesProviderFactory.java

@@ -0,0 +1,23 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+import org.elasticsearch.xpack.security.authz.store.FileRolesStore;
+
+public interface QueryableBuiltInRolesProviderFactory {
+
+    QueryableBuiltInRoles.Provider createProvider(ReservedRolesStore reservedRolesStore, FileRolesStore fileRolesStore);
+
+    class Default implements QueryableBuiltInRolesProviderFactory {
+        @Override
+        public QueryableBuiltInRoles.Provider createProvider(ReservedRolesStore reservedRolesStore, FileRolesStore fileRolesStore) {
+            return new QueryableReservedRolesProvider(reservedRolesStore);
+        }
+    }
+}

+ 546 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesSynchronizer.java

@@ -0,0 +1,546 @@
+/*
+ * 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.support;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.ResourceAlreadyExistsException;
+import org.elasticsearch.Version;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.TransportActions;
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.cluster.ClusterChangedEvent;
+import org.elasticsearch.cluster.ClusterState;
+import org.elasticsearch.cluster.ClusterStateListener;
+import org.elasticsearch.cluster.ClusterStateTaskListener;
+import org.elasticsearch.cluster.NotMasterException;
+import org.elasticsearch.cluster.SimpleBatchedExecutor;
+import org.elasticsearch.cluster.metadata.IndexMetadata;
+import org.elasticsearch.cluster.metadata.Metadata;
+import org.elasticsearch.cluster.node.DiscoveryNodes;
+import org.elasticsearch.cluster.service.ClusterService;
+import org.elasticsearch.cluster.service.MasterServiceTaskQueue;
+import org.elasticsearch.common.Priority;
+import org.elasticsearch.common.collect.ImmutableOpenMap;
+import org.elasticsearch.common.component.LifecycleListener;
+import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.Strings;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.features.FeatureService;
+import org.elasticsearch.features.NodeFeature;
+import org.elasticsearch.index.Index;
+import org.elasticsearch.index.IndexNotFoundException;
+import org.elasticsearch.index.engine.DocumentMissingException;
+import org.elasticsearch.index.engine.VersionConflictEngineException;
+import org.elasticsearch.indices.IndexClosedException;
+import org.elasticsearch.indices.IndexPrimaryShardNotAllocatedException;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xpack.core.security.action.role.BulkRolesResponse;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+import org.elasticsearch.xpack.security.authz.store.FileRolesStore;
+import org.elasticsearch.xpack.security.authz.store.NativeRolesStore;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesUtils.determineRolesToDelete;
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesUtils.determineRolesToUpsert;
+import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS;
+
+/**
+ * Synchronizes built-in roles to the .security index.
+ * The .security index is created if it does not exist.
+ * <p>
+ * The synchronization is executed only on the elected master node
+ * after the cluster has recovered and roles need to be synced.
+ * The goal is to reduce the potential for conflicting operations.
+ * While in most cases, there should be only a single node that’s
+ * attempting to create/update/delete roles, it’s still possible
+ * that the master node changes in the middle of the syncing process.
+ */
+public final class QueryableBuiltInRolesSynchronizer implements ClusterStateListener {
+
+    private static final Logger logger = LogManager.getLogger(QueryableBuiltInRolesSynchronizer.class);
+
+    /**
+     * This is a temporary feature flag to allow enabling the synchronization of built-in roles to the .security index.
+     * Initially, it is disabled by default due to the number of tests that need to be adjusted now that .security index
+     * is created earlier in the cluster lifecycle.
+     * <p>
+     * Once all tests are adjusted, this flag will be set to enabled by default and later removed altogether.
+     */
+    public static final boolean QUERYABLE_BUILT_IN_ROLES_ENABLED;
+    static {
+        final var propertyValue = System.getProperty("es.queryable_built_in_roles_enabled");
+        if (propertyValue == null || propertyValue.isEmpty() || "false".equals(propertyValue)) {
+            QUERYABLE_BUILT_IN_ROLES_ENABLED = false;
+        } else if ("true".equals(propertyValue)) {
+            QUERYABLE_BUILT_IN_ROLES_ENABLED = true;
+        } else {
+            throw new IllegalStateException(
+                "system property [es.queryable_built_in_roles_enabled] may only be set to [true] or [false], but was ["
+                    + propertyValue
+                    + "]"
+            );
+        }
+    }
+
+    public static final NodeFeature QUERYABLE_BUILT_IN_ROLES_FEATURE = new NodeFeature("security.queryable_built_in_roles");
+
+    /**
+     * Index metadata key of the digest of built-in roles indexed in the .security index.
+     * <p>
+     * The value is a map of built-in role names to their digests (calculated by sha256 of the role definition).
+     */
+    public static final String METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY = "queryable_built_in_roles_digest";
+
+    private static final SimpleBatchedExecutor<MarkRolesAsSyncedTask, Map<String, String>> MARK_ROLES_AS_SYNCED_TASK_EXECUTOR =
+        new SimpleBatchedExecutor<>() {
+            @Override
+            public Tuple<ClusterState, Map<String, String>> executeTask(MarkRolesAsSyncedTask task, ClusterState clusterState) {
+                return task.execute(clusterState);
+            }
+
+            @Override
+            public void taskSucceeded(MarkRolesAsSyncedTask task, Map<String, String> value) {
+                task.success(value);
+            }
+        };
+
+    private final MasterServiceTaskQueue<MarkRolesAsSyncedTask> markRolesAsSyncedTaskQueue;
+
+    private final ClusterService clusterService;
+    private final FeatureService featureService;
+    private final QueryableBuiltInRoles.Provider rolesProvider;
+    private final NativeRolesStore nativeRolesStore;
+    private final Executor executor;
+    private final AtomicBoolean synchronizationInProgress = new AtomicBoolean(false);
+
+    private volatile boolean securityIndexDeleted = false;
+
+    /**
+     * Constructs a new built-in roles synchronizer.
+     *
+     * @param clusterService the cluster service to register as a listener
+     * @param featureService the feature service to check if the cluster has the queryable built-in roles feature
+     * @param rolesProviderFactory the factory to create the built-in roles provider
+     * @param nativeRolesStore the native roles store to sync the built-in roles to
+     * @param reservedRolesStore the reserved roles store to fetch the built-in roles from
+     * @param fileRolesStore the file roles store to fetch the built-in roles from
+     * @param threadPool the thread pool
+     */
+    public QueryableBuiltInRolesSynchronizer(
+        ClusterService clusterService,
+        FeatureService featureService,
+        QueryableBuiltInRolesProviderFactory rolesProviderFactory,
+        NativeRolesStore nativeRolesStore,
+        ReservedRolesStore reservedRolesStore,
+        FileRolesStore fileRolesStore,
+        ThreadPool threadPool
+    ) {
+        this.clusterService = clusterService;
+        this.featureService = featureService;
+        this.rolesProvider = rolesProviderFactory.createProvider(reservedRolesStore, fileRolesStore);
+        this.nativeRolesStore = nativeRolesStore;
+        this.executor = threadPool.generic();
+        this.markRolesAsSyncedTaskQueue = clusterService.createTaskQueue(
+            "mark-built-in-roles-as-synced-task-queue",
+            Priority.LOW,
+            MARK_ROLES_AS_SYNCED_TASK_EXECUTOR
+        );
+        this.rolesProvider.addListener(this::builtInRolesChanged);
+        this.clusterService.addLifecycleListener(new LifecycleListener() {
+            @Override
+            public void beforeStop() {
+                clusterService.removeListener(QueryableBuiltInRolesSynchronizer.this);
+            }
+
+            @Override
+            public void beforeStart() {
+                clusterService.addListener(QueryableBuiltInRolesSynchronizer.this);
+            }
+        });
+    }
+
+    private void builtInRolesChanged(QueryableBuiltInRoles roles) {
+        logger.debug("Built-in roles changed, attempting to sync to .security index");
+        final ClusterState state = clusterService.state();
+        if (shouldSyncBuiltInRoles(state)) {
+            syncBuiltInRoles(roles);
+        }
+    }
+
+    @Override
+    public void clusterChanged(ClusterChangedEvent event) {
+        final ClusterState state = event.state();
+        if (isSecurityIndexDeleted(event)) {
+            this.securityIndexDeleted = true;
+            logger.trace("Received security index deletion event, skipping built-in roles synchronization");
+            return;
+        } else if (isSecurityIndexCreatedOrRecovered(event)) {
+            this.securityIndexDeleted = false;
+            logger.trace("Security index has been created/recovered, attempting to sync built-in roles");
+        }
+        if (shouldSyncBuiltInRoles(state)) {
+            final QueryableBuiltInRoles roles = rolesProvider.getRoles();
+            syncBuiltInRoles(roles);
+        }
+    }
+
+    private void syncBuiltInRoles(final QueryableBuiltInRoles roles) {
+        if (synchronizationInProgress.compareAndSet(false, true)) {
+            final Map<String, String> indexedRolesDigests = readIndexedBuiltInRolesDigests(clusterService.state());
+            if (roles.rolesDigest().equals(indexedRolesDigests)) {
+                logger.debug("Security index already contains the latest built-in roles indexed, skipping synchronization");
+                return;
+            }
+            executor.execute(() -> doSyncBuiltinRoles(indexedRolesDigests, roles, ActionListener.wrap(v -> {
+                logger.info("Successfully synced [" + roles.roleDescriptors().size() + "] built-in roles to .security index");
+                synchronizationInProgress.set(false);
+            }, e -> {
+                handleException(e);
+                synchronizationInProgress.set(false);
+            })));
+        }
+    }
+
+    private static void handleException(Exception e) {
+        if (e instanceof BulkRolesResponseException bulkException) {
+            final boolean isBulkDeleteFailure = bulkException instanceof BulkDeleteRolesResponseException;
+            for (final Map.Entry<String, Exception> bulkFailure : bulkException.getFailures().entrySet()) {
+                final String logMessage = Strings.format(
+                    "Failed to [%s] built-in role [%s]",
+                    isBulkDeleteFailure ? "delete" : "create/update",
+                    bulkFailure.getKey()
+                );
+                if (isExpectedFailure(bulkFailure.getValue())) {
+                    logger.info(logMessage, bulkFailure.getValue());
+                } else {
+                    logger.warn(logMessage, bulkFailure.getValue());
+                }
+            }
+        } else if (isExpectedFailure(e)) {
+            logger.info("Failed to sync built-in roles to .security index", e);
+        } else {
+            logger.warn("Failed to sync built-in roles to .security index due to unexpected exception", e);
+        }
+    }
+
+    /**
+     * Some failures are expected and should not be logged as errors.
+     * These exceptions are either:
+     * - transient (e.g. connection errors),
+     * - recoverable (e.g. no longer master, index reallocating or caused by concurrent operations)
+     * - not recoverable but expected (e.g. index closed).
+     *
+     * @param e to check
+     * @return {@code true} if the exception is expected and should not be logged as an error
+     */
+    private static boolean isExpectedFailure(final Exception e) {
+        final Throwable cause = ExceptionsHelper.unwrapCause(e);
+        return ExceptionsHelper.isNodeOrShardUnavailableTypeException(cause)
+            || TransportActions.isShardNotAvailableException(cause)
+            || cause instanceof IndexClosedException
+            || cause instanceof IndexPrimaryShardNotAllocatedException
+            || cause instanceof NotMasterException
+            || cause instanceof ResourceAlreadyExistsException
+            || cause instanceof VersionConflictEngineException
+            || cause instanceof DocumentMissingException
+            || cause instanceof FailedToMarkBuiltInRolesAsSyncedException;
+    }
+
+    private static boolean isMixedVersionCluster(DiscoveryNodes nodes) {
+        Version version = null;
+        for (var n : nodes) {
+            if (version == null) {
+                version = n.getVersion();
+            } else if (version.equals(n.getVersion()) == false) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean shouldSyncBuiltInRoles(final ClusterState state) {
+        if (false == state.nodes().isLocalNodeElectedMaster()) {
+            logger.trace("Local node is not the master, skipping built-in roles synchronization");
+            return false;
+        }
+        if (false == state.clusterRecovered()) {
+            logger.trace("Cluster state has not recovered yet, skipping built-in roles synchronization");
+            return false;
+        }
+        if (nativeRolesStore.isEnabled() == false) {
+            logger.trace("Native roles store is not enabled, skipping built-in roles synchronization");
+            return false;
+        }
+        if (state.nodes().getDataNodes().isEmpty()) {
+            logger.trace("No data nodes in the cluster, skipping built-in roles synchronization");
+            return false;
+        }
+        if (isMixedVersionCluster(state.nodes())) {
+            // To keep things simple and avoid potential overwrites with an older version of built-in roles,
+            // we only sync built-in roles if all nodes are on the same version.
+            logger.trace("Not all nodes are on the same version, skipping built-in roles synchronization");
+            return false;
+        }
+        if (false == featureService.clusterHasFeature(state, QUERYABLE_BUILT_IN_ROLES_FEATURE)) {
+            logger.trace("Not all nodes support queryable built-in roles feature, skipping built-in roles synchronization");
+            return false;
+        }
+        if (securityIndexDeleted) {
+            logger.trace("Security index is deleted, skipping built-in roles synchronization");
+            return false;
+        }
+        if (isSecurityIndexClosed(state)) {
+            logger.trace("Security index is closed, skipping built-in roles synchronization");
+            return false;
+        }
+        return true;
+    }
+
+    private void doSyncBuiltinRoles(
+        final Map<String, String> indexedRolesDigests,
+        final QueryableBuiltInRoles roles,
+        final ActionListener<Void> listener
+    ) {
+        final Set<RoleDescriptor> rolesToUpsert = determineRolesToUpsert(roles, indexedRolesDigests);
+        final Set<String> rolesToDelete = determineRolesToDelete(roles, indexedRolesDigests);
+
+        assert Sets.intersection(rolesToUpsert.stream().map(RoleDescriptor::getName).collect(toSet()), rolesToDelete).isEmpty()
+            : "The roles to upsert and delete should not have any common roles";
+
+        if (rolesToUpsert.isEmpty() && rolesToDelete.isEmpty()) {
+            logger.debug("No changes to built-in roles to sync to .security index");
+            listener.onResponse(null);
+            return;
+        }
+
+        indexRoles(rolesToUpsert, listener.delegateFailureAndWrap((l1, indexResponse) -> {
+            deleteRoles(rolesToDelete, l1.delegateFailureAndWrap((l2, deleteResponse) -> {
+                markRolesAsSynced(indexedRolesDigests, roles.rolesDigest(), l2);
+            }));
+        }));
+    }
+
+    private void deleteRoles(final Set<String> rolesToDelete, final ActionListener<Void> listener) {
+        if (rolesToDelete.isEmpty()) {
+            listener.onResponse(null);
+            return;
+        }
+        nativeRolesStore.deleteRoles(rolesToDelete, WriteRequest.RefreshPolicy.IMMEDIATE, false, ActionListener.wrap(deleteResponse -> {
+            final Map<String, Exception> deleteFailure = deleteResponse.getItems()
+                .stream()
+                .filter(BulkRolesResponse.Item::isFailed)
+                .collect(toMap(BulkRolesResponse.Item::getRoleName, BulkRolesResponse.Item::getCause));
+            if (deleteFailure.isEmpty()) {
+                listener.onResponse(null);
+            } else {
+                listener.onFailure(new BulkDeleteRolesResponseException(deleteFailure));
+            }
+        }, listener::onFailure));
+    }
+
+    private void indexRoles(final Collection<RoleDescriptor> rolesToUpsert, final ActionListener<Void> listener) {
+        if (rolesToUpsert.isEmpty()) {
+            listener.onResponse(null);
+            return;
+        }
+        nativeRolesStore.putRoles(WriteRequest.RefreshPolicy.IMMEDIATE, rolesToUpsert, false, ActionListener.wrap(response -> {
+            final Map<String, Exception> indexFailures = response.getItems()
+                .stream()
+                .filter(BulkRolesResponse.Item::isFailed)
+                .collect(toMap(BulkRolesResponse.Item::getRoleName, BulkRolesResponse.Item::getCause));
+            if (indexFailures.isEmpty()) {
+                listener.onResponse(null);
+            } else {
+                listener.onFailure(new BulkIndexRolesResponseException(indexFailures));
+            }
+        }, listener::onFailure));
+    }
+
+    private boolean isSecurityIndexDeleted(final ClusterChangedEvent event) {
+        final IndexMetadata previousSecurityIndexMetadata = resolveSecurityIndexMetadata(event.previousState().metadata());
+        final IndexMetadata currentSecurityIndexMetadata = resolveSecurityIndexMetadata(event.state().metadata());
+        return previousSecurityIndexMetadata != null && currentSecurityIndexMetadata == null;
+    }
+
+    private boolean isSecurityIndexCreatedOrRecovered(final ClusterChangedEvent event) {
+        final IndexMetadata previousSecurityIndexMetadata = resolveSecurityIndexMetadata(event.previousState().metadata());
+        final IndexMetadata currentSecurityIndexMetadata = resolveSecurityIndexMetadata(event.state().metadata());
+        return previousSecurityIndexMetadata == null && currentSecurityIndexMetadata != null;
+    }
+
+    private boolean isSecurityIndexClosed(final ClusterState state) {
+        final IndexMetadata indexMetadata = resolveSecurityIndexMetadata(state.metadata());
+        return indexMetadata != null && indexMetadata.getState() == IndexMetadata.State.CLOSE;
+    }
+
+    /**
+     * This method marks the built-in roles as synced in the .security index
+     * by setting the new roles digests in the metadata of the .security index.
+     * <p>
+     * The marking is done as a compare and swap operation to ensure that the roles
+     * are marked as synced only when new roles are indexed. The operation is idempotent
+     * and will succeed if the expected roles digests are equal to the digests in the
+     * .security index or if they are equal to the new roles digests.
+     */
+    private void markRolesAsSynced(
+        final Map<String, String> expectedRolesDigests,
+        final Map<String, String> newRolesDigests,
+        final ActionListener<Void> listener
+    ) {
+        final IndexMetadata securityIndexMetadata = resolveSecurityIndexMetadata(clusterService.state().metadata());
+        if (securityIndexMetadata == null) {
+            listener.onFailure(new IndexNotFoundException(SECURITY_MAIN_ALIAS));
+            return;
+        }
+        final Index concreteSecurityIndex = securityIndexMetadata.getIndex();
+        markRolesAsSyncedTaskQueue.submitTask(
+            "mark built-in roles as synced task",
+            new MarkRolesAsSyncedTask(listener.delegateFailureAndWrap((l, response) -> {
+                if (newRolesDigests.equals(response) == false) {
+                    logger.debug(
+                        () -> Strings.format(
+                            "Another master node most probably indexed a newer versions of built-in roles in the meantime. "
+                                + "Expected: [%s], Actual: [%s]",
+                            newRolesDigests,
+                            response
+                        )
+                    );
+                    l.onFailure(
+                        new FailedToMarkBuiltInRolesAsSyncedException(
+                            "Failed to mark built-in roles as synced. The expected role digests have changed."
+                        )
+                    );
+                } else {
+                    l.onResponse(null);
+                }
+            }), concreteSecurityIndex.getName(), expectedRolesDigests, newRolesDigests),
+            null
+        );
+    }
+
+    private Map<String, String> readIndexedBuiltInRolesDigests(final ClusterState state) {
+        final IndexMetadata indexMetadata = resolveSecurityIndexMetadata(state.metadata());
+        if (indexMetadata == null) {
+            return null;
+        }
+        return indexMetadata.getCustomData(METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY);
+    }
+
+    private static IndexMetadata resolveSecurityIndexMetadata(final Metadata metadata) {
+        return SecurityIndexManager.resolveConcreteIndex(SECURITY_MAIN_ALIAS, metadata);
+    }
+
+    static class MarkRolesAsSyncedTask implements ClusterStateTaskListener {
+
+        private final ActionListener<Map<String, String>> listener;
+        private final String concreteSecurityIndexName;
+        private final Map<String, String> expectedRoleDigests;
+        private final Map<String, String> newRoleDigests;
+
+        MarkRolesAsSyncedTask(
+            ActionListener<Map<String, String>> listener,
+            String concreteSecurityIndexName,
+            @Nullable Map<String, String> expectedRoleDigests,
+            @Nullable Map<String, String> newRoleDigests
+        ) {
+            this.listener = listener;
+            this.concreteSecurityIndexName = concreteSecurityIndexName;
+            this.expectedRoleDigests = expectedRoleDigests;
+            this.newRoleDigests = newRoleDigests;
+        }
+
+        Tuple<ClusterState, Map<String, String>> execute(ClusterState state) {
+            IndexMetadata indexMetadata = state.metadata().index(concreteSecurityIndexName);
+            if (indexMetadata == null) {
+                throw new IndexNotFoundException(concreteSecurityIndexName);
+            }
+            Map<String, String> existingRoleDigests = indexMetadata.getCustomData(METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY);
+            if (Objects.equals(expectedRoleDigests, existingRoleDigests)) {
+                IndexMetadata.Builder indexMetadataBuilder = IndexMetadata.builder(indexMetadata);
+                if (newRoleDigests != null) {
+                    indexMetadataBuilder.putCustom(METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY, newRoleDigests);
+                } else {
+                    indexMetadataBuilder.removeCustom(METADATA_QUERYABLE_BUILT_IN_ROLES_DIGEST_KEY);
+                }
+                indexMetadataBuilder.version(indexMetadataBuilder.version() + 1);
+                ImmutableOpenMap.Builder<String, IndexMetadata> builder = ImmutableOpenMap.builder(state.metadata().indices());
+                builder.put(concreteSecurityIndexName, indexMetadataBuilder.build());
+                return new Tuple<>(
+                    ClusterState.builder(state).metadata(Metadata.builder(state.metadata()).indices(builder.build()).build()).build(),
+                    newRoleDigests
+                );
+            } else {
+                // returns existing value when expectation is not met
+                return new Tuple<>(state, existingRoleDigests);
+            }
+        }
+
+        void success(Map<String, String> value) {
+            listener.onResponse(value);
+        }
+
+        @Override
+        public void onFailure(Exception e) {
+            listener.onFailure(e);
+        }
+    }
+
+    private static class BulkDeleteRolesResponseException extends BulkRolesResponseException {
+
+        BulkDeleteRolesResponseException(Map<String, Exception> failures) {
+            super("Failed to bulk delete built-in roles", failures);
+        }
+
+    }
+
+    private static class BulkIndexRolesResponseException extends BulkRolesResponseException {
+
+        BulkIndexRolesResponseException(Map<String, Exception> failures) {
+            super("Failed to bulk create/update built-in roles", failures);
+        }
+
+    }
+
+    private abstract static class BulkRolesResponseException extends RuntimeException {
+
+        private final Map<String, Exception> failures;
+
+        BulkRolesResponseException(String message, Map<String, Exception> failures) {
+            super(message);
+            assert failures != null && failures.isEmpty() == false;
+            this.failures = failures;
+            failures.values().forEach(this::addSuppressed);
+        }
+
+        Map<String, Exception> getFailures() {
+            return failures;
+        }
+
+    }
+
+    private static class FailedToMarkBuiltInRolesAsSyncedException extends RuntimeException {
+
+        FailedToMarkBuiltInRolesAsSyncedException(String message) {
+            super(message);
+        }
+
+    }
+
+}

+ 101 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesUtils.java

@@ -0,0 +1,101 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.hash.MessageDigests;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.util.Maps;
+import org.elasticsearch.common.util.set.Sets;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.xcontent.ToXContent.EMPTY_PARAMS;
+
+/**
+ * Utility class which provides helper method for calculating the hash of a role descriptor,
+ * determining the roles to upsert and the roles to delete.
+ */
+public final class QueryableBuiltInRolesUtils {
+
+    /**
+     * Calculates the hash of the given role descriptor by serializing it by calling {@link RoleDescriptor#writeTo(StreamOutput)} method
+     * and then SHA256 hashing the bytes.
+     *
+     * @param roleDescriptor the role descriptor to hash
+     * @return the base64 encoded SHA256 hash of the role descriptor
+     */
+    public static String calculateHash(final RoleDescriptor roleDescriptor) {
+        final MessageDigest hash = MessageDigests.sha256();
+        try (XContentBuilder jsonBuilder = XContentFactory.jsonBuilder()) {
+            roleDescriptor.toXContent(jsonBuilder, EMPTY_PARAMS);
+            final Map<String, Object> flattenMap = Maps.flatten(
+                XContentHelper.convertToMap(BytesReference.bytes(jsonBuilder), true, XContentType.JSON).v2(),
+                false,
+                true
+            );
+            hash.update(flattenMap.toString().getBytes(StandardCharsets.UTF_8));
+        } catch (IOException e) {
+            throw new IllegalStateException("failed to compute digest for [" + roleDescriptor.getName() + "] role", e);
+        }
+        // HEX vs Base64 encoding is a trade-off between readability and space efficiency
+        // opting for Base64 here to reduce the size of the cluster state
+        return Base64.getEncoder().encodeToString(hash.digest());
+    }
+
+    /**
+     * Determines the roles to delete by comparing the indexed roles with the roles in the built-in roles.
+     * @return the set of roles to delete
+     */
+    public static Set<String> determineRolesToDelete(final QueryableBuiltInRoles roles, final Map<String, String> indexedRolesDigests) {
+        assert roles != null;
+        if (indexedRolesDigests == null) {
+            // nothing indexed, nothing to delete
+            return Set.of();
+        }
+        final Set<String> rolesToDelete = Sets.difference(indexedRolesDigests.keySet(), roles.rolesDigest().keySet());
+        return Collections.unmodifiableSet(rolesToDelete);
+    }
+
+    /**
+     * Determines the roles to upsert by comparing the indexed roles and their digests with the current built-in roles.
+     * @return the set of roles to upsert (create or update)
+     */
+    public static Set<RoleDescriptor> determineRolesToUpsert(
+        final QueryableBuiltInRoles roles,
+        final Map<String, String> indexedRolesDigests
+    ) {
+        assert roles != null;
+        final Set<RoleDescriptor> rolesToUpsert = new HashSet<>();
+        for (RoleDescriptor role : roles.roleDescriptors()) {
+            final String roleDigest = roles.rolesDigest().get(role.getName());
+            if (indexedRolesDigests == null || indexedRolesDigests.containsKey(role.getName()) == false) {
+                rolesToUpsert.add(role);  // a new role to create
+            } else if (indexedRolesDigests.get(role.getName()).equals(roleDigest) == false) {
+                rolesToUpsert.add(role);  // an existing role that needs to be updated
+            }
+        }
+        return Collections.unmodifiableSet(rolesToUpsert);
+    }
+
+    private QueryableBuiltInRolesUtils() {
+        throw new IllegalAccessError("not allowed");
+    }
+}

+ 56 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/QueryableReservedRolesProvider.java

@@ -0,0 +1,56 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.common.util.CachedSupplier;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * A provider of the built-in reserved roles.
+ * <p>
+ * This provider fetches all reserved roles from the {@link ReservedRolesStore} and calculates their hashes lazily.
+ * The reserved roles are static and do not change during runtime, hence this provider will never notify any listeners.
+ * </p>
+ */
+public final class QueryableReservedRolesProvider implements QueryableBuiltInRoles.Provider {
+
+    private final Supplier<QueryableBuiltInRoles> reservedRolesSupplier;
+
+    /**
+     * Constructs a new reserved roles provider.
+     *
+     * @param reservedRolesStore the store to fetch the reserved roles from.
+     *                           Having a store reference here is necessary to ensure that static fields are initialized.
+     */
+    public QueryableReservedRolesProvider(ReservedRolesStore reservedRolesStore) {
+        this.reservedRolesSupplier = CachedSupplier.wrap(() -> {
+            final Collection<RoleDescriptor> roleDescriptors = Collections.unmodifiableCollection(ReservedRolesStore.roleDescriptors());
+            return new QueryableBuiltInRoles(
+                roleDescriptors.stream()
+                    .collect(Collectors.toUnmodifiableMap(RoleDescriptor::getName, QueryableBuiltInRolesUtils::calculateHash)),
+                roleDescriptors
+            );
+        });
+    }
+
+    @Override
+    public QueryableBuiltInRoles getRoles() {
+        return reservedRolesSupplier.get();
+    }
+
+    @Override
+    public void addListener(QueryableBuiltInRoles.Listener listener) {
+        // no-op: reserved roles are static and do not change
+    }
+}

+ 1 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexManager.java

@@ -586,7 +586,7 @@ public class SecurityIndexManager implements ClusterStateListener {
      * Resolves a concrete index name or alias to a {@link IndexMetadata} instance.  Requires
      * that if supplied with an alias, the alias resolves to at most one concrete index.
      */
-    private static IndexMetadata resolveConcreteIndex(final String indexOrAliasName, final Metadata metadata) {
+    public static IndexMetadata resolveConcreteIndex(final String indexOrAliasName, final Metadata metadata) {
         final IndexAbstraction indexAbstraction = metadata.getIndicesLookup().get(indexOrAliasName);
         if (indexAbstraction != null) {
             final List<Index> indices = indexAbstraction.getIndices();

+ 296 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/QueryableBuiltInRolesUtilsTests.java

@@ -0,0 +1,296 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptorTestHelper;
+import org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermissionGroup;
+import org.elasticsearch.xpack.core.security.authz.permission.RemoteClusterPermissions;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+import org.junit.BeforeClass;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import static org.elasticsearch.xpack.core.security.support.MetadataUtils.RESERVED_METADATA_KEY;
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesUtils.determineRolesToDelete;
+import static org.elasticsearch.xpack.security.support.QueryableBuiltInRolesUtils.determineRolesToUpsert;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+
+public class QueryableBuiltInRolesUtilsTests extends ESTestCase {
+
+    @BeforeClass
+    public static void setupReservedRolesStore() {
+        new ReservedRolesStore(); // initialize the store
+    }
+
+    public void testCalculateHash() {
+        assertThat(
+            QueryableBuiltInRolesUtils.calculateHash(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR),
+            equalTo("bWEFdFo4WX229wdhdecfiz5QHMYEssh3ex8hizRgg+Q=")
+        );
+    }
+
+    public void testEmptyOrNullRolesToUpsertOrDelete() {
+        // test empty roles and index digests
+        final QueryableBuiltInRoles emptyRoles = new QueryableBuiltInRoles(Map.of(), Set.of());
+        assertThat(determineRolesToDelete(emptyRoles, Map.of()), is(empty()));
+        assertThat(determineRolesToUpsert(emptyRoles, Map.of()), is(empty()));
+
+        // test empty roles and null indexed digests
+        assertThat(determineRolesToDelete(emptyRoles, null), is(empty()));
+        assertThat(determineRolesToUpsert(emptyRoles, null), is(empty()));
+    }
+
+    public void testNoRolesToUpsertOrDelete() {
+        {
+            QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+                Set.of(
+                    ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                    ReservedRolesStore.roleDescriptor("viewer"),
+                    ReservedRolesStore.roleDescriptor("editor")
+                )
+            );
+
+            // no roles to delete or upsert since the built-in roles are the same as the indexed roles
+            assertThat(determineRolesToDelete(currentBuiltInRoles, currentBuiltInRoles.rolesDigest()), is(empty()));
+            assertThat(determineRolesToUpsert(currentBuiltInRoles, currentBuiltInRoles.rolesDigest()), is(empty()));
+        }
+        {
+            QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+                Set.of(
+                    ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                    ReservedRolesStore.roleDescriptor("viewer"),
+                    ReservedRolesStore.roleDescriptor("editor"),
+                    supermanRole("monitor", "read")
+                )
+            );
+
+            Map<String, String> digests = buildDigests(
+                Set.of(
+                    ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                    ReservedRolesStore.roleDescriptor("viewer"),
+                    ReservedRolesStore.roleDescriptor("editor"),
+                    supermanRole("monitor", "read")
+                )
+            );
+
+            // no roles to delete or upsert since the built-in roles are the same as the indexed roles
+            assertThat(determineRolesToDelete(currentBuiltInRoles, digests), is(empty()));
+            assertThat(determineRolesToUpsert(currentBuiltInRoles, digests), is(empty()));
+        }
+        {
+            final RoleDescriptor randomRole = RoleDescriptorTestHelper.randomRoleDescriptor();
+            final QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(Set.of(randomRole));
+            final Map<String, String> digests = buildDigests(
+                Set.of(
+                    new RoleDescriptor(
+                        randomRole.getName(),
+                        randomRole.getClusterPrivileges(),
+                        randomRole.getIndicesPrivileges(),
+                        randomRole.getApplicationPrivileges(),
+                        randomRole.getConditionalClusterPrivileges(),
+                        randomRole.getRunAs(),
+                        randomRole.getMetadata(),
+                        randomRole.getTransientMetadata(),
+                        randomRole.getRemoteIndicesPrivileges(),
+                        randomRole.getRemoteClusterPermissions(),
+                        randomRole.getRestriction(),
+                        randomRole.getDescription()
+                    )
+                )
+            );
+
+            assertThat(determineRolesToDelete(currentBuiltInRoles, digests), is(empty()));
+            assertThat(determineRolesToUpsert(currentBuiltInRoles, digests), is(empty()));
+        }
+    }
+
+    public void testRolesToDeleteOnly() {
+        Map<String, String> indexedDigests = buildDigests(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor"),
+                supermanRole("monitor", "read", "view_index_metadata", "read_cross_cluster")
+            )
+        );
+
+        QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor")
+            )
+        );
+
+        // superman is the only role that needs to be deleted since it is not in a current built-in role
+        assertThat(determineRolesToDelete(currentBuiltInRoles, indexedDigests), containsInAnyOrder("superman"));
+        assertThat(determineRolesToUpsert(currentBuiltInRoles, indexedDigests), is(empty()));
+
+        // passing empty built-in roles should result in all indexed roles needing to be deleted
+        QueryableBuiltInRoles emptyBuiltInRoles = new QueryableBuiltInRoles(Map.of(), Set.of());
+        assertThat(
+            determineRolesToDelete(emptyBuiltInRoles, indexedDigests),
+            containsInAnyOrder("superman", "viewer", "editor", "superuser")
+        );
+        assertThat(determineRolesToUpsert(emptyBuiltInRoles, indexedDigests), is(empty()));
+    }
+
+    public void testRolesToUpdateOnly() {
+        Map<String, String> indexedDigests = buildDigests(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor"),
+                supermanRole("monitor", "read", "write")
+            )
+        );
+
+        RoleDescriptor updatedSupermanRole = supermanRole("monitor", "read", "view_index_metadata", "read_cross_cluster");
+        QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor"),
+                updatedSupermanRole
+            )
+        );
+
+        // superman is the only role that needs to be updated since its definition has changed
+        assertThat(determineRolesToDelete(currentBuiltInRoles, indexedDigests), is(empty()));
+        assertThat(determineRolesToUpsert(currentBuiltInRoles, indexedDigests), containsInAnyOrder(updatedSupermanRole));
+        assertThat(currentBuiltInRoles.rolesDigest().get("superman"), is(not(equalTo(indexedDigests.get("superman")))));
+    }
+
+    public void testRolesToCreateOnly() {
+        Map<String, String> indexedDigests = buildDigests(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor")
+            )
+        );
+
+        RoleDescriptor newSupermanRole = supermanRole("monitor", "read", "view_index_metadata", "read_cross_cluster");
+        QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor"),
+                newSupermanRole
+            )
+        );
+
+        // superman is the only role that needs to be created since it is not in the indexed roles
+        assertThat(determineRolesToDelete(currentBuiltInRoles, indexedDigests), is(empty()));
+        assertThat(determineRolesToUpsert(currentBuiltInRoles, indexedDigests), containsInAnyOrder(newSupermanRole));
+
+        // passing empty indexed roles should result in all roles needing to be created
+        assertThat(determineRolesToDelete(currentBuiltInRoles, Map.of()), is(empty()));
+        assertThat(
+            determineRolesToUpsert(currentBuiltInRoles, Map.of()),
+            containsInAnyOrder(currentBuiltInRoles.roleDescriptors().toArray(new RoleDescriptor[0]))
+        );
+    }
+
+    public void testRolesToUpsertAndDelete() {
+        Map<String, String> indexedDigests = buildDigests(
+            Set.of(
+                ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR,
+                ReservedRolesStore.roleDescriptor("viewer"),
+                ReservedRolesStore.roleDescriptor("editor")
+            )
+        );
+
+        RoleDescriptor newSupermanRole = supermanRole("monitor");
+        QueryableBuiltInRoles currentBuiltInRoles = buildQueryableBuiltInRoles(
+            Set.of(ReservedRolesStore.SUPERUSER_ROLE_DESCRIPTOR, newSupermanRole)
+        );
+
+        // superman is the only role that needs to be updated since its definition has changed
+        assertThat(determineRolesToDelete(currentBuiltInRoles, indexedDigests), containsInAnyOrder("viewer", "editor"));
+        assertThat(determineRolesToUpsert(currentBuiltInRoles, indexedDigests), containsInAnyOrder(newSupermanRole));
+    }
+
+    private static RoleDescriptor supermanRole(String... indicesPrivileges) {
+        return new RoleDescriptor(
+            "superman",
+            new String[] { "all" },
+            new RoleDescriptor.IndicesPrivileges[] {
+                RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("all").allowRestrictedIndices(false).build(),
+                RoleDescriptor.IndicesPrivileges.builder()
+                    .indices("*")
+                    .privileges(indicesPrivileges)
+                    .allowRestrictedIndices(true)
+                    .build() },
+            new RoleDescriptor.ApplicationResourcePrivileges[] {
+                RoleDescriptor.ApplicationResourcePrivileges.builder().application("*").privileges("*").resources("*").build() },
+            null,
+            new String[] { "*" },
+            randomlyOrderedSupermanMetadata(),
+            Collections.emptyMap(),
+            new RoleDescriptor.RemoteIndicesPrivileges[] {
+                new RoleDescriptor.RemoteIndicesPrivileges(
+                    RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("all").allowRestrictedIndices(false).build(),
+                    "*"
+                ),
+                new RoleDescriptor.RemoteIndicesPrivileges(
+                    RoleDescriptor.IndicesPrivileges.builder()
+                        .indices("*")
+                        .privileges(indicesPrivileges)
+                        .allowRestrictedIndices(true)
+                        .build(),
+                    "*"
+                ) },
+            new RemoteClusterPermissions().addGroup(
+                new RemoteClusterPermissionGroup(
+                    RemoteClusterPermissions.getSupportedRemoteClusterPermissions().toArray(new String[0]),
+                    new String[] { "*" }
+                )
+            ),
+            null,
+            "Grants full access to cluster management and data indices."
+        );
+    }
+
+    private static Map<String, Object> randomlyOrderedSupermanMetadata() {
+        final LinkedHashMap<String, Object> metadata = new LinkedHashMap<>();
+        if (randomBoolean()) {
+            metadata.put("foo", "bar");
+            metadata.put("baz", "qux");
+            metadata.put(RESERVED_METADATA_KEY, true);
+        } else {
+            metadata.put(RESERVED_METADATA_KEY, true);
+            metadata.put("foo", "bar");
+            metadata.put("baz", "qux");
+        }
+        return metadata;
+    }
+
+    private static QueryableBuiltInRoles buildQueryableBuiltInRoles(Set<RoleDescriptor> roles) {
+        final Map<String, String> digests = buildDigests(roles);
+        return new QueryableBuiltInRoles(digests, roles);
+    }
+
+    private static Map<String, String> buildDigests(Set<RoleDescriptor> roles) {
+        final Map<String, String> digests = new HashMap<>();
+        for (RoleDescriptor role : roles) {
+            digests.put(role.getName(), QueryableBuiltInRolesUtils.calculateHash(role));
+        }
+        return digests;
+    }
+}

+ 31 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/QueryableReservedRolesProviderTests.java

@@ -0,0 +1,31 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.authz.store.ReservedRolesStore;
+
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class QueryableReservedRolesProviderTests extends ESTestCase {
+
+    public void testReservedRoleProvider() {
+        QueryableReservedRolesProvider provider = new QueryableReservedRolesProvider(new ReservedRolesStore());
+        assertNotNull(provider.getRoles());
+        assertThat(provider.getRoles(), equalTo(provider.getRoles()));
+        assertThat(provider.getRoles().rolesDigest().size(), equalTo(ReservedRolesStore.roleDescriptors().size()));
+        assertThat(
+            provider.getRoles().rolesDigest().keySet(),
+            equalTo(ReservedRolesStore.roleDescriptors().stream().map(RoleDescriptor::getName).collect(Collectors.toSet()))
+        );
+    }
+
+}