Forráskód Böngészése

Option to return profile uid in GetUser response (#89570)

This PR adds a new query parameter to the GetUser API to optionally return
profile uid for each user if there is an associated profile.
Yang Wang 3 éve
szülő
commit
80c6d9faea
20 módosított fájl, 1054 hozzáadás és 129 törlés
  1. 5 0
      docs/changelog/89570.yaml
  2. 7 1
      rest-api-spec/src/main/resources/rest-api-spec/api/security.get_user.json
  3. 26 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/GetUsersRequest.java
  4. 5 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/GetUsersRequestBuilder.java
  5. 54 5
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/GetUsersResponse.java
  6. 1 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java
  7. 5 5
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java
  8. 5 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/user/User.java
  9. 139 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/user/GetUsersResponseTests.java
  10. 29 3
      x-pack/plugin/security/qa/profile/src/javaRestTest/java/org/elasticsearch/xpack/security/profile/ProfileIT.java
  11. 79 0
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/profile/ProfileIntegTests.java
  12. 67 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/user/TransportGetUsersAction.java
  13. 36 10
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java
  14. 150 65
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java
  15. 18 19
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUsersAction.java
  16. 279 6
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportGetUsersActionTests.java
  17. 119 6
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java
  18. 8 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java
  19. 13 4
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java
  20. 9 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/user_profile/10_basic.yml

+ 5 - 0
docs/changelog/89570.yaml

@@ -0,0 +1,5 @@
+pr: 89570
+summary: Option to return profile uid in `GetUser` response
+area: Security
+type: enhancement
+issues: []

+ 7 - 1
rest-api-spec/src/main/resources/rest-api-spec/api/security.get_user.json

@@ -31,6 +31,12 @@
         }
       ]
     },
-    "params":{}
+    "params":{
+      "with_profile_uid": {
+        "type":"boolean",
+        "default":false,
+        "description":"flag to retrieve profile uid (if exists) associated to the user"
+      }
+    }
   }
 }

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

@@ -6,6 +6,7 @@
  */
 package org.elasticsearch.xpack.core.security.action.user;
 
+import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionRequest;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.common.Strings;
@@ -22,10 +23,16 @@ import static org.elasticsearch.action.ValidateActions.addValidationError;
 public class GetUsersRequest extends ActionRequest implements UserRequest {
 
     private String[] usernames;
+    private boolean withProfileUid;
 
     public GetUsersRequest(StreamInput in) throws IOException {
         super(in);
         usernames = in.readStringArray();
+        if (in.getVersion().onOrAfter(Version.V_8_5_0)) {
+            withProfileUid = in.readBoolean();
+        } else {
+            withProfileUid = false;
+        }
     }
 
     public GetUsersRequest() {
@@ -50,10 +57,29 @@ public class GetUsersRequest extends ActionRequest implements UserRequest {
         return usernames;
     }
 
+    public String[] getUsernames() {
+        return usernames;
+    }
+
+    public void setUsernames(String[] usernames) {
+        this.usernames = usernames;
+    }
+
+    public boolean isWithProfileUid() {
+        return withProfileUid;
+    }
+
+    public void setWithProfileUid(boolean withProfileUid) {
+        this.withProfileUid = withProfileUid;
+    }
+
     @Override
     public void writeTo(StreamOutput out) throws IOException {
         super.writeTo(out);
         out.writeStringArray(usernames);
+        if (out.getVersion().onOrAfter(Version.V_8_5_0)) {
+            out.writeBoolean(withProfileUid);
+        }
     }
 
 }

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

@@ -23,4 +23,9 @@ public class GetUsersRequestBuilder extends ActionRequestBuilder<GetUsersRequest
         request.usernames(usernames);
         return this;
     }
+
+    public GetUsersRequestBuilder withProfileUid(boolean withProfileUid) {
+        request.setWithProfileUid(withProfileUid);
+        return this;
+    }
 }

+ 54 - 5
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/user/GetUsersResponse.java

@@ -6,21 +6,28 @@
  */
 package org.elasticsearch.xpack.core.security.action.user;
 
+import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionResponse;
 import org.elasticsearch.common.io.stream.StreamInput;
 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.authc.Authentication;
 import org.elasticsearch.xpack.core.security.user.User;
 
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Map;
 
 /**
  * Response containing a User retrieved from the security index
  */
-public class GetUsersResponse extends ActionResponse {
+public class GetUsersResponse extends ActionResponse implements ToXContentObject {
 
     private final User[] users;
+    @Nullable
+    private final Map<String, String> profileUidLookup;
 
     public GetUsersResponse(StreamInput in) throws IOException {
         super(in);
@@ -35,20 +42,34 @@ public class GetUsersResponse extends ActionResponse {
                 users[i] = user;
             }
         }
+        if (in.getVersion().onOrAfter(Version.V_8_5_0)) {
+            if (in.readBoolean()) {
+                profileUidLookup = in.readMap(StreamInput::readString, StreamInput::readString);
+            } else {
+                profileUidLookup = null;
+            }
+        } else {
+            profileUidLookup = null;
+        }
     }
 
-    public GetUsersResponse(User... users) {
-        this.users = users;
+    public GetUsersResponse(Collection<User> users) {
+        this(users, null);
     }
 
-    public GetUsersResponse(Collection<User> users) {
-        this(users.toArray(new User[users.size()]));
+    public GetUsersResponse(Collection<User> users, @Nullable Map<String, String> profileUidLookup) {
+        this.users = users.toArray(User[]::new);
+        this.profileUidLookup = profileUidLookup;
     }
 
     public User[] users() {
         return users;
     }
 
+    public Map<String, String> getProfileUidLookup() {
+        return profileUidLookup;
+    }
+
     public boolean hasUsers() {
         return users != null && users.length > 0;
     }
@@ -61,6 +82,34 @@ public class GetUsersResponse extends ActionResponse {
                 Authentication.AuthenticationSerializationHelper.writeUserTo(user, out);
             }
         }
+        if (out.getVersion().onOrAfter(Version.V_8_5_0)) {
+            if (profileUidLookup != null) {
+                out.writeBoolean(true);
+                out.writeMap(profileUidLookup, StreamOutput::writeString, StreamOutput::writeString);
+            } else {
+                out.writeBoolean(false);
+            }
+        }
     }
 
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        for (User user : users) {
+            builder.field(user.principal());
+            builder.startObject();
+            {
+                user.innerToXContent(builder);
+                if (profileUidLookup != null) {
+                    final String profileUid = profileUidLookup.get(user.principal());
+                    if (profileUid != null) {
+                        builder.field("profile_uid", profileUid);
+                    }
+                }
+            }
+            builder.endObject();
+        }
+        builder.endObject();
+        return builder;
+    }
 }

+ 1 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Authentication.java

@@ -796,7 +796,7 @@ public final class Authentication implements ToXContentObject {
             return realmRef;
         }
 
-        static RealmRef newAnonymousRealmRef(String nodeName) {
+        public static RealmRef newAnonymousRealmRef(String nodeName) {
             // the "anonymous" internal realm is not part of any realm domain
             return new Authentication.RealmRef(ANONYMOUS_REALM_NAME, ANONYMOUS_REALM_TYPE, nodeName, null);
         }

+ 5 - 5
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/Realm.java

@@ -11,11 +11,10 @@ import org.apache.logging.log4j.Logger;
 import org.apache.lucene.util.SetOnce;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.common.util.concurrent.ThreadContext;
-import org.elasticsearch.core.Nullable;
 import org.elasticsearch.license.XPackLicenseState;
-import org.elasticsearch.node.Node;
 import org.elasticsearch.xpack.core.XPackField;
 import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
+import org.elasticsearch.xpack.core.security.authc.RealmConfig.RealmIdentifier;
 import org.elasticsearch.xpack.core.security.authc.support.DelegatedAuthorizationSettings;
 import org.elasticsearch.xpack.core.security.user.User;
 
@@ -23,6 +22,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 /**
  * An authentication mechanism to which the default authentication org.elasticsearch.xpack.security.authc.AuthenticationService
@@ -145,9 +145,9 @@ public abstract class Realm implements Comparable<Realm> {
         listener.onResponse(stats);
     }
 
-    public void initRealmRef(@Nullable RealmDomain domain) {
-        final String nodeName = Node.NODE_NAME_SETTING.get(config.settings());
-        this.realmRef.set(new RealmRef(config.name(), config.type(), nodeName, domain));
+    public void initRealmRef(Map<RealmIdentifier, RealmRef> realmRefs) {
+        final RealmRef realmRef = Objects.requireNonNull(realmRefs.get(new RealmIdentifier(type(), name())), "realmRef must not be null");
+        this.realmRef.set(realmRef);
     }
 
     public RealmRef realmRef() {

+ 5 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/user/User.java

@@ -134,13 +134,17 @@ public class User implements ToXContentObject {
     @Override
     public final XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
         builder.startObject();
+        innerToXContent(builder);
+        return builder.endObject();
+    }
+
+    public void innerToXContent(XContentBuilder builder) throws IOException {
         builder.field(Fields.USERNAME.getPreferredName(), principal());
         builder.array(Fields.ROLES.getPreferredName(), roles());
         builder.field(Fields.FULL_NAME.getPreferredName(), fullName());
         builder.field(Fields.EMAIL.getPreferredName(), email());
         builder.field(Fields.METADATA.getPreferredName(), metadata());
         builder.field(Fields.ENABLED.getPreferredName(), enabled());
-        return builder.endObject();
     }
 
     public static boolean isInternal(User user) {

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

@@ -0,0 +1,139 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.user;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.ToXContent;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
+import org.elasticsearch.xpack.core.security.user.AnonymousUser;
+import org.elasticsearch.xpack.core.security.user.ElasticUser;
+import org.elasticsearch.xpack.core.security.user.KibanaSystemUser;
+import org.elasticsearch.xpack.core.security.user.User;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class GetUsersResponseTests extends ESTestCase {
+
+    public void testToXContent() throws IOException {
+
+        final Settings settings = Settings.builder()
+            .put(AnonymousUser.USERNAME_SETTING.getKey(), "_" + randomAlphaOfLengthBetween(5, 18))
+            .put(AnonymousUser.ROLES_SETTING.getKey(), "superuser")
+            .build();
+
+        final User reservedUser = randomFrom(new ElasticUser(true), new KibanaSystemUser(true));
+        final User anonymousUser = new AnonymousUser(settings);
+        final User nativeUser = AuthenticationTestHelper.randomUser();
+
+        final Map<String, String> profileUidLookup;
+        if (randomBoolean()) {
+            profileUidLookup = new HashMap<>();
+            if (randomBoolean()) {
+                profileUidLookup.put(reservedUser.principal(), "u_profile_" + reservedUser.principal());
+            }
+            if (randomBoolean()) {
+                profileUidLookup.put(anonymousUser.principal(), "u_profile_" + anonymousUser.principal());
+            }
+            if (randomBoolean()) {
+                profileUidLookup.put(nativeUser.principal(), "u_profile_" + nativeUser.principal());
+            }
+        } else {
+            profileUidLookup = null;
+        }
+
+        final var response = new GetUsersResponse(List.of(reservedUser, anonymousUser, nativeUser), profileUidLookup);
+        final XContentBuilder builder = XContentFactory.jsonBuilder();
+        response.toXContent(builder, ToXContent.EMPTY_PARAMS);
+
+        assertThat(
+            Strings.toString(builder),
+            equalTo(
+                XContentHelper.stripWhitespace(
+                    """
+                        {
+                          "%s": {
+                            "username": "%s",
+                            "roles": [
+                              %s
+                            ],
+                            "full_name": null,
+                            "email": null,
+                            "metadata": {
+                              "_reserved": true
+                            },
+                            "enabled": true%s
+                          },
+                          "%s": {
+                            "username": "%s",
+                            "roles": [
+                              "superuser"
+                            ],
+                            "full_name": null,
+                            "email": null,
+                            "metadata": {
+                              "_reserved": true
+                            },
+                            "enabled": true%s
+                          },
+                          "%s": {
+                            "username": "%s",
+                            "roles": [
+                              %s
+                            ],
+                            "full_name": null,
+                            "email": null,
+                            "metadata": {},
+                            "enabled": true%s
+                          }
+                        }""".formatted(
+                        reservedUser.principal(),
+                        reservedUser.principal(),
+                        getRolesOutput(reservedUser),
+                        getProfileUidOutput(reservedUser, profileUidLookup),
+                        anonymousUser.principal(),
+                        anonymousUser.principal(),
+                        getProfileUidOutput(anonymousUser, profileUidLookup),
+                        nativeUser.principal(),
+                        nativeUser.principal(),
+                        getRolesOutput(nativeUser),
+                        getProfileUidOutput(nativeUser, profileUidLookup)
+                    )
+                )
+            )
+        );
+    }
+
+    private String getRolesOutput(User user) {
+        return Arrays.stream(user.roles()).map(r -> "\"" + r + "\"").collect(Collectors.joining(","));
+    }
+
+    private String getProfileUidOutput(User user, Map<String, String> profileUidLookup) {
+        if (profileUidLookup == null) {
+            return "";
+        } else {
+            final String uid = profileUidLookup.get(user.principal());
+            if (uid == null) {
+                return "";
+            } else {
+                return ", \"profile_uid\": \"" + uid + "\"";
+            }
+        }
+    }
+}

+ 29 - 3
x-pack/plugin/security/qa/profile/src/javaRestTest/java/org/elasticsearch/xpack/security/profile/ProfileIT.java

@@ -32,6 +32,7 @@ import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.hasKey;
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.nullValue;
 
 public class ProfileIT extends ESRestTestCase {
@@ -351,14 +352,39 @@ public class ProfileIT extends ESRestTestCase {
         assertThat(otherDomainRealms, containsInAnyOrder("saml1", "ad1"));
     }
 
+    public void testGetUsersWithProfileUid() throws IOException {
+        final String username = randomAlphaOfLengthBetween(3, 8);
+        final Request putUserRequest = new Request("PUT", "_security/user/" + username);
+        putUserRequest.setJsonEntity("{\"password\":\"x-pack-test-password\",\"roles\":[\"superuser\"]}");
+        assertOK(adminClient().performRequest(putUserRequest));
+        final Map<String, Object> profile = doActivateProfile(username, "x-pack-test-password");
+
+        final Request getUserRequest = new Request("GET", "_security/user" + (randomBoolean() ? "/" + username : ""));
+        getUserRequest.addParameter("with_profile_uid", "true");
+        final Response getUserResponse = adminClient().performRequest(getUserRequest);
+        assertOK(getUserResponse);
+
+        responseAsMap(getUserResponse).forEach((k, v) -> {
+            if (username.equals(k)) {
+                assertThat(castToMap(v).get("profile_uid"), equalTo(profile.get("uid")));
+            } else {
+                assertThat(castToMap(v), not(hasKey("profile_uid")));
+            }
+        });
+    }
+
     private Map<String, Object> doActivateProfile() throws IOException {
+        return doActivateProfile("rac-user", "x-pack-test-password");
+    }
+
+    private Map<String, Object> doActivateProfile(String username, String password) throws IOException {
         final Request activateProfileRequest = new Request("POST", "_security/profile/_activate");
         activateProfileRequest.setJsonEntity("""
             {
               "grant_type": "password",
-              "username": "rac-user",
-              "password": "x-pack-test-password"
-            }""");
+              "username": "%s",
+              "password": "%s"
+            }""".formatted(username, password));
 
         final Response activateProfileResponse = adminClient().performRequest(activateProfileRequest);
         assertOK(activateProfileResponse);

+ 79 - 0
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/profile/ProfileIntegTests.java

@@ -15,13 +15,20 @@ import org.elasticsearch.action.admin.indices.get.GetIndexResponse;
 import org.elasticsearch.action.support.PlainActionFuture;
 import org.elasticsearch.action.support.WriteRequest;
 import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.index.engine.DocumentMissingException;
+import org.elasticsearch.test.XContentTestUtils;
 import org.elasticsearch.test.rest.ObjectPath;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.common.ResultsAndErrors;
 import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction;
 import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesRequest;
+import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileRequest;
+import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileResponse;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfilesAction;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfilesRequest;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfilesResponse;
@@ -35,6 +42,10 @@ import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAct
 import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
 import org.elasticsearch.xpack.core.security.action.role.PutRoleAction;
 import org.elasticsearch.xpack.core.security.action.role.PutRoleRequest;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequestBuilder;
+import org.elasticsearch.xpack.core.security.action.user.GetUsersAction;
+import org.elasticsearch.xpack.core.security.action.user.GetUsersRequest;
+import org.elasticsearch.xpack.core.security.action.user.GetUsersResponse;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesAction;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesResponse;
@@ -47,9 +58,12 @@ import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
 import org.elasticsearch.xpack.core.security.authc.RealmConfig;
 import org.elasticsearch.xpack.core.security.authc.RealmDomain;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
 import org.elasticsearch.xpack.core.security.authz.AuthorizationEngine.PrivilegesToCheck;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.privilege.ApplicationPrivilegeDescriptor;
+import org.elasticsearch.xpack.core.security.user.AnonymousUser;
+import org.elasticsearch.xpack.core.security.user.ElasticUser;
 import org.elasticsearch.xpack.core.security.user.User;
 
 import java.io.IOException;
@@ -90,9 +104,16 @@ public class ProfileIntegTests extends AbstractProfileIntegTestCase {
         final Settings.Builder builder = Settings.builder().put(super.nodeSettings(nodeOrdinal, otherSettings));
         // This setting tests that the setting is registered
         builder.put("xpack.security.authc.domains.my_domain.realms", "file");
+        // enable anonymous
+        builder.putList(AnonymousUser.ROLES_SETTING.getKey(), RAC_ROLE);
         return builder.build();
     }
 
+    @Override
+    protected boolean addMockHttpTransport() {
+        return false; // enable http
+    }
+
     public void testProfileIndexAutoCreation() {
         // Index does not exist yet
         assertThat(getProfileIndexResponse().getIndices(), not(hasItemInArray(INTERNAL_SECURITY_PROFILE_INDEX_8)));
@@ -755,6 +776,64 @@ public class ProfileIntegTests extends AbstractProfileIntegTestCase {
         }
     }
 
+    public void testGetUsersWithProfileUid() throws IOException {
+        new ChangePasswordRequestBuilder(client()).username(ElasticUser.NAME)
+            .password(TEST_PASSWORD_SECURE_STRING.clone().getChars(), Hasher.BCRYPT)
+            .execute()
+            .actionGet();
+
+        final Profile elasticUserProfile = doActivateProfile(ElasticUser.NAME, TEST_PASSWORD_SECURE_STRING);
+        final Profile nativeRacUserProfile = doActivateProfile(RAC_USER_NAME, NATIVE_RAC_USER_PASSWORD);
+        if (randomBoolean()) {
+            // Disabled profile still works for retrieving profile uid for users
+            final SetProfileEnabledRequest setProfileEnabledRequest = new SetProfileEnabledRequest(
+                nativeRacUserProfile.uid(),
+                false,
+                WriteRequest.RefreshPolicy.IMMEDIATE
+            );
+            client().execute(SetProfileEnabledAction.INSTANCE, setProfileEnabledRequest).actionGet();
+        }
+
+        // activate profile for anonymous user
+        final Request createTokenRequest = new Request("POST", "/_security/oauth2/token");
+        createTokenRequest.setJsonEntity("{\"grant_type\": \"client_credentials\"}");
+        final Response createTokenResponse = getRestClient().performRequest(createTokenRequest);
+        assertThat(createTokenResponse.getStatusLine().getStatusCode(), equalTo(200));
+        final String accessToken = XContentTestUtils.createJsonMapView(createTokenResponse.getEntity().getContent()).get("access_token");
+
+        final ActivateProfileRequest activateProfileRequest = new ActivateProfileRequest();
+        activateProfileRequest.getGrant().setType("access_token");
+        activateProfileRequest.getGrant().setAccessToken(new SecureString(accessToken.toCharArray()));
+        final ActivateProfileResponse activateProfileResponse = client().execute(ActivateProfileAction.INSTANCE, activateProfileRequest)
+            .actionGet();
+        final Profile anonymousUserProfile = activateProfileResponse.getProfile();
+
+        final GetUsersRequest getUsersRequest = new GetUsersRequest();
+        getUsersRequest.setWithProfileUid(true);
+        if (randomBoolean()) {
+            getUsersRequest.usernames(ElasticUser.NAME, RAC_USER_NAME, AnonymousUser.DEFAULT_ANONYMOUS_USERNAME);
+        }
+        final GetUsersResponse getUsersResponse = client().execute(GetUsersAction.INSTANCE, getUsersRequest).actionGet();
+
+        assertThat(
+            Arrays.stream(getUsersResponse.users()).map(User::principal).toList(),
+            hasItems(ElasticUser.NAME, RAC_USER_NAME, AnonymousUser.DEFAULT_ANONYMOUS_USERNAME)
+        );
+        assertThat(
+            getUsersResponse.getProfileUidLookup(),
+            equalTo(
+                Map.of(
+                    ElasticUser.NAME,
+                    elasticUserProfile.uid(),
+                    RAC_USER_NAME,
+                    nativeRacUserProfile.uid(),
+                    AnonymousUser.DEFAULT_ANONYMOUS_USERNAME,
+                    anonymousUserProfile.uid()
+                )
+            )
+        );
+    }
+
     private SuggestProfilesResponse.ProfileHit[] doSuggest(String name) {
         return doSuggest(name, Set.of());
     }

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

@@ -6,33 +6,47 @@
  */
 package org.elasticsearch.xpack.security.action.user;
 
+import org.elasticsearch.ElasticsearchStatusException;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.support.ActionFilters;
 import org.elasticsearch.action.support.GroupedActionListener;
 import org.elasticsearch.action.support.HandledTransportAction;
 import org.elasticsearch.common.inject.Inject;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.tasks.Task;
 import org.elasticsearch.transport.TransportService;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersAction;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.Subject;
 import org.elasticsearch.xpack.core.security.authc.esnative.ClientReservedRealm;
+import org.elasticsearch.xpack.core.security.authc.esnative.NativeRealmSettings;
+import org.elasticsearch.xpack.core.security.user.AnonymousUser;
 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.authc.esnative.ReservedRealm;
+import org.elasticsearch.xpack.security.profile.ProfileService;
 
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 public class TransportGetUsersAction extends HandledTransportAction<GetUsersRequest, GetUsersResponse> {
 
     private final Settings settings;
     private final NativeUsersStore usersStore;
     private final ReservedRealm reservedRealm;
+    private final Authentication.RealmRef nativeRealmRef;
+    private final ProfileService profileService;
 
     @Inject
     public TransportGetUsersAction(
@@ -40,12 +54,21 @@ public class TransportGetUsersAction extends HandledTransportAction<GetUsersRequ
         ActionFilters actionFilters,
         NativeUsersStore usersStore,
         TransportService transportService,
-        ReservedRealm reservedRealm
+        ReservedRealm reservedRealm,
+        Realms realms,
+        ProfileService profileService
     ) {
         super(GetUsersAction.NAME, transportService, actionFilters, GetUsersRequest::new);
         this.settings = settings;
         this.usersStore = usersStore;
         this.reservedRealm = reservedRealm;
+        this.nativeRealmRef = realms.getRealmRefs()
+            .values()
+            .stream()
+            .filter(realmRef -> NativeRealmSettings.TYPE.equals(realmRef.getType()))
+            .findFirst()
+            .orElseThrow(() -> new IllegalStateException("native realm realm ref not found"));
+        this.profileService = profileService;
     }
 
     @Override
@@ -67,7 +90,18 @@ public class TransportGetUsersAction extends HandledTransportAction<GetUsersRequ
 
         final ActionListener<Collection<Collection<User>>> sendingListener = ActionListener.wrap((userLists) -> {
             users.addAll(userLists.stream().flatMap(Collection::stream).filter(Objects::nonNull).toList());
-            listener.onResponse(new GetUsersResponse(users));
+            if (request.isWithProfileUid()) {
+                resolveProfileUids(
+                    users,
+                    ActionListener.wrap(
+                        profileUidLookup -> listener.onResponse(new GetUsersResponse(users, profileUidLookup)),
+                        listener::onFailure
+                    )
+                );
+            } else {
+                listener.onResponse(new GetUsersResponse(users));
+            }
+
         }, listener::onFailure);
         final GroupedActionListener<Collection<User>> groupListener = new GroupedActionListener<>(sendingListener, 2);
         // We have two sources for the users object, the reservedRealm and the usersStore, we query both at the same time with a
@@ -97,4 +131,35 @@ public class TransportGetUsersAction extends HandledTransportAction<GetUsersRequ
             usersStore.getUsers(usersToSearchFor.toArray(new String[usersToSearchFor.size()]), groupListener);
         }
     }
+
+    private void resolveProfileUids(List<User> users, ActionListener<Map<String, String>> listener) {
+        final List<Subject> subjects = users.stream().map(user -> {
+            if (user instanceof AnonymousUser) {
+                return new Subject(user, Authentication.RealmRef.newAnonymousRealmRef(Node.NODE_NAME_SETTING.get(settings)));
+            } else if (ClientReservedRealm.isReserved(user.principal(), settings)) {
+                return new Subject(user, reservedRealm.realmRef());
+            } else {
+                return new Subject(user, nativeRealmRef);
+            }
+        }).toList();
+
+        profileService.searchProfilesForSubjects(subjects, ActionListener.wrap(resultsAndErrors -> {
+            if (resultsAndErrors.errors().isEmpty()) {
+                assert users.size() == resultsAndErrors.results().size();
+                final Map<String, String> profileUidLookup = 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));
+                listener.onResponse(profileUidLookup);
+            } 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));
+    }
 }

+ 36 - 10
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/Realms.java

@@ -23,7 +23,9 @@ import org.elasticsearch.core.Nullable;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.license.LicensedFeature;
 import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.node.Node;
 import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.DomainConfig;
 import org.elasticsearch.xpack.core.security.authc.Realm;
 import org.elasticsearch.xpack.core.security.authc.RealmConfig;
@@ -72,6 +74,8 @@ public class Realms extends AbstractLifecycleComponent implements Iterable<Realm
     private final List<Realm> allConfiguredRealms;
 
     private final Map<String, DomainConfig> domainNameToConfig;
+    // The realmRefs include all realms explicitly or implicitly configured regardless whether they are disabled or not
+    private final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs;
 
     // the realms in current use. This list will change dynamically as the license changes
     private volatile List<Realm> activeRealms;
@@ -100,8 +104,11 @@ public class Realms extends AbstractLifecycleComponent implements Iterable<Realm
             .distinct()
             .collect(Collectors.toMap(DomainConfig::name, Function.identity()));
         final List<RealmConfig> realmConfigs = buildRealmConfigs();
+
+        // initRealms will add default file and native realm config if they are not explicitly configured
         final List<Realm> initialRealms = initRealms(realmConfigs);
-        configureRealmRef(initialRealms, realmConfigs, realmToDomainConfig);
+        realmRefs = calculateRealmRefs(realmConfigs, realmToDomainConfig);
+        initialRealms.forEach(realm -> realm.initRealmRef(realmRefs));
 
         this.allConfiguredRealms = initialRealms;
         this.allConfiguredRealms.forEach(r -> r.initialize(this.allConfiguredRealms, licenseState));
@@ -111,27 +118,42 @@ public class Realms extends AbstractLifecycleComponent implements Iterable<Realm
         licenseState.addListener(this::recomputeActiveRealms);
     }
 
-    static void configureRealmRef(
-        Collection<Realm> realms,
+    private Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> calculateRealmRefs(
         Collection<RealmConfig> realmConfigs,
         Map<String, DomainConfig> domainForRealm
     ) {
-        for (Realm realm : realms) {
-            final DomainConfig domainConfig = domainForRealm.get(realm.name());
+        final String nodeName = Node.NODE_NAME_SETTING.get(settings);
+        final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs = new HashMap<>();
+        assert realmConfigs.stream().noneMatch(rc -> rc.name().equals(reservedRealm.name()) && rc.type().equals(reservedRealm.type()))
+            : "reserved realm cannot be configured";
+        realmRefs.put(
+            new RealmConfig.RealmIdentifier(reservedRealm.type(), reservedRealm.name()),
+            new Authentication.RealmRef(reservedRealm.name(), reservedRealm.type(), nodeName, null)
+        );
+
+        for (RealmConfig realmConfig : realmConfigs) {
+            final RealmConfig.RealmIdentifier realmIdentifier = new RealmConfig.RealmIdentifier(realmConfig.type(), realmConfig.name());
+            final DomainConfig domainConfig = domainForRealm.get(realmConfig.name());
+            final RealmDomain realmDomain;
             if (domainConfig != null) {
                 final String domainName = domainConfig.name();
                 Set<RealmConfig.RealmIdentifier> domainIdentifiers = new HashSet<>();
-                for (RealmConfig realmConfig : realmConfigs) {
-                    final DomainConfig otherDomainConfig = domainForRealm.get(realmConfig.name());
+                for (RealmConfig otherRealmConfig : realmConfigs) {
+                    final DomainConfig otherDomainConfig = domainForRealm.get(otherRealmConfig.name());
                     if (otherDomainConfig != null && domainName.equals(otherDomainConfig.name())) {
-                        domainIdentifiers.add(realmConfig.identifier());
+                        domainIdentifiers.add(otherRealmConfig.identifier());
                     }
                 }
-                realm.initRealmRef(new RealmDomain(domainName, domainIdentifiers));
+                realmDomain = new RealmDomain(domainName, domainIdentifiers);
             } else {
-                realm.initRealmRef(null);
+                realmDomain = null;
             }
+            realmRefs.put(
+                realmIdentifier,
+                new Authentication.RealmRef(realmIdentifier.getName(), realmIdentifier.getType(), nodeName, realmDomain)
+            );
         }
+        return Map.copyOf(realmRefs);
     }
 
     protected void recomputeActiveRealms() {
@@ -351,6 +373,10 @@ public class Realms extends AbstractLifecycleComponent implements Iterable<Realm
         }
     }
 
+    public Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> getRealmRefs() {
+        return realmRefs;
+    }
+
     @Override
     protected void doStart() {}
 

+ 150 - 65
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/profile/ProfileService.java

@@ -23,6 +23,9 @@ import org.elasticsearch.action.get.GetAction;
 import org.elasticsearch.action.get.GetRequest;
 import org.elasticsearch.action.get.MultiGetItemResponse;
 import org.elasticsearch.action.index.IndexResponse;
+import org.elasticsearch.action.search.MultiSearchAction;
+import org.elasticsearch.action.search.MultiSearchRequest;
+import org.elasticsearch.action.search.MultiSearchResponse;
 import org.elasticsearch.action.search.SearchAction;
 import org.elasticsearch.action.search.SearchRequest;
 import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
@@ -74,6 +77,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
@@ -254,7 +258,7 @@ public class ProfileService {
                 new TotalHits(0, TotalHits.Relation.EQUAL_TO)
             );
         })).ifPresent(frozenProfileIndex -> {
-            final SearchRequest searchRequest = buildSearchRequest(request, parentTaskId);
+            final SearchRequest searchRequest = buildSearchRequestForSuggest(request, parentTaskId);
 
             frozenProfileIndex.checkIndexVersionThenExecute(
                 listener::onFailure,
@@ -305,8 +309,25 @@ public class ProfileService {
         doUpdate(buildUpdateRequest(uid, builder, refreshPolicy, -1, -1), listener.map(updateResponse -> AcknowledgedResponse.TRUE));
     }
 
+    public void searchProfilesForSubjects(List<Subject> subjects, ActionListener<SubjectSearchResultsAndErrors<Profile>> listener) {
+        searchVersionedDocumentsForSubjects(subjects, ActionListener.wrap(resultsAndErrors -> {
+            if (resultsAndErrors == null) {
+                // profile index does not exist
+                listener.onResponse(null);
+                return;
+            }
+            listener.onResponse(new SubjectSearchResultsAndErrors<>(resultsAndErrors.results().stream().map(t -> {
+                if (t.v2() != null) {
+                    return new Tuple<>(t.v1(), t.v2().toProfile(Set.of()));
+                } else {
+                    return new Tuple<>(t.v1(), (Profile) null);
+                }
+            }).toList(), resultsAndErrors.errors()));
+        }, listener::onFailure));
+    }
+
     // package private for testing
-    SearchRequest buildSearchRequest(SuggestProfilesRequest request, TaskId parentTaskId) {
+    SearchRequest buildSearchRequestForSuggest(SuggestProfilesRequest request, TaskId parentTaskId) {
         final BoolQueryBuilder query = QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("user_profile.enabled", true));
         if (Strings.hasText(request.getName())) {
             query.must(
@@ -408,8 +429,7 @@ public class ProfileService {
                                 logger.error("Inconsistent mget item response [{}] [{}]", itemResponse.getIndex(), itemResponse.getId());
                             }
                         }
-                        final ResultsAndErrors<VersionedDocument> resultsAndErrors = new ResultsAndErrors<>(retrievedDocs, errors);
-                        listener.onResponse(resultsAndErrors);
+                        listener.onResponse(new ResultsAndErrors<>(retrievedDocs, errors));
                     }, listener::onFailure))
             );
         });
@@ -417,71 +437,105 @@ public class ProfileService {
 
     // Package private for testing
     void searchVersionedDocumentForSubject(Subject subject, ActionListener<VersionedDocument> listener) {
-        tryFreezeAndCheckIndex(listener).ifPresent(frozenProfileIndex -> {
-            final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
-                .filter(QueryBuilders.termQuery("user_profile.user.username.keyword", subject.getUser().principal()));
-            if (subject.getRealm().getDomain() == null) {
-                boolQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.type", subject.getRealm().getType()));
-                if (false == isFileOrNativeRealm(subject.getRealm().getType())) {
-                    boolQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.name", subject.getRealm().getName()));
-                }
+        searchVersionedDocumentsForSubjects(List.of(subject), ActionListener.wrap(resultsAndErrors -> {
+            if (resultsAndErrors == null) {
+                // profile index does not exist
+                listener.onResponse(null);
+                return;
+            }
+
+            assert resultsAndErrors.results().size() + resultsAndErrors.errors().size() == 1
+                : "a single subject must have either a single result or error";
+            if (resultsAndErrors.results().size() == 1) {
+                listener.onResponse(resultsAndErrors.results().iterator().next().v2());
+            } else if (resultsAndErrors.errors().size() == 1) {
+                final Exception exception = resultsAndErrors.errors().values().iterator().next();
+                logger.error(exception.getMessage());
+                listener.onFailure(exception);
             } else {
-                logger.debug(
-                    () -> format(
-                        "searching existing profile document for user [%s] from any of the realms [%s] under domain [%s]",
-                        subject.getUser().principal(),
-                        collectionToCommaDelimitedString(subject.getRealm().getDomain().realms()),
-                        subject.getRealm().getDomain().name()
-                    )
-                );
-                subject.getRealm().getDomain().realms().forEach(realmIdentifier -> {
-                    final BoolQueryBuilder perRealmQuery = QueryBuilders.boolQuery()
-                        .filter(QueryBuilders.termQuery("user_profile.user.realm.type", realmIdentifier.getType()));
-                    if (false == isFileOrNativeRealm(realmIdentifier.getType())) {
-                        perRealmQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.name", realmIdentifier.getName()));
-                    }
-                    boolQuery.should(perRealmQuery);
-                });
-                boolQuery.minimumShouldMatch(1);
+                assert false : "a single subject must have either a single result or error";
+                listener.onFailure(new ElasticsearchException("a single subject must have either a single result or error"));
             }
 
-            final SearchRequest searchRequest = client.prepareSearch(SECURITY_PROFILE_ALIAS).setQuery(boolQuery).request();
-            frozenProfileIndex.checkIndexVersionThenExecute(
-                listener::onFailure,
-                () -> executeAsyncWithOrigin(
+        }, listener::onFailure));
+    }
+
+    private void searchVersionedDocumentsForSubjects(
+        List<Subject> subjects,
+        ActionListener<SubjectSearchResultsAndErrors<VersionedDocument>> listener
+    ) {
+        if (subjects.isEmpty()) {
+            listener.onResponse(new SubjectSearchResultsAndErrors<>(List.of(), Map.of()));
+            return;
+        }
+        tryFreezeAndCheckIndex(listener).ifPresent(frozenProfileIndex -> {
+            frozenProfileIndex.checkIndexVersionThenExecute(listener::onFailure, () -> {
+                final MultiSearchRequest multiSearchRequest = new MultiSearchRequest();
+                subjects.forEach(subject -> multiSearchRequest.add(buildSearchRequestForSubject(subject)));
+                executeAsyncWithOrigin(
                     client,
                     getActionOrigin(),
-                    SearchAction.INSTANCE,
-                    searchRequest,
-                    ActionListener.wrap(searchResponse -> {
-                        final SearchHits searchHits = searchResponse.getHits();
-                        final SearchHit[] hits = searchHits.getHits();
-                        if (hits.length < 1) {
-                            logger.debug(
-                                "profile does not exist for username [{}] and realm name [{}]",
-                                subject.getUser().principal(),
-                                subject.getRealm().getName()
-                            );
-                            listener.onResponse(null);
-                        } else if (hits.length == 1) {
-                            final SearchHit hit = hits[0];
-                            final ProfileDocument profileDocument = buildProfileDocument(hit.getSourceRef());
-                            if (subject.canAccessResourcesOf(profileDocument.user().toSubject())) {
-                                listener.onResponse(new VersionedDocument(profileDocument, hit.getPrimaryTerm(), hit.getSeqNo()));
-                            } else {
-                                final String errorMessage = org.elasticsearch.core.Strings.format(
+                    MultiSearchAction.INSTANCE,
+                    multiSearchRequest,
+                    ActionListener.wrap(
+                        multiSearchResponse -> listener.onResponse(convertSubjectMultiSearchResponse(multiSearchResponse, subjects)),
+                        listener::onFailure
+                    )
+                );
+            });
+        });
+    }
+
+    private static SubjectSearchResultsAndErrors<VersionedDocument> convertSubjectMultiSearchResponse(
+        MultiSearchResponse multiSearchResponse,
+        List<Subject> subjects
+    ) throws IOException {
+        final MultiSearchResponse.Item[] items = multiSearchResponse.getResponses();
+        assert items.length == subjects.size() : "size of responses does not match size of subjects";
+        final List<Tuple<Subject, VersionedDocument>> versionedDocs = new ArrayList<>(items.length);
+        final Map<Subject, Exception> errors = new HashMap<>();
+        for (int i = 0; i < items.length; i++) {
+            final MultiSearchResponse.Item item = items[i];
+            final Subject subject = subjects.get(i);
+            if (item.isFailure()) {
+                errors.put(subject, item.getFailure());
+            } else {
+                final SearchHits searchHits = item.getResponse().getHits();
+                final SearchHit[] hits = searchHits.getHits();
+                if (hits.length < 1) {
+                    logger.debug(
+                        "profile does not exist for username [{}] and realm name [{}]",
+                        subject.getUser().principal(),
+                        subject.getRealm().getName()
+                    );
+                    versionedDocs.add(new Tuple<>(subject, null));
+                } else if (hits.length == 1) {
+                    final SearchHit hit = hits[0];
+                    final ProfileDocument profileDocument = buildProfileDocument(hit.getSourceRef());
+                    if (subject.canAccessResourcesOf(profileDocument.user().toSubject())) {
+                        versionedDocs.add(
+                            new Tuple<>(subject, new VersionedDocument(profileDocument, hit.getPrimaryTerm(), hit.getSeqNo()))
+                        );
+                    } else {
+                        assert false : "this should not happen";
+                        errors.put(
+                            subject,
+                            new ElasticsearchException(
+                                format(
                                     "profile [%s] matches search criteria but is not accessible to "
                                         + "the current subject with username [%s] and realm name [%s]",
                                     profileDocument.uid(),
                                     subject.getUser().principal(),
                                     subject.getRealm().getName()
-                                );
-                                logger.error(errorMessage);
-                                assert false : "this should not happen";
-                                listener.onFailure(new ElasticsearchException(errorMessage));
-                            }
-                        } else {
-                            final String errorMessage = org.elasticsearch.core.Strings.format(
+                                )
+                            )
+                        );
+                    }
+                } else {
+                    errors.put(
+                        subject,
+                        new ElasticsearchException(
+                            format(
                                 "multiple [%s] profiles [%s] found for user [%s] from realm [%s]%s",
                                 hits.length,
                                 Arrays.stream(hits)
@@ -494,14 +548,43 @@ public class ProfileService {
                                 subject.getRealm().getDomain() == null
                                     ? ""
                                     : (" under domain [" + subject.getRealm().getDomain().name() + "]")
-                            );
-                            logger.error(errorMessage);
-                            listener.onFailure(new ElasticsearchException(errorMessage));
-                        }
-                    }, listener::onFailure)
+                            )
+                        )
+                    );
+                }
+            }
+        }
+        return new SubjectSearchResultsAndErrors<>(versionedDocs, errors);
+    }
+
+    private SearchRequest buildSearchRequestForSubject(Subject subject) {
+        final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
+            .filter(QueryBuilders.termQuery("user_profile.user.username.keyword", subject.getUser().principal()));
+        if (subject.getRealm().getDomain() == null) {
+            boolQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.type", subject.getRealm().getType()));
+            if (false == isFileOrNativeRealm(subject.getRealm().getType())) {
+                boolQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.name", subject.getRealm().getName()));
+            }
+        } else {
+            logger.debug(
+                () -> format(
+                    "searching existing profile document for user [%s] from any of the realms [%s] under domain [%s]",
+                    subject.getUser().principal(),
+                    collectionToCommaDelimitedString(subject.getRealm().getDomain().realms()),
+                    subject.getRealm().getDomain().name()
                 )
             );
-        });
+            subject.getRealm().getDomain().realms().forEach(realmIdentifier -> {
+                final BoolQueryBuilder perRealmQuery = QueryBuilders.boolQuery()
+                    .filter(QueryBuilders.termQuery("user_profile.user.realm.type", realmIdentifier.getType()));
+                if (false == isFileOrNativeRealm(realmIdentifier.getType())) {
+                    perRealmQuery.filter(QueryBuilders.termQuery("user_profile.user.realm.name", realmIdentifier.getName()));
+                }
+                boolQuery.should(perRealmQuery);
+            });
+            boolQuery.minimumShouldMatch(1);
+        }
+        return client.prepareSearch(SECURITY_PROFILE_ALIAS).setQuery(boolQuery).request();
     }
 
     private static final Pattern VALID_LITERAL_USERNAME = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9-]{0,255}$");
@@ -858,4 +941,6 @@ public class ProfileService {
         }
 
     }
+
+    public record SubjectSearchResultsAndErrors<T> (List<Tuple<Subject, T>> results, Map<Subject, Exception> errors) {}
 }

+ 18 - 19
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/user/RestGetUsersAction.java

@@ -15,10 +15,10 @@ import org.elasticsearch.rest.RestRequest;
 import org.elasticsearch.rest.RestResponse;
 import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.rest.action.RestBuilderListener;
+import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersRequestBuilder;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersResponse;
-import org.elasticsearch.xpack.core.security.user.User;
 import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
 
 import java.io.IOException;
@@ -51,26 +51,25 @@ public class RestGetUsersAction extends SecurityBaseRestHandler {
     @Override
     public RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
         String[] usernames = request.paramAsStringArray("username", Strings.EMPTY_ARRAY);
+        final boolean withProfileUid = request.paramAsBoolean("with_profile_uid", false);
 
-        return channel -> new GetUsersRequestBuilder(client).usernames(usernames).execute(new RestBuilderListener<>(channel) {
-            @Override
-            public RestResponse buildResponse(GetUsersResponse response, XContentBuilder builder) throws Exception {
-                builder.startObject();
-                for (User user : response.users()) {
-                    builder.field(user.principal(), user);
-                }
-                builder.endObject();
+        return channel -> new GetUsersRequestBuilder(client).usernames(usernames)
+            .withProfileUid(withProfileUid)
+            .execute(new RestBuilderListener<>(channel) {
+                @Override
+                public RestResponse buildResponse(GetUsersResponse response, XContentBuilder builder) throws Exception {
+                    response.toXContent(builder, ToXContent.EMPTY_PARAMS);
 
-                // if the user asked for specific users, but none of them were found
-                // we'll return an empty result and 404 status code
-                if (usernames.length != 0 && response.users().length == 0) {
-                    return new RestResponse(RestStatus.NOT_FOUND, builder);
-                }
+                    // if the user asked for specific users, but none of them were found
+                    // we'll return an empty result and 404 status code
+                    if (usernames.length != 0 && response.users().length == 0) {
+                        return new RestResponse(RestStatus.NOT_FOUND, builder);
+                    }
 
-                // either the user asked for all users, or at least one of the users
-                // was found
-                return new RestResponse(RestStatus.OK, builder);
-            }
-        });
+                    // either the user asked for all users, or at least one of the users
+                    // was found
+                    return new RestResponse(RestStatus.OK, builder);
+                }
+            });
     }
 }

+ 279 - 6
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/user/TransportGetUsersActionTests.java

@@ -6,28 +6,40 @@
  */
 package org.elasticsearch.xpack.security.action.user;
 
+import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.ElasticsearchStatusException;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.support.ActionFilters;
 import org.elasticsearch.action.support.PlainActionFuture;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.ValidationException;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.env.Environment;
+import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.tasks.Task;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.threadpool.TestThreadPool;
 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.GetUsersRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUsersResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
+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.AnonymousUser;
 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.authc.esnative.ReservedRealm;
 import org.elasticsearch.xpack.security.authc.esnative.ReservedRealmTests;
+import org.elasticsearch.xpack.security.profile.ProfileService;
+import org.elasticsearch.xpack.security.profile.ProfileService.SubjectSearchResultsAndErrors;
 import org.elasticsearch.xpack.security.support.SecurityIndexManager;
 import org.junit.After;
 import org.junit.Before;
@@ -37,17 +49,24 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
 import static org.elasticsearch.test.ActionListenerUtils.anyActionListener;
 import static org.hamcrest.Matchers.arrayContaining;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.emptyArray;
+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.hamcrest.Matchers.sameInstance;
 import static org.mockito.AdditionalMatchers.aryEq;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
@@ -61,6 +80,9 @@ public class TransportGetUsersActionTests extends ESTestCase {
     private boolean anonymousEnabled;
     private Settings settings;
     private ThreadPool threadPool;
+    private boolean hasAnonymousProfile;
+    private boolean hasReservedProfile;
+    private boolean hasNativeProfile;
 
     @Before
     public void maybeEnableAnonymous() {
@@ -71,6 +93,9 @@ public class TransportGetUsersActionTests extends ESTestCase {
             settings = Settings.EMPTY;
         }
         threadPool = new TestThreadPool("TransportGetUsersActionTests");
+        hasAnonymousProfile = randomBoolean();
+        hasReservedProfile = randomBoolean();
+        hasNativeProfile = randomBoolean();
     }
 
     @After
@@ -86,6 +111,12 @@ public class TransportGetUsersActionTests extends ESTestCase {
         when(securityIndex.isAvailable()).thenReturn(true);
         AnonymousUser anonymousUser = new AnonymousUser(settings);
         ReservedRealm reservedRealm = new ReservedRealm(mock(Environment.class), settings, usersStore, anonymousUser, threadPool);
+        reservedRealm.initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(ReservedRealm.TYPE, ReservedRealm.NAME),
+                new Authentication.RealmRef(ReservedRealm.NAME, ReservedRealm.TYPE, "node")
+            )
+        );
         TransportService transportService = new TransportService(
             Settings.EMPTY,
             mock(Transport.class),
@@ -100,11 +131,15 @@ public class TransportGetUsersActionTests extends ESTestCase {
             mock(ActionFilters.class),
             usersStore,
             transportService,
-            reservedRealm
+            reservedRealm,
+            mockRealms(),
+            mockProfileService()
         );
 
         GetUsersRequest request = new GetUsersRequest();
         request.usernames(anonymousUser.principal());
+        final boolean withProfileUid = randomBoolean();
+        request.setWithProfileUid(withProfileUid);
 
         final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
         final AtomicReference<GetUsersResponse> responseRef = new AtomicReference<>();
@@ -128,6 +163,18 @@ public class TransportGetUsersActionTests extends ESTestCase {
         } else {
             assertThat("expected an empty array but got: " + Arrays.toString(users), users, emptyArray());
         }
+        if (withProfileUid) {
+            assertThat(
+                responseRef.get().getProfileUidLookup(),
+                equalTo(
+                    Arrays.stream(users)
+                        .filter(user -> hasAnonymousProfile)
+                        .collect(Collectors.toUnmodifiableMap(User::principal, user -> "u_profile_" + user.principal()))
+                )
+            );
+        } else {
+            assertThat(responseRef.get().getProfileUidLookup(), nullValue());
+        }
         verifyNoMoreInteractions(usersStore);
     }
 
@@ -144,6 +191,12 @@ public class TransportGetUsersActionTests extends ESTestCase {
             new AnonymousUser(settings),
             threadPool
         );
+        reservedRealm.initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(ReservedRealm.TYPE, ReservedRealm.NAME),
+                new Authentication.RealmRef(ReservedRealm.NAME, ReservedRealm.TYPE, "node")
+            )
+        );
         PlainActionFuture<Collection<User>> userFuture = new PlainActionFuture<>();
         reservedRealm.users(userFuture);
         final Collection<User> allReservedUsers = userFuture.actionGet();
@@ -164,12 +217,16 @@ public class TransportGetUsersActionTests extends ESTestCase {
             mock(ActionFilters.class),
             usersStore,
             transportService,
-            reservedRealm
+            reservedRealm,
+            mockRealms(),
+            mockProfileService()
         );
 
         logger.error("names {}", names);
         GetUsersRequest request = new GetUsersRequest();
         request.usernames(names.toArray(new String[names.size()]));
+        final boolean withProfileUid = randomBoolean();
+        request.setWithProfileUid(withProfileUid);
 
         final AtomicReference<Throwable> throwableRef = new AtomicReference<>();
         final AtomicReference<GetUsersResponse> responseRef = new AtomicReference<>();
@@ -191,6 +248,17 @@ public class TransportGetUsersActionTests extends ESTestCase {
         assertThat(throwableRef.get(), is(nullValue()));
         assertThat(responseRef.get(), is(notNullValue()));
         assertThat(users, arrayContaining(reservedUsers.toArray(new User[reservedUsers.size()])));
+        if (withProfileUid) {
+            assertThat(responseRef.get().getProfileUidLookup(), equalTo(reservedUsers.stream().filter(user -> {
+                if (user instanceof AnonymousUser) {
+                    return hasAnonymousProfile;
+                } else {
+                    return hasReservedProfile;
+                }
+            }).collect(Collectors.toUnmodifiableMap(User::principal, user -> "u_profile_" + user.principal()))));
+        } else {
+            assertThat(responseRef.get().getProfileUidLookup(), nullValue());
+        }
     }
 
     public void testGetAllUsers() {
@@ -211,6 +279,12 @@ public class TransportGetUsersActionTests extends ESTestCase {
             new AnonymousUser(settings),
             threadPool
         );
+        reservedRealm.initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(ReservedRealm.TYPE, ReservedRealm.NAME),
+                new Authentication.RealmRef(ReservedRealm.NAME, ReservedRealm.TYPE, "node")
+            )
+        );
         TransportService transportService = new TransportService(
             Settings.EMPTY,
             mock(Transport.class),
@@ -225,10 +299,14 @@ public class TransportGetUsersActionTests extends ESTestCase {
             mock(ActionFilters.class),
             usersStore,
             transportService,
-            reservedRealm
+            reservedRealm,
+            mockRealms(),
+            mockProfileService()
         );
 
         GetUsersRequest request = new GetUsersRequest();
+        final boolean withProfileUid = randomBoolean();
+        request.setWithProfileUid(withProfileUid);
         doAnswer(invocation -> {
             Object[] args = invocation.getArguments();
             assert args.length == 2;
@@ -255,12 +333,26 @@ public class TransportGetUsersActionTests extends ESTestCase {
         final List<User> expectedList = new ArrayList<>();
         PlainActionFuture<Collection<User>> userFuture = new PlainActionFuture<>();
         reservedRealm.users(userFuture);
-        expectedList.addAll(userFuture.actionGet());
+        final Collection<User> reservedUsers = userFuture.actionGet();
+        expectedList.addAll(reservedUsers);
         expectedList.addAll(storeUsers);
 
         assertThat(throwableRef.get(), is(nullValue()));
         assertThat(responseRef.get(), is(notNullValue()));
         assertThat(responseRef.get().users(), arrayContaining(expectedList.toArray(new User[expectedList.size()])));
+        if (withProfileUid) {
+            assertThat(responseRef.get().getProfileUidLookup(), equalTo(expectedList.stream().filter(user -> {
+                if (user instanceof AnonymousUser) {
+                    return hasAnonymousProfile;
+                } else if (reservedUsers.contains(user)) {
+                    return hasReservedProfile;
+                } else {
+                    return hasNativeProfile;
+                }
+            }).collect(Collectors.toUnmodifiableMap(User::principal, user -> "u_profile_" + user.principal()))));
+        } else {
+            assertThat(responseRef.get().getProfileUidLookup(), nullValue());
+        }
         verify(usersStore, times(1)).getUsers(aryEq(Strings.EMPTY_ARRAY), anyActionListener());
     }
 
@@ -274,9 +366,84 @@ public class TransportGetUsersActionTests extends ESTestCase {
         testGetStoreOnlyUsers(randomUsersWithInternalUsernames());
     }
 
+    public void testGetUsersWithProfileUidException() {
+        final List<User> storeUsers = randomFrom(
+            Collections.<User>emptyList(),
+            Collections.singletonList(new User("joe")),
+            Arrays.asList(new User("jane"), new User("fred")),
+            randomUsers()
+        );
+        NativeUsersStore usersStore = mock(NativeUsersStore.class);
+        SecurityIndexManager securityIndex = mock(SecurityIndexManager.class);
+        when(securityIndex.isAvailable()).thenReturn(true);
+        ReservedRealmTests.mockGetAllReservedUserInfo(usersStore, Collections.emptyMap());
+        ReservedRealm reservedRealm = new ReservedRealm(
+            mock(Environment.class),
+            settings,
+            usersStore,
+            new AnonymousUser(settings),
+            threadPool
+        );
+        reservedRealm.initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(ReservedRealm.TYPE, ReservedRealm.NAME),
+                new Authentication.RealmRef(ReservedRealm.NAME, ReservedRealm.TYPE, "node")
+            )
+        );
+        TransportService transportService = new TransportService(
+            Settings.EMPTY,
+            mock(Transport.class),
+            mock(ThreadPool.class),
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet()
+        );
+        TransportGetUsersAction action = new TransportGetUsersAction(
+            Settings.EMPTY,
+            mock(ActionFilters.class),
+            usersStore,
+            transportService,
+            reservedRealm,
+            mockRealms(),
+            mockProfileService(true)
+        );
+
+        GetUsersRequest request = new GetUsersRequest();
+        request.setWithProfileUid(true);
+        doAnswer(invocation -> {
+            Object[] args = invocation.getArguments();
+            assert args.length == 2;
+            @SuppressWarnings("unchecked")
+            ActionListener<List<User>> listener = (ActionListener<List<User>>) args[1];
+            listener.onResponse(storeUsers);
+            return null;
+        }).when(usersStore).getUsers(eq(Strings.EMPTY_ARRAY), anyActionListener());
+
+        final PlainActionFuture<GetUsersResponse> 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 void testGetStoreOnlyUsers(List<User> storeUsers) {
         final String[] storeUsernames = storeUsers.stream().map(User::principal).collect(Collectors.toList()).toArray(Strings.EMPTY_ARRAY);
         NativeUsersStore usersStore = mock(NativeUsersStore.class);
+        AnonymousUser anonymousUser = new AnonymousUser(settings);
+        ReservedRealm reservedRealm = new ReservedRealm(mock(Environment.class), settings, usersStore, anonymousUser, threadPool);
+        reservedRealm.initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(ReservedRealm.TYPE, ReservedRealm.NAME),
+                new Authentication.RealmRef(ReservedRealm.NAME, ReservedRealm.TYPE, "node")
+            )
+        );
         TransportService transportService = new TransportService(
             Settings.EMPTY,
             mock(Transport.class),
@@ -291,11 +458,15 @@ public class TransportGetUsersActionTests extends ESTestCase {
             mock(ActionFilters.class),
             usersStore,
             transportService,
-            mock(ReservedRealm.class)
+            reservedRealm,
+            mockRealms(),
+            mockProfileService()
         );
 
         GetUsersRequest request = new GetUsersRequest();
         request.usernames(storeUsernames);
+        final boolean withProfileUid = randomBoolean();
+        request.setWithProfileUid(withProfileUid);
         doAnswer(invocation -> {
             Object[] args = invocation.getArguments();
             assert args.length == 2;
@@ -325,6 +496,18 @@ public class TransportGetUsersActionTests extends ESTestCase {
         assertThat(throwableRef.get(), is(nullValue()));
         assertThat(responseRef.get(), is(notNullValue()));
         assertThat(responseRef.get().users(), arrayContaining(expectedList.toArray(new User[expectedList.size()])));
+        if (withProfileUid) {
+            assertThat(
+                responseRef.get().getProfileUidLookup(),
+                equalTo(
+                    expectedList.stream()
+                        .filter(user -> hasNativeProfile)
+                        .collect(Collectors.toUnmodifiableMap(User::principal, user -> "u_profile_" + user.principal()))
+                )
+            );
+        } else {
+            assertThat(responseRef.get().getProfileUidLookup(), nullValue());
+        }
         if (storeUsers.size() > 1) {
             verify(usersStore, times(1)).getUsers(aryEq(storeUsernames), anyActionListener());
         } else {
@@ -355,7 +538,9 @@ public class TransportGetUsersActionTests extends ESTestCase {
             mock(ActionFilters.class),
             usersStore,
             transportService,
-            mock(ReservedRealm.class)
+            mock(ReservedRealm.class),
+            mockRealms(),
+            mockProfileService()
         );
 
         GetUsersRequest request = new GetUsersRequest();
@@ -401,4 +586,92 @@ public class TransportGetUsersActionTests extends ESTestCase {
     private List<User> randomUsersWithInternalUsernames() {
         return AuthenticationTestHelper.randomInternalUsernames().stream().map(User::new).collect(Collectors.toList());
     }
+
+    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 ProfileService mockProfileService() {
+        return mockProfileService(false);
+    }
+
+    @SuppressWarnings("unchecked")
+    private ProfileService mockProfileService(boolean randomException) {
+        final ProfileService profileService = mock(ProfileService.class);
+        doAnswer(invocation -> {
+            final List<Subject> subjects = (List<Subject>) invocation.getArguments()[0];
+            final var listener = (ActionListener<SubjectSearchResultsAndErrors<Profile>>) invocation.getArguments()[1];
+            List<Tuple<Subject, Profile>> results = subjects.stream().map(subject -> {
+                final User user = subject.getUser();
+                if (user instanceof AnonymousUser) {
+                    if (hasAnonymousProfile) {
+                        return new Tuple<>(subject, profileFromSubject(subject));
+                    } else {
+                        return new Tuple<>(subject, (Profile) null);
+                    }
+                } else if ("reserved".equals(subject.getRealm().getType())) {
+                    if (hasReservedProfile) {
+                        return new Tuple<>(subject, profileFromSubject(subject));
+                    } else {
+                        return new Tuple<>(subject, (Profile) null);
+                    }
+                } else {
+                    if (hasNativeProfile) {
+                        return new Tuple<>(subject, profileFromSubject(subject));
+                    } else {
+                        return new Tuple<>(subject, (Profile) null);
+                    }
+                }
+            }).toList();
+            if (randomException) {
+                assertThat("random exception requires non-empty results", results, not(empty()));
+            }
+            final Map<Subject, Exception> errors;
+            if (randomException) {
+                final int exceptionSize = randomIntBetween(1, results.size());
+                errors = results.subList(0, exceptionSize)
+                    .stream()
+                    .collect(Collectors.toUnmodifiableMap(Tuple::v1, t -> new ElasticsearchException("something is not right")));
+                results = results.subList(exceptionSize - 1, results.size());
+            } else {
+                errors = Map.of();
+            }
+            listener.onResponse(new SubjectSearchResultsAndErrors<>(results, errors));
+            return null;
+        }).when(profileService).searchProfilesForSubjects(anyList(), anyActionListener());
+        return profileService;
+    }
+
+    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())
+        );
+    }
 }

+ 119 - 6
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/RealmsTests.java

@@ -70,6 +70,7 @@ import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import static org.hamcrest.Matchers.aMapWithSize;
 import static org.hamcrest.Matchers.arrayWithSize;
 import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
@@ -84,6 +85,7 @@ import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.iterableWithSize;
 import static org.hamcrest.Matchers.notNullValue;
 import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.oneOf;
 import static org.hamcrest.Matchers.sameInstance;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doAnswer;
@@ -182,7 +184,8 @@ public class RealmsTests extends ESTestCase {
     }
 
     public void testWithSettings() throws Exception {
-        Settings.Builder builder = Settings.builder().put("path.home", createTempDir());
+        final String nodeName = randomAlphaOfLengthBetween(3, 8);
+        Settings.Builder builder = Settings.builder().put("path.home", createTempDir()).put(Node.NODE_NAME_SETTING.getKey(), nodeName);
         List<Integer> orders = new ArrayList<>(randomRealmTypesCount);
         for (int i = 0; i < randomRealmTypesCount; i++) {
             orders.add(i);
@@ -231,6 +234,39 @@ public class RealmsTests extends ESTestCase {
 
         assertThat(realms.getUnlicensedRealms(), empty());
         assertThat(realms.getUnlicensedRealms(), sameInstance(realms.getUnlicensedRealms()));
+
+        // realmRefs contains all implicitly and explicitly configured realm (disabled or not)
+        final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs = realms.getRealmRefs();
+        realms.forEach(
+            activeRealm -> assertThat(
+                activeRealm.realmRef(),
+                equalTo(realmRefs.get(new RealmConfig.RealmIdentifier(activeRealm.type(), activeRealm.name())))
+            )
+        );
+        // reserved, file, native and custom realms
+        assertThat(realmRefs, aMapWithSize(3 + randomRealmTypesCount));
+        assertThat(
+            realmRefs,
+            hasEntry(new RealmConfig.RealmIdentifier("reserved", "reserved"), buildRealmRef("reserved", "reserved", nodeName))
+        );
+        assertThat(
+            realmRefs,
+            hasEntry(new RealmConfig.RealmIdentifier("file", "default_file"), buildRealmRef("default_file", "file", nodeName))
+        );
+        assertThat(
+            realmRefs,
+            hasEntry(new RealmConfig.RealmIdentifier("native", "default_native"), buildRealmRef("default_native", "native", nodeName))
+        );
+        IntStream.range(0, randomRealmTypesCount)
+            .forEach(
+                index -> assertThat(
+                    realmRefs,
+                    hasEntry(
+                        new RealmConfig.RealmIdentifier("type_" + index, "realm_" + index),
+                        buildRealmRef("realm_" + index, "type_" + index, nodeName)
+                    )
+                )
+            );
     }
 
     public void testDomainAssignment() throws Exception {
@@ -361,17 +397,72 @@ public class RealmsTests extends ESTestCase {
                 assertThat(realms.getDomainConfig(name), equalTo(config));
             }
         });
+
+        // realmRefs contains all implicitly and explicitly configured realm (disabled or not)
+        final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs = realms.getRealmRefs();
+
+        // reserved, file, native and custom realms
+        assertThat(realmRefs, aMapWithSize(3 + randomRealmTypesCount));
+        realms.forEach(
+            activeRealm -> assertThat(
+                activeRealm.realmRef(),
+                equalTo(realmRefs.get(new RealmConfig.RealmIdentifier(activeRealm.type(), activeRealm.name())))
+            )
+        );
+        assertThat(
+            realmRefs,
+            hasEntry(
+                new RealmConfig.RealmIdentifier("reserved", "reserved"),
+                buildRealmRef("reserved", "reserved", nodeName, realmsForDomain)
+            )
+        );
+        assertThat(
+            realmRefs,
+            hasEntry(
+                new RealmConfig.RealmIdentifier("file", "default_file"),
+                buildRealmRef("default_file", "file", nodeName, realmsForDomain)
+            )
+        );
+        assertThat(
+            realmRefs,
+            hasEntry(
+                new RealmConfig.RealmIdentifier("native", "default_native"),
+                buildRealmRef("default_native", "native", nodeName, realmsForDomain)
+            )
+        );
+        IntStream.range(0, randomRealmTypesCount)
+            .forEach(
+                index -> assertThat(
+                    realmRefs,
+                    hasEntry(
+                        new RealmConfig.RealmIdentifier("type_" + index, "realm_" + index),
+                        buildRealmRef("realm_" + index, "type_" + index, nodeName, realmsForDomain)
+                    )
+                )
+            );
     }
 
     private void assertDomainForRealm(Realm realm, String nodeName, Map<String, Set<RealmConfig.RealmIdentifier>> realmsByDomainName) {
+        assertThat(realm.realmRef(), equalTo(buildRealmRef(realm.name(), realm.type(), nodeName, realmsByDomainName)));
+    }
+
+    private Authentication.RealmRef buildRealmRef(String realmName, String realmType, String nodeName) {
+        return buildRealmRef(realmName, realmType, nodeName, Map.of());
+    }
+
+    private Authentication.RealmRef buildRealmRef(
+        String realmName,
+        String realmType,
+        String nodeName,
+        Map<String, Set<RealmConfig.RealmIdentifier>> realmsByDomainName
+    ) {
         for (var domainRealmIds : realmsByDomainName.entrySet()) {
-            if (domainRealmIds.getValue().contains(new RealmConfig.RealmIdentifier(realm.type(), realm.name()))) {
+            if (domainRealmIds.getValue().contains(new RealmConfig.RealmIdentifier(realmType, realmName))) {
                 RealmDomain domain = new RealmDomain(domainRealmIds.getKey(), domainRealmIds.getValue());
-                assertThat(realm.realmRef(), is(new Authentication.RealmRef(realm.name(), realm.type(), nodeName, domain)));
-                return;
+                return new Authentication.RealmRef(realmName, realmType, nodeName, domain);
             }
         }
-        assertThat(realm.realmRef(), is(new Authentication.RealmRef(realm.name(), realm.type(), nodeName, null)));
+        return new Authentication.RealmRef(realmName, realmType, nodeName);
     }
 
     public void testRealmAssignedToMultipleDomainsNotPermitted() {
@@ -516,6 +607,23 @@ public class RealmsTests extends ESTestCase {
 
         assertThat(realms.getUnlicensedRealms(), empty());
         assertThat(realms.getUnlicensedRealms(), sameInstance(realms.getUnlicensedRealms()));
+
+        // realmRefs contains all implicitly and explicitly configured realm (disabled or not)
+        final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs = realms.getRealmRefs();
+        realms.forEach(
+            activeRealm -> assertThat(
+                activeRealm.realmRef(),
+                equalTo(realmRefs.get(new RealmConfig.RealmIdentifier(activeRealm.type(), activeRealm.name())))
+            )
+        );
+        // reserved, file, native
+        assertThat(realmRefs.size(), equalTo(3));
+        assertThat(realmRefs, hasEntry(new RealmConfig.RealmIdentifier("reserved", "reserved"), buildRealmRef("reserved", "reserved", "")));
+        assertThat(realmRefs, hasEntry(new RealmConfig.RealmIdentifier("file", "default_file"), buildRealmRef("default_file", "file", "")));
+        assertThat(
+            realmRefs,
+            hasEntry(new RealmConfig.RealmIdentifier("native", "default_native"), buildRealmRef("default_native", "native", ""))
+        );
     }
 
     public void testFeatureTrackingWithMultipleRealms() throws Exception {
@@ -569,6 +677,8 @@ public class RealmsTests extends ESTestCase {
         final Realms realms = new Realms(settings, env, factories, licenseState, threadContext, reservedRealm);
         assertThat(realms.getUnlicensedRealms(), empty());
         assertThat(realms.getActiveRealms(), hasSize(9)); // 0..7 configured + reserved
+        final Map<RealmConfig.RealmIdentifier, Authentication.RealmRef> realmRefs = Map.copyOf(realms.getRealmRefs());
+        assertThat(realmRefs, aMapWithSize(9));
 
         verify(licenseState).enableUsageTracking(Security.KERBEROS_REALM_FEATURE, "kerberos_realm");
         verify(licenseState).enableUsageTracking(Security.LDAP_REALM_FEATURE, "ldap_realm_1");
@@ -604,6 +714,8 @@ public class RealmsTests extends ESTestCase {
         final List<String> unlicensedRealmNames = realms.getUnlicensedRealms().stream().map(r -> r.name()).collect(Collectors.toList());
         assertThat(unlicensedRealmNames, containsInAnyOrder("kerberos_realm", "custom_realm_1", "custom_realm_2"));
         assertThat(realms.getActiveRealms(), hasSize(6)); // 9 - 3
+        // no change to realm refs
+        assertThat(realms.getRealmRefs(), equalTo(realmRefs));
 
         verify(licenseState).disableUsageTracking(Security.KERBEROS_REALM_FEATURE, "kerberos_realm");
         verify(licenseState).disableUsageTracking(Security.CUSTOM_REALMS_FEATURE, "custom_realm_1");
@@ -1210,9 +1322,10 @@ public class RealmsTests extends ESTestCase {
     }
 
     private boolean randomDisableRealm(Settings.Builder builder, String type) {
+        assertThat(type, oneOf("file", "native"));
         final boolean disabled = randomBoolean();
         if (disabled) {
-            builder.put("xpack.security.authc.realms." + type + ".native.enabled", false);
+            builder.put("xpack.security.authc.realms." + type + ".default_" + type + ".enabled", false);
         }
         return disabled;
     }

+ 8 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/DummyUsernamePasswordRealm.java

@@ -10,6 +10,8 @@ package org.elasticsearch.xpack.security.authc.support;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.core.Tuple;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
 import org.elasticsearch.xpack.core.security.authc.RealmConfig;
@@ -25,7 +27,12 @@ public class DummyUsernamePasswordRealm extends UsernamePasswordRealm {
 
     public DummyUsernamePasswordRealm(RealmConfig config) {
         super(config);
-        initRealmRef(null);
+        initRealmRef(
+            Map.of(
+                new RealmConfig.RealmIdentifier(config.type(), config.name()),
+                new Authentication.RealmRef(config.name(), config.type(), Node.NODE_NAME_SETTING.get(config.settings()))
+            )
+        );
         this.users = new HashMap<>();
     }
 

+ 13 - 4
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/profile/ProfileServiceTests.java

@@ -20,6 +20,9 @@ import org.elasticsearch.action.get.MultiGetRequest;
 import org.elasticsearch.action.get.MultiGetResponse;
 import org.elasticsearch.action.index.IndexAction;
 import org.elasticsearch.action.index.IndexRequestBuilder;
+import org.elasticsearch.action.search.MultiSearchAction;
+import org.elasticsearch.action.search.MultiSearchRequest;
+import org.elasticsearch.action.search.MultiSearchResponse;
 import org.elasticsearch.action.search.SearchAction;
 import org.elasticsearch.action.search.SearchRequest;
 import org.elasticsearch.action.search.SearchRequestBuilder;
@@ -494,7 +497,7 @@ public class ProfileServiceTests extends ESTestCase {
         final SuggestProfilesRequest suggestProfilesRequest = new SuggestProfilesRequest(Set.of(), name, size, hint);
         final TaskId parentTaskId = new TaskId(randomAlphaOfLength(20), randomNonNegativeLong());
 
-        final SearchRequest searchRequest = profileService.buildSearchRequest(suggestProfilesRequest, parentTaskId);
+        final SearchRequest searchRequest = profileService.buildSearchRequestForSuggest(suggestProfilesRequest, parentTaskId);
         assertThat(searchRequest.getParentTask(), is(parentTaskId));
 
         final SearchSourceBuilder searchSourceBuilder = searchRequest.source();
@@ -557,10 +560,16 @@ public class ProfileServiceTests extends ESTestCase {
                 equalTo(minNodeVersion.onOrAfter(Version.V_8_3_0) ? SECURITY_PROFILE_ORIGIN : SECURITY_ORIGIN)
             );
             @SuppressWarnings("unchecked")
-            final ActionListener<SearchResponse> listener = (ActionListener<SearchResponse>) invocation.getArguments()[2];
-            listener.onResponse(SearchResponse.empty(() -> 1L, SearchResponse.Clusters.EMPTY));
+            final ActionListener<MultiSearchResponse> listener = (ActionListener<MultiSearchResponse>) invocation.getArguments()[2];
+            listener.onResponse(
+                new MultiSearchResponse(
+                    new MultiSearchResponse.Item[] {
+                        new MultiSearchResponse.Item(SearchResponse.empty(() -> 1L, SearchResponse.Clusters.EMPTY), null) },
+                    1L
+                )
+            );
             return null;
-        }).when(client).execute(eq(SearchAction.INSTANCE), any(SearchRequest.class), anyActionListener());
+        }).when(client).execute(eq(MultiSearchAction.INSTANCE), any(MultiSearchRequest.class), anyActionListener());
 
         when(client.prepareIndex(SECURITY_PROFILE_ALIAS)).thenReturn(
             new IndexRequestBuilder(client, IndexAction.INSTANCE, SECURITY_PROFILE_ALIAS)

+ 9 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/user_profile/10_basic.yml

@@ -95,6 +95,15 @@ teardown:
   - match: { $errors.count : 1 }
   - match: { $errors.details.does_not_exist.type: "resource_not_found_exception" }
 
+  # Get users with profile uid
+  - do:
+      security.get_user:
+        username: [ 'joe', 'doe' ]
+        with_profile_uid: true
+
+  - match: { "joe.profile_uid": "$profile_uid_1" }
+  - match: { "doe.profile_uid": "$profile_uid_2" }
+
 
 ---
 "Test user profile apis":