Ver código fonte

Add support for fetching user profileId in Query Users (#104923)

Add support for fetching user profileId in Query Users
Johannes Fredén 1 ano atrás
pai
commit
334aa1bc8d

+ 47 - 0
docs/reference/rest-api/security/query-user.asciidoc

@@ -62,6 +62,13 @@ The email of the user.
 `enabled`::
 Specifies whether the user is enabled.
 
+[[security-api-query-user-query-params]]
+==== {api-query-parms-title}
+
+`with_profile_uid`::
+(Optional, boolean) Determines whether to retrieve the <<user-profile,user profile>> `uid`,
+if exists, for the users. Defaults to `false`.
+
 ====
 
 include::{es-repo-dir}/rest-api/common-parms.asciidoc[tag=from]
@@ -218,6 +225,46 @@ A successful call returns a JSON structure for a user:
 --------------------------------------------------
 // NOTCONSOLE
 
+To retrieve the user `profile_uid` as part of the response:
+
+[source,console]
+--------------------------------------------------
+GET /_security/_query/user?with_profile_uid=true
+{
+    "query": {
+        "prefix": {
+            "roles": "other"
+        }
+    }
+}
+--------------------------------------------------
+// TEST[setup:jacknich_user]
+
+[source,console-result]
+--------------------------------------------------
+{
+    "total": 1,
+    "count": 1,
+    "users": [
+        {
+            "username": "jacknich",
+            "roles": [
+                "admin",
+                "other_role1"
+            ],
+            "full_name": "Jack Nicholson",
+            "email": "jacknich@example.com",
+            "metadata": {
+                "intelligence": 7
+            },
+            "enabled": true,
+            "profile_uid": "u_79HkWkwmnBH5gqFKwoxggWPjEBOur1zLPXQPEl1VBW0_0"
+        }
+    ]
+}
+--------------------------------------------------
+// NOTCONSOLE
+
 Use a `bool` query to issue complex logical conditions and use
 `from`, `size`, `sort` to help paginate the result:
 

+ 7 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/security.query_user.json

@@ -28,6 +28,13 @@
     "body": {
       "description": "From, size, query, sort and search_after",
       "required": false
+    },
+    "params": {
+      "with_profile_uid": {
+        "type": "boolean",
+        "default": false,
+        "description": "flag to retrieve profile uid (if exists) associated with the user"
+      }
     }
   }
 }

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

@@ -38,12 +38,14 @@ public final class QueryUserRequest extends ActionRequest {
     @Nullable
     private final SearchAfterBuilder searchAfterBuilder;
 
+    private final boolean withProfileUid;
+
     public QueryUserRequest() {
         this(null);
     }
 
     public QueryUserRequest(QueryBuilder queryBuilder) {
-        this(queryBuilder, null, null, null, null);
+        this(queryBuilder, null, null, null, null, false);
     }
 
     public QueryUserRequest(
@@ -51,13 +53,15 @@ public final class QueryUserRequest extends ActionRequest {
         @Nullable Integer from,
         @Nullable Integer size,
         @Nullable List<FieldSortBuilder> fieldSortBuilders,
-        @Nullable SearchAfterBuilder searchAfterBuilder
+        @Nullable SearchAfterBuilder searchAfterBuilder,
+        boolean withProfileUid
     ) {
         this.queryBuilder = queryBuilder;
         this.from = from;
         this.size = size;
         this.fieldSortBuilders = fieldSortBuilders;
         this.searchAfterBuilder = searchAfterBuilder;
+        this.withProfileUid = withProfileUid;
     }
 
     public QueryBuilder getQueryBuilder() {
@@ -96,4 +100,8 @@ public final class QueryUserRequest extends ActionRequest {
     public void writeTo(StreamOutput out) throws IOException {
         TransportAction.localOnly();
     }
+
+    public boolean isWithProfileUid() {
+        return withProfileUid;
+    }
 }

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

@@ -68,7 +68,7 @@ public final class QueryUserResponse extends ActionResponse implements ToXConten
         TransportAction.localOnly();
     }
 
-    public record Item(User user, @Nullable Object[] sortValues) implements ToXContentObject {
+    public record Item(User user, @Nullable Object[] sortValues, @Nullable String profileUid) implements ToXContentObject {
 
         @Override
         public Object[] sortValues() {
@@ -82,6 +82,9 @@ public final class QueryUserResponse extends ActionResponse implements ToXConten
             if (sortValues != null && sortValues.length > 0) {
                 builder.array("_sort", sortValues);
             }
+            if (profileUid != null) {
+                builder.field("profile_uid", profileUid);
+            }
             builder.endObject();
             return builder;
         }

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

@@ -18,7 +18,8 @@ public class QueryUserRequestTests extends ESTestCase {
             randomIntBetween(0, Integer.MAX_VALUE),
             randomIntBetween(0, Integer.MAX_VALUE),
             null,
-            null
+            null,
+            false
         );
         assertThat(request1.validate(), nullValue());
 
@@ -27,7 +28,8 @@ public class QueryUserRequestTests extends ESTestCase {
             randomIntBetween(Integer.MIN_VALUE, -1),
             randomIntBetween(0, Integer.MAX_VALUE),
             null,
-            null
+            null,
+            false
         );
         assertThat(request2.validate().getMessage(), containsString("[from] parameter cannot be negative"));
 
@@ -36,7 +38,8 @@ public class QueryUserRequestTests extends ESTestCase {
             randomIntBetween(0, Integer.MAX_VALUE),
             randomIntBetween(Integer.MIN_VALUE, -1),
             null,
-            null
+            null,
+            false
         );
         assertThat(request3.validate().getMessage(), containsString("[size] parameter cannot be negative"));
     }

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

@@ -12,6 +12,7 @@ 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.Nullable;
 import org.elasticsearch.core.Strings;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.xpack.core.security.user.User;
@@ -20,6 +21,7 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -47,15 +49,22 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
     );
 
     private Request queryUserRequestWithAuth() {
-        final Request request = new Request(randomFrom("POST", "GET"), "/_security/_query/user");
+        return queryUserRequestWithAuth(false);
+    }
+
+    private Request queryUserRequestWithAuth(boolean withProfileId) {
+        final Request request = new Request(
+            randomFrom("POST", "GET"),
+            "/_security/_query/user" + (withProfileId ? "?with_profile_uid=true" : randomFrom("", "?with_profile_uid=false"))
+        );
         request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, READ_USERS_USER_AUTH_HEADER));
         return request;
     }
 
     public void testQuery() throws IOException {
+        boolean withProfileId = randomBoolean();
         // No users to match yet
-        assertQuery("", users -> assertThat(users, empty()));
-
+        assertQuery("", users -> assertThat(users, empty()), withProfileId);
         int randomUserCount = createRandomUsers().size();
 
         // An empty request body means search for all users (page size = 10)
@@ -65,7 +74,8 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
         assertQuery(
             String.format("""
                 {"query":{"match_all":{}},"from":0,"size":%s}""", randomUserCount),
-            users -> assertThat(users.size(), equalTo(randomUserCount))
+            users -> assertThat(users.size(), equalTo(randomUserCount)),
+            withProfileId
         );
 
         // Exists query
@@ -73,7 +83,8 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
         assertQuery(
             String.format("""
                 {"query":{"exists":{"field":"%s"}},"from":0,"size":%s}""", field, randomUserCount),
-            users -> assertEquals(users.size(), randomUserCount)
+            users -> assertEquals(users.size(), randomUserCount),
+            withProfileId
         );
 
         // Prefix search
@@ -93,28 +104,41 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
             Map.of(),
             true
         );
+        // Extract map to be able to assert on profile id (not part of User model)
+        Map<String, Object> prefixUser1Map;
+        Map<String, Object> prefixUser2Map;
+        if (withProfileId) {
+            prefixUser1Map = userToMap(prefixUser1, doActivateProfile(prefixUser1.principal(), "100%-security-guaranteed"));
+            prefixUser2Map = userToMap(prefixUser2, doActivateProfile(prefixUser2.principal(), "100%-security-guaranteed"));
+            assertTrue(prefixUser1Map.containsKey("profile_uid"));
+            assertTrue(prefixUser2Map.containsKey("profile_uid"));
+        } else {
+            prefixUser1Map = userToMap(prefixUser1);
+            prefixUser2Map = userToMap(prefixUser2);
+        }
+
         assertQuery("""
             {"query":{"bool":{"must":[{"prefix":{"roles":"master-of-the"}}]}},"sort":["username"]}""", returnedUsers -> {
             assertThat(returnedUsers, hasSize(2));
-            assertUser(prefixUser1, returnedUsers.get(0));
-            assertUser(prefixUser2, returnedUsers.get(1));
-        });
+            assertUser(prefixUser1Map, returnedUsers.get(0));
+            assertUser(prefixUser2Map, returnedUsers.get(1));
+        }, withProfileId);
 
         // Wildcard search
         assertQuery("""
             { "query": { "wildcard": {"username": "mr-prefix*"} },"sort":["username"]}""", users -> {
             assertThat(users.size(), equalTo(2));
-            assertUser(prefixUser1, users.get(0));
-            assertUser(prefixUser2, users.get(1));
-        });
+            assertUser(prefixUser1Map, users.get(0));
+            assertUser(prefixUser2Map, users.get(1));
+        }, withProfileId);
 
         // Terms query
         assertQuery("""
             {"query":{"terms":{"roles":["some-other-role"]}},"sort":["username"]}""", users -> {
             assertThat(users.size(), equalTo(2));
-            assertUser(prefixUser1, users.get(0));
-            assertUser(prefixUser2, users.get(1));
-        });
+            assertUser(prefixUser1Map, users.get(0));
+            assertUser(prefixUser2Map, users.get(1));
+        }, withProfileId);
 
         // Test other fields
         User otherFieldsTestUser = createUser(
@@ -136,17 +160,27 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
             users -> assertThat(
                 users.stream().map(u -> u.get(User.Fields.USERNAME.getPreferredName()).toString()).toList(),
                 hasItem("batman-official-user")
-            )
+            ),
+            withProfileId
         );
-
+        Map<String, Object> otherFieldsTestUserMap;
+        if (withProfileId) {
+            otherFieldsTestUserMap = userToMap(
+                otherFieldsTestUser,
+                doActivateProfile(otherFieldsTestUser.principal(), "100%-security-guaranteed")
+            );
+            assertTrue(otherFieldsTestUserMap.containsKey("profile_uid"));
+        } else {
+            otherFieldsTestUserMap = userToMap(otherFieldsTestUser);
+        }
         // 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));
-        });
+            assertUser(otherFieldsTestUserMap, users.get(0));
+        }, withProfileId);
 
         // Search for fields outside the allowlist fails
         assertQueryError(400, """
@@ -223,7 +257,7 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
         assertUsers(users, allUserInfos, sortField, from);
 
         // size can be zero, but total should still reflect the number of users matched
-        final Request request = queryUserRequestWithAuth();
+        final Request request = queryUserRequestWithAuth(false);
         request.setJsonEntity("{\"size\":0}");
         final Response response = client().performRequest(request);
         assertOK(response);
@@ -348,7 +382,11 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
     }
 
     private void assertQuery(String body, Consumer<List<Map<String, Object>>> userVerifier) throws IOException {
-        final Request request = queryUserRequestWithAuth();
+        assertQuery(body, userVerifier, false);
+    }
+
+    private void assertQuery(String body, Consumer<List<Map<String, Object>>> userVerifier, boolean withProfileId) throws IOException {
+        final Request request = queryUserRequestWithAuth(withProfileId);
         request.setJsonEntity(body);
         final Response response = client().performRequest(request);
         assertOK(response);
@@ -369,6 +407,8 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
             ((List<String>) expectedUser.get(User.Fields.ROLES.getPreferredName())).toArray(),
             ((List<String>) actualUser.get(User.Fields.ROLES.getPreferredName())).toArray()
         );
+        assertEquals(expectedUser.getOrDefault("profile_uid", null), actualUser.getOrDefault("profile_uid", null));
+
         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()));
@@ -376,20 +416,21 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
     }
 
     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()
-        );
+        return userToMap(user, null);
+    }
+
+    private Map<String, Object> userToMap(User user, @Nullable String profileId) {
+        Map<String, Object> userMap = new HashMap<>();
+        userMap.put(User.Fields.USERNAME.getPreferredName(), user.principal());
+        userMap.put(User.Fields.ROLES.getPreferredName(), Arrays.stream(user.roles()).toList());
+        userMap.put(User.Fields.FULL_NAME.getPreferredName(), user.fullName());
+        userMap.put(User.Fields.EMAIL.getPreferredName(), user.email());
+        userMap.put(User.Fields.METADATA.getPreferredName(), user.metadata());
+        userMap.put(User.Fields.ENABLED.getPreferredName(), user.enabled());
+        if (profileId != null) {
+            userMap.put("profile_uid", profileId);
+        }
+        return userMap;
     }
 
     private void assertUsers(List<User> expectedUsers, List<Map<String, Object>> actualUsers, String sortField, int from) {
@@ -423,6 +464,26 @@ public class QueryUserIT extends SecurityInBasicRestTestCase {
         );
     }
 
+    private String doActivateProfile(String username, String password) {
+        final Request activateProfileRequest = new Request("POST", "_security/profile/_activate");
+        activateProfileRequest.setJsonEntity(org.elasticsearch.common.Strings.format("""
+            {
+              "grant_type": "password",
+              "username": "%s",
+              "password": "%s"
+            }""", username, password));
+
+        final Response activateProfileResponse;
+
+        try {
+            activateProfileResponse = adminClient().performRequest(activateProfileRequest);
+            assertOK(activateProfileResponse);
+            return responseAsMap(activateProfileResponse).get("uid").toString();
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     private List<User> createRandomUsers() throws IOException {
         int randomUserCount = randomIntBetween(8, 15);
         final List<User> users = new ArrayList<>(randomUserCount);

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

@@ -7,11 +7,14 @@
 
 package org.elasticsearch.xpack.security.action.user;
 
+import org.elasticsearch.ElasticsearchStatusException;
 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.core.Tuple;
+import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.search.builder.SearchSourceBuilder;
 import org.elasticsearch.search.sort.FieldSortBuilder;
 import org.elasticsearch.tasks.Task;
@@ -19,24 +22,47 @@ 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.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.Subject;
+import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings;
+import org.elasticsearch.xpack.security.authc.Realms;
 import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
+import org.elasticsearch.xpack.security.profile.ProfileService;
 import org.elasticsearch.xpack.security.support.UserBoolQueryBuilder;
 
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 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 final ProfileService profileService;
+    private final Authentication.RealmRef nativeRealmRef;
     private static final Set<String> FIELD_NAMES_WITH_SORT_SUPPORT = Set.of("username", "roles", "enabled");
 
     @Inject
-    public TransportQueryUserAction(TransportService transportService, ActionFilters actionFilters, NativeUsersStore usersStore) {
+    public TransportQueryUserAction(
+        TransportService transportService,
+        ActionFilters actionFilters,
+        NativeUsersStore usersStore,
+        ProfileService profileService,
+        Realms realms
+    ) {
         super(ActionTypes.QUERY_USER_ACTION.name(), actionFilters, transportService.getTaskManager());
         this.usersStore = usersStore;
+        this.profileService = profileService;
+        this.nativeRealmRef = realms.getRealmRefs()
+            .values()
+            .stream()
+            .filter(realmRef -> NativeRealmSettings.TYPE.equals(realmRef.getType()))
+            .findFirst()
+            .orElseThrow(() -> new IllegalStateException("native realm realm ref not found"));
     }
 
     @Override
@@ -64,7 +90,56 @@ public final class TransportQueryUserAction extends TransportAction<QueryUserReq
         }
 
         final SearchRequest searchRequest = new SearchRequest(new String[] { SECURITY_MAIN_ALIAS }, searchSourceBuilder);
-        usersStore.queryUsers(searchRequest, listener);
+
+        usersStore.queryUsers(searchRequest, ActionListener.wrap(queryUserResults -> {
+            if (request.isWithProfileUid()) {
+                resolveProfileUids(queryUserResults, listener);
+            } else {
+                List<QueryUserResponse.Item> queryUserResponseResults = queryUserResults.userQueryResult()
+                    .stream()
+                    .map(queryUserResult -> new QueryUserResponse.Item(queryUserResult.user(), queryUserResult.sortValues(), null))
+                    .toList();
+                listener.onResponse(new QueryUserResponse(queryUserResults.total(), queryUserResponseResults));
+            }
+        }, listener::onFailure));
+    }
+
+    private void resolveProfileUids(NativeUsersStore.QueryUserResults queryUserResults, ActionListener<QueryUserResponse> listener) {
+        final List<Subject> subjects = queryUserResults.userQueryResult()
+            .stream()
+            .map(item -> new Subject(item.user(), nativeRealmRef))
+            .toList();
+
+        profileService.searchProfilesForSubjects(subjects, ActionListener.wrap(resultsAndErrors -> {
+            if (resultsAndErrors == null || resultsAndErrors.errors().isEmpty()) {
+                final Map<String, String> profileUidLookup = resultsAndErrors == null
+                    ? Map.of()
+                    : resultsAndErrors.results()
+                        .stream()
+                        .filter(t -> Objects.nonNull(t.v2()))
+                        .map(t -> new Tuple<>(t.v1().getUser().principal(), t.v2().uid()))
+                        .collect(Collectors.toUnmodifiableMap(Tuple::v1, Tuple::v2));
+
+                List<QueryUserResponse.Item> queryUserResponseResults = queryUserResults.userQueryResult()
+                    .stream()
+                    .map(
+                        userResult -> new QueryUserResponse.Item(
+                            userResult.user(),
+                            userResult.sortValues(),
+                            profileUidLookup.getOrDefault(userResult.user().principal(), null)
+                        )
+                    )
+                    .toList();
+                listener.onResponse(new QueryUserResponse(queryUserResults.total(), queryUserResponseResults));
+            } else {
+                final ElasticsearchStatusException exception = new ElasticsearchStatusException(
+                    "failed to retrieve profile for users. please retry without fetching profile uid (with_profile_uid=false)",
+                    RestStatus.INTERNAL_SERVER_ERROR
+                );
+                resultsAndErrors.errors().values().forEach(exception::addSuppressed);
+                listener.onFailure(exception);
+            }
+        }, listener::onFailure));
     }
 
     // package private for testing

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

@@ -44,7 +44,6 @@ 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;
@@ -164,11 +163,11 @@ public class NativeUsersStore {
         }
     }
 
-    public void queryUsers(SearchRequest searchRequest, ActionListener<QueryUserResponse> listener) {
+    public void queryUsers(SearchRequest searchRequest, ActionListener<QueryUserResults> listener) {
         final SecurityIndexManager frozenSecurityIndex = securityIndex.defensiveCopy();
         if (frozenSecurityIndex.indexExists() == false) {
             logger.debug("security index does not exist");
-            listener.onResponse(QueryUserResponse.emptyResponse());
+            listener.onResponse(QueryUserResults.EMPTY);
         } else if (frozenSecurityIndex.isAvailable(SEARCH_SHARDS) == false) {
             listener.onFailure(frozenSecurityIndex.getUnavailableReason(SEARCH_SHARDS));
         } else {
@@ -183,15 +182,15 @@ public class NativeUsersStore {
                         final long total = searchResponse.getHits().getTotalHits().value;
                         if (total == 0) {
                             logger.debug("No users found for query [{}]", searchRequest.source().query());
-                            listener.onResponse(QueryUserResponse.emptyResponse());
+                            listener.onResponse(QueryUserResults.EMPTY);
                             return;
                         }
 
-                        final List<QueryUserResponse.Item> userItem = Arrays.stream(searchResponse.getHits().getHits()).map(hit -> {
+                        final List<QueryUserResult> userItems = 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;
+                            return userAndPassword != null ? new QueryUserResult(userAndPassword.user(), hit.getSortValues()) : null;
                         }).filter(Objects::nonNull).toList();
-                        listener.onResponse(new QueryUserResponse(total, userItem));
+                        listener.onResponse(new QueryUserResults(userItems, total));
                     }, listener::onFailure)
                 )
             );
@@ -839,4 +838,16 @@ public class NativeUsersStore {
             return new ReservedUserInfo(new char[0], false);
         }
     }
+
+    /**
+     * Result record for every document matching a user
+     */
+    public record QueryUserResult(User user, Object[] sortValues) {}
+
+    /**
+     * Total result for a Query User query
+     */
+    public record QueryUserResults(List<QueryUserResult> userQueryResult, long total) {
+        public static final QueryUserResults EMPTY = new QueryUserResults(List.of(), 0);
+    }
 }

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

@@ -89,6 +89,7 @@ public final class RestQueryUserAction extends SecurityBaseRestHandler {
 
     @Override
     protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+        final boolean withProfileUid = request.paramAsBoolean("with_profile_uid", false);
         final QueryUserRequest queryUserRequest;
         if (request.hasContentOrSourceParam()) {
             final Payload payload = PARSER.parse(request.contentOrSourceParamParser(), null);
@@ -97,10 +98,11 @@ public final class RestQueryUserAction extends SecurityBaseRestHandler {
                 payload.from,
                 payload.size,
                 payload.fieldSortBuilders,
-                payload.searchAfterBuilder
+                payload.searchAfterBuilder,
+                withProfileUid
             );
         } else {
-            queryUserRequest = new QueryUserRequest(null, null, null, null, null);
+            queryUserRequest = new QueryUserRequest(null, null, null, null, null, withProfileUid);
         }
         return channel -> client.execute(ActionTypes.QUERY_USER_ACTION, queryUserRequest, new RestToXContentListener<>(channel));
     }

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

@@ -7,18 +7,62 @@
 
 package org.elasticsearch.xpack.security.action.user;
 
+import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.ElasticsearchStatusException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.search.SearchRequest;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.PlainActionFuture;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.rest.RestStatus;
 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.tasks.Task;
 import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.Transport;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.profile.Profile;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserRequest;
+import org.elasticsearch.xpack.core.security.action.user.QueryUserResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.RealmConfig;
+import org.elasticsearch.xpack.core.security.authc.Subject;
+import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings;
+import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.authc.Realms;
+import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore;
+import org.elasticsearch.xpack.security.profile.ProfileService;
+import org.mockito.ArgumentMatchers;
 
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 public class TransportQueryUserActionTests extends ESTestCase {
     private static final String[] allowedIndexFieldNames = new String[] { "username", "roles", "enabled" };
@@ -68,6 +112,213 @@ public class TransportQueryUserActionTests extends ESTestCase {
         assertThat(e.getMessage(), equalTo(String.format(Locale.ROOT, "sorting is not supported for field [%s] in User query", fieldName)));
     }
 
+    public void testQueryUsers() {
+        final List<User> storeUsers = randomFrom(
+            Collections.singletonList(new User("joe")),
+            Arrays.asList(new User("jane"), new User("fred")),
+            randomUsers()
+        );
+        final boolean profileIndexExists = randomBoolean();
+        NativeUsersStore usersStore = mock(NativeUsersStore.class);
+
+        TransportService transportService = new TransportService(
+            Settings.EMPTY,
+            mock(Transport.class),
+            mock(ThreadPool.class),
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet()
+        );
+
+        TransportQueryUserAction action = new TransportQueryUserAction(
+            transportService,
+            mock(ActionFilters.class),
+            usersStore,
+            mockProfileService(false, profileIndexExists),
+            mockRealms()
+        );
+        boolean withProfileUid = randomBoolean();
+        QueryUserRequest request = new QueryUserRequest(null, null, null, null, null, withProfileUid);
+
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            @SuppressWarnings("unchecked")
+            ActionListener<NativeUsersStore.QueryUserResults> listener = (ActionListener<NativeUsersStore.QueryUserResults>) args[1];
+
+            listener.onResponse(
+                new NativeUsersStore.QueryUserResults(
+                    storeUsers.stream().map(user -> new NativeUsersStore.QueryUserResult(user, null)).toList(),
+                    storeUsers.size()
+                )
+            );
+            return null;
+        }).when(usersStore).queryUsers(ArgumentMatchers.any(SearchRequest.class), anyActionListener());
+
+        final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
+        final AtomicReference<QueryUserResponse> responseRef = new AtomicReference<>();
+        action.doExecute(mock(Task.class), request, new ActionListener<>() {
+            @Override
+            public void onResponse(QueryUserResponse response) {
+                responseRef.set(response);
+            }
+
+            @Override
+            public void onFailure(Exception e) {
+                throwableRef.set(e);
+            }
+        });
+
+        assertThat(throwableRef.get(), is(nullValue()));
+        assertThat(responseRef.get(), is(notNullValue()));
+        assertEquals(responseRef.get().getItems().length, storeUsers.size());
+
+        if (profileIndexExists && withProfileUid) {
+            assertEquals(
+                storeUsers.stream().map(user -> "u_profile_" + user.principal()).toList(),
+                Arrays.stream(responseRef.get().getItems()).map(QueryUserResponse.Item::profileUid).toList()
+            );
+        } else {
+            for (QueryUserResponse.Item item : responseRef.get().getItems()) {
+                assertThat(item.profileUid(), nullValue());
+            }
+        }
+    }
+
+    public void testQueryUsersWithProfileUidException() {
+        final List<User> storeUsers = randomFrom(
+            Collections.singletonList(new User("joe")),
+            Arrays.asList(new User("jane"), new User("fred")),
+            randomUsers()
+        );
+        NativeUsersStore usersStore = mock(NativeUsersStore.class);
+
+        TransportService transportService = new TransportService(
+            Settings.EMPTY,
+            mock(Transport.class),
+            mock(ThreadPool.class),
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet()
+        );
+
+        TransportQueryUserAction action = new TransportQueryUserAction(
+            transportService,
+            mock(ActionFilters.class),
+            usersStore,
+            mockProfileService(true, true),
+            mockRealms()
+        );
+
+        QueryUserRequest request = new QueryUserRequest(null, null, null, null, null, true);
+
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            @SuppressWarnings("unchecked")
+            ActionListener<NativeUsersStore.QueryUserResults> listener = (ActionListener<NativeUsersStore.QueryUserResults>) args[1];
+
+            listener.onResponse(
+                new NativeUsersStore.QueryUserResults(
+                    storeUsers.stream().map(user -> new NativeUsersStore.QueryUserResult(user, null)).toList(),
+                    storeUsers.size()
+                )
+            );
+            return null;
+        }).when(usersStore).queryUsers(ArgumentMatchers.any(SearchRequest.class), anyActionListener());
+
+        final PlainActionFuture<QueryUserResponse> future = new PlainActionFuture<>();
+        action.doExecute(mock(Task.class), request, future);
+
+        final ElasticsearchStatusException e = expectThrows(ElasticsearchStatusException.class, future::actionGet);
+
+        assertThat(e.status(), equalTo(RestStatus.INTERNAL_SERVER_ERROR));
+        assertThat(e.getSuppressed().length, greaterThan(0));
+        Arrays.stream(e.getSuppressed()).forEach(suppressed -> {
+            assertThat(suppressed, instanceOf(ElasticsearchException.class));
+            assertThat(suppressed.getMessage(), equalTo("something is not right"));
+        });
+    }
+
+    private List<User> randomUsers() {
+        int size = scaledRandomIntBetween(3, 16);
+        List<User> users = new ArrayList<>(size);
+        for (int i = 0; i < size; i++) {
+            users.add(new User("user_" + i, randomAlphaOfLengthBetween(4, 12)));
+        }
+        return users;
+    }
+
+    private Profile profileFromSubject(Subject subject) {
+        final User user = subject.getUser();
+        final Authentication.RealmRef realmRef = subject.getRealm();
+        return new Profile(
+            "u_profile_" + user.principal(),
+            randomBoolean(),
+            randomNonNegativeLong(),
+            new Profile.ProfileUser(
+                user.principal(),
+                Arrays.asList(user.roles()),
+                realmRef.getName(),
+                realmRef.getDomain() == null ? null : realmRef.getDomain().name(),
+                user.email(),
+                user.fullName()
+            ),
+            Map.of(),
+            Map.of(),
+            new Profile.VersionControl(randomNonNegativeLong(), randomNonNegativeLong())
+        );
+    }
+
+    private ProfileService mockProfileService(boolean throwException, boolean profileIndexExists) {
+        final ProfileService profileService = mock(ProfileService.class);
+        doAnswer(invocation -> {
+            @SuppressWarnings("unchecked")
+            final var listener = (ActionListener<ProfileService.SubjectSearchResultsAndErrors<Profile>>) invocation.getArguments()[1];
+            if (false == profileIndexExists) {
+                listener.onResponse(null);
+                return null;
+            }
+            @SuppressWarnings("unchecked")
+            final List<Subject> subjects = (List<Subject>) invocation.getArguments()[0];
+            List<Tuple<Subject, Profile>> results = subjects.stream()
+                .map(subject -> new Tuple<>(subject, profileFromSubject(subject)))
+                .toList();
+
+            final Map<Subject, Exception> errors = new HashMap<>();
+            if (throwException) {
+                assertThat("random exception requires non-empty results", results, not(empty()));
+                final int exceptionSize = randomIntBetween(1, results.size());
+                errors.putAll(
+                    results.subList(0, exceptionSize)
+                        .stream()
+                        .collect(Collectors.toUnmodifiableMap(Tuple::v1, t -> new ElasticsearchException("something is not right")))
+                );
+                results = results.subList(exceptionSize - 1, results.size());
+            }
+
+            listener.onResponse(new ProfileService.SubjectSearchResultsAndErrors<>(results, errors));
+            return null;
+        }).when(profileService).searchProfilesForSubjects(anyList(), anyActionListener());
+        return profileService;
+    }
+
+    private Realms mockRealms() {
+        final Realms realms = mock(Realms.class);
+        when(realms.getRealmRefs()).thenReturn(
+            Map.of(
+                new RealmConfig.RealmIdentifier(NativeRealmSettings.TYPE, NativeRealmSettings.DEFAULT_NAME),
+                new Authentication.RealmRef(
+                    NativeRealmSettings.DEFAULT_NAME,
+                    NativeRealmSettings.TYPE,
+                    randomAlphaOfLengthBetween(3, 8),
+                    null
+                )
+            )
+        );
+        return realms;
+    }
+
     private FieldSortBuilder randomFieldSortBuilderWithName(String name) {
         final FieldSortBuilder fieldSortBuilder = new FieldSortBuilder(name);
         fieldSortBuilder.order(randomBoolean() ? SortOrder.ASC : SortOrder.DESC);

+ 13 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/users/40_query.yml

@@ -63,6 +63,17 @@ teardown:
           }
   - match: { "created": true }
 
+  - do:
+      security.activate_user_profile:
+        body: >
+          {
+            "grant_type": "password",
+            "username": "test_user_1",
+            "password" : "x-pack-test-password"
+          }
+  - is_true: uid
+  - set: { uid: profile_uid_1 }
+
   - do:
       headers:
         Authorization: "Basic dXNlcnNfYWRtaW46eC1wYWNrLXRlc3QtcGFzc3dvcmQ=" # users_admin
@@ -120,6 +131,8 @@ teardown:
             "from": 1,
             "size": 1
           }
+        with_profile_uid: true
   - match: { total: 2 }
   - match: { count: 1 }
   - match: { users.0.username: "test_user_1" }
+  - match: { users.0.profile_uid: "$profile_uid_1" }