瀏覽代碼

Support updates of API key attributes [REST and transport layer] (#88186)

REST and transport layer implementation to add support for updating
attributes of existing API keys. This allows end-users to modify
privileges and metadata associated with API keys dynamically, without
requiring rolling out new API keys every time there is a change.

The new route supports updates to one API key, given its ID:

PUT /_security/api_key/{id}

The request body consists of optional fields role_descriptors and
metadata. If a request field is absent, the existing value of the
field on the given API key is retained. If a request field is set to
{} it replaces the existing value with {}. Explicit null-values for
request fields are not allowed and will produce a 400.
limited_by_role_descriptors, creator, and version are
automatically updated on every call. Attributes a replaced, not merged.

Only the owner user of an API key can update it. API keys cannot update
themselves, nor can other users (even users with all or
manage_security cluster privileges).

Relates: #87870
Nikolaj Volgushev 3 年之前
父節點
當前提交
12bf451012
共有 17 個文件被更改,包括 759 次插入215 次删除
  1. 5 0
      docs/changelog/88186.yaml
  2. 20 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyAction.java
  3. 22 14
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java
  4. 7 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java
  5. 48 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java
  6. 4 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTestHelper.java
  7. 13 1
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
  8. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  9. 155 25
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java
  10. 221 132
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java
  11. 5 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  12. 69 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java
  13. 41 40
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
  14. 11 2
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java
  15. 74 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateApiKeyAction.java
  16. 5 1
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java
  17. 58 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateApiKeyActionTests.java

+ 5 - 0
docs/changelog/88186.yaml

@@ -0,0 +1,5 @@
+pr: 88186
+summary: Support updates of API key attributes (single operation route)
+area: Authentication
+type: feature
+issues: []

+ 20 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyAction.java

@@ -0,0 +1,20 @@
+/*
+ * 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.apikey;
+
+import org.elasticsearch.action.ActionType;
+
+public final class UpdateApiKeyAction extends ActionType<UpdateApiKeyResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/api_key/update";
+    public static final UpdateApiKeyAction INSTANCE = new UpdateApiKeyAction();
+
+    private UpdateApiKeyAction() {
+        super(NAME, UpdateApiKeyResponse::new);
+    }
+}

+ 22 - 14
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java

@@ -31,7 +31,11 @@ public final class UpdateApiKeyRequest extends ActionRequest {
     @Nullable
     private final List<RoleDescriptor> roleDescriptors;
 
-    public UpdateApiKeyRequest(String id, @Nullable List<RoleDescriptor> roleDescriptors, @Nullable Map<String, Object> metadata) {
+    public UpdateApiKeyRequest(
+        final String id,
+        @Nullable final List<RoleDescriptor> roleDescriptors,
+        @Nullable final Map<String, Object> metadata
+    ) {
         this.id = Objects.requireNonNull(id, "API key ID must not be null");
         this.roleDescriptors = roleDescriptors;
         this.metadata = metadata;
@@ -40,7 +44,7 @@ public final class UpdateApiKeyRequest extends ActionRequest {
     public UpdateApiKeyRequest(StreamInput in) throws IOException {
         super(in);
         this.id = in.readString();
-        this.roleDescriptors = readOptionalList(in);
+        this.roleDescriptors = in.readOptionalList(RoleDescriptor::new);
         this.metadata = in.readMap();
     }
 
@@ -65,21 +69,12 @@ public final class UpdateApiKeyRequest extends ActionRequest {
     public void writeTo(StreamOutput out) throws IOException {
         super.writeTo(out);
         out.writeString(id);
-        writeOptionalList(out);
+        out.writeOptionalCollection(roleDescriptors);
         out.writeGenericMap(metadata);
     }
 
-    private List<RoleDescriptor> readOptionalList(StreamInput in) throws IOException {
-        return in.readBoolean() ? in.readList(RoleDescriptor::new) : null;
-    }
-
-    private void writeOptionalList(StreamOutput out) throws IOException {
-        if (roleDescriptors == null) {
-            out.writeBoolean(false);
-        } else {
-            out.writeBoolean(true);
-            out.writeList(roleDescriptors);
-        }
+    public static UpdateApiKeyRequest usingApiKeyId(String id) {
+        return new UpdateApiKeyRequest(id, null, null);
     }
 
     public String getId() {
@@ -93,4 +88,17 @@ public final class UpdateApiKeyRequest extends ActionRequest {
     public List<RoleDescriptor> getRoleDescriptors() {
         return roleDescriptors;
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        UpdateApiKeyRequest that = (UpdateApiKeyRequest) o;
+        return id.equals(that.id) && Objects.equals(metadata, that.metadata) && Objects.equals(roleDescriptors, that.roleDescriptors);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(id, metadata, roleDescriptors);
+    }
 }

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

@@ -14,6 +14,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationField;
 import org.elasticsearch.xpack.core.security.authc.RealmDomain;
@@ -61,6 +62,12 @@ public class ManageOwnApiKeyClusterPrivilege implements NamedClusterPrivilege {
         protected boolean extendedCheck(String action, TransportRequest request, Authentication authentication) {
             if (request instanceof CreateApiKeyRequest) {
                 return true;
+            } else if (request instanceof UpdateApiKeyRequest) {
+                // Note: we return `true` here even if the authenticated entity is an API key. API keys *cannot* update themselves,
+                // however this is a business logic restriction, rather than one driven solely by privileges. We therefore enforce this
+                // limitation at the transport layer, in `TransportUpdateApiKeyAction`.
+                // Ownership of an API key, for regular users, is enforced at the service layer.
+                return true;
             } else if (request instanceof final GetApiKeyRequest getApiKeyRequest) {
                 return checkIfUserIsOwnerOfApiKeys(
                     authentication,

+ 48 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.core.security.action.apikey;
 
+import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.common.io.stream.BytesStreamOutput;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.test.ESTestCase;
@@ -15,6 +16,11 @@ import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.containsStringIgnoringCase;
+import static org.hamcrest.Matchers.equalTo;
 
 public class UpdateApiKeyRequestTests extends ESTestCase {
 
@@ -50,4 +56,46 @@ public class UpdateApiKeyRequestTests extends ESTestCase {
             }
         }
     }
+
+    public void testMetadataKeyValidation() {
+        final var reservedKey = "_" + randomAlphaOfLengthBetween(0, 10);
+        final var metadataValue = randomAlphaOfLengthBetween(1, 10);
+        UpdateApiKeyRequest request = new UpdateApiKeyRequest(randomAlphaOfLength(10), null, Map.of(reservedKey, metadataValue));
+        final ActionRequestValidationException ve = request.validate();
+        assertNotNull(ve);
+        assertThat(ve.validationErrors().size(), equalTo(1));
+        assertThat(ve.validationErrors().get(0), containsString("API key metadata keys may not start with [_]"));
+    }
+
+    public void testRoleDescriptorValidation() {
+        final var request1 = new UpdateApiKeyRequest(
+            randomAlphaOfLength(10),
+            List.of(
+                new RoleDescriptor(
+                    randomAlphaOfLength(5),
+                    new String[] { "manage_index_template" },
+                    new RoleDescriptor.IndicesPrivileges[] {
+                        RoleDescriptor.IndicesPrivileges.builder().indices("*").privileges("rad").build() },
+                    new RoleDescriptor.ApplicationResourcePrivileges[] {
+                        RoleDescriptor.ApplicationResourcePrivileges.builder()
+                            .application(randomFrom("app*tab", "app 1"))
+                            .privileges(randomFrom(" ", "\n"))
+                            .resources("resource")
+                            .build() },
+                    null,
+                    null,
+                    Map.of("_key", "value"),
+                    null
+                )
+            ),
+            null
+        );
+        final ActionRequestValidationException ve1 = request1.validate();
+        assertNotNull(ve1);
+        assertThat(ve1.validationErrors().get(0), containsString("unknown cluster privilege"));
+        assertThat(ve1.validationErrors().get(1), containsString("unknown index privilege"));
+        assertThat(ve1.validationErrors().get(2), containsStringIgnoringCase("application name"));
+        assertThat(ve1.validationErrors().get(3), containsStringIgnoringCase("Application privilege names"));
+        assertThat(ve1.validationErrors().get(4), containsStringIgnoringCase("role descriptor metadata keys may not start with "));
+    }
 }

+ 4 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTestHelper.java

@@ -136,6 +136,10 @@ public class AuthenticationTestHelper {
         }
     }
 
+    public static RealmConfig.RealmIdentifier randomRealmIdentifier(boolean includeInternal) {
+        return new RealmConfig.RealmIdentifier(randomRealmTypeSupplier(includeInternal).get(), ESTestCase.randomAlphaOfLengthBetween(3, 8));
+    }
+
     private static Supplier<String> randomRealmTypeSupplier(boolean includeInternal) {
         final Supplier<String> randomAllRealmTypeSupplier = () -> ESTestCase.randomFrom(
             "reserved",

+ 13 - 1
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationTestHelper;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationTests;
@@ -40,12 +41,21 @@ public class ManageOwnApiKeyClusterPrivilegeTests extends ESTestCase {
         final Authentication authentication = AuthenticationTests.randomApiKeyAuthentication(userJoe, apiKeyId);
         final TransportRequest getApiKeyRequest = GetApiKeyRequest.usingApiKeyId(apiKeyId, randomBoolean());
         final TransportRequest invalidateApiKeyRequest = InvalidateApiKeyRequest.usingApiKeyId(apiKeyId, randomBoolean());
-
         assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/get", getApiKeyRequest, authentication));
         assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/invalidate", invalidateApiKeyRequest, authentication));
         assertFalse(clusterPermission.check("cluster:admin/something", mock(TransportRequest.class), authentication));
     }
 
+    public void testAuthenticationForUpdateApiKeyAllowsAll() {
+        final ClusterPermission clusterPermission = ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder())
+            .build();
+        final String apiKeyId = randomAlphaOfLengthBetween(4, 7);
+        final Authentication authentication = AuthenticationTestHelper.builder().build();
+        final TransportRequest updateApiKeyRequest = UpdateApiKeyRequest.usingApiKeyId(apiKeyId);
+
+        assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/update", updateApiKeyRequest, authentication));
+    }
+
     public void testAuthenticationWithApiKeyDeniesAccessToApiKeyActionsWhenItIsNotOwner() {
         final ClusterPermission clusterPermission = ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder())
             .build();
@@ -69,8 +79,10 @@ public class ManageOwnApiKeyClusterPrivilegeTests extends ESTestCase {
 
         TransportRequest getApiKeyRequest = GetApiKeyRequest.usingRealmAndUserName(realmRef.getName(), "joe");
         TransportRequest invalidateApiKeyRequest = InvalidateApiKeyRequest.usingRealmAndUserName(realmRef.getName(), "joe");
+        TransportRequest updateApiKeyRequest = UpdateApiKeyRequest.usingApiKeyId(randomAlphaOfLength(10));
         assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/get", getApiKeyRequest, authentication));
         assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/invalidate", invalidateApiKeyRequest, authentication));
+        assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/update", updateApiKeyRequest, authentication));
 
         assertFalse(clusterPermission.check("cluster:admin/something", mock(TransportRequest.class), authentication));
 

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

@@ -178,6 +178,7 @@ public class Constants {
         "cluster:admin/xpack/security/api_key/grant",
         "cluster:admin/xpack/security/api_key/invalidate",
         "cluster:admin/xpack/security/api_key/query",
+        "cluster:admin/xpack/security/api_key/update",
         "cluster:admin/xpack/security/cache/clear",
         "cluster:admin/xpack/security/delegate_pki",
         "cluster:admin/xpack/security/enroll/node",

+ 155 - 25
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java

@@ -14,8 +14,10 @@ import org.elasticsearch.client.ResponseException;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.test.XContentTestUtils;
+import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.security.action.apikey.ApiKey;
+import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction;
 import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
 import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase;
@@ -29,6 +31,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import static org.elasticsearch.xpack.core.security.authc.AuthenticationServiceField.RUN_AS_USER_HEADER;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.emptyString;
 import static org.hamcrest.Matchers.equalTo;
@@ -49,6 +52,7 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
     private static final SecureString SYSTEM_USER_PASSWORD = new SecureString("system-user-password".toCharArray());
     private static final String END_USER = "end_user";
     private static final SecureString END_USER_PASSWORD = new SecureString("end-user-password".toCharArray());
+    private static final String MANAGE_OWN_API_KEY_USER = "manage_own_api_key_user";
 
     @Before
     public void createUsers() throws IOException {
@@ -56,15 +60,20 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         createRole("system_role", Set.of("grant_api_key"));
         createUser(END_USER, END_USER_PASSWORD, List.of("user_role"));
         createRole("user_role", Set.of("monitor"));
+        createUser(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD, List.of("manage_own_api_key_role"));
+        createRole("manage_own_api_key_role", Set.of("manage_own_api_key"));
     }
 
     @After
     public void cleanUp() throws IOException {
-        deleteUser("system_user");
-        deleteUser("end_user");
+        deleteUser(SYSTEM_USER);
+        deleteUser(END_USER);
+        deleteUser(MANAGE_OWN_API_KEY_USER);
         deleteRole("system_role");
         deleteRole("user_role");
+        deleteRole("manage_own_api_key_role");
         invalidateApiKeysForUser(END_USER);
+        invalidateApiKeysForUser(MANAGE_OWN_API_KEY_USER);
     }
 
     @SuppressWarnings({ "unchecked" })
@@ -85,18 +94,7 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         assertThat(actualApiKeyName, equalTo(expectedApiKeyName));
         assertThat(actualApiKeyEncoded, not(emptyString()));
 
-        final Request authenticateRequest = new Request("GET", "_security/_authenticate");
-        authenticateRequest.setOptions(
-            authenticateRequest.getOptions().toBuilder().addHeader("Authorization", "ApiKey " + actualApiKeyEncoded)
-        );
-
-        final Response authenticateResponse = client().performRequest(authenticateRequest);
-        assertOK(authenticateResponse);
-        final Map<String, Object> authenticate = responseAsMap(authenticateResponse); // keys: username, roles, full_name, etc
-
-        // If authentication type is API_KEY, authentication.api_key={"id":"abc123","name":"my-api-key"}. No encoded, api_key, or metadata.
-        // If authentication type is other, authentication.api_key not present.
-        assertThat(authenticate, hasEntry("api_key", Map.of("id", actualApiKeyId, "name", expectedApiKeyName)));
+        doTestAuthenticationWithApiKey(expectedApiKeyName, actualApiKeyId, actualApiKeyEncoded);
     }
 
     public void testGrantApiKeyForOtherUserWithPassword() throws IOException {
@@ -179,21 +177,15 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
     }
 
     public void testGrantApiKeyWithOnlyManageOwnApiKeyPrivilegeFails() throws IOException {
-        final String manageOwnApiKeyUser = "manage-own-api-key-user";
-        final SecureString manageOwnApiKeyUserPassword = new SecureString("manage-own-api-key-password".toCharArray());
-        final String manageOwnApiKeyRole = "manage_own_api_key_role";
-        createUser(manageOwnApiKeyUser, manageOwnApiKeyUserPassword, List.of(manageOwnApiKeyRole));
-        createRole(manageOwnApiKeyRole, Set.of("manage_own_api_key"));
-
         final Request request = new Request("POST", "_security/api_key/grant");
         request.setOptions(
             RequestOptions.DEFAULT.toBuilder()
-                .addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(manageOwnApiKeyUser, manageOwnApiKeyUserPassword))
+                .addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD))
         );
         final Map<String, Object> requestBody = Map.ofEntries(
             Map.entry("grant_type", "password"),
-            Map.entry("username", manageOwnApiKeyUser),
-            Map.entry("password", manageOwnApiKeyUserPassword.toString()),
+            Map.entry("username", MANAGE_OWN_API_KEY_USER),
+            Map.entry("password", END_USER_PASSWORD.toString()),
             Map.entry("api_key", Map.of("name", "test_api_key_password"))
         );
         request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString());
@@ -202,7 +194,145 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
 
         assertEquals(403, e.getResponse().getStatusLine().getStatusCode());
         assertThat(e.getMessage(), containsString("action [" + GrantApiKeyAction.NAME + "] is unauthorized for user"));
-        deleteUser(manageOwnApiKeyUser);
-        deleteRole(manageOwnApiKeyRole);
+    }
+
+    public void testUpdateApiKey() throws IOException {
+        final var apiKeyName = "my-api-key-name";
+        final Map<String, String> apiKeyMetadata = Map.of("not", "returned");
+        final Map<String, Object> createApiKeyRequestBody = Map.of("name", apiKeyName, "metadata", apiKeyMetadata);
+
+        final Request createApiKeyRequest = new Request("POST", "_security/api_key");
+        createApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(createApiKeyRequestBody, XContentType.JSON).utf8ToString());
+        createApiKeyRequest.setOptions(
+            RequestOptions.DEFAULT.toBuilder()
+                .addHeader("Authorization", headerFromRandomAuthMethod(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD))
+        );
+
+        final Response createApiKeyResponse = client().performRequest(createApiKeyRequest);
+        final Map<String, Object> createApiKeyResponseMap = responseAsMap(createApiKeyResponse); // keys: id, name, api_key, encoded
+        final var apiKeyId = (String) createApiKeyResponseMap.get("id");
+        final var apiKeyEncoded = (String) createApiKeyResponseMap.get("encoded"); // Base64(id:api_key)
+        assertThat(apiKeyId, not(emptyString()));
+        assertThat(apiKeyEncoded, not(emptyString()));
+
+        doTestUpdateApiKey(apiKeyName, apiKeyId, apiKeyEncoded);
+    }
+
+    public void testGrantTargetCanUpdateApiKey() throws IOException {
+        final var request = new Request("POST", "_security/api_key/grant");
+        request.setOptions(
+            RequestOptions.DEFAULT.toBuilder()
+                .addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(SYSTEM_USER, SYSTEM_USER_PASSWORD))
+        );
+        final var apiKeyName = "test_api_key_password";
+        final Map<String, Object> requestBody = Map.ofEntries(
+            Map.entry("grant_type", "password"),
+            Map.entry("username", MANAGE_OWN_API_KEY_USER),
+            Map.entry("password", END_USER_PASSWORD.toString()),
+            Map.entry("api_key", Map.of("name", apiKeyName))
+        );
+        request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString());
+
+        final Response response = client().performRequest(request);
+        final Map<String, Object> createApiKeyResponseMap = responseAsMap(response); // keys: id, name, api_key, encoded
+        final var apiKeyId = (String) createApiKeyResponseMap.get("id");
+        final var apiKeyEncoded = (String) createApiKeyResponseMap.get("encoded"); // Base64(id:api_key)
+        assertThat(apiKeyId, not(emptyString()));
+        assertThat(apiKeyEncoded, not(emptyString()));
+
+        doTestUpdateApiKey(apiKeyName, apiKeyId, apiKeyEncoded);
+    }
+
+    public void testGrantorCannotUpdateApiKeyOfGrantTarget() throws IOException {
+        final var request = new Request("POST", "_security/api_key/grant");
+        final var apiKeyName = "test_api_key_password";
+        final Map<String, Object> requestBody = Map.ofEntries(
+            Map.entry("grant_type", "password"),
+            Map.entry("username", MANAGE_OWN_API_KEY_USER),
+            Map.entry("password", END_USER_PASSWORD.toString()),
+            Map.entry("api_key", Map.of("name", apiKeyName))
+        );
+        request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString());
+        final Response response = adminClient().performRequest(request);
+
+        final Map<String, Object> createApiKeyResponseMap = responseAsMap(response); // keys: id, name, api_key, encoded
+        final var apiKeyId = (String) createApiKeyResponseMap.get("id");
+        final var apiKeyEncoded = (String) createApiKeyResponseMap.get("encoded"); // Base64(id:api_key)
+        assertThat(apiKeyId, not(emptyString()));
+        assertThat(apiKeyEncoded, not(emptyString()));
+
+        final var updateApiKeyRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
+        updateApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(Map.of(), XContentType.JSON).utf8ToString());
+        final ResponseException e = expectThrows(ResponseException.class, () -> adminClient().performRequest(updateApiKeyRequest));
+
+        assertEquals(404, e.getResponse().getStatusLine().getStatusCode());
+        assertThat(e.getMessage(), containsString("no API key owned by requesting user found for ID [" + apiKeyId + "]"));
+    }
+
+    private void doTestAuthenticationWithApiKey(final String apiKeyName, final String apiKeyId, final String apiKeyEncoded)
+        throws IOException {
+        final var authenticateRequest = new Request("GET", "_security/_authenticate");
+        authenticateRequest.setOptions(authenticateRequest.getOptions().toBuilder().addHeader("Authorization", "ApiKey " + apiKeyEncoded));
+
+        final Response authenticateResponse = client().performRequest(authenticateRequest);
+        assertOK(authenticateResponse);
+        final Map<String, Object> authenticate = responseAsMap(authenticateResponse); // keys: username, roles, full_name, etc
+
+        // If authentication type is API_KEY, authentication.api_key={"id":"abc123","name":"my-api-key"}. No encoded, api_key, or metadata.
+        // If authentication type is other, authentication.api_key not present.
+        assertThat(authenticate, hasEntry("api_key", Map.of("id", apiKeyId, "name", apiKeyName)));
+    }
+
+    private void doTestUpdateApiKey(String apiKeyName, String apiKeyId, String apiKeyEncoded) throws IOException {
+        final var updateApiKeyRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
+        final Map<String, Object> expectedApiKeyMetadata = Map.of("not", "returned (changed)", "foo", "bar");
+        final Map<String, Object> updateApiKeyRequestBody = Map.of("metadata", expectedApiKeyMetadata);
+        updateApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(updateApiKeyRequestBody, XContentType.JSON).utf8ToString());
+
+        final Response updateApiKeyResponse = doUpdateUsingRandomAuthMethod(updateApiKeyRequest);
+
+        assertOK(updateApiKeyResponse);
+        final Map<String, Object> updateApiKeyResponseMap = responseAsMap(updateApiKeyResponse);
+        assertTrue((Boolean) updateApiKeyResponseMap.get("updated"));
+        expectMetadata(apiKeyId, expectedApiKeyMetadata);
+        // validate authentication still works after update
+        doTestAuthenticationWithApiKey(apiKeyName, apiKeyId, apiKeyEncoded);
+    }
+
+    private Response doUpdateUsingRandomAuthMethod(Request updateApiKeyRequest) throws IOException {
+        final boolean useRunAs = randomBoolean();
+        if (useRunAs) {
+            updateApiKeyRequest.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader(RUN_AS_USER_HEADER, MANAGE_OWN_API_KEY_USER));
+            return adminClient().performRequest(updateApiKeyRequest);
+        } else {
+            updateApiKeyRequest.setOptions(
+                RequestOptions.DEFAULT.toBuilder()
+                    .addHeader("Authorization", headerFromRandomAuthMethod(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD))
+            );
+            return client().performRequest(updateApiKeyRequest);
+        }
+    }
+
+    private String headerFromRandomAuthMethod(final String username, final SecureString password) throws IOException {
+        final boolean useBearerTokenAuth = randomBoolean();
+        if (useBearerTokenAuth) {
+            final Tuple<String, String> token = super.createOAuthToken(username, password);
+            return "Bearer " + token.v1();
+        } else {
+            return UsernamePasswordToken.basicAuthHeaderValue(username, password);
+        }
+    }
+
+    @SuppressWarnings({ "unchecked" })
+    private void expectMetadata(final String apiKeyId, final Map<String, Object> expectedMetadata) throws IOException {
+        final var request = new Request("GET", "_security/api_key/");
+        request.addParameter("id", apiKeyId);
+        final Response response = adminClient().performRequest(request);
+        assertOK(response);
+        try (XContentParser parser = responseAsParser(response)) {
+            final var apiKeyResponse = GetApiKeyResponse.fromXContent(parser);
+            assertThat(apiKeyResponse.getApiKeyInfos().length, equalTo(1));
+            assertThat(apiKeyResponse.getApiKeyInfos()[0].getMetadata(), equalTo(expectedMetadata));
+        }
     }
 }

+ 221 - 132
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java

@@ -19,7 +19,6 @@ import org.elasticsearch.action.admin.indices.refresh.RefreshRequestBuilder;
 import org.elasticsearch.action.admin.indices.refresh.RefreshResponse;
 import org.elasticsearch.action.get.GetAction;
 import org.elasticsearch.action.get.GetRequest;
-import org.elasticsearch.action.get.GetResponse;
 import org.elasticsearch.action.support.PlainActionFuture;
 import org.elasticsearch.action.support.WriteRequest;
 import org.elasticsearch.action.update.UpdateResponse;
@@ -59,8 +58,13 @@ import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse;
+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.role.PutRoleResponse;
+import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator;
 import org.elasticsearch.xpack.core.security.action.token.CreateTokenAction;
 import org.elasticsearch.xpack.core.security.action.token.CreateTokenRequestBuilder;
 import org.elasticsearch.xpack.core.security.action.token.CreateTokenResponse;
@@ -71,9 +75,9 @@ 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.esnative.NativeRealmSettings;
 import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.authz.privilege.ClusterPrivilegeResolver;
 import org.elasticsearch.xpack.core.security.user.User;
 import org.elasticsearch.xpack.security.authz.RoleDescriptorTests;
 import org.elasticsearch.xpack.security.transport.filter.IPFilter;
@@ -105,7 +109,9 @@ import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
 import static org.elasticsearch.test.SecuritySettingsSource.ES_TEST_ROOT_USER;
-import static org.elasticsearch.test.SecuritySettingsSourceField.ES_TEST_ROOT_ROLE;
+import static org.elasticsearch.test.SecuritySettingsSource.HASHER;
+import static org.elasticsearch.test.SecuritySettingsSource.TEST_ROLE;
+import static org.elasticsearch.test.SecuritySettingsSource.TEST_USER_NAME;
 import static org.elasticsearch.test.SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING;
 import static org.elasticsearch.test.TestMatchers.throwableWithMessage;
 import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
@@ -1427,56 +1433,52 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
     }
 
     public void testUpdateApiKey() throws ExecutionException, InterruptedException, IOException {
-        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null);
+        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(TEST_USER_NAME, null);
         final var apiKeyId = createdApiKey.v1().getId();
-
         final var newRoleDescriptors = randomRoleDescriptors();
         final boolean nullRoleDescriptors = newRoleDescriptors == null;
+        // Role descriptor corresponding to SecuritySettingsSource.TEST_ROLE_YML
         final var expectedLimitedByRoleDescriptors = Set.of(
-            new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null)
+            new RoleDescriptor(
+                TEST_ROLE,
+                new String[] { "ALL" },
+                new RoleDescriptor.IndicesPrivileges[] {
+                    RoleDescriptor.IndicesPrivileges.builder().indices("*").allowRestrictedIndices(true).privileges("ALL").build() },
+                null
+            )
         );
         final var request = new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, ApiKeyTests.randomMetadata());
 
-        final var serviceWithNodeName = getServiceWithNodeName();
         final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
-        serviceWithNodeName.service()
-            .updateApiKey(
-                fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-                request,
-                expectedLimitedByRoleDescriptors,
-                listener
-            );
-        final var response = listener.get();
+        final UpdateApiKeyResponse response = executeUpdateApiKey(TEST_USER_NAME, request, listener);
 
         assertNotNull(response);
         assertTrue(response.isUpdated());
 
-        // Correct data returned from GET API
-        Client client = client().filterWithHeader(
-            Collections.singletonMap("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING))
-        );
         final PlainActionFuture<GetApiKeyResponse> getListener = new PlainActionFuture<>();
-        client.execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyId(apiKeyId, false), getListener);
-        GetApiKeyResponse getResponse = getListener.get();
+        client().filterWithHeader(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, TEST_PASSWORD_SECURE_STRING))
+        ).execute(GetApiKeyAction.INSTANCE, GetApiKeyRequest.usingApiKeyId(apiKeyId, false), getListener);
+        final GetApiKeyResponse getResponse = getListener.get();
         assertEquals(1, getResponse.getApiKeyInfos().length);
         // When metadata for the update request is null (i.e., absent), we don't overwrite old metadata with it
         final var expectedMetadata = request.getMetadata() != null ? request.getMetadata() : createdApiKey.v2();
         assertEquals(expectedMetadata == null ? Map.of() : expectedMetadata, getResponse.getApiKeyInfos()[0].getMetadata());
-        assertEquals(ES_TEST_ROOT_USER, getResponse.getApiKeyInfos()[0].getUsername());
+        assertEquals(TEST_USER_NAME, getResponse.getApiKeyInfos()[0].getUsername());
         assertEquals("file", getResponse.getApiKeyInfos()[0].getRealm());
 
         // Test authenticate works with updated API key
         final var authResponse = authenticateWithApiKey(apiKeyId, createdApiKey.v1().getKey());
-        assertThat(authResponse.get(User.Fields.USERNAME.getPreferredName()), equalTo(ES_TEST_ROOT_USER));
+        assertThat(authResponse.get(User.Fields.USERNAME.getPreferredName()), equalTo(TEST_USER_NAME));
 
         // Document updated as expected
         final var updatedApiKeyDoc = getApiKeyDocument(apiKeyId);
         expectMetadataForApiKey(expectedMetadata, updatedApiKeyDoc);
-        expectRoleDescriptorForApiKey("limited_by_role_descriptors", expectedLimitedByRoleDescriptors, updatedApiKeyDoc);
+        expectRoleDescriptorsForApiKey("limited_by_role_descriptors", expectedLimitedByRoleDescriptors, updatedApiKeyDoc);
         if (nullRoleDescriptors) {
             // Default role descriptor assigned to api key in `createApiKey`
             final var expectedRoleDescriptor = new RoleDescriptor("role", new String[] { "monitor" }, null, null);
-            expectRoleDescriptorForApiKey("role_descriptors", List.of(expectedRoleDescriptor), updatedApiKeyDoc);
+            expectRoleDescriptorsForApiKey("role_descriptors", List.of(expectedRoleDescriptor), updatedApiKeyDoc);
 
             // Create user action unauthorized because we did not update key role; it only has `monitor` cluster priv
             final Map<String, String> authorizationHeaders = Collections.singletonMap(
@@ -1487,7 +1489,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             assertThat(e.getMessage(), containsString("unauthorized"));
             assertThat(e.getCause(), instanceOf(ElasticsearchSecurityException.class));
         } else {
-            expectRoleDescriptorForApiKey("role_descriptors", newRoleDescriptors, updatedApiKeyDoc);
+            expectRoleDescriptorsForApiKey("role_descriptors", newRoleDescriptors, updatedApiKeyDoc);
             // Create user action authorized because we updated key role to `all` cluster priv
             final var authorizationHeaders = Collections.singletonMap(
                 "Authorization",
@@ -1497,71 +1499,119 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         }
     }
 
-    private List<RoleDescriptor> randomRoleDescriptors() {
-        int caseNo = randomIntBetween(0, 2);
-        return switch (caseNo) {
-            case 0 -> List.of(new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null));
-            case 1 -> List.of(
-                new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null),
-                RoleDescriptorTests.randomRoleDescriptor()
-            );
-            case 2 -> null;
-            default -> throw new IllegalStateException("unexpected case no");
-        };
+    public void testUpdateApiKeyAutoUpdatesUserRoles() throws IOException, ExecutionException, InterruptedException {
+        // Create separate native realm user and role for user role change test
+        final var nativeRealmUser = randomAlphaOfLengthBetween(5, 10);
+        final var nativeRealmRole = randomAlphaOfLengthBetween(5, 10);
+        createNativeRealmUser(
+            nativeRealmUser,
+            nativeRealmRole,
+            new String(HASHER.hash(TEST_PASSWORD_SECURE_STRING)),
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, TEST_PASSWORD_SECURE_STRING))
+        );
+        final List<String> clusterPrivileges = new ArrayList<>(randomSubsetOf(ClusterPrivilegeResolver.names()));
+        // At a minimum include privilege to manage own API key to ensure no 403
+        clusterPrivileges.add(randomFrom("manage_api_key", "manage_own_api_key"));
+        final RoleDescriptor roleDescriptorBeforeUpdate = putRoleWithClusterPrivileges(
+            nativeRealmRole,
+            clusterPrivileges.toArray(new String[0])
+        );
+
+        // Create api key
+        final CreateApiKeyResponse createdApiKey = createApiKeys(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(nativeRealmUser, TEST_PASSWORD_SECURE_STRING)),
+            1,
+            null,
+            "all"
+        ).v1().get(0);
+        final String apiKeyId = createdApiKey.getId();
+        expectRoleDescriptorsForApiKey("limited_by_role_descriptors", Set.of(roleDescriptorBeforeUpdate), getApiKeyDocument(apiKeyId));
+
+        final List<String> newClusterPrivileges = new ArrayList<>(randomSubsetOf(ClusterPrivilegeResolver.names()));
+        // At a minimum include privilege to manage own API key to ensure no 403
+        newClusterPrivileges.add(randomFrom("manage_api_key", "manage_own_api_key"));
+        // Update user role
+        final RoleDescriptor roleDescriptorAfterUpdate = putRoleWithClusterPrivileges(
+            nativeRealmRole,
+            newClusterPrivileges.toArray(new String[0])
+        );
+
+        // Update API key
+        final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
+        final UpdateApiKeyResponse response = executeUpdateApiKey(nativeRealmUser, UpdateApiKeyRequest.usingApiKeyId(apiKeyId), listener);
+
+        assertNotNull(response);
+        assertTrue(response.isUpdated());
+        expectRoleDescriptorsForApiKey("limited_by_role_descriptors", Set.of(roleDescriptorAfterUpdate), getApiKeyDocument(apiKeyId));
     }
 
     public void testUpdateApiKeyNotFoundScenarios() throws ExecutionException, InterruptedException {
-        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null);
+        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(TEST_USER_NAME, null);
         final var apiKeyId = createdApiKey.v1().getId();
         final var expectedRoleDescriptor = new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null);
         final var request = new UpdateApiKeyRequest(apiKeyId, List.of(expectedRoleDescriptor), ApiKeyTests.randomMetadata());
 
         // Validate can update own API key
-        final var serviceWithNodeName = getServiceWithNodeName();
         final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
-        serviceWithNodeName.service()
-            .updateApiKey(
-                fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-                request,
-                Set.of(expectedRoleDescriptor),
-                listener
-            );
-        final var response = listener.get();
-
+        final UpdateApiKeyResponse response = executeUpdateApiKey(TEST_USER_NAME, request, listener);
         assertNotNull(response);
         assertTrue(response.isUpdated());
 
         // Test not found exception on non-existent API key
         final var otherApiKeyId = randomValueOtherThan(apiKeyId, () -> randomAlphaOfLength(20));
-        doTestUpdateApiKeyNotFound(
-            serviceWithNodeName,
-            fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-            new UpdateApiKeyRequest(otherApiKeyId, request.getRoleDescriptors(), request.getMetadata())
-        );
+        doTestUpdateApiKeyNotFound(new UpdateApiKeyRequest(otherApiKeyId, request.getRoleDescriptors(), request.getMetadata()));
 
         // Test not found exception on other user's API key
         final Tuple<CreateApiKeyResponse, Map<String, Object>> otherUsersApiKey = createApiKey("user_with_manage_api_key_role", null);
         doTestUpdateApiKeyNotFound(
-            serviceWithNodeName,
-            fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
             new UpdateApiKeyRequest(otherUsersApiKey.v1().getId(), request.getRoleDescriptors(), request.getMetadata())
         );
 
         // Test not found exception on API key of user with the same username but from a different realm
+        // Create native realm user with same username but different password to allow us to create an API key for _that_ user
+        // instead of file realm one
+        final var passwordSecureString = new SecureString("x-pack-test-other-password".toCharArray());
+        createNativeRealmUser(
+            TEST_USER_NAME,
+            TEST_ROLE,
+            new String(HASHER.hash(passwordSecureString)),
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, TEST_PASSWORD_SECURE_STRING))
+        );
+        final CreateApiKeyResponse apiKeyForNativeRealmUser = createApiKeys(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, passwordSecureString)),
+            1,
+            null,
+            "all"
+        ).v1().get(0);
         doTestUpdateApiKeyNotFound(
-            serviceWithNodeName,
-            Authentication.newRealmAuthentication(
-                new User(ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-                // Use native realm; no need to actually create user since we are injecting the authentication object directly
-                new Authentication.RealmRef(NativeRealmSettings.DEFAULT_NAME, NativeRealmSettings.TYPE, serviceWithNodeName.nodeName())
-            ),
-            new UpdateApiKeyRequest(apiKeyId, request.getRoleDescriptors(), request.getMetadata())
+            new UpdateApiKeyRequest(apiKeyForNativeRealmUser.getId(), request.getRoleDescriptors(), request.getMetadata())
         );
     }
 
     public void testInvalidUpdateApiKeyScenarios() throws ExecutionException, InterruptedException {
-        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(ES_TEST_ROOT_USER, null);
-        final var apiKeyId = createdApiKey.v1().getId();
+        final List<String> apiKeyPrivileges = new ArrayList<>(randomSubsetOf(ClusterPrivilegeResolver.names()));
+        // At a minimum include privilege to manage own API key to ensure no 403
+        apiKeyPrivileges.add(randomFrom("manage_api_key", "manage_own_api_key"));
+        final CreateApiKeyResponse createdApiKey = createApiKeys(TEST_USER_NAME, 1, null, apiKeyPrivileges.toArray(new String[0])).v1()
+            .get(0);
+        final var apiKeyId = createdApiKey.getId();
+
+        final var roleDescriptor = new RoleDescriptor(randomAlphaOfLength(10), new String[] { "manage_own_api_key" }, null, null);
+        final var request = new UpdateApiKeyRequest(apiKeyId, List.of(roleDescriptor), ApiKeyTests.randomMetadata());
+        PlainActionFuture<UpdateApiKeyResponse> updateListener = new PlainActionFuture<>();
+        client().filterWithHeader(
+            Collections.singletonMap(
+                "Authorization",
+                "ApiKey " + getBase64EncodedApiKeyValue(createdApiKey.getId(), createdApiKey.getKey())
+            )
+        ).execute(UpdateApiKeyAction.INSTANCE, request, updateListener);
+
+        final var apiKeysNotAllowedEx = expectThrows(ExecutionException.class, updateListener::get);
+        assertThat(apiKeysNotAllowedEx.getCause(), instanceOf(IllegalArgumentException.class));
+        assertThat(
+            apiKeysNotAllowedEx.getMessage(),
+            containsString("authentication via API key not supported: only the owner user can update an API key")
+        );
 
         final boolean invalidated = randomBoolean();
         if (invalidated) {
@@ -1581,18 +1631,11 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             assertThat(expirationDateUpdatedResponse.getResult(), is(DocWriteResponse.Result.UPDATED));
         }
 
-        final var roleDescriptor = new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null);
-        final var request = new UpdateApiKeyRequest(apiKeyId, List.of(roleDescriptor), ApiKeyTests.randomMetadata());
-
-        final var serviceWithNodeName = getServiceWithNodeName();
-        PlainActionFuture<UpdateApiKeyResponse> updateListener = new PlainActionFuture<>();
-        serviceWithNodeName.service()
-            .updateApiKey(
-                fileRealmAuth(serviceWithNodeName.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-                request,
-                Set.of(roleDescriptor),
-                updateListener
-            );
+        updateListener = new PlainActionFuture<>();
+        final Client client = client().filterWithHeader(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, TEST_PASSWORD_SECURE_STRING))
+        );
+        client.execute(UpdateApiKeyAction.INSTANCE, request, updateListener);
         final var ex = expectThrows(ExecutionException.class, updateListener::get);
 
         assertThat(ex.getCause(), instanceOf(IllegalArgumentException.class));
@@ -1601,17 +1644,41 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         } else {
             assertThat(ex.getMessage(), containsString("cannot update expired API key [" + apiKeyId + "]"));
         }
+    }
 
-        updateListener = new PlainActionFuture<>();
-        serviceWithNodeName.service()
-            .updateApiKey(AuthenticationTestHelper.builder().apiKey().build(false), request, Set.of(roleDescriptor), updateListener);
-        final var apiKeysNotAllowedEx = expectThrows(ExecutionException.class, updateListener::get);
+    public void testUpdateApiKeyAccountsForSecurityDomains() throws ExecutionException, InterruptedException {
+        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(TEST_USER_NAME, null);
+        final var apiKeyId = createdApiKey.v1().getId();
 
-        assertThat(apiKeysNotAllowedEx.getCause(), instanceOf(IllegalArgumentException.class));
-        assertThat(
-            apiKeysNotAllowedEx.getMessage(),
-            containsString("authentication via an API key is not supported for updating API keys")
+        final ServiceWithNodeName serviceWithNodeName = getServiceWithNodeName();
+        final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
+        final RealmConfig.RealmIdentifier creatorRealmOnCreatedApiKey = new RealmConfig.RealmIdentifier(FileRealmSettings.TYPE, "file");
+        final RealmConfig.RealmIdentifier otherRealmInDomain = AuthenticationTestHelper.randomRealmIdentifier(true);
+        final var realmDomain = new RealmDomain(
+            ESTestCase.randomAlphaOfLengthBetween(3, 8),
+            Set.of(creatorRealmOnCreatedApiKey, otherRealmInDomain)
+        );
+        // Update should work for any of the realms within the domain
+        final var authenticatingRealm = randomFrom(creatorRealmOnCreatedApiKey, otherRealmInDomain);
+        final var authentication = randomValueOtherThanMany(
+            Authentication::isApiKey,
+            () -> AuthenticationTestHelper.builder()
+                .user(new User(TEST_USER_NAME, TEST_ROLE))
+                .realmRef(
+                    new Authentication.RealmRef(
+                        authenticatingRealm.getName(),
+                        authenticatingRealm.getType(),
+                        serviceWithNodeName.nodeName(),
+                        realmDomain
+                    )
+                )
+                .build()
         );
+        serviceWithNodeName.service().updateApiKey(authentication, UpdateApiKeyRequest.usingApiKeyId(apiKeyId), Set.of(), listener);
+        final UpdateApiKeyResponse response = listener.get();
+
+        assertNotNull(response);
+        assertTrue(response.isUpdated());
     }
 
     public void testUpdateApiKeyClearsApiKeyDocCache() throws IOException, ExecutionException, InterruptedException {
@@ -1650,17 +1717,15 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
 
         // Update the first key
         final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
-        serviceForDoc1.updateApiKey(
-            fileRealmAuth(serviceWithNameForDoc1.nodeName(), ES_TEST_ROOT_USER, ES_TEST_ROOT_ROLE),
-            new UpdateApiKeyRequest(apiKey1.v1(), List.of(), null),
-            Set.of(),
-            listener
+        final Client client = client().filterWithHeader(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING))
         );
+        client.execute(UpdateApiKeyAction.INSTANCE, new UpdateApiKeyRequest(apiKey1.v1(), List.of(), null), listener);
         final var response = listener.get();
         assertNotNull(response);
         assertTrue(response.isUpdated());
 
-        // The cache entry should be gone for the first key
+        // The doc cache entry should be gone for the first key
         if (sameServiceNode) {
             assertEquals(1, serviceForDoc1.getDocCache().count());
             assertNull(serviceForDoc1.getDocCache().get(apiKey1.v1()));
@@ -1675,44 +1740,34 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         assertEquals(serviceForDoc2AuthCacheCount, serviceForDoc2.getApiKeyAuthCache().count());
     }
 
-    private void doTestUpdateApiKeyNotFound(
-        ServiceWithNodeName serviceWithNodeName,
-        Authentication authentication,
-        UpdateApiKeyRequest request
-    ) {
+    private List<RoleDescriptor> randomRoleDescriptors() {
+        int caseNo = randomIntBetween(0, 2);
+        return switch (caseNo) {
+            case 0 -> List.of(new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null));
+            case 1 -> List.of(
+                new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null),
+                randomValueOtherThanMany(
+                    rd -> RoleDescriptorRequestValidator.validate(rd) != null,
+                    () -> RoleDescriptorTests.randomRoleDescriptor(false)
+                )
+            );
+            case 2 -> null;
+            default -> throw new IllegalStateException("unexpected case no");
+        };
+    }
+
+    private void doTestUpdateApiKeyNotFound(UpdateApiKeyRequest request) {
         final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
-        serviceWithNodeName.service().updateApiKey(authentication, request, Set.of(), listener);
+        final Client client = client().filterWithHeader(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(TEST_USER_NAME, TEST_PASSWORD_SECURE_STRING))
+        );
+        client.execute(UpdateApiKeyAction.INSTANCE, request, listener);
         final var ex = expectThrows(ExecutionException.class, listener::get);
         assertThat(ex.getCause(), instanceOf(ResourceNotFoundException.class));
         assertThat(ex.getMessage(), containsString("no API key owned by requesting user found for ID [" + request.getId() + "]"));
     }
 
-    private static Authentication fileRealmAuth(String nodeName, String userName, String roleName) {
-        boolean includeDomain = randomBoolean();
-        final var realmName = "file";
-        final String realmType = FileRealmSettings.TYPE;
-        return randomValueOtherThanMany(
-            Authentication::isApiKey,
-            () -> AuthenticationTestHelper.builder()
-                .user(new User(userName, roleName))
-                .realmRef(
-                    new Authentication.RealmRef(
-                        realmName,
-                        realmType,
-                        nodeName,
-                        includeDomain
-                            ? new RealmDomain(
-                                ESTestCase.randomAlphaOfLengthBetween(3, 8),
-                                Set.of(new RealmConfig.RealmIdentifier(realmType, realmName))
-                            )
-                            : null
-                    )
-                )
-                .build()
-        );
-    }
-
-    private void expectMetadataForApiKey(Map<String, Object> expectedMetadata, Map<String, Object> actualRawApiKeyDoc) {
+    private void expectMetadataForApiKey(final Map<String, Object> expectedMetadata, final Map<String, Object> actualRawApiKeyDoc) {
         assertNotNull(actualRawApiKeyDoc);
         @SuppressWarnings("unchecked")
         final var actualMetadata = (Map<String, Object>) actualRawApiKeyDoc.get("metadata_flattened");
@@ -1720,10 +1775,10 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
     }
 
     @SuppressWarnings("unchecked")
-    private void expectRoleDescriptorForApiKey(
-        String roleDescriptorType,
-        Collection<RoleDescriptor> expectedRoleDescriptors,
-        Map<String, Object> actualRawApiKeyDoc
+    private void expectRoleDescriptorsForApiKey(
+        final String roleDescriptorType,
+        final Collection<RoleDescriptor> expectedRoleDescriptors,
+        final Map<String, Object> actualRawApiKeyDoc
     ) throws IOException {
         assertNotNull(actualRawApiKeyDoc);
         assertThat(roleDescriptorType, in(new String[] { "role_descriptors", "limited_by_role_descriptors" }));
@@ -1743,12 +1798,11 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
     }
 
     private Map<String, Object> getApiKeyDocument(String apiKeyId) {
-        final GetResponse getResponse = client().execute(GetAction.INSTANCE, new GetRequest(SECURITY_MAIN_ALIAS, apiKeyId)).actionGet();
-        return getResponse.getSource();
+        return client().execute(GetAction.INSTANCE, new GetRequest(SECURITY_MAIN_ALIAS, apiKeyId)).actionGet().getSource();
     }
 
     private ServiceWithNodeName getServiceWithNodeName() {
-        final var nodeName = internalCluster().getNodeNames()[0];
+        final var nodeName = randomFrom(internalCluster().getNodeNames());
         final var service = internalCluster().getInstance(ApiKeyService.class, nodeName);
         return new ServiceWithNodeName(service, nodeName);
     }
@@ -1967,10 +2021,19 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
     }
 
     private void createUserWithRunAsRole(Map<String, String> authHeaders) throws ExecutionException, InterruptedException {
+        createNativeRealmUser("user_with_run_as_role", "run_as_role", SecuritySettingsSource.TEST_PASSWORD_HASHED, authHeaders);
+    }
+
+    private void createNativeRealmUser(
+        final String username,
+        final String role,
+        final String passwordHashed,
+        final Map<String, String> authHeaders
+    ) throws ExecutionException, InterruptedException {
         final PutUserRequest putUserRequest = new PutUserRequest();
-        putUserRequest.username("user_with_run_as_role");
-        putUserRequest.roles("run_as_role");
-        putUserRequest.passwordHash(SecuritySettingsSource.TEST_PASSWORD_HASHED.toCharArray());
+        putUserRequest.username(username);
+        putUserRequest.roles(role);
+        putUserRequest.passwordHash(passwordHashed.toCharArray());
         PlainActionFuture<PutUserResponse> listener = new PlainActionFuture<>();
         final Client client = client().filterWithHeader(authHeaders);
         client.execute(PutUserAction.INSTANCE, putUserRequest, listener);
@@ -1978,6 +2041,20 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         assertTrue(putUserResponse.created());
     }
 
+    private RoleDescriptor putRoleWithClusterPrivileges(final String nativeRealmRoleName, String... clusterPrivileges)
+        throws InterruptedException, ExecutionException {
+        final PutRoleRequest putRoleRequest = new PutRoleRequest();
+        putRoleRequest.name(nativeRealmRoleName);
+        for (final String clusterPrivilege : clusterPrivileges) {
+            putRoleRequest.cluster(clusterPrivilege);
+        }
+        final PlainActionFuture<PutRoleResponse> roleListener = new PlainActionFuture<>();
+        client().filterWithHeader(Map.of("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING)))
+            .execute(PutRoleAction.INSTANCE, putRoleRequest, roleListener);
+        assertNotNull(roleListener.get());
+        return putRoleRequest.roleDescriptor();
+    }
+
     private Client getClientForRunAsUser() {
         return client().filterWithHeader(
             Map.of(
@@ -1989,6 +2066,18 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         );
     }
 
+    private UpdateApiKeyResponse executeUpdateApiKey(
+        final String username,
+        final UpdateApiKeyRequest request,
+        final PlainActionFuture<UpdateApiKeyResponse> listener
+    ) throws InterruptedException, ExecutionException {
+        final Client client = client().filterWithHeader(
+            Collections.singletonMap("Authorization", basicAuthHeaderValue(username, TEST_PASSWORD_SECURE_STRING))
+        );
+        client.execute(UpdateApiKeyAction.INSTANCE, request, listener);
+        return listener.get();
+    }
+
     private void assertErrorMessage(final ElasticsearchSecurityException ese, String action, String userName, String apiKeyId) {
         assertThat(
             ese,

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

@@ -98,6 +98,7 @@ import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.GrantApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
@@ -179,6 +180,7 @@ import org.elasticsearch.xpack.security.action.apikey.TransportGetApiKeyAction;
 import org.elasticsearch.xpack.security.action.apikey.TransportGrantApiKeyAction;
 import org.elasticsearch.xpack.security.action.apikey.TransportInvalidateApiKeyAction;
 import org.elasticsearch.xpack.security.action.apikey.TransportQueryApiKeyAction;
+import org.elasticsearch.xpack.security.action.apikey.TransportUpdateApiKeyAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportKibanaEnrollmentAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
@@ -276,6 +278,7 @@ import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestQueryApiKeyAction;
+import org.elasticsearch.xpack.security.rest.action.apikey.RestUpdateApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestKibanaEnrollAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
@@ -1221,6 +1224,7 @@ public class Security extends Plugin
             new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class),
             new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class),
             new ActionHandler<>(QueryApiKeyAction.INSTANCE, TransportQueryApiKeyAction.class),
+            new ActionHandler<>(UpdateApiKeyAction.INSTANCE, TransportUpdateApiKeyAction.class),
             new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class),
             new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class),
             new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class),
@@ -1298,6 +1302,7 @@ public class Security extends Plugin
             new RestPutPrivilegesAction(settings, getLicenseState()),
             new RestDeletePrivilegesAction(settings, getLicenseState()),
             new RestCreateApiKeyAction(settings, getLicenseState()),
+            new RestUpdateApiKeyAction(settings, getLicenseState()),
             new RestGrantApiKeyAction(settings, getLicenseState()),
             new RestInvalidateApiKeyAction(settings, getLicenseState()),
             new RestGetApiKeyAction(settings, getLicenseState()),

+ 69 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateApiKeyAction.java

@@ -0,0 +1,69 @@
+/*
+ * 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.apikey;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xpack.core.security.SecurityContext;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator;
+import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
+
+public final class TransportUpdateApiKeyAction extends HandledTransportAction<UpdateApiKeyRequest, UpdateApiKeyResponse> {
+
+    private final ApiKeyService apiKeyService;
+    private final SecurityContext securityContext;
+    private final ApiKeyGenerator apiKeyGenerator;
+
+    @Inject
+    public TransportUpdateApiKeyAction(
+        final TransportService transportService,
+        final ActionFilters actionFilters,
+        final ApiKeyService apiKeyService,
+        final SecurityContext context,
+        final CompositeRolesStore rolesStore,
+        final NamedXContentRegistry xContentRegistry
+    ) {
+        super(UpdateApiKeyAction.NAME, transportService, actionFilters, UpdateApiKeyRequest::new);
+        this.apiKeyService = apiKeyService;
+        this.securityContext = context;
+        this.apiKeyGenerator = new ApiKeyGenerator(apiKeyService, rolesStore, xContentRegistry);
+    }
+
+    @Override
+    protected void doExecute(Task task, UpdateApiKeyRequest request, ActionListener<UpdateApiKeyResponse> listener) {
+        final var authentication = securityContext.getAuthentication();
+        if (authentication == null) {
+            listener.onFailure(new IllegalStateException("authentication is required"));
+            return;
+        } else if (authentication.isApiKey()) {
+            listener.onFailure(
+                new IllegalArgumentException("authentication via API key not supported: only the owner user can update an API key")
+            );
+            return;
+        }
+
+        // TODO generalize `ApiKeyGenerator` to handle updates
+        apiKeyService.ensureEnabled();
+        apiKeyGenerator.getUserRoleDescriptors(
+            authentication,
+            ActionListener.wrap(
+                roleDescriptors -> apiKeyService.updateApiKey(authentication, request, roleDescriptors, listener),
+                listener::onFailure
+            )
+        );
+    }
+}

+ 41 - 40
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java

@@ -365,7 +365,9 @@ public class ApiKeyService {
             listener.onFailure(new IllegalArgumentException("authentication must be provided"));
             return;
         } else if (authentication.isApiKey()) {
-            listener.onFailure(new IllegalArgumentException("authentication via an API key is not supported for updating API keys"));
+            listener.onFailure(
+                new IllegalArgumentException("authentication via API key not supported: only the owner user can update an API key")
+            );
             return;
         }
 
@@ -378,10 +380,12 @@ public class ApiKeyService {
                 throw new ResourceNotFoundException("no API key owned by requesting user found for ID [" + apiKeyId + "]");
             }
 
-            validateCurrentApiKeyDocForUpdate(apiKeyId, authentication, single(apiKeyId, versionedDocs).doc());
+            final VersionedApiKeyDoc versionedDoc = singleDoc(apiKeyId, versionedDocs);
+
+            validateCurrentApiKeyDocForUpdate(apiKeyId, authentication, versionedDoc.doc());
 
             executeBulkRequest(
-                buildBulkRequestForUpdate(versionedDocs, authentication, request, userRoles),
+                buildBulkRequestForUpdate(versionedDoc, authentication, request, userRoles),
                 ActionListener.wrap(bulkResponse -> translateResponseAndClearCache(apiKeyId, bulkResponse, listener), listener::onFailure)
             );
         }, listener::onFailure));
@@ -1216,7 +1220,7 @@ public class ApiKeyService {
         }
     }
 
-    private static VersionedApiKeyDoc single(final String apiKeyId, final Collection<VersionedApiKeyDoc> elements) {
+    private static VersionedApiKeyDoc singleDoc(final String apiKeyId, final Collection<VersionedApiKeyDoc> elements) {
         if (elements.size() != 1) {
             final var message = "expected single API key doc with ID ["
                 + apiKeyId
@@ -1230,50 +1234,47 @@ public class ApiKeyService {
     }
 
     private BulkRequest buildBulkRequestForUpdate(
-        final Collection<VersionedApiKeyDoc> currentVersionedDocs,
+        final VersionedApiKeyDoc versionedDoc,
         final Authentication authentication,
         final UpdateApiKeyRequest request,
         final Set<RoleDescriptor> userRoles
     ) throws IOException {
-        assert currentVersionedDocs.isEmpty() == false;
+        logger.trace(
+            "Building update request for API key doc [{}] with seqNo [{}] and primaryTerm [{}]",
+            request.getId(),
+            versionedDoc.seqNo(),
+            versionedDoc.primaryTerm()
+        );
+        final var currentDocVersion = Version.fromId(versionedDoc.doc().version);
         final var targetDocVersion = clusterService.state().nodes().getMinNodeVersion();
-        final var bulkRequestBuilder = client.prepareBulk();
-        for (final VersionedApiKeyDoc apiKeyDoc : currentVersionedDocs) {
-            logger.trace(
-                "Building update request for API key doc [{}] with seqNo [{}] and primaryTerm [{}]",
+        assert currentDocVersion.onOrBefore(targetDocVersion) : "current API key doc version must be on or before target version";
+        if (currentDocVersion.before(targetDocVersion)) {
+            logger.debug(
+                "API key update for [{}] will update version from [{}] to [{}]",
                 request.getId(),
-                apiKeyDoc.seqNo(),
-                apiKeyDoc.primaryTerm()
-            );
-            final var currentDocVersion = Version.fromId(apiKeyDoc.doc().version);
-            assert currentDocVersion.onOrBefore(targetDocVersion) : "current API key doc version must be on or before target version";
-            if (currentDocVersion.before(targetDocVersion)) {
-                logger.debug(
-                    "API key update for [{}] will update version from [{}] to [{}]",
-                    request.getId(),
-                    currentDocVersion,
-                    targetDocVersion
-                );
-            }
-            bulkRequestBuilder.add(
-                client.prepareIndex(SECURITY_MAIN_ALIAS)
-                    .setId(request.getId())
-                    .setSource(
-                        buildUpdatedDocument(
-                            apiKeyDoc.doc(),
-                            authentication,
-                            userRoles,
-                            request.getRoleDescriptors(),
-                            targetDocVersion,
-                            request.getMetadata()
-                        )
-                    )
-                    .setIfSeqNo(apiKeyDoc.seqNo())
-                    .setIfPrimaryTerm(apiKeyDoc.primaryTerm())
-                    .setOpType(DocWriteRequest.OpType.INDEX)
-                    .request()
+                currentDocVersion,
+                targetDocVersion
             );
         }
+        final var bulkRequestBuilder = client.prepareBulk();
+        bulkRequestBuilder.add(
+            client.prepareIndex(SECURITY_MAIN_ALIAS)
+                .setId(request.getId())
+                .setSource(
+                    buildUpdatedDocument(
+                        versionedDoc.doc(),
+                        authentication,
+                        userRoles,
+                        request.getRoleDescriptors(),
+                        targetDocVersion,
+                        request.getMetadata()
+                    )
+                )
+                .setIfSeqNo(versionedDoc.seqNo())
+                .setIfPrimaryTerm(versionedDoc.primaryTerm())
+                .setOpType(DocWriteRequest.OpType.INDEX)
+                .request()
+        );
         bulkRequestBuilder.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL);
         return bulkRequestBuilder.request();
     }

+ 11 - 2
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java

@@ -41,6 +41,16 @@ public class ApiKeyGenerator {
         }
         apiKeyService.ensureEnabled();
 
+        getUserRoleDescriptors(
+            authentication,
+            ActionListener.wrap(
+                roleDescriptors -> apiKeyService.createApiKey(authentication, request, roleDescriptors, listener),
+                listener::onFailure
+            )
+        );
+    }
+
+    public void getUserRoleDescriptors(Authentication authentication, ActionListener<Set<RoleDescriptor>> listener) {
         final ActionListener<Set<RoleDescriptor>> roleDescriptorsListener = ActionListener.wrap(roleDescriptors -> {
             for (RoleDescriptor rd : roleDescriptors) {
                 try {
@@ -50,7 +60,7 @@ public class ApiKeyGenerator {
                     return;
                 }
             }
-            apiKeyService.createApiKey(authentication, request, roleDescriptors, listener);
+            listener.onResponse(roleDescriptors);
         }, listener::onFailure);
 
         final Subject effectiveSubject = authentication.getEffectiveSubject();
@@ -64,7 +74,6 @@ public class ApiKeyGenerator {
         rolesStore.getRoleDescriptorsList(effectiveSubject, ActionListener.wrap(roleDescriptorsList -> {
             assert roleDescriptorsList.size() == 1;
             roleDescriptorsListener.onResponse(roleDescriptorsList.iterator().next());
-
         }, roleDescriptorsListener::onFailure));
     }
 }

+ 74 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateApiKeyAction.java

@@ -0,0 +1,74 @@
+/*
+ * 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.apikey;
+
+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.apikey.UpdateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.elasticsearch.rest.RestRequest.Method.PUT;
+import static org.elasticsearch.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+public final class RestUpdateApiKeyAction extends SecurityBaseRestHandler {
+
+    @SuppressWarnings("unchecked")
+    static final ConstructingObjectParser<Payload, Void> PARSER = new ConstructingObjectParser<>(
+        "update_api_key_request_payload",
+        a -> new Payload((List<RoleDescriptor>) a[0], (Map<String, Object>) a[1])
+    );
+
+    static {
+        PARSER.declareNamedObjects(optionalConstructorArg(), (p, c, n) -> {
+            p.nextToken();
+            return RoleDescriptor.parse(n, p, false);
+        }, new ParseField("role_descriptors"));
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata"));
+    }
+
+    public RestUpdateApiKeyAction(final Settings settings, final XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(new Route(PUT, "/_security/api_key/{ids}"));
+    }
+
+    @Override
+    public String getName() {
+        return "xpack_security_update_api_key";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+        // Note that we use `ids` here even though we only support a single id. This is because this route shares a path prefix with
+        // `RestClearApiKeyCacheAction` and our current REST implementation requires that path params have the same wildcard if their paths
+        // share a prefix
+        final var apiKeyId = request.param("ids");
+        final var payload = request.hasContent() == false ? new Payload(null, null) : PARSER.parse(request.contentParser(), null);
+        return channel -> client.execute(
+            UpdateApiKeyAction.INSTANCE,
+            new UpdateApiKeyRequest(apiKeyId, payload.roleDescriptors, payload.metadata),
+            new RestToXContentListener<>(channel)
+        );
+    }
+
+    record Payload(List<RoleDescriptor> roleDescriptors, Map<String, Object> metadata) {}
+}

+ 5 - 1
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authz/RoleDescriptorTests.java

@@ -627,6 +627,10 @@ public class RoleDescriptorTests extends ESTestCase {
     }
 
     public static RoleDescriptor randomRoleDescriptor() {
+        return randomRoleDescriptor(true);
+    }
+
+    public static RoleDescriptor randomRoleDescriptor(boolean allowReservedMetadata) {
         final RoleDescriptor.IndicesPrivileges[] indexPrivileges = new RoleDescriptor.IndicesPrivileges[randomIntBetween(0, 3)];
         for (int i = 0; i < indexPrivileges.length; i++) {
             final RoleDescriptor.IndicesPrivileges.Builder builder = RoleDescriptor.IndicesPrivileges.builder()
@@ -695,7 +699,7 @@ public class RoleDescriptorTests extends ESTestCase {
         final Map<String, Object> metadata = new HashMap<>();
         while (randomBoolean()) {
             String key = randomAlphaOfLengthBetween(4, 12);
-            if (randomBoolean()) {
+            if (allowReservedMetadata && randomBoolean()) {
                 key = MetadataUtils.RESERVED_PREFIX + key;
             }
             final Object value = randomBoolean() ? randomInt() : randomAlphaOfLengthBetween(3, 50);

+ 58 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateApiKeyActionTests.java

@@ -0,0 +1,58 @@
+/*
+ * 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.apikey;
+
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.test.rest.RestActionTestCase;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse;
+import org.junit.Before;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.Matchers.instanceOf;
+import static org.mockito.Mockito.mock;
+
+public class RestUpdateApiKeyActionTests extends RestActionTestCase {
+
+    private RestUpdateApiKeyAction restAction;
+    private AtomicReference<UpdateApiKeyRequest> requestHolder;
+
+    @Before
+    public void init() {
+        final Settings settings = Settings.builder().put(XPackSettings.SECURITY_ENABLED.getKey(), true).build();
+        final XPackLicenseState licenseState = mock(XPackLicenseState.class);
+        requestHolder = new AtomicReference<>();
+        restAction = new RestUpdateApiKeyAction(settings, licenseState);
+        controller().registerHandler(restAction);
+        verifyingClient.setExecuteVerifier(((actionType, actionRequest) -> {
+            assertThat(actionRequest, instanceOf(UpdateApiKeyRequest.class));
+            requestHolder.set((UpdateApiKeyRequest) actionRequest);
+            return new UpdateApiKeyResponse(true);
+        }));
+    }
+
+    public void testAbsentRoleDescriptorsAndMetadataSetToNull() {
+        final var apiKeyId = "api_key_id";
+        final var builder = new FakeRestRequest.Builder(xContentRegistry()).withMethod(RestRequest.Method.PUT)
+            .withPath("/_security/api_key/" + apiKeyId);
+        if (randomBoolean()) {
+            builder.withContent(new BytesArray("{}"), XContentType.JSON);
+        }
+
+        dispatchRequest(builder.build());
+
+        assertEquals(new UpdateApiKeyRequest(apiKeyId, null, null), requestHolder.get());
+    }
+}