Browse Source

User Profile - Add update profile data API (#82772)

Add a new API to update both the "access" and "data" sections of the user
profile document.
Note that the two sections are meant to store application specific data.
The special privilege required for namespacing the applications will be
added in a separate PR.
Yang Wang 3 years ago
parent
commit
4b1941fc22

+ 21 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/profile/UpdateProfileDataAction.java

@@ -0,0 +1,21 @@
+/*
+ * 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.profile;
+
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+
+public class UpdateProfileDataAction extends ActionType<AcknowledgedResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/profile/put/data";
+    public static final UpdateProfileDataAction INSTANCE = new UpdateProfileDataAction();
+
+    public UpdateProfileDataAction() {
+        super(NAME, AcknowledgedResponse::readFrom);
+    }
+}

+ 119 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/profile/UpdateProfileDataRequest.java

@@ -0,0 +1,119 @@
+/*
+ * 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.profile;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.StreamInput;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public class UpdateProfileDataRequest extends ActionRequest {
+
+    private final String uid;
+    private final Map<String, Object> access;
+    private final Map<String, Object> data;
+    private final long ifPrimaryTerm;
+    private final long ifSeqNo;
+    private final RefreshPolicy refreshPolicy;
+
+    public UpdateProfileDataRequest(
+        String uid,
+        Map<String, Object> access,
+        Map<String, Object> data,
+        long ifPrimaryTerm,
+        long ifSeqNo,
+        RefreshPolicy refreshPolicy
+    ) {
+        this.uid = Objects.requireNonNull(uid, "profile uid must not be null");
+        this.access = access != null ? access : Map.of();
+        this.data = data != null ? data : Map.of();
+        this.ifPrimaryTerm = ifPrimaryTerm;
+        this.ifSeqNo = ifSeqNo;
+        this.refreshPolicy = refreshPolicy;
+    }
+
+    public UpdateProfileDataRequest(StreamInput in) throws IOException {
+        super(in);
+        this.uid = in.readString();
+        this.access = in.readMap();
+        this.data = in.readMap();
+        this.ifPrimaryTerm = in.readLong();
+        this.ifSeqNo = in.readLong();
+        this.refreshPolicy = RefreshPolicy.readFrom(in);
+    }
+
+    public String getUid() {
+        return uid;
+    }
+
+    public Map<String, Object> getAccess() {
+        return access;
+    }
+
+    public Map<String, Object> getData() {
+        return data;
+    }
+
+    public long getIfPrimaryTerm() {
+        return ifPrimaryTerm;
+    }
+
+    public long getIfSeqNo() {
+        return ifSeqNo;
+    }
+
+    public RefreshPolicy getRefreshPolicy() {
+        return refreshPolicy;
+    }
+
+    public Set<String> applicationNames() {
+        final Set<String> names = new HashSet<>(access.keySet());
+        names.addAll(data.keySet());
+        return Set.copyOf(names);
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        ActionRequestValidationException validationException = null;
+        final Set<String> applicationNames = applicationNames();
+        if (applicationNames.isEmpty()) {
+            validationException = addValidationError("update request is empty", validationException);
+        }
+        final Set<String> namesWithDot = applicationNames.stream()
+            .filter(name -> name.contains("."))
+            .collect(Collectors.toUnmodifiableSet());
+        if (false == namesWithDot.isEmpty()) {
+            validationException = addValidationError(
+                "application name must not contain dot, but found [" + Strings.collectionToCommaDelimitedString(namesWithDot) + "]",
+                validationException
+            );
+        }
+        final Set<String> namesStartsWithUnderscore = applicationNames.stream()
+            .filter(name -> name.startsWith("_"))
+            .collect(Collectors.toUnmodifiableSet());
+        if (false == namesStartsWithUnderscore.isEmpty()) {
+            validationException = addValidationError(
+                "application name must not start with underscore, but found ["
+                    + Strings.collectionToCommaDelimitedString(namesStartsWithUnderscore)
+                    + "]",
+                validationException
+            );
+        }
+        return validationException;
+    }
+}

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

@@ -192,6 +192,7 @@ public class Constants {
         "cluster:admin/xpack/security/privilege/put",
         "cluster:admin/xpack/security/profile/activate",
         "cluster:admin/xpack/security/profile/get",
+        "cluster:admin/xpack/security/profile/put/data",
         "cluster:admin/xpack/security/realm/cache/clear",
         "cluster:admin/xpack/security/role/delete",
         "cluster:admin/xpack/security/role/get",

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

@@ -13,10 +13,12 @@ import org.elasticsearch.client.ResponseException;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.core.Nullable;
 import org.elasticsearch.test.rest.ESRestTestCase;
 
 import java.io.IOException;
 import java.time.Instant;
+import java.util.List;
 import java.util.Map;
 
 import static org.hamcrest.Matchers.anEmptyMap;
@@ -75,24 +77,10 @@ public class ProfileIT extends ESRestTestCase {
     }
 
     public void testActivateProfile() throws IOException {
-        final Request activateProfileRequest = new Request("POST", "_security/profile/_activate");
-        activateProfileRequest.setJsonEntity("""
-            {
-              "grant_type": "password",
-              "username": "rac_user",
-              "password": "x-pack-test-password"
-            }""");
-
-        final Response activateProfileResponse = adminClient().performRequest(activateProfileRequest);
-        assertOK(activateProfileResponse);
-        final Map<String, Object> activateProfileMap = responseAsMap(activateProfileResponse);
+        final Map<String, Object> activateProfileMap = doActivateProfile();
 
         final String profileUid = (String) activateProfileMap.get("uid");
-        final Request getProfileRequest1 = new Request("GET", "_security/profile/" + profileUid);
-        final Response getProfileResponse1 = adminClient().performRequest(getProfileRequest1);
-        assertOK(getProfileResponse1);
-        final Map<String, Object> getProfileMap1 = responseAsMap(getProfileResponse1);
-        final Map<String, Object> profile1 = castToMap(getProfileMap1.get(profileUid));
+        final Map<String, Object> profile1 = doGetProfile(profileUid);
         assertThat(profile1, equalTo(activateProfileMap));
     }
 
@@ -110,29 +98,16 @@ public class ProfileIT extends ESRestTestCase {
         );
         assertOK(adminClient().performRequest(indexRequest));
 
-        final Request getProfileRequest1 = new Request("GET", "_security/profile/" + uid);
-        final Response getProfileResponse1 = adminClient().performRequest(getProfileRequest1);
-        assertOK(getProfileResponse1);
-        final Map<String, Object> getProfileMap1 = responseAsMap(getProfileResponse1);
-        assertThat(getProfileMap1.keySet(), contains(uid));
-        final Map<String, Object> profile1 = castToMap(getProfileMap1.get(uid));
-        assertThat(castToMap(profile1.get("data")), anEmptyMap());
+        final Map<String, Object> profileMap1 = doGetProfile(uid);
+        assertThat(castToMap(profileMap1.get("data")), anEmptyMap());
 
         // Retrieve application data along the profile
-        final Request getProfileRequest2 = new Request("GET", "_security/profile/" + uid);
-        getProfileRequest2.addParameter("data", "app1");
-        final Map<String, Object> getProfileMap2 = responseAsMap(adminClient().performRequest(getProfileRequest2));
-        assertThat(getProfileMap2.keySet(), contains(uid));
-        final Map<String, Object> profile2 = castToMap(getProfileMap2.get(uid));
-        assertThat(castToMap(profile2.get("data")), equalTo(Map.of("app1", Map.of("name", "app1"))));
+        final Map<String, Object> profileMap2 = doGetProfile(uid, "app1");
+        assertThat(castToMap(profileMap2.get("data")), equalTo(Map.of("app1", Map.of("name", "app1"))));
 
         // Retrieve multiple application data
-        final Request getProfileRequest3 = new Request("GET", "_security/profile/" + uid);
-        getProfileRequest3.addParameter("data", randomFrom("app1,app2", "*", "app*"));
-        final Map<String, Object> getProfileMap3 = responseAsMap(adminClient().performRequest(getProfileRequest3));
-        assertThat(getProfileMap3.keySet(), contains(uid));
-        final Map<String, Object> profile3 = castToMap(getProfileMap3.get(uid));
-        assertThat(castToMap(profile3.get("data")), equalTo(Map.of("app1", Map.of("name", "app1"), "app2", Map.of("name", "app2"))));
+        final Map<String, Object> profileMap3 = doGetProfile(uid, randomFrom("app1,app2", "*", "app*"));
+        assertThat(castToMap(profileMap3.get("data")), equalTo(Map.of("app1", Map.of("name", "app1"), "app2", Map.of("name", "app2"))));
 
         // Non-existing profile
         final Request getProfileRequest4 = new Request("GET", "_security/profile/not_" + uid);
@@ -140,6 +115,56 @@ public class ProfileIT extends ESRestTestCase {
         assertThat(e4.getResponse().getStatusLine().getStatusCode(), equalTo(404));
     }
 
+    public void testUpdateProfileData() throws IOException {
+        final Map<String, Object> activateProfileMap = doActivateProfile();
+        final String uid = (String) activateProfileMap.get("uid");
+        final Request updateProfileRequest1 = new Request("POST", "_security/profile/_data/" + uid);
+        updateProfileRequest1.setJsonEntity("""
+            {
+              "access": {
+                "app1": { "tags": [ "prod", "east" ] }
+              },
+              "data": {
+                "app1": { "theme": "default" }
+              }
+            }""");
+        assertOK(adminClient().performRequest(updateProfileRequest1));
+
+        final Map<String, Object> profileMap1 = doGetProfile(uid, "app1");
+        assertThat(castToMap(profileMap1.get("access")), equalTo(Map.of("app1", Map.of("tags", List.of("prod", "east")))));
+        assertThat(castToMap(profileMap1.get("data")), equalTo(Map.of("app1", Map.of("theme", "default"))));
+    }
+
+    private Map<String, Object> doActivateProfile() throws IOException {
+        final Request activateProfileRequest = new Request("POST", "_security/profile/_activate");
+        activateProfileRequest.setJsonEntity("""
+            {
+              "grant_type": "password",
+              "username": "rac_user",
+              "password": "x-pack-test-password"
+            }""");
+
+        final Response activateProfileResponse = adminClient().performRequest(activateProfileRequest);
+        assertOK(activateProfileResponse);
+        return responseAsMap(activateProfileResponse);
+    }
+
+    private Map<String, Object> doGetProfile(String uid) throws IOException {
+        return doGetProfile(uid, null);
+    }
+
+    private Map<String, Object> doGetProfile(String uid, @Nullable String dataKey) throws IOException {
+        final Request getProfileRequest1 = new Request("GET", "_security/profile/" + uid);
+        if (dataKey != null) {
+            getProfileRequest1.addParameter("data", dataKey);
+        }
+        final Response getProfileResponse1 = adminClient().performRequest(getProfileRequest1);
+        assertOK(getProfileResponse1);
+        final Map<String, Object> getProfileMap1 = responseAsMap(getProfileResponse1);
+        assertThat(getProfileMap1.keySet(), contains(uid));
+        return castToMap(getProfileMap1.get(uid));
+    }
+
     @SuppressWarnings("unchecked")
     private Map<String, Object> castToMap(Object o) {
         return (Map<String, Object>) o;

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

@@ -14,6 +14,7 @@ import org.elasticsearch.action.support.PlainActionFuture;
 import org.elasticsearch.action.support.WriteRequest;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.engine.DocumentMissingException;
 import org.elasticsearch.test.SecuritySingleNodeTestCase;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
@@ -23,12 +24,15 @@ import org.elasticsearch.xpack.core.security.action.profile.GetProfileAction;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfileRequest;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfilesResponse;
 import org.elasticsearch.xpack.core.security.action.profile.Profile;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
 import org.elasticsearch.xpack.core.security.action.user.PutUserAction;
 import org.elasticsearch.xpack.core.security.action.user.PutUserRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.user.User;
 
 import java.time.Instant;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -220,6 +224,65 @@ public class ProfileSingleNodeTests extends SecuritySingleNodeTestCase {
         assertThat(getProfile(profile5.uid(), Set.of("my_app")).applicationData(), equalTo(Map.of("my_app", Map.of("theme", "default"))));
     }
 
+    public void testUpdateProfileData() {
+        final Profile profile1 = doActivateProfile(RAC_USER_NAME, TEST_PASSWORD_SECURE_STRING);
+
+        final UpdateProfileDataRequest updateProfileDataRequest1 = new UpdateProfileDataRequest(
+            profile1.uid(),
+            Map.of("app1", List.of("tab1", "tab2")),
+            Map.of("app1", Map.of("name", "app1", "type", "app")),
+            -1,
+            -1,
+            WriteRequest.RefreshPolicy.WAIT_UNTIL
+        );
+        client().execute(UpdateProfileDataAction.INSTANCE, updateProfileDataRequest1).actionGet();
+
+        final Profile profile2 = getProfile(profile1.uid(), Set.of("app1", "app2"));
+
+        assertThat(profile2.uid(), equalTo(profile1.uid()));
+        assertThat(profile2.access(), equalTo(Map.of("app1", List.of("tab1", "tab2"))));
+        assertThat(profile2.applicationData(), equalTo(Map.of("app1", Map.of("name", "app1", "type", "app"))));
+
+        // Update again should be incremental
+        final UpdateProfileDataRequest updateProfileDataRequest2 = new UpdateProfileDataRequest(
+            profile1.uid(),
+            null,
+            Map.of("app1", Map.of("name", "app1_take2", "active", false), "app2", Map.of("name", "app2")),
+            -1,
+            -1,
+            WriteRequest.RefreshPolicy.WAIT_UNTIL
+        );
+        client().execute(UpdateProfileDataAction.INSTANCE, updateProfileDataRequest2).actionGet();
+
+        final Profile profile3 = getProfile(profile1.uid(), Set.of("app1", "app2"));
+        assertThat(profile3.uid(), equalTo(profile1.uid()));
+        assertThat(profile3.access(), equalTo(profile2.access()));
+        assertThat(
+            profile3.applicationData(),
+            equalTo(Map.of("app1", Map.of("name", "app1_take2", "type", "app", "active", false), "app2", Map.of("name", "app2")))
+        );
+
+        // Activate profile again should not affect the data section
+        doActivateProfile(RAC_USER_NAME, TEST_PASSWORD_SECURE_STRING);
+        final Profile profile4 = getProfile(profile1.uid(), Set.of("app1", "app2"));
+        assertThat(profile4.access(), equalTo(profile3.access()));
+        assertThat(profile4.applicationData(), equalTo(profile3.applicationData()));
+
+        // Update non-existent profile should throw error
+        final UpdateProfileDataRequest updateProfileDataRequest3 = new UpdateProfileDataRequest(
+            "not-" + profile1.uid(),
+            null,
+            Map.of("foo", "bar"),
+            -1,
+            -1,
+            WriteRequest.RefreshPolicy.WAIT_UNTIL
+        );
+        expectThrows(
+            DocumentMissingException.class,
+            () -> client().execute(UpdateProfileDataAction.INSTANCE, updateProfileDataRequest3).actionGet()
+        );
+    }
+
     private Profile doActivateProfile(String username, SecureString password) {
         final ActivateProfileRequest activateProfileRequest = new ActivateProfileRequest();
         activateProfileRequest.getGrant().setType("password");

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

@@ -110,6 +110,7 @@ import org.elasticsearch.xpack.core.security.action.privilege.GetPrivilegesActio
 import org.elasticsearch.xpack.core.security.action.privilege.PutPrivilegesAction;
 import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
 import org.elasticsearch.xpack.core.security.action.profile.GetProfileAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
 import org.elasticsearch.xpack.core.security.action.realm.ClearRealmCacheAction;
 import org.elasticsearch.xpack.core.security.action.role.ClearRolesCacheAction;
 import org.elasticsearch.xpack.core.security.action.role.DeleteRoleAction;
@@ -185,6 +186,7 @@ import org.elasticsearch.xpack.security.action.privilege.TransportGetPrivilegesA
 import org.elasticsearch.xpack.security.action.privilege.TransportPutPrivilegesAction;
 import org.elasticsearch.xpack.security.action.profile.TransportActivateProfileAction;
 import org.elasticsearch.xpack.security.action.profile.TransportGetProfileAction;
+import org.elasticsearch.xpack.security.action.profile.TransportUpdateProfileDataAction;
 import org.elasticsearch.xpack.security.action.realm.TransportClearRealmCacheAction;
 import org.elasticsearch.xpack.security.action.role.TransportClearRolesCacheAction;
 import org.elasticsearch.xpack.security.action.role.TransportDeleteRoleAction;
@@ -278,6 +280,7 @@ import org.elasticsearch.xpack.security.rest.action.privilege.RestGetPrivilegesA
 import org.elasticsearch.xpack.security.rest.action.privilege.RestPutPrivilegesAction;
 import org.elasticsearch.xpack.security.rest.action.profile.RestActivateProfileAction;
 import org.elasticsearch.xpack.security.rest.action.profile.RestGetProfileAction;
+import org.elasticsearch.xpack.security.rest.action.profile.RestUpdateProfileDataAction;
 import org.elasticsearch.xpack.security.rest.action.realm.RestClearRealmCacheAction;
 import org.elasticsearch.xpack.security.rest.action.role.RestClearRolesCacheAction;
 import org.elasticsearch.xpack.security.rest.action.role.RestDeleteRoleAction;
@@ -1205,6 +1208,7 @@ public class Security extends Plugin
             new ActionHandler<>(NodeEnrollmentAction.INSTANCE, TransportNodeEnrollmentAction.class),
             new ActionHandler<>(GetProfileAction.INSTANCE, TransportGetProfileAction.class),
             new ActionHandler<>(ActivateProfileAction.INSTANCE, TransportActivateProfileAction.class),
+            new ActionHandler<>(UpdateProfileDataAction.INSTANCE, TransportUpdateProfileDataAction.class),
             usageAction,
             infoAction
         );
@@ -1280,7 +1284,8 @@ public class Security extends Plugin
             new RestKibanaEnrollAction(settings, getLicenseState()),
             new RestNodeEnrollmentAction(settings, getLicenseState()),
             new RestGetProfileAction(settings, getLicenseState()),
-            new RestActivateProfileAction(settings, getLicenseState())
+            new RestActivateProfileAction(settings, getLicenseState()),
+            new RestUpdateProfileDataAction(settings, getLicenseState())
         );
     }
 

+ 35 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/profile/TransportUpdateProfileDataAction.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.action.profile;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
+import org.elasticsearch.xpack.security.profile.ProfileService;
+
+public class TransportUpdateProfileDataAction extends HandledTransportAction<UpdateProfileDataRequest, AcknowledgedResponse> {
+
+    private final ProfileService profileService;
+
+    @Inject
+    public TransportUpdateProfileDataAction(TransportService transportService, ActionFilters actionFilters, ProfileService profileService) {
+        super(UpdateProfileDataAction.NAME, transportService, actionFilters, UpdateProfileDataRequest::new);
+        this.profileService = profileService;
+    }
+
+    @Override
+    protected void doExecute(Task task, UpdateProfileDataRequest request, ActionListener<AcknowledgedResponse> listener) {
+        profileService.updateProfileData(request, listener);
+    }
+}

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

@@ -22,6 +22,7 @@ import org.elasticsearch.action.index.IndexResponse;
 import org.elasticsearch.action.search.SearchAction;
 import org.elasticsearch.action.search.SearchRequest;
 import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
+import org.elasticsearch.action.support.master.AcknowledgedResponse;
 import org.elasticsearch.action.update.UpdateAction;
 import org.elasticsearch.action.update.UpdateRequest;
 import org.elasticsearch.action.update.UpdateRequestBuilder;
@@ -42,6 +43,7 @@ import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.security.action.profile.Profile;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationContext;
 import org.elasticsearch.xpack.core.security.authc.Subject;
@@ -127,6 +129,36 @@ public class ProfileService {
         }, listener::onFailure));
     }
 
+    public void updateProfileData(UpdateProfileDataRequest request, ActionListener<AcknowledgedResponse> listener) {
+        final XContentBuilder builder;
+        try {
+            builder = XContentFactory.jsonBuilder();
+            builder.startObject();
+            {
+                builder.field("user_profile");
+                builder.startObject();
+                {
+                    if (false == request.getAccess().isEmpty()) {
+                        builder.field("access", request.getAccess());
+                    }
+                    if (false == request.getData().isEmpty()) {
+                        builder.field("application_data", request.getData());
+                    }
+                }
+                builder.endObject();
+            }
+            builder.endObject();
+        } catch (IOException e) {
+            listener.onFailure(e);
+            return;
+        }
+
+        doUpdate(
+            buildUpdateRequest(request.getUid(), builder, request.getRefreshPolicy(), request.getIfPrimaryTerm(), request.getIfSeqNo()),
+            listener.map(updateResponse -> AcknowledgedResponse.TRUE)
+        );
+    }
+
     private void getVersionedDocument(String uid, ActionListener<VersionedDocument> listener) {
         tryFreezeAndCheckIndex(listener).ifPresent(frozenProfileIndex -> {
             final GetRequest getRequest = new GetRequest(SECURITY_PROFILE_ALIAS, uidToDocId(uid));

+ 77 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/profile/RestUpdateProfileDataAction.java

@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.rest.action.profile;
+
+import org.elasticsearch.client.internal.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xcontent.ConstructingObjectParser;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataAction;
+import org.elasticsearch.xpack.core.security.action.profile.UpdateProfileDataRequest;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+public class RestUpdateProfileDataAction extends SecurityBaseRestHandler {
+
+    @SuppressWarnings("unchecked")
+    static final ConstructingObjectParser<Payload, Void> PARSER = new ConstructingObjectParser<>(
+        "update_profile_data_request_payload",
+        a -> new Payload((Map<String, Object>) a[0], (Map<String, Object>) a[1])
+    );
+
+    static {
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("access"));
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("data"));
+    }
+
+    public RestUpdateProfileDataAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(POST, "/_security/profile/_data/{uid}"));
+    }
+
+    @Override
+    public String getName() {
+        return "xpack_security_update_profile_data";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(RestRequest request, NodeClient client) throws IOException {
+        final String uid = request.param("uid");
+        final long ifPrimaryTerm = request.paramAsLong("if_primary_term", -1);
+        final long ifSeqNo = request.paramAsLong("if_seq_no", -1);
+        final RefreshPolicy refreshPolicy = RefreshPolicy.parse(request.param("refresh", "wait_for"));
+        final Payload payload = PARSER.parse(request.contentParser(), null);
+
+        final UpdateProfileDataRequest updateProfileDataRequest = new UpdateProfileDataRequest(
+            uid,
+            payload.access,
+            payload.data,
+            ifPrimaryTerm,
+            ifSeqNo,
+            refreshPolicy
+        );
+
+        return channel -> client.execute(UpdateProfileDataAction.INSTANCE, updateProfileDataRequest, new RestToXContentListener<>(channel));
+    }
+
+    record Payload(Map<String, Object> access, Map<String, Object> data) {}
+}