Browse Source

Add Query Users API (#104033)

* Add query users API
Johannes Fredén 1 year ago
parent
commit
de992c278d
20 changed files with 1679 additions and 6 deletions
  1. 5 0
      docs/changelog/104033.yaml
  2. 3 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ActionTypes.java
  3. 99 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/QueryUserRequest.java
  4. 94 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/QueryUserResponse.java
  5. 2 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java
  6. 43 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/user/QueryUserRequestTests.java
  7. 3 6
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/PrivilegeTests.java
  8. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  9. 490 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryUserIT.java
  10. 4 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java
  11. 5 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml
  12. 4 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  13. 107 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportQueryUserAction.java
  14. 37 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeUsersStore.java
  15. 115 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestQueryUserAction.java
  16. 84 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/SecurityIndexFieldNameTranslator.java
  17. 101 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/UserBoolQueryBuilder.java
  18. 86 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportQueryUserActionTests.java
  19. 175 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/user/RestQueryUserActionTests.java
  20. 221 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/UserBoolQueryBuilderTests.java

+ 5 - 0
docs/changelog/104033.yaml

@@ -0,0 +1,5 @@
+pr: 104033
+summary: Add Query Users API
+area: Security
+type: enhancement
+issues: []

+ 3 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ActionTypes.java

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.core.security.action;
 
 import org.elasticsearch.action.ActionResponse;
 import org.elasticsearch.action.ActionType;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
 
 /**
  * A collection of actions types for the Security plugin that need to be available in xpack.core.security and thus cannot be stored
@@ -20,4 +21,6 @@ public final class ActionTypes {
     public static final ActionType<ActionResponse.Empty> RELOAD_REMOTE_CLUSTER_CREDENTIALS_ACTION = ActionType.localOnly(
         "cluster:admin/xpack/security/remote_cluster_credentials/reload"
     );
+
+    public static final ActionType<QueryUserResponse> QUERY_USER_ACTION = ActionType.localOnly("cluster:admin/xpack/security/user/query");
 }

+ 99 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/QueryUserRequest.java

@@ -0,0 +1,99 @@
+/*
+ * 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.core.security.action.user;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.support.TransportAction;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.searchafter.SearchAfterBuilder;
+import org.elasticsearch.search.sort.FieldSortBuilder;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+/**
+ * Request for the query Users API. <br>
+ * Model for API requests to the query users API
+ */
+public final class QueryUserRequest extends ActionRequest {
+
+    @Nullable
+    private final QueryBuilder queryBuilder;
+    @Nullable
+    private final Integer from;
+    @Nullable
+    private final Integer size;
+    @Nullable
+    private final List<FieldSortBuilder> fieldSortBuilders;
+    @Nullable
+    private final SearchAfterBuilder searchAfterBuilder;
+
+    public QueryUserRequest() {
+        this(null);
+    }
+
+    public QueryUserRequest(QueryBuilder queryBuilder) {
+        this(queryBuilder, null, null, null, null);
+    }
+
+    public QueryUserRequest(
+        @Nullable QueryBuilder queryBuilder,
+        @Nullable Integer from,
+        @Nullable Integer size,
+        @Nullable List<FieldSortBuilder> fieldSortBuilders,
+        @Nullable SearchAfterBuilder searchAfterBuilder
+    ) {
+        this.queryBuilder = queryBuilder;
+        this.from = from;
+        this.size = size;
+        this.fieldSortBuilders = fieldSortBuilders;
+        this.searchAfterBuilder = searchAfterBuilder;
+    }
+
+    public QueryBuilder getQueryBuilder() {
+        return queryBuilder;
+    }
+
+    public Integer getFrom() {
+        return from;
+    }
+
+    public Integer getSize() {
+        return size;
+    }
+
+    public List<FieldSortBuilder> getFieldSortBuilders() {
+        return fieldSortBuilders;
+    }
+
+    public SearchAfterBuilder getSearchAfterBuilder() {
+        return searchAfterBuilder;
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        ActionRequestValidationException validationException = null;
+        if (from != null && from < 0) {
+            validationException = addValidationError("[from] parameter cannot be negative but was [" + from + "]", validationException);
+        }
+        if (size != null && size < 0) {
+            validationException = addValidationError("[size] parameter cannot be negative but was [" + size + "]", validationException);
+        }
+        return validationException;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        TransportAction.localOnly();
+    }
+}

+ 94 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/QueryUserResponse.java

@@ -0,0 +1,94 @@
+/*
+ * 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.core.security.action.user;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.support.TransportAction;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.core.security.user.User;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+
+/**
+ * Response for the query Users API. <br>
+ * Model used to serialize information about the Users that were found.
+ */
+public final class QueryUserResponse extends ActionResponse implements ToXContentObject {
+
+    private final long total;
+    private final Item[] items;
+
+    public QueryUserResponse(long total, Collection<Item> items) {
+        this.total = total;
+        Objects.requireNonNull(items, "items must be provided");
+        this.items = items.toArray(new Item[0]);
+    }
+
+    public static QueryUserResponse emptyResponse() {
+        return new QueryUserResponse(0, Collections.emptyList());
+    }
+
+    public long getTotal() {
+        return total;
+    }
+
+    public Item[] getItems() {
+        return items;
+    }
+
+    public int getCount() {
+        return items.length;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject().field("total", total).field("count", items.length).array("users", (Object[]) items);
+        return builder.endObject();
+    }
+
+    @Override
+    public String toString() {
+        return "QueryUsersResponse{" + "total=" + total + ", items=" + Arrays.toString(items) + '}';
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        TransportAction.localOnly();
+    }
+
+    public record Item(User user, @Nullable Object[] sortValues) implements ToXContentObject {
+
+        @Override
+        public Object[] sortValues() {
+            return sortValues;
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder.startObject();
+            user.innerToXContent(builder);
+            if (sortValues != null && sortValues.length > 0) {
+                builder.array("_sort", sortValues);
+            }
+            builder.endObject();
+            return builder;
+        }
+
+        @Override
+        public String toString() {
+            return "Item{" + "user=" + user + ", sortValues=" + Arrays.toString(sortValues) + '}';
+        }
+    }
+}

+ 2 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java

@@ -29,6 +29,7 @@ import org.elasticsearch.xpack.core.action.XPackInfoAction;
 import org.elasticsearch.xpack.core.ilm.action.GetLifecycleAction;
 import org.elasticsearch.xpack.core.ilm.action.GetStatusAction;
 import org.elasticsearch.xpack.core.ilm.action.ILMActions;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
 import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction;
 import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction;
@@ -234,6 +235,7 @@ public class ClusterPrivilegeResolver {
             GetServiceAccountAction.NAME,
             GetServiceAccountCredentialsAction.NAME + "*",
             GetUsersAction.NAME,
+            ActionTypes.QUERY_USER_ACTION.name(),
             GetUserPrivilegesAction.NAME, // normally authorized under the "same-user" authz check, but added here for uniformity
             HasPrivilegesAction.NAME,
             GetSecuritySettingsAction.NAME

+ 43 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/user/QueryUserRequestTests.java

@@ -0,0 +1,43 @@
+/*
+ * 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.core.security.action.user;
+
+import org.elasticsearch.test.ESTestCase;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.nullValue;
+
+public class QueryUserRequestTests extends ESTestCase {
+    public void testValidate() {
+        final QueryUserRequest request1 = new QueryUserRequest(
+            null,
+            randomIntBetween(0, Integer.MAX_VALUE),
+            randomIntBetween(0, Integer.MAX_VALUE),
+            null,
+            null
+        );
+        assertThat(request1.validate(), nullValue());
+
+        final QueryUserRequest request2 = new QueryUserRequest(
+            null,
+            randomIntBetween(Integer.MIN_VALUE, -1),
+            randomIntBetween(0, Integer.MAX_VALUE),
+            null,
+            null
+        );
+        assertThat(request2.validate().getMessage(), containsString("[from] parameter cannot be negative"));
+
+        final QueryUserRequest request3 = new QueryUserRequest(
+            null,
+            randomIntBetween(0, Integer.MAX_VALUE),
+            randomIntBetween(Integer.MIN_VALUE, -1),
+            null,
+            null
+        );
+        assertThat(request3.validate().getMessage(), containsString("[size] parameter cannot be negative"));
+    }
+}

+ 3 - 6
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/PrivilegeTests.java

@@ -22,6 +22,7 @@ import org.elasticsearch.xpack.core.enrich.action.DeleteEnrichPolicyAction;
 import org.elasticsearch.xpack.core.enrich.action.ExecuteEnrichPolicyAction;
 import org.elasticsearch.xpack.core.enrich.action.GetEnrichPolicyAction;
 import org.elasticsearch.xpack.core.enrich.action.PutEnrichPolicyAction;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
 import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
 import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction;
 import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction;
@@ -281,6 +282,7 @@ public class PrivilegeTests extends ESTestCase {
             GetServiceAccountAction.NAME,
             GetServiceAccountCredentialsAction.NAME,
             GetUsersAction.NAME,
+            ActionTypes.QUERY_USER_ACTION.name(),
             HasPrivilegesAction.NAME,
             GetUserPrivilegesAction.NAME,
             GetSecuritySettingsAction.NAME
@@ -339,16 +341,11 @@ public class PrivilegeTests extends ESTestCase {
             "cluster:admin/xpack/security/role/get",
             "cluster:admin/xpack/security/role/delete"
         );
-        verifyClusterActionDenied(
-            ClusterPrivilegeResolver.MANAGE_USER_PROFILE,
-            "cluster:admin/xpack/security/role/put",
-            "cluster:admin/xpack/security/role/get",
-            "cluster:admin/xpack/security/role/delete"
-        );
         verifyClusterActionDenied(
             ClusterPrivilegeResolver.MANAGE_USER_PROFILE,
             "cluster:admin/xpack/security/user/put",
             "cluster:admin/xpack/security/user/get",
+            "cluster:admin/xpack/security/user/query",
             "cluster:admin/xpack/security/user/delete"
         );
         verifyClusterActionDenied(

+ 1 - 0
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -282,6 +282,7 @@ public class Constants {
         "cluster:admin/xpack/security/user/change_password",
         "cluster:admin/xpack/security/user/delete",
         "cluster:admin/xpack/security/user/get",
+        "cluster:admin/xpack/security/user/query",
         "cluster:admin/xpack/security/user/has_privileges",
         "cluster:admin/xpack/security/user/list_privileges",
         "cluster:admin/xpack/security/user/put",

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

@@ -0,0 +1,490 @@
+/*
+ * 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 org.apache.http.HttpHeaders;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.core.Strings;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.user.User;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasKey;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.core.IsEqual.equalTo;
+import static org.hamcrest.core.IsNot.not;
+
+public class QueryUserIT extends SecurityInBasicRestTestCase {
+
+    private static final String READ_USERS_USER_AUTH_HEADER = "Basic cmVhZF91c2Vyc191c2VyOnJlYWQtdXNlcnMtcGFzc3dvcmQ=";
+    private static final String TEST_USER_NO_READ_USERS_AUTH_HEADER = "Basic c2VjdXJpdHlfdGVzdF91c2VyOnNlY3VyaXR5LXRlc3QtcGFzc3dvcmQ=";
+
+    private static final Set<String> reservedUsers = Set.of(
+        "elastic",
+        "kibana",
+        "kibana_system",
+        "logstash_system",
+        "beats_system",
+        "apm_system",
+        "remote_monitoring_user"
+    );
+
+    private Request queryUserRequestWithAuth() {
+        final Request request = new Request(randomFrom("POST", "GET"), "/_security/_query/user");
+        request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, READ_USERS_USER_AUTH_HEADER));
+        return request;
+    }
+
+    public void testQuery() throws IOException {
+        // No users to match yet
+        assertQuery("", users -> assertThat(users, empty()));
+
+        int randomUserCount = createRandomUsers().size();
+
+        // An empty request body means search for all users (page size = 10)
+        assertQuery("", users -> assertThat(users.size(), equalTo(Math.min(randomUserCount, 10))));
+
+        // Match all
+        assertQuery(
+            String.format("""
+                {"query":{"match_all":{}},"from":0,"size":%s}""", randomUserCount),
+            users -> assertThat(users.size(), equalTo(randomUserCount))
+        );
+
+        // Exists query
+        String field = randomFrom("username", "full_name", "roles", "enabled");
+        assertQuery(
+            String.format("""
+                {"query":{"exists":{"field":"%s"}},"from":0,"size":%s}""", field, randomUserCount),
+            users -> assertEquals(users.size(), randomUserCount)
+        );
+
+        // Prefix search
+        User prefixUser1 = createUser(
+            "mr-prefix1",
+            new String[] { "master-of-the-universe", "some-other-role" },
+            "Prefix1",
+            "email@something.com",
+            Map.of(),
+            true
+        );
+        User prefixUser2 = createUser(
+            "mr-prefix2",
+            new String[] { "master-of-the-world", "some-other-role" },
+            "Prefix2",
+            "email@something.com",
+            Map.of(),
+            true
+        );
+        assertQuery("""
+            {"query":{"bool":{"must":[{"prefix":{"roles":"master-of-the"}}]}}}""", returnedUsers -> {
+            assertThat(returnedUsers, hasSize(2));
+            assertUser(prefixUser1, returnedUsers.get(0));
+            assertUser(prefixUser2, returnedUsers.get(1));
+        });
+
+        // Wildcard search
+        assertQuery("""
+            { "query": { "wildcard": {"username": "mr-prefix*"} } }""", users -> {
+            assertThat(users.size(), equalTo(2));
+            assertUser(prefixUser1, users.get(0));
+            assertUser(prefixUser2, users.get(1));
+            users.forEach(k -> assertThat(k, not(hasKey("_sort"))));
+        });
+
+        // Terms query
+        assertQuery("""
+            {"query":{"terms":{"roles":["some-other-role"]}}}""", users -> {
+            assertThat(users.size(), equalTo(2));
+            assertUser(prefixUser1, users.get(0));
+            assertUser(prefixUser2, users.get(1));
+        });
+
+        // Test other fields
+        User otherFieldsTestUser = createUser(
+            "batman-official-user",
+            new String[] { "bat-cave-admin" },
+            "Batman",
+            "batman@hotmail.com",
+            Map.of(),
+            true
+        );
+        String enabledTerm = "\"enabled\":true";
+        String fullNameTerm = "\"full_name\":\"batman\"";
+        String emailTerm = "\"email\":\"batman@hotmail.com\"";
+
+        final String term = randomFrom(enabledTerm, fullNameTerm, emailTerm);
+        assertQuery(
+            Strings.format("""
+                {"query":{"term":{%s}},"size":100}""", term),
+            users -> assertThat(
+                users.stream().map(u -> u.get(User.Fields.USERNAME.getPreferredName()).toString()).toList(),
+                hasItem("batman-official-user")
+            )
+        );
+
+        // Test complex query
+        assertQuery("""
+            { "query": {"bool": {"must": [
+            {"wildcard": {"username": "batman-official*"}},
+            {"term": {"enabled": true}}],"filter": [{"prefix": {"roles": "bat-cave"}}]}}}""", users -> {
+            assertThat(users.size(), equalTo(1));
+            assertUser(otherFieldsTestUser, users.get(0));
+        });
+
+        // Search for fields outside the allowlist fails
+        assertQueryError(400, """
+            { "query": { "prefix": {"not_allowed": "ABC"} } }""");
+
+        // Search for fields that are not allowed in Query DSL but used internally by the service itself
+        final String fieldName = randomFrom("type", "password");
+        assertQueryError(400, Strings.format("""
+            { "query": { "term": {"%s": "%s"} } }""", fieldName, randomAlphaOfLengthBetween(3, 8)));
+
+        // User without read_security gets 403 trying to search Users
+        assertQueryError(TEST_USER_NO_READ_USERS_AUTH_HEADER, 403, """
+            { "query": { "wildcard": {"name": "*prefix*"} } }""");
+
+        // Range query not supported
+        assertQueryError(400, """
+            {"query":{"range":{"username":{"lt":"now"}}}}""");
+
+        // IDs query not supported
+        assertQueryError(400, """
+            { "query": { "ids": { "values": "abc" } } }""");
+
+        // Make sure we can't query reserved users
+        String reservedUsername = getReservedUsernameAndAssertExists();
+        assertQuery(String.format("""
+            {"query":{"term":{"username":"%s"}}}""", reservedUsername), users -> assertTrue(users.isEmpty()));
+    }
+
+    public void testPagination() throws IOException {
+        final List<User> users = createRandomUsers();
+
+        final int from = randomIntBetween(0, 3);
+        final int size = randomIntBetween(2, 5);
+        final int remaining = users.size() - from;
+
+        // Using string only sorting to simplify test
+        final String sortField = "username";
+        final List<Map<String, Object>> allUserInfos = new ArrayList<>(remaining);
+        {
+            Request request = queryUserRequestWithAuth();
+            request.setJsonEntity("{\"from\":" + from + ",\"size\":" + size + ",\"sort\":[\"" + sortField + "\"]}");
+            allUserInfos.addAll(collectUsers(request, users.size()));
+        }
+        // first batch should be a full page
+        assertThat(allUserInfos.size(), equalTo(size));
+
+        while (allUserInfos.size() < remaining) {
+            final Request request = queryUserRequestWithAuth();
+            final List<Object> sortValues = extractSortValues(allUserInfos.get(allUserInfos.size() - 1));
+
+            request.setJsonEntity(Strings.format("""
+                {"size":%s,"sort":["%s"],"search_after":["%s"]}
+                """, size, sortField, sortValues.get(0)));
+            final List<Map<String, Object>> userInfoPage = collectUsers(request, users.size());
+
+            if (userInfoPage.isEmpty() && allUserInfos.size() < remaining) {
+                fail("fail to retrieve all Users, expect [" + remaining + "], got [" + allUserInfos + "]");
+            }
+            allUserInfos.addAll(userInfoPage);
+
+            // Before all users are retrieved, each page should be a full page
+            if (allUserInfos.size() < remaining) {
+                assertThat(userInfoPage.size(), equalTo(size));
+            }
+        }
+
+        // Assert sort values match the field of User information
+        assertThat(
+            allUserInfos.stream().map(m -> m.get(sortField)).toList(),
+            equalTo(allUserInfos.stream().map(m -> extractSortValues(m).get(0)).toList())
+        );
+
+        // Assert that all users match the created users and that they're sorted correctly
+        assertUsers(users, allUserInfos, sortField, from);
+
+        // size can be zero, but total should still reflect the number of users matched
+        final Request request = queryUserRequestWithAuth();
+        request.setJsonEntity("{\"size\":0}");
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        assertThat(responseMap.get("total"), equalTo(users.size()));
+        assertThat(responseMap.get("count"), equalTo(0));
+    }
+
+    @SuppressWarnings("unchecked")
+    public void testSort() throws IOException {
+        final List<User> testUsers = List.of(
+            createUser("a", new String[] { "4", "5", "6" }),
+            createUser("b", new String[] { "5", "6" }),
+            createUser("c", new String[] { "7", "8" })
+        );
+        assertQuery("""
+            {"sort":[{"username":{"order":"desc"}}]}""", users -> {
+            assertThat(users.size(), equalTo(3));
+            for (int i = 2, j = 0; i >= 0; i--, j++) {
+                assertUser(testUsers.get(j), users.get(i));
+                assertThat(users.get(i).get("username"), equalTo(((List<String>) users.get(i).get("_sort")).get(0)));
+            }
+        });
+
+        assertQuery("""
+            {"sort":[{"username":{"order":"asc"}}]}""", users -> {
+            assertThat(users.size(), equalTo(3));
+            for (int i = 0; i <= 2; i++) {
+                assertUser(testUsers.get(i), users.get(i));
+                assertThat(users.get(i).get("username"), equalTo(((List<String>) users.get(i).get("_sort")).get(0)));
+            }
+        });
+
+        assertQuery("""
+            {"sort":[{"roles":{"order":"asc"}}]}""", users -> {
+            assertThat(users.size(), equalTo(3));
+            for (int i = 0; i <= 2; i++) {
+                assertUser(testUsers.get(i), users.get(i));
+                // Only first element of array is used for sorting
+                assertThat(((List<String>) users.get(i).get("roles")).get(0), equalTo(((List<String>) users.get(i).get("_sort")).get(0)));
+            }
+        });
+
+        // Make sure sorting on _doc works
+        assertQuery("""
+            {"sort":["_doc"]}""", users -> assertThat(users.size(), equalTo(3)));
+
+        // Make sure multi-field sorting works
+        assertQuery("""
+            {"sort":[{"username":{"order":"asc"}}, {"roles":{"order":"asc"}}]}""", users -> {
+            assertThat(users.size(), equalTo(3));
+            for (int i = 0; i <= 2; i++) {
+                assertUser(testUsers.get(i), users.get(i));
+                assertThat(users.get(i).get("username"), equalTo(((List<String>) users.get(i).get("_sort")).get(0)));
+                assertThat(((List<String>) users.get(i).get("roles")).get(0), equalTo(((List<String>) users.get(i).get("_sort")).get(1)));
+            }
+        });
+
+        final String invalidFieldName = randomFrom("doc_type", "invalid", "password");
+        assertQueryError(400, "{\"sort\":[\"" + invalidFieldName + "\"]}");
+
+        final String invalidSortName = randomFrom("email", "full_name");
+        assertQueryError(
+            READ_USERS_USER_AUTH_HEADER,
+            400,
+            String.format("{\"sort\":[\"%s\"]}", invalidSortName),
+            String.format("sorting is not supported for field [%s] in User query", invalidSortName)
+        );
+    }
+
+    private String getReservedUsernameAndAssertExists() throws IOException {
+        String username = randomFrom(reservedUsers);
+        final Request request = new Request("GET", "/_security/user");
+
+        if (randomBoolean()) {
+            // Update the user to create it in the security index
+            Request putUserRequest = new Request("PUT", "/_security/user/" + username);
+            putUserRequest.setJsonEntity("{\"enabled\": true}");
+        }
+
+        request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, READ_USERS_USER_AUTH_HEADER));
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        assertNotNull(responseMap.get(username));
+        return username;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Object> extractSortValues(Map<String, Object> userInfo) {
+        return (List<Object>) userInfo.get("_sort");
+    }
+
+    private List<Map<String, Object>> collectUsers(Request request, int total) throws IOException {
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        @SuppressWarnings("unchecked")
+        final List<Map<String, Object>> userInfos = (List<Map<String, Object>>) responseMap.get("users");
+        assertThat(responseMap.get("total"), equalTo(total));
+        assertThat(responseMap.get("count"), equalTo(userInfos.size()));
+        return userInfos;
+    }
+
+    private void assertQueryError(int statusCode, String body) {
+        assertQueryError(READ_USERS_USER_AUTH_HEADER, statusCode, body);
+    }
+
+    private void assertQueryError(String authHeader, int statusCode, String body) {
+        assertQueryError(authHeader, statusCode, body, null);
+    }
+
+    private void assertQueryError(String authHeader, int statusCode, String body, String errorMessage) {
+        final Request request = new Request(randomFrom("GET", "POST"), "/_security/_query/user");
+        request.setJsonEntity(body);
+        request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final ResponseException responseException = expectThrows(ResponseException.class, () -> client().performRequest(request));
+        assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(statusCode));
+        if (errorMessage != null) {
+            assertTrue(responseException.getMessage().contains(errorMessage));
+        }
+    }
+
+    private void assertQuery(String body, Consumer<List<Map<String, Object>>> userVerifier) throws IOException {
+        final Request request = queryUserRequestWithAuth();
+        request.setJsonEntity(body);
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        @SuppressWarnings("unchecked")
+        final List<Map<String, Object>> users = (List<Map<String, Object>>) responseMap.get("users");
+        userVerifier.accept(users);
+    }
+
+    private void assertUser(User expectedUser, Map<String, Object> actualUser) {
+        assertUser(userToMap(expectedUser), actualUser);
+    }
+
+    @SuppressWarnings("unchecked")
+    private void assertUser(Map<String, Object> expectedUser, Map<String, Object> actualUser) {
+        assertEquals(expectedUser.get(User.Fields.USERNAME.getPreferredName()), actualUser.get(User.Fields.USERNAME.getPreferredName()));
+        assertArrayEquals(
+            ((List<String>) expectedUser.get(User.Fields.ROLES.getPreferredName())).toArray(),
+            ((List<String>) actualUser.get(User.Fields.ROLES.getPreferredName())).toArray()
+        );
+        assertEquals(expectedUser.get(User.Fields.FULL_NAME.getPreferredName()), actualUser.get(User.Fields.FULL_NAME.getPreferredName()));
+        assertEquals(expectedUser.get(User.Fields.EMAIL.getPreferredName()), actualUser.get(User.Fields.EMAIL.getPreferredName()));
+        assertEquals(expectedUser.get(User.Fields.METADATA.getPreferredName()), actualUser.get(User.Fields.METADATA.getPreferredName()));
+        assertEquals(expectedUser.get(User.Fields.ENABLED.getPreferredName()), actualUser.get(User.Fields.ENABLED.getPreferredName()));
+    }
+
+    private Map<String, Object> userToMap(User user) {
+        return Map.of(
+            User.Fields.USERNAME.getPreferredName(),
+            user.principal(),
+            User.Fields.ROLES.getPreferredName(),
+            Arrays.stream(user.roles()).toList(),
+            User.Fields.FULL_NAME.getPreferredName(),
+            user.fullName(),
+            User.Fields.EMAIL.getPreferredName(),
+            user.email(),
+            User.Fields.METADATA.getPreferredName(),
+            user.metadata(),
+            User.Fields.ENABLED.getPreferredName(),
+            user.enabled()
+        );
+    }
+
+    private void assertUsers(List<User> expectedUsers, List<Map<String, Object>> actualUsers, String sortField, int from) {
+        assertEquals(expectedUsers.size() - from, actualUsers.size());
+
+        List<Map<String, Object>> sortedExpectedUsers = expectedUsers.stream()
+            .map(this::userToMap)
+            .sorted(Comparator.comparing(user -> user.get(sortField).toString()))
+            .toList();
+
+        for (int i = from; i < sortedExpectedUsers.size(); i++) {
+            assertUser(sortedExpectedUsers.get(i), actualUsers.get(i - from));
+        }
+    }
+
+    public static Map<String, Object> randomUserMetadata() {
+        return ESTestCase.randomFrom(
+            Map.of(
+                "employee_id",
+                ESTestCase.randomAlphaOfLength(5),
+                "number",
+                1,
+                "numbers",
+                List.of(1, 3, 5),
+                "extra",
+                Map.of("favorite pizza", "margherita", "age", 42)
+            ),
+            Map.of(ESTestCase.randomAlphaOfLengthBetween(3, 8), ESTestCase.randomAlphaOfLengthBetween(3, 8)),
+            Map.of(),
+            null
+        );
+    }
+
+    private List<User> createRandomUsers() throws IOException {
+        int randomUserCount = randomIntBetween(8, 15);
+        final List<User> users = new ArrayList<>(randomUserCount);
+
+        for (int i = 0; i < randomUserCount; i++) {
+            users.add(
+                createUser(
+                    randomValueOtherThanMany(reservedUsers::contains, () -> randomAlphaOfLengthBetween(3, 8)) + "-" + i,
+                    randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)),
+                    randomAlphaOfLengthBetween(3, 8),
+                    randomAlphaOfLengthBetween(3, 8),
+                    randomUserMetadata(),
+                    randomBoolean()
+                )
+            );
+        }
+
+        return users;
+    }
+
+    private User createUser(String userName, String[] roles) throws IOException {
+        return createUser(
+            userName,
+            roles,
+            randomAlphaOfLengthBetween(3, 8),
+            randomAlphaOfLengthBetween(3, 8),
+            randomUserMetadata(),
+            randomBoolean()
+        );
+    }
+
+    private User createUser(String userName, String[] roles, String fullName, String email, Map<String, Object> metadata, boolean enabled)
+        throws IOException {
+
+        final Request request = new Request("POST", "/_security/user/" + userName);
+        BytesReference source = BytesReference.bytes(
+            jsonBuilder().map(
+                Map.of(
+                    User.Fields.USERNAME.getPreferredName(),
+                    userName,
+                    User.Fields.ROLES.getPreferredName(),
+                    roles,
+                    User.Fields.FULL_NAME.getPreferredName(),
+                    fullName,
+                    User.Fields.EMAIL.getPreferredName(),
+                    email,
+                    User.Fields.METADATA.getPreferredName(),
+                    metadata == null ? Map.of() : metadata,
+                    User.Fields.PASSWORD.getPreferredName(),
+                    "100%-security-guaranteed",
+                    User.Fields.ENABLED.getPreferredName(),
+                    enabled
+                )
+            )
+        );
+        request.setJsonEntity(source.utf8ToString());
+        Response response = adminClient().performRequest(request);
+        assertOK(response);
+        assertTrue((boolean) responseAsMap(response).get("created"));
+        return new User(userName, roles, fullName, email, metadata, enabled);
+    }
+}

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

@@ -22,6 +22,9 @@ public abstract class SecurityInBasicRestTestCase extends ESRestTestCase {
     protected static final String REST_USER = "security_test_user";
     private static final SecureString REST_PASSWORD = new SecureString("security-test-password".toCharArray());
 
+    protected static final String READ_USERS_USER = "read_users_user";
+    private static final SecureString READ_USERS_PASSWORD = new SecureString("read-users-password".toCharArray());
+
     private static final String ADMIN_USER = "admin_user";
     private static final SecureString ADMIN_PASSWORD = new SecureString("admin-password".toCharArray());
 
@@ -47,6 +50,7 @@ public abstract class SecurityInBasicRestTestCase extends ESRestTestCase {
         .user(REST_USER, REST_PASSWORD.toString(), "security_test_role", false)
         .user(API_KEY_USER, API_KEY_USER_PASSWORD.toString(), "api_key_user_role", false)
         .user(API_KEY_ADMIN_USER, API_KEY_ADMIN_USER_PASSWORD.toString(), "api_key_admin_role", false)
+        .user(READ_USERS_USER, READ_USERS_PASSWORD.toString(), "read_users_user_role", false)
         .build();
 
     @Override

+ 5 - 0
x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml

@@ -18,6 +18,11 @@ api_key_user_role:
   cluster:
     - manage_own_api_key
 
+# Used to perform query user operations
+read_users_user_role:
+  cluster:
+    - read_security
+
 # Role with remote indices privileges
 role_remote_indices:
   remote_indices:

+ 4 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -260,6 +260,7 @@ import org.elasticsearch.xpack.security.action.user.TransportGetUserPrivilegesAc
 import org.elasticsearch.xpack.security.action.user.TransportGetUsersAction;
 import org.elasticsearch.xpack.security.action.user.TransportHasPrivilegesAction;
 import org.elasticsearch.xpack.security.action.user.TransportPutUserAction;
+import org.elasticsearch.xpack.security.action.user.TransportQueryUserAction;
 import org.elasticsearch.xpack.security.action.user.TransportSetEnabledAction;
 import org.elasticsearch.xpack.security.audit.AuditTrail;
 import org.elasticsearch.xpack.security.audit.AuditTrailService;
@@ -365,6 +366,7 @@ import org.elasticsearch.xpack.security.rest.action.user.RestGetUsersAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestHasPrivilegesAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestProfileHasPrivilegesAction;
 import org.elasticsearch.xpack.security.rest.action.user.RestPutUserAction;
+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;
@@ -1315,6 +1317,7 @@ public class Security extends Plugin
             new ActionHandler<>(ClearPrivilegesCacheAction.INSTANCE, TransportClearPrivilegesCacheAction.class),
             new ActionHandler<>(ClearSecurityCacheAction.INSTANCE, TransportClearSecurityCacheAction.class),
             new ActionHandler<>(GetUsersAction.INSTANCE, TransportGetUsersAction.class),
+            new ActionHandler<>(ActionTypes.QUERY_USER_ACTION, TransportQueryUserAction.class),
             new ActionHandler<>(PutUserAction.INSTANCE, TransportPutUserAction.class),
             new ActionHandler<>(DeleteUserAction.INSTANCE, TransportDeleteUserAction.class),
             new ActionHandler<>(GetRolesAction.INSTANCE, TransportGetRolesAction.class),
@@ -1406,6 +1409,7 @@ public class Security extends Plugin
             new RestClearApiKeyCacheAction(settings, getLicenseState()),
             new RestClearServiceAccountTokenStoreCacheAction(settings, getLicenseState()),
             new RestGetUsersAction(settings, getLicenseState()),
+            new RestQueryUserAction(settings, getLicenseState()),
             new RestPutUserAction(settings, getLicenseState()),
             new RestDeleteUserAction(settings, getLicenseState()),
             new RestGetRolesAction(settings, getLicenseState()),

+ 107 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportQueryUserAction.java

@@ -0,0 +1,107 @@
+/*
+ * 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.action.user;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.search.SearchRequest;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.TransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.sort.FieldSortBuilder;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserRequest;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
+import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
+import org.elasticsearch.xpack.security.support.UserBoolQueryBuilder;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS;
+import static org.elasticsearch.xpack.security.support.UserBoolQueryBuilder.USER_FIELD_NAME_TRANSLATOR;
+
+public final class TransportQueryUserAction extends TransportAction<QueryUserRequest, QueryUserResponse> {
+    private final NativeUsersStore usersStore;
+    private static final Set<String> FIELD_NAMES_WITH_SORT_SUPPORT = Set.of("username", "roles", "enabled");
+
+    @Inject
+    public TransportQueryUserAction(TransportService transportService, ActionFilters actionFilters, NativeUsersStore usersStore) {
+        super(ActionTypes.QUERY_USER_ACTION.name(), actionFilters, transportService.getTaskManager());
+        this.usersStore = usersStore;
+    }
+
+    @Override
+    protected void doExecute(Task task, QueryUserRequest request, ActionListener<QueryUserResponse> listener) {
+        final SearchSourceBuilder searchSourceBuilder = SearchSourceBuilder.searchSource()
+            .version(false)
+            .fetchSource(true)
+            .trackTotalHits(true);
+
+        if (request.getFrom() != null) {
+            searchSourceBuilder.from(request.getFrom());
+        }
+        if (request.getSize() != null) {
+            searchSourceBuilder.size(request.getSize());
+        }
+
+        searchSourceBuilder.query(UserBoolQueryBuilder.build(request.getQueryBuilder()));
+
+        if (request.getFieldSortBuilders() != null) {
+            translateFieldSortBuilders(request.getFieldSortBuilders(), searchSourceBuilder);
+        }
+
+        if (request.getSearchAfterBuilder() != null) {
+            searchSourceBuilder.searchAfter(request.getSearchAfterBuilder().getSortValues());
+        }
+
+        final SearchRequest searchRequest = new SearchRequest(new String[] { SECURITY_MAIN_ALIAS }, searchSourceBuilder);
+        usersStore.queryUsers(searchRequest, listener);
+    }
+
+    // package private for testing
+    static void translateFieldSortBuilders(List<FieldSortBuilder> fieldSortBuilders, SearchSourceBuilder searchSourceBuilder) {
+        fieldSortBuilders.forEach(fieldSortBuilder -> {
+            if (fieldSortBuilder.getNestedSort() != null) {
+                throw new IllegalArgumentException("nested sorting is not supported for User query");
+            }
+            if (FieldSortBuilder.DOC_FIELD_NAME.equals(fieldSortBuilder.getFieldName())) {
+                searchSourceBuilder.sort(fieldSortBuilder);
+            } else {
+                final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(fieldSortBuilder.getFieldName());
+                if (FIELD_NAMES_WITH_SORT_SUPPORT.contains(translatedFieldName) == false) {
+                    throw new IllegalArgumentException(
+                        String.format(Locale.ROOT, "sorting is not supported for field [%s] in User query", fieldSortBuilder.getFieldName())
+                    );
+                }
+
+                if (translatedFieldName.equals(fieldSortBuilder.getFieldName())) {
+                    searchSourceBuilder.sort(fieldSortBuilder);
+                } else {
+                    final FieldSortBuilder translatedFieldSortBuilder = new FieldSortBuilder(translatedFieldName).order(
+                        fieldSortBuilder.order()
+                    )
+                        .missing(fieldSortBuilder.missing())
+                        .unmappedType(fieldSortBuilder.unmappedType())
+                        .setFormat(fieldSortBuilder.getFormat());
+
+                    if (fieldSortBuilder.sortMode() != null) {
+                        translatedFieldSortBuilder.sortMode(fieldSortBuilder.sortMode());
+                    }
+                    if (fieldSortBuilder.getNumericType() != null) {
+                        translatedFieldSortBuilder.setNumericType(fieldSortBuilder.getNumericType());
+                    }
+                    searchSourceBuilder.sort(translatedFieldSortBuilder);
+                }
+            }
+        });
+    }
+}

+ 37 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/esnative/NativeUsersStore.java

@@ -18,6 +18,7 @@ import org.elasticsearch.action.delete.DeleteResponse;
 import org.elasticsearch.action.get.GetResponse;
 import org.elasticsearch.action.search.SearchRequest;
 import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.action.search.TransportSearchAction;
 import org.elasticsearch.action.support.ContextPreservingActionListener;
 import org.elasticsearch.action.support.TransportActions;
 import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
@@ -43,6 +44,7 @@ import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheRespons
 import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequest;
 import org.elasticsearch.xpack.core.security.action.user.DeleteUserRequest;
 import org.elasticsearch.xpack.core.security.action.user.PutUserRequest;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
 import org.elasticsearch.xpack.core.security.authc.esnative.ClientReservedRealm;
 import org.elasticsearch.xpack.core.security.authc.support.Hasher;
@@ -57,6 +59,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
@@ -161,6 +164,40 @@ public class NativeUsersStore {
         }
     }
 
+    public void queryUsers(SearchRequest searchRequest, ActionListener<QueryUserResponse> listener) {
+        final SecurityIndexManager frozenSecurityIndex = securityIndex.defensiveCopy();
+        if (frozenSecurityIndex.indexExists() == false) {
+            logger.debug("security index does not exist");
+            listener.onResponse(QueryUserResponse.emptyResponse());
+        } else if (frozenSecurityIndex.isAvailable(SEARCH_SHARDS) == false) {
+            listener.onFailure(frozenSecurityIndex.getUnavailableReason(SEARCH_SHARDS));
+        } else {
+            securityIndex.checkIndexVersionThenExecute(
+                listener::onFailure,
+                () -> executeAsyncWithOrigin(
+                    client,
+                    SECURITY_ORIGIN,
+                    TransportSearchAction.TYPE,
+                    searchRequest,
+                    ActionListener.wrap(searchResponse -> {
+                        final long total = searchResponse.getHits().getTotalHits().value;
+                        if (total == 0) {
+                            logger.debug("No users found for query [{}]", searchRequest.source().query());
+                            listener.onResponse(QueryUserResponse.emptyResponse());
+                            return;
+                        }
+
+                        final List<QueryUserResponse.Item> userItem = Arrays.stream(searchResponse.getHits().getHits()).map(hit -> {
+                            UserAndPassword userAndPassword = transformUser(hit.getId(), hit.getSourceAsMap());
+                            return userAndPassword != null ? new QueryUserResponse.Item(userAndPassword.user(), hit.getSortValues()) : null;
+                        }).filter(Objects::nonNull).toList();
+                        listener.onResponse(new QueryUserResponse(total, userItem));
+                    }, listener::onFailure)
+                )
+            );
+        }
+    }
+
     void getUserCount(final ActionListener<Long> listener) {
         final SecurityIndexManager frozenSecurityIndex = this.securityIndex.defensiveCopy();
         if (frozenSecurityIndex.indexExists() == false) {

+ 115 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestQueryUserAction.java

@@ -0,0 +1,115 @@
+/*
+ * 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.rest.action.user;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentParserUtils;
+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.action.RestToXContentListener;
+import org.elasticsearch.search.searchafter.SearchAfterBuilder;
+import org.elasticsearch.search.sort.FieldSortBuilder;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.core.security.action.ActionTypes;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserRequest;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.index.query.AbstractQueryBuilder.parseTopLevelQuery;
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * Rest action to search for Users
+ */
+public final class RestQueryUserAction extends SecurityBaseRestHandler {
+
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<Payload, Void> PARSER = new ConstructingObjectParser<>(
+        "query_user_request_payload",
+        a -> new Payload((QueryBuilder) a[0], (Integer) a[1], (Integer) a[2], (List<FieldSortBuilder>) a[3], (SearchAfterBuilder) a[4])
+    );
+
+    static {
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> parseTopLevelQuery(p), new ParseField("query"));
+        PARSER.declareInt(optionalConstructorArg(), new ParseField("from"));
+        PARSER.declareInt(optionalConstructorArg(), new ParseField("size"));
+        PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> {
+            if (p.currentToken() == XContentParser.Token.VALUE_STRING) {
+                return new FieldSortBuilder(p.text());
+            } else if (p.currentToken() == XContentParser.Token.START_OBJECT) {
+                XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, p.nextToken(), p);
+                final FieldSortBuilder fieldSortBuilder = FieldSortBuilder.fromXContent(p, p.currentName());
+                XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, p.nextToken(), p);
+                return fieldSortBuilder;
+            } else {
+                throw new IllegalArgumentException("mal-formatted sort object");
+            }
+        }, new ParseField("sort"));
+        PARSER.declareField(
+            optionalConstructorArg(),
+            (p, c) -> SearchAfterBuilder.fromXContent(p),
+            new ParseField("search_after"),
+            ObjectParser.ValueType.VALUE_ARRAY
+        );
+    }
+
+    /**
+     * @param settings the node's settings
+     * @param licenseState the license state that will be used to determine if
+     * security is licensed
+     */
+    public RestQueryUserAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(GET, "/_security/_query/user"), new Route(POST, "/_security/_query/user"));
+    }
+
+    @Override
+    public String getName() {
+        return "xpack_security_query_user";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+        final QueryUserRequest queryUserRequest;
+        if (request.hasContentOrSourceParam()) {
+            final Payload payload = PARSER.parse(request.contentOrSourceParamParser(), null);
+            queryUserRequest = new QueryUserRequest(
+                payload.queryBuilder,
+                payload.from,
+                payload.size,
+                payload.fieldSortBuilders,
+                payload.searchAfterBuilder
+            );
+        } else {
+            queryUserRequest = new QueryUserRequest(null, null, null, null, null);
+        }
+        return channel -> client.execute(ActionTypes.QUERY_USER_ACTION, queryUserRequest, new RestToXContentListener<>(channel));
+    }
+
+    private record Payload(
+        @Nullable QueryBuilder queryBuilder,
+        @Nullable Integer from,
+        @Nullable Integer size,
+        @Nullable List<FieldSortBuilder> fieldSortBuilders,
+        @Nullable SearchAfterBuilder searchAfterBuilder
+    ) {}
+}

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

@@ -0,0 +1,84 @@
+/*
+ * 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 java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+public class SecurityIndexFieldNameTranslator {
+
+    private final List<SecurityIndexFieldNameTranslator.FieldName> fieldNameTranslators;
+
+    public SecurityIndexFieldNameTranslator(List<SecurityIndexFieldNameTranslator.FieldName> fieldNameTranslators) {
+        this.fieldNameTranslators = fieldNameTranslators;
+    }
+
+    public String translate(String queryFieldName) {
+        for (FieldName fieldName : this.fieldNameTranslators) {
+            if (fieldName.supportsQueryName(queryFieldName)) {
+                return fieldName.indexFieldName(queryFieldName);
+            }
+        }
+        throw new IllegalArgumentException("Field [" + queryFieldName + "] is not allowed");
+    }
+
+    public boolean supportedIndexFieldName(String indexFieldName) {
+        for (FieldName fieldName : this.fieldNameTranslators) {
+            if (fieldName.supportsIndexName(indexFieldName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static FieldName exact(String name) {
+        return exact(name, Function.identity());
+    }
+
+    public static FieldName exact(String name, Function<String, String> translation) {
+        return new SecurityIndexFieldNameTranslator.ExactFieldName(name, translation);
+    }
+
+    public abstract static class FieldName {
+        private final Function<String, String> toIndexFieldName;
+        protected final Predicate<String> validIndexNamePredicate;
+
+        FieldName(Function<String, String> toIndexFieldName, Predicate<String> validIndexNamePredicate) {
+            this.toIndexFieldName = toIndexFieldName;
+            this.validIndexNamePredicate = validIndexNamePredicate;
+        }
+
+        public abstract boolean supportsQueryName(String queryFieldName);
+
+        public abstract boolean supportsIndexName(String indexFieldName);
+
+        public String indexFieldName(String queryFieldName) {
+            return toIndexFieldName.apply(queryFieldName);
+        }
+    }
+
+    private static class ExactFieldName extends FieldName {
+        private final String name;
+
+        private ExactFieldName(String name, Function<String, String> toIndexFieldName) {
+            super(toIndexFieldName, fieldName -> toIndexFieldName.apply(name).equals(fieldName));
+            this.name = name;
+        }
+
+        @Override
+        public boolean supportsQueryName(String queryFieldName) {
+            return queryFieldName.equals(name);
+        }
+
+        @Override
+        public boolean supportsIndexName(String indexFieldName) {
+            return validIndexNamePredicate.test(indexFieldName);
+        }
+    }
+}

+ 101 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/UserBoolQueryBuilder.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.apache.lucene.search.Query;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.ExistsQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.QueryRewriteContext;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.xpack.security.support.SecurityIndexFieldNameTranslator.exact;
+
+public class UserBoolQueryBuilder extends BoolQueryBuilder {
+    public static final SecurityIndexFieldNameTranslator USER_FIELD_NAME_TRANSLATOR = new SecurityIndexFieldNameTranslator(
+        List.of(exact("username"), exact("roles"), exact("full_name"), exact("email"), exact("enabled"))
+    );
+
+    private UserBoolQueryBuilder() {}
+
+    public static UserBoolQueryBuilder build(QueryBuilder queryBuilder) {
+        UserBoolQueryBuilder userQueryBuilder = new UserBoolQueryBuilder();
+        if (queryBuilder != null) {
+            QueryBuilder translaterdQueryBuilder = translateToUserQueryBuilder(queryBuilder);
+            userQueryBuilder.must(translaterdQueryBuilder);
+        }
+        userQueryBuilder.filter(QueryBuilders.termQuery("type", "user"));
+
+        return userQueryBuilder;
+    }
+
+    private static QueryBuilder translateToUserQueryBuilder(QueryBuilder qb) {
+        if (qb instanceof final BoolQueryBuilder query) {
+            final BoolQueryBuilder newQuery = QueryBuilders.boolQuery()
+                .minimumShouldMatch(query.minimumShouldMatch())
+                .adjustPureNegative(query.adjustPureNegative());
+            query.must().stream().map(UserBoolQueryBuilder::translateToUserQueryBuilder).forEach(newQuery::must);
+            query.should().stream().map(UserBoolQueryBuilder::translateToUserQueryBuilder).forEach(newQuery::should);
+            query.mustNot().stream().map(UserBoolQueryBuilder::translateToUserQueryBuilder).forEach(newQuery::mustNot);
+            query.filter().stream().map(UserBoolQueryBuilder::translateToUserQueryBuilder).forEach(newQuery::filter);
+            return newQuery;
+        } else if (qb instanceof MatchAllQueryBuilder) {
+            return qb;
+        } else if (qb instanceof final TermQueryBuilder query) {
+            final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(query.fieldName());
+            return QueryBuilders.termQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof final ExistsQueryBuilder query) {
+            final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(query.fieldName());
+            return QueryBuilders.existsQuery(translatedFieldName);
+        } else if (qb instanceof final TermsQueryBuilder query) {
+            if (query.termsLookup() != null) {
+                throw new IllegalArgumentException("Terms query with terms lookup is not supported for User query");
+            }
+            final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(query.fieldName());
+            return QueryBuilders.termsQuery(translatedFieldName, query.getValues());
+        } else if (qb instanceof final PrefixQueryBuilder query) {
+            final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(query.fieldName());
+            return QueryBuilders.prefixQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof final WildcardQueryBuilder query) {
+            final String translatedFieldName = USER_FIELD_NAME_TRANSLATOR.translate(query.fieldName());
+            return QueryBuilders.wildcardQuery(translatedFieldName, query.value())
+                .caseInsensitive(query.caseInsensitive())
+                .rewrite(query.rewrite());
+        } else {
+            throw new IllegalArgumentException("Query type [" + qb.getName() + "] is not supported for User query");
+        }
+    }
+
+    @Override
+    protected Query doToQuery(SearchExecutionContext context) throws IOException {
+        context.setAllowedFields(this::isIndexFieldNameAllowed);
+        return super.doToQuery(context);
+    }
+
+    @Override
+    protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException {
+        if (queryRewriteContext instanceof SearchExecutionContext) {
+            ((SearchExecutionContext) queryRewriteContext).setAllowedFields(this::isIndexFieldNameAllowed);
+        }
+        return super.doRewrite(queryRewriteContext);
+    }
+
+    boolean isIndexFieldNameAllowed(String queryFieldName) {
+        // Type is needed to filter on user doc type
+        return queryFieldName.equals("type") || USER_FIELD_NAME_TRANSLATOR.supportedIndexFieldName(queryFieldName);
+    }
+}

+ 86 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportQueryUserActionTests.java

@@ -0,0 +1,86 @@
+/*
+ * 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.action.user;
+
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.elasticsearch.search.sort.FieldSortBuilder;
+import org.elasticsearch.search.sort.NestedSortBuilder;
+import org.elasticsearch.search.sort.SortMode;
+import org.elasticsearch.search.sort.SortOrder;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.IntStream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class TransportQueryUserActionTests extends ESTestCase {
+    private static final String[] allowedIndexFieldNames = new String[] { "username", "roles", "enabled" };
+
+    public void testTranslateFieldSortBuilders() {
+        final List<String> fieldNames = List.of(allowedIndexFieldNames);
+
+        final List<FieldSortBuilder> originals = fieldNames.stream().map(this::randomFieldSortBuilderWithName).toList();
+
+        final SearchSourceBuilder searchSourceBuilder = SearchSourceBuilder.searchSource();
+        TransportQueryUserAction.translateFieldSortBuilders(originals, searchSourceBuilder);
+
+        IntStream.range(0, originals.size()).forEach(i -> {
+            final FieldSortBuilder original = originals.get(i);
+            final FieldSortBuilder translated = (FieldSortBuilder) searchSourceBuilder.sorts().get(i);
+            assertThat(original.getFieldName(), equalTo(translated.getFieldName()));
+
+            assertThat(translated.order(), equalTo(original.order()));
+            assertThat(translated.missing(), equalTo(original.missing()));
+            assertThat(translated.unmappedType(), equalTo(original.unmappedType()));
+            assertThat(translated.getNumericType(), equalTo(original.getNumericType()));
+            assertThat(translated.getFormat(), equalTo(original.getFormat()));
+            assertThat(translated.sortMode(), equalTo(original.sortMode()));
+        });
+    }
+
+    public void testNestedSortingIsNotAllowed() {
+        final FieldSortBuilder fieldSortBuilder = new FieldSortBuilder("roles");
+        fieldSortBuilder.setNestedSort(new NestedSortBuilder("something"));
+        final IllegalArgumentException e = expectThrows(
+            IllegalArgumentException.class,
+            () -> TransportQueryUserAction.translateFieldSortBuilders(List.of(fieldSortBuilder), SearchSourceBuilder.searchSource())
+        );
+        assertThat(e.getMessage(), equalTo("nested sorting is not supported for User query"));
+    }
+
+    public void testNestedSortingOnTextFieldsNotAllowed() {
+        String fieldName = randomFrom("full_name", "email");
+        final List<String> fieldNames = List.of(fieldName);
+        final List<FieldSortBuilder> originals = fieldNames.stream().map(this::randomFieldSortBuilderWithName).toList();
+        final SearchSourceBuilder searchSourceBuilder = SearchSourceBuilder.searchSource();
+
+        final IllegalArgumentException e = expectThrows(
+            IllegalArgumentException.class,
+            () -> TransportQueryUserAction.translateFieldSortBuilders(originals, searchSourceBuilder)
+        );
+        assertThat(e.getMessage(), equalTo(String.format(Locale.ROOT, "sorting is not supported for field [%s] in User query", fieldName)));
+    }
+
+    private FieldSortBuilder randomFieldSortBuilderWithName(String name) {
+        final FieldSortBuilder fieldSortBuilder = new FieldSortBuilder(name);
+        fieldSortBuilder.order(randomBoolean() ? SortOrder.ASC : SortOrder.DESC);
+        fieldSortBuilder.setFormat(randomBoolean() ? randomAlphaOfLengthBetween(3, 16) : null);
+        if (randomBoolean()) {
+            fieldSortBuilder.setNumericType(randomFrom("long", "double", "date", "date_nanos"));
+        }
+        if (randomBoolean()) {
+            fieldSortBuilder.missing(randomAlphaOfLengthBetween(3, 8));
+        }
+        if (randomBoolean()) {
+            fieldSortBuilder.sortMode(randomFrom(SortMode.values()));
+        }
+        return fieldSortBuilder;
+    }
+}

+ 175 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/user/RestQueryUserActionTests.java

@@ -0,0 +1,175 @@
+/*
+ * 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.rest.action.user;
+
+import org.apache.lucene.util.SetOnce;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.AbstractRestChannel;
+import org.elasticsearch.rest.RestChannel;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.search.searchafter.SearchAfterBuilder;
+import org.elasticsearch.search.sort.FieldSortBuilder;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserRequest;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
+
+import java.util.List;
+
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsEqual.equalTo;
+import static org.mockito.Mockito.mock;
+
+public class RestQueryUserActionTests extends ESTestCase {
+
+    private final XPackLicenseState mockLicenseState = mock(XPackLicenseState.class);
+
+    @Override
+    protected NamedXContentRegistry xContentRegistry() {
+        final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
+        return new NamedXContentRegistry(searchModule.getNamedXContents());
+    }
+
+    public void testQueryParsing() throws Exception {
+        final String query1 = """
+            {
+              "query": {
+                "bool": {
+                  "must": [
+                    {
+                      "terms": {
+                        "username": [ "bart", "homer" ]
+                      }
+                    }
+                  ],
+                  "should": [ { "prefix": { "username": "ba" } } ]
+                }
+              }
+            }""";
+        final FakeRestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withContent(
+            new BytesArray(query1),
+            XContentType.JSON
+        ).build();
+
+        final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
+        final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
+            @Override
+            public void sendResponse(RestResponse restResponse) {
+                responseSetOnce.set(restResponse);
+            }
+        };
+
+        try (var threadPool = createThreadPool()) {
+            final var client = new NodeClient(Settings.EMPTY, threadPool) {
+                @SuppressWarnings("unchecked")
+                @Override
+                public <Request extends ActionRequest, Response extends ActionResponse> void doExecute(
+                    ActionType<Response> action,
+                    Request request,
+                    ActionListener<Response> listener
+                ) {
+                    QueryUserRequest queryUserRequest = (QueryUserRequest) request;
+                    final QueryBuilder queryBuilder = queryUserRequest.getQueryBuilder();
+                    assertNotNull(queryBuilder);
+                    assertThat(queryBuilder.getClass(), is(BoolQueryBuilder.class));
+                    final BoolQueryBuilder boolQueryBuilder = (BoolQueryBuilder) queryBuilder;
+                    assertTrue(boolQueryBuilder.filter().isEmpty());
+                    assertTrue(boolQueryBuilder.mustNot().isEmpty());
+                    assertThat(boolQueryBuilder.must(), hasSize(1));
+                    final QueryBuilder mustQueryBuilder = boolQueryBuilder.must().get(0);
+                    assertThat(mustQueryBuilder.getClass(), is(TermsQueryBuilder.class));
+                    assertThat(((TermsQueryBuilder) mustQueryBuilder).fieldName(), equalTo("username"));
+                    assertThat(boolQueryBuilder.should(), hasSize(1));
+                    final QueryBuilder shouldQueryBuilder = boolQueryBuilder.should().get(0);
+                    assertThat(shouldQueryBuilder.getClass(), is(PrefixQueryBuilder.class));
+                    assertThat(((PrefixQueryBuilder) shouldQueryBuilder).fieldName(), equalTo("username"));
+                    listener.onResponse((Response) new QueryUserResponse(0, List.of()));
+                }
+            };
+            final RestQueryUserAction restQueryUserAction = new RestQueryUserAction(Settings.EMPTY, mockLicenseState);
+            restQueryUserAction.handleRequest(restRequest, restChannel, client);
+        }
+
+        assertNotNull(responseSetOnce.get());
+    }
+
+    public void testParsingSearchParameters() throws Exception {
+        final String requestBody = """
+            {
+              "query": {
+                "match_all": {}
+              },
+              "from": 42,
+              "size": 20,
+              "sort": [ "username", "full_name"],
+              "search_after": [ "bart" ]
+            }""";
+
+        final FakeRestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry()).withContent(
+            new BytesArray(requestBody),
+            XContentType.JSON
+        ).build();
+
+        final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
+        final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
+            @Override
+            public void sendResponse(RestResponse restResponse) {
+                responseSetOnce.set(restResponse);
+            }
+        };
+
+        try (var threadPool = createThreadPool()) {
+            final var client = new NodeClient(Settings.EMPTY, threadPool) {
+                @SuppressWarnings("unchecked")
+                @Override
+                public <Request extends ActionRequest, Response extends ActionResponse> void doExecute(
+                    ActionType<Response> action,
+                    Request request,
+                    ActionListener<Response> listener
+                ) {
+                    QueryUserRequest queryUserRequest = (QueryUserRequest) request;
+                    final QueryBuilder queryBuilder = queryUserRequest.getQueryBuilder();
+                    assertNotNull(queryBuilder);
+                    assertThat(queryBuilder.getClass(), is(MatchAllQueryBuilder.class));
+                    assertThat(queryUserRequest.getFrom(), equalTo(42));
+                    assertThat(queryUserRequest.getSize(), equalTo(20));
+                    final List<FieldSortBuilder> fieldSortBuilders = queryUserRequest.getFieldSortBuilders();
+                    assertThat(fieldSortBuilders, hasSize(2));
+
+                    assertThat(fieldSortBuilders.get(0), equalTo(new FieldSortBuilder("username")));
+                    assertThat(fieldSortBuilders.get(1), equalTo(new FieldSortBuilder("full_name")));
+
+                    final SearchAfterBuilder searchAfterBuilder = queryUserRequest.getSearchAfterBuilder();
+                    assertThat(searchAfterBuilder, equalTo(new SearchAfterBuilder().setSortValues(new String[] { "bart" })));
+
+                    listener.onResponse((Response) new QueryUserResponse(0, List.of()));
+                }
+            };
+
+            final RestQueryUserAction queryUserAction = new RestQueryUserAction(Settings.EMPTY, mockLicenseState);
+            queryUserAction.handleRequest(restRequest, restChannel, client);
+        }
+        assertNotNull(responseSetOnce.get());
+    }
+}

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

@@ -0,0 +1,221 @@
+/*
+ * 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.index.query.AbstractQueryBuilder;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.DistanceFeatureQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.MultiTermQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.SpanQueryBuilder;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+import org.elasticsearch.indices.TermsLookup;
+import org.elasticsearch.script.Script;
+import org.elasticsearch.test.ESTestCase;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Predicate;
+
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsEqual.equalTo;
+import static org.hamcrest.core.IsIterableContaining.hasItem;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class UserBoolQueryBuilderTests extends ESTestCase {
+    private static final String[] allowedIndexFieldNames = new String[] { "username", "roles", "full_name", "email", "enabled" };
+
+    public void testBuildFromSimpleQuery() {
+        final QueryBuilder query = randomSimpleQuery();
+        final UserBoolQueryBuilder userQueryBuilder = UserBoolQueryBuilder.build(query);
+        assertCommonFilterQueries(userQueryBuilder);
+        final List<QueryBuilder> mustQueries = userQueryBuilder.must();
+        assertThat(mustQueries, hasSize(1));
+        assertThat(mustQueries.get(0), equalTo(query));
+        assertTrue(userQueryBuilder.should().isEmpty());
+        assertTrue(userQueryBuilder.mustNot().isEmpty());
+    }
+
+    public void testBuildFromBoolQuery() {
+        final BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
+
+        if (randomBoolean()) {
+            boolQueryBuilder.must(QueryBuilders.prefixQuery(randomAllowedField(), "bar"));
+        }
+        if (randomBoolean()) {
+            boolQueryBuilder.should(QueryBuilders.wildcardQuery(randomAllowedField(), "*ar*"));
+        }
+        if (randomBoolean()) {
+            boolQueryBuilder.filter(QueryBuilders.termsQuery("roles", randomArray(3, 8, String[]::new, () -> "role-" + randomInt())));
+        }
+        if (randomBoolean()) {
+            boolQueryBuilder.minimumShouldMatch(randomIntBetween(1, 2));
+        }
+        final UserBoolQueryBuilder userBoolQueryBuilder = UserBoolQueryBuilder.build(boolQueryBuilder);
+        assertCommonFilterQueries(userBoolQueryBuilder);
+
+        assertThat(userBoolQueryBuilder.must(), hasSize(1));
+        assertThat(userBoolQueryBuilder.should(), empty());
+        assertThat(userBoolQueryBuilder.mustNot(), empty());
+        assertThat(userBoolQueryBuilder.filter(), hasItem(QueryBuilders.termQuery("type", "user")));
+        assertThat(userBoolQueryBuilder.must().get(0).getClass(), is(BoolQueryBuilder.class));
+        final BoolQueryBuilder translated = (BoolQueryBuilder) userBoolQueryBuilder.must().get(0);
+        assertThat(translated.must(), equalTo(boolQueryBuilder.must()));
+        assertThat(translated.should(), equalTo(boolQueryBuilder.should()));
+        assertThat(translated.mustNot(), equalTo(boolQueryBuilder.mustNot()));
+        assertThat(translated.minimumShouldMatch(), equalTo(boolQueryBuilder.minimumShouldMatch()));
+        assertThat(translated.filter(), equalTo(boolQueryBuilder.filter()));
+    }
+
+    public void testFieldNameTranslation() {
+        String field = randomAllowedField();
+        final WildcardQueryBuilder wildcardQueryBuilder = QueryBuilders.wildcardQuery(field, "*" + randomAlphaOfLength(3));
+        final UserBoolQueryBuilder userBoolQueryBuilder = UserBoolQueryBuilder.build(wildcardQueryBuilder);
+        assertCommonFilterQueries(userBoolQueryBuilder);
+        assertThat(userBoolQueryBuilder.must().get(0), equalTo(QueryBuilders.wildcardQuery(field, wildcardQueryBuilder.value())));
+    }
+
+    public void testAllowListOfFieldNames() {
+        final String fieldName = randomValueOtherThanMany(
+            v -> Arrays.asList(allowedIndexFieldNames).contains(v),
+            () -> randomFrom(randomAlphaOfLengthBetween(3, 20), "type", "password")
+        );
+
+        // MatchAllQueryBuilder doesn't do any translation, so skip
+        final QueryBuilder q1 = randomValueOtherThanMany(
+            q -> q.getClass() == MatchAllQueryBuilder.class,
+            () -> randomSimpleQuery(fieldName)
+        );
+        final IllegalArgumentException exception = expectThrows(IllegalArgumentException.class, () -> UserBoolQueryBuilder.build(q1));
+
+        assertThat(exception.getMessage(), containsString("Field [" + fieldName + "] is not allowed"));
+    }
+
+    public void testTermsLookupIsNotAllowed() {
+        final TermsQueryBuilder q1 = QueryBuilders.termsLookupQuery("roles", new TermsLookup("lookup", "1", "id"));
+        final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, () -> UserBoolQueryBuilder.build(q1));
+        assertThat(e1.getMessage(), containsString("Terms query with terms lookup is not supported for User query"));
+    }
+
+    public void testDisallowedQueryTypes() {
+        final AbstractQueryBuilder<? extends AbstractQueryBuilder<?>> q1 = randomFrom(
+            QueryBuilders.idsQuery(),
+            QueryBuilders.rangeQuery(randomAlphaOfLength(5)),
+            QueryBuilders.matchQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.constantScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.boostingQuery(mock(QueryBuilder.class), mock(QueryBuilder.class)),
+            QueryBuilders.queryStringQuery("q=a:42"),
+            QueryBuilders.simpleQueryStringQuery(randomAlphaOfLength(5)),
+            QueryBuilders.combinedFieldsQuery(randomAlphaOfLength(5)),
+            QueryBuilders.disMaxQuery(),
+            QueryBuilders.distanceFeatureQuery(
+                randomAlphaOfLength(5),
+                mock(DistanceFeatureQueryBuilder.Origin.class),
+                randomAlphaOfLength(5)
+            ),
+            QueryBuilders.fieldMaskingSpanQuery(mock(SpanQueryBuilder.class), randomAlphaOfLength(5)),
+            QueryBuilders.functionScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.fuzzyQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.wrapperQuery(randomAlphaOfLength(5)),
+            QueryBuilders.matchBoolPrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhraseQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhrasePrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.moreLikeThisQuery(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(5))),
+            QueryBuilders.regexpQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanTermQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanOrQuery(mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanContainingQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanFirstQuery(mock(SpanQueryBuilder.class), randomIntBetween(1, 3)),
+            QueryBuilders.spanMultiTermQueryBuilder(mock(MultiTermQueryBuilder.class)),
+            QueryBuilders.spanNotQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.scriptQuery(new Script(randomAlphaOfLength(5))),
+            QueryBuilders.scriptScoreQuery(mock(QueryBuilder.class), new Script(randomAlphaOfLength(5))),
+            QueryBuilders.geoWithinQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoBoundingBoxQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoDisjointQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoDistanceQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoIntersectionQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoShapeQuery(randomAlphaOfLength(5), randomAlphaOfLength(5))
+        );
+
+        final IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, () -> UserBoolQueryBuilder.build(q1));
+        assertThat(e1.getMessage(), containsString("Query type [" + q1.getName() + "] is not supported for User query"));
+    }
+
+    public void testWillSetAllowedFields() {
+        final UserBoolQueryBuilder userBoolQueryBuilder = UserBoolQueryBuilder.build(randomSimpleQuery());
+
+        final SearchExecutionContext context = mock(SearchExecutionContext.class);
+        doAnswer(invocationOnMock -> {
+            final Object[] args = invocationOnMock.getArguments();
+            @SuppressWarnings("unchecked")
+            final Predicate<String> predicate = (Predicate<String>) args[0];
+            assertTrue(predicate.getClass().getName().startsWith(UserBoolQueryBuilder.class.getName()));
+            testAllowedIndexFieldName(predicate);
+            return null;
+        }).when(context).setAllowedFields(any());
+        try {
+            if (randomBoolean()) {
+                userBoolQueryBuilder.doToQuery(context);
+            } else {
+                userBoolQueryBuilder.doRewrite(context);
+            }
+        } catch (Exception e) {
+            // just ignore any exception from superclass since we only need verify the allowedFields are set
+        } finally {
+            verify(context).setAllowedFields(any());
+        }
+    }
+
+    private void testAllowedIndexFieldName(Predicate<String> predicate) {
+        final String allowedField = randomAllowedField();
+        assertTrue(predicate.test(allowedField));
+
+        final String disallowedField = randomBoolean() ? (randomAlphaOfLengthBetween(1, 3) + allowedField) : (allowedField.substring(1));
+        assertFalse(predicate.test(disallowedField));
+    }
+
+    private void assertCommonFilterQueries(UserBoolQueryBuilder qb) {
+        final List<TermQueryBuilder> tqb = qb.filter()
+            .stream()
+            .filter(q -> q.getClass() == TermQueryBuilder.class)
+            .map(q -> (TermQueryBuilder) q)
+            .toList();
+        assertTrue(tqb.stream().anyMatch(q -> q.equals(QueryBuilders.termQuery("type", "user"))));
+    }
+
+    private String randomAllowedField() {
+        return randomFrom(allowedIndexFieldNames);
+    }
+
+    private QueryBuilder randomSimpleQuery() {
+        return randomSimpleQuery(randomAllowedField());
+    }
+
+    private QueryBuilder randomSimpleQuery(String fieldName) {
+        return randomFrom(
+            QueryBuilders.termQuery(fieldName, randomAlphaOfLengthBetween(3, 8)),
+            QueryBuilders.termsQuery(fieldName, randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8))),
+            QueryBuilders.prefixQuery(fieldName, randomAlphaOfLength(randomIntBetween(3, 10))),
+            QueryBuilders.wildcardQuery(fieldName, "*" + randomAlphaOfLength(randomIntBetween(3, 10))),
+            QueryBuilders.matchAllQuery(),
+            QueryBuilders.existsQuery(fieldName)
+        );
+    }
+}