Browse Source

Support updates of API key attributes [service layer] (#87924)

Service level 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.

Updatable attributes are role_descriptors and metadata. Several
other attributes are updated automatically, on every update call,
including limited_by_role_descriptors, creator, and version. API
key attributes are replaced, not merged.

On every update, the API key doc cache is cleared for the updated API
key.

This PR implements the necessary service layer changes in
ApiKeyService. I will integrate this with the REST and transport
layers in a subsequent PR.

Relates: #87870
Nikolaj Volgushev 3 years ago
parent
commit
a0c9026c8a

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

@@ -0,0 +1,96 @@
+/*
+ * 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.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.xpack.core.security.action.role.RoleDescriptorRequestValidator;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+import org.elasticsearch.xpack.core.security.support.MetadataUtils;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.elasticsearch.action.ValidateActions.addValidationError;
+
+public final class UpdateApiKeyRequest extends ActionRequest {
+
+    private final String id;
+    @Nullable
+    private final Map<String, Object> metadata;
+    @Nullable
+    private final List<RoleDescriptor> roleDescriptors;
+
+    public UpdateApiKeyRequest(String id, @Nullable List<RoleDescriptor> roleDescriptors, @Nullable Map<String, Object> metadata) {
+        this.id = Objects.requireNonNull(id, "API key ID must not be null");
+        this.roleDescriptors = roleDescriptors;
+        this.metadata = metadata;
+    }
+
+    public UpdateApiKeyRequest(StreamInput in) throws IOException {
+        super(in);
+        this.id = in.readString();
+        this.roleDescriptors = readOptionalList(in);
+        this.metadata = in.readMap();
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        ActionRequestValidationException validationException = null;
+        if (metadata != null && MetadataUtils.containsReservedMetadata(metadata)) {
+            validationException = addValidationError(
+                "API key metadata keys may not start with [" + MetadataUtils.RESERVED_PREFIX + "]",
+                validationException
+            );
+        }
+        if (roleDescriptors != null) {
+            for (RoleDescriptor roleDescriptor : roleDescriptors) {
+                validationException = RoleDescriptorRequestValidator.validate(roleDescriptor, validationException);
+            }
+        }
+        return validationException;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeString(id);
+        writeOptionalList(out);
+        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 String getId() {
+        return id;
+    }
+
+    public Map<String, Object> getMetadata() {
+        return metadata;
+    }
+
+    public List<RoleDescriptor> getRoleDescriptors() {
+        return roleDescriptors;
+    }
+}

+ 58 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyResponse.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.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.xcontent.ToXContentObject;
+import org.elasticsearch.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.util.Objects;
+
+public final class UpdateApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable {
+    private final boolean updated;
+
+    public UpdateApiKeyResponse(boolean updated) {
+        this.updated = updated;
+    }
+
+    public UpdateApiKeyResponse(StreamInput in) throws IOException {
+        super(in);
+        this.updated = in.readBoolean();
+    }
+
+    public boolean isUpdated() {
+        return updated;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        return builder.startObject().field("updated", updated).endObject();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeBoolean(updated);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        UpdateApiKeyResponse that = (UpdateApiKeyResponse) o;
+        return updated == that.updated;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(updated);
+    }
+}

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

@@ -0,0 +1,53 @@
+/*
+ * 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.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class UpdateApiKeyRequestTests extends ESTestCase {
+
+    public void testNullValuesValid() {
+        final var request = new UpdateApiKeyRequest("id", null, null);
+        assertNull(request.validate());
+    }
+
+    public void testSerialization() throws IOException {
+        final boolean roleDescriptorsPresent = randomBoolean();
+        final List<RoleDescriptor> descriptorList;
+        if (roleDescriptorsPresent == false) {
+            descriptorList = null;
+        } else {
+            final int numDescriptors = randomIntBetween(0, 4);
+            descriptorList = new ArrayList<>();
+            for (int i = 0; i < numDescriptors; i++) {
+                descriptorList.add(new RoleDescriptor("role_" + i, new String[] { "all" }, null, null));
+            }
+        }
+
+        final var id = randomAlphaOfLength(10);
+        final var metadata = ApiKeyTests.randomMetadata();
+        final var request = new UpdateApiKeyRequest(id, descriptorList, metadata);
+
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            request.writeTo(out);
+            try (StreamInput in = out.bytes().streamInput()) {
+                final var serialized = new UpdateApiKeyRequest(in);
+                assertEquals(id, serialized.getId());
+                assertEquals(descriptorList, serialized.getRoleDescriptors());
+                assertEquals(metadata, request.getMetadata());
+            }
+        }
+    }
+}

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

@@ -8,6 +8,7 @@
 package org.elasticsearch.xpack.security.authc;
 
 import org.elasticsearch.ElasticsearchSecurityException;
+import org.elasticsearch.ResourceNotFoundException;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.action.DocWriteResponse;
 import org.elasticsearch.action.admin.cluster.node.info.NodeInfo;
@@ -16,6 +17,9 @@ import org.elasticsearch.action.admin.indices.close.CloseIndexResponse;
 import org.elasticsearch.action.admin.indices.refresh.RefreshAction;
 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;
@@ -33,11 +37,14 @@ import org.elasticsearch.common.util.set.Sets;
 import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.rest.RestStatus;
+import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.SecurityIntegTestCase;
 import org.elasticsearch.test.SecuritySettingsSource;
 import org.elasticsearch.test.TestSecurityClient;
+import org.elasticsearch.test.XContentTestUtils;
 import org.elasticsearch.test.rest.ObjectPath;
 import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheAction;
 import org.elasticsearch.xpack.core.security.action.ClearSecurityCacheRequest;
@@ -52,6 +59,8 @@ 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.UpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse;
 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;
@@ -59,8 +68,14 @@ import org.elasticsearch.xpack.core.security.action.user.PutUserAction;
 import org.elasticsearch.xpack.core.security.action.user.PutUserRequest;
 import org.elasticsearch.xpack.core.security.action.user.PutUserResponse;
 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.user.User;
+import org.elasticsearch.xpack.security.authz.RoleDescriptorTests;
 import org.elasticsearch.xpack.security.transport.filter.IPFilter;
 import org.junit.After;
 import org.junit.Before;
@@ -72,6 +87,7 @@ import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -89,6 +105,7 @@ 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.SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING;
 import static org.elasticsearch.test.TestMatchers.throwableWithMessage;
 import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue;
@@ -96,11 +113,15 @@ import static org.elasticsearch.xpack.core.security.test.TestRestrictedIndices.I
 import static org.elasticsearch.xpack.security.Security.SECURITY_CRYPTO_THREAD_POOL_NAME;
 import static org.elasticsearch.xpack.security.support.SecuritySystemIndices.SECURITY_MAIN_ALIAS;
 import static org.hamcrest.Matchers.arrayWithSize;
+import static org.hamcrest.Matchers.contains;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasKey;
 import static org.hamcrest.Matchers.in;
+import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.is;
 import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.nullValue;
@@ -1405,6 +1426,335 @@ 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 var apiKeyId = createdApiKey.v1().getId();
+
+        final var newRoleDescriptors = randomRoleDescriptors();
+        final boolean nullRoleDescriptors = newRoleDescriptors == null;
+        final var expectedLimitedByRoleDescriptors = Set.of(
+            new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, 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();
+
+        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();
+        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("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));
+
+        // Document updated as expected
+        final var updatedApiKeyDoc = getApiKeyDocument(apiKeyId);
+        expectMetadataForApiKey(expectedMetadata, updatedApiKeyDoc);
+        expectRoleDescriptorForApiKey("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);
+
+            // Create user action unauthorized because we did not update key role; it only has `monitor` cluster priv
+            final Map<String, String> authorizationHeaders = Collections.singletonMap(
+                "Authorization",
+                "ApiKey " + getBase64EncodedApiKeyValue(createdApiKey.v1().getId(), createdApiKey.v1().getKey())
+            );
+            ExecutionException e = expectThrows(ExecutionException.class, () -> createUserWithRunAsRole(authorizationHeaders));
+            assertThat(e.getMessage(), containsString("unauthorized"));
+            assertThat(e.getCause(), instanceOf(ElasticsearchSecurityException.class));
+        } else {
+            expectRoleDescriptorForApiKey("role_descriptors", newRoleDescriptors, updatedApiKeyDoc);
+            // Create user action authorized because we updated key role to `all` cluster priv
+            final var authorizationHeaders = Collections.singletonMap(
+                "Authorization",
+                "ApiKey " + getBase64EncodedApiKeyValue(createdApiKey.v1().getId(), createdApiKey.v1().getKey())
+            );
+            createUserWithRunAsRole(authorizationHeaders);
+        }
+    }
+
+    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 testUpdateApiKeyNotFoundScenarios() throws ExecutionException, InterruptedException {
+        final Tuple<CreateApiKeyResponse, Map<String, Object>> createdApiKey = createApiKey(ES_TEST_ROOT_USER, 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();
+
+        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())
+        );
+
+        // 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
+        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())
+        );
+    }
+
+    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 boolean invalidated = randomBoolean();
+        if (invalidated) {
+            final PlainActionFuture<InvalidateApiKeyResponse> listener = new PlainActionFuture<>();
+            client().execute(InvalidateApiKeyAction.INSTANCE, InvalidateApiKeyRequest.usingRealmName("file"), listener);
+            final var invalidateResponse = listener.get();
+            assertThat(invalidateResponse.getErrors(), empty());
+            assertThat(invalidateResponse.getInvalidatedApiKeys(), contains(apiKeyId));
+        }
+        if (invalidated == false || randomBoolean()) {
+            final var dayBefore = Instant.now().minus(1L, ChronoUnit.DAYS);
+            assertTrue(Instant.now().isAfter(dayBefore));
+            final var expirationDateUpdatedResponse = client().prepareUpdate(SECURITY_MAIN_ALIAS, apiKeyId)
+                .setDoc("expiration_time", dayBefore.toEpochMilli())
+                .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE)
+                .get();
+            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
+            );
+        final var ex = expectThrows(ExecutionException.class, updateListener::get);
+
+        assertThat(ex.getCause(), instanceOf(IllegalArgumentException.class));
+        if (invalidated) {
+            assertThat(ex.getMessage(), containsString("cannot update invalidated API key [" + apiKeyId + "]"));
+        } 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);
+
+        assertThat(apiKeysNotAllowedEx.getCause(), instanceOf(IllegalArgumentException.class));
+        assertThat(
+            apiKeysNotAllowedEx.getMessage(),
+            containsString("authentication via an API key is not supported for updating API keys")
+        );
+    }
+
+    public void testUpdateApiKeyClearsApiKeyDocCache() throws IOException, ExecutionException, InterruptedException {
+        final List<ServiceWithNodeName> services = Arrays.stream(internalCluster().getNodeNames())
+            .map(n -> new ServiceWithNodeName(internalCluster().getInstance(ApiKeyService.class, n), n))
+            .toList();
+
+        // Create two API keys and authenticate with them
+        final var apiKey1 = createApiKeyAndAuthenticateWithIt();
+        final var apiKey2 = createApiKeyAndAuthenticateWithIt();
+
+        // Find out which nodes handled the above authentication requests
+        final var serviceWithNameForDoc1 = services.stream()
+            .filter(s -> s.service().getDocCache().get(apiKey1.v1()) != null)
+            .findFirst()
+            .orElseThrow();
+        final var serviceWithNameForDoc2 = services.stream()
+            .filter(s -> s.service().getDocCache().get(apiKey2.v1()) != null)
+            .findFirst()
+            .orElseThrow();
+        final var serviceForDoc1 = serviceWithNameForDoc1.service();
+        final var serviceForDoc2 = serviceWithNameForDoc2.service();
+        assertNotNull(serviceForDoc1.getFromCache(apiKey1.v1()));
+        assertNotNull(serviceForDoc2.getFromCache(apiKey2.v1()));
+
+        final boolean sameServiceNode = serviceWithNameForDoc1 == serviceWithNameForDoc2;
+        if (sameServiceNode) {
+            assertEquals(2, serviceForDoc1.getDocCache().count());
+        } else {
+            assertEquals(1, serviceForDoc1.getDocCache().count());
+            assertEquals(1, serviceForDoc2.getDocCache().count());
+        }
+
+        final int serviceForDoc1AuthCacheCount = serviceForDoc1.getApiKeyAuthCache().count();
+        final int serviceForDoc2AuthCacheCount = serviceForDoc2.getApiKeyAuthCache().count();
+
+        // 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 var response = listener.get();
+        assertNotNull(response);
+        assertTrue(response.isUpdated());
+
+        // The cache entry should be gone for the first key
+        if (sameServiceNode) {
+            assertEquals(1, serviceForDoc1.getDocCache().count());
+            assertNull(serviceForDoc1.getDocCache().get(apiKey1.v1()));
+            assertNotNull(serviceForDoc1.getDocCache().get(apiKey2.v1()));
+        } else {
+            assertEquals(0, serviceForDoc1.getDocCache().count());
+            assertEquals(1, serviceForDoc2.getDocCache().count());
+        }
+
+        // Auth cache has not been affected
+        assertEquals(serviceForDoc1AuthCacheCount, serviceForDoc1.getApiKeyAuthCache().count());
+        assertEquals(serviceForDoc2AuthCacheCount, serviceForDoc2.getApiKeyAuthCache().count());
+    }
+
+    private void doTestUpdateApiKeyNotFound(
+        ServiceWithNodeName serviceWithNodeName,
+        Authentication authentication,
+        UpdateApiKeyRequest request
+    ) {
+        final PlainActionFuture<UpdateApiKeyResponse> listener = new PlainActionFuture<>();
+        serviceWithNodeName.service().updateApiKey(authentication, request, Set.of(), 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) {
+        assertNotNull(actualRawApiKeyDoc);
+        @SuppressWarnings("unchecked")
+        final var actualMetadata = (Map<String, Object>) actualRawApiKeyDoc.get("metadata_flattened");
+        assertThat("for api key doc " + actualRawApiKeyDoc, actualMetadata, equalTo(expectedMetadata));
+    }
+
+    @SuppressWarnings("unchecked")
+    private void expectRoleDescriptorForApiKey(
+        String roleDescriptorType,
+        Collection<RoleDescriptor> expectedRoleDescriptors,
+        Map<String, Object> actualRawApiKeyDoc
+    ) throws IOException {
+        assertNotNull(actualRawApiKeyDoc);
+        assertThat(roleDescriptorType, in(new String[] { "role_descriptors", "limited_by_role_descriptors" }));
+        final var rawRoleDescriptor = (Map<String, Object>) actualRawApiKeyDoc.get(roleDescriptorType);
+        assertEquals(expectedRoleDescriptors.size(), rawRoleDescriptor.size());
+        for (RoleDescriptor expectedRoleDescriptor : expectedRoleDescriptors) {
+            assertThat(rawRoleDescriptor, hasKey(expectedRoleDescriptor.getName()));
+            final var descriptor = (Map<String, ?>) rawRoleDescriptor.get(expectedRoleDescriptor.getName());
+            final var roleDescriptor = RoleDescriptor.parse(
+                expectedRoleDescriptor.getName(),
+                XContentTestUtils.convertToXContent(descriptor, XContentType.JSON),
+                false,
+                XContentType.JSON
+            );
+            assertEquals(expectedRoleDescriptor, roleDescriptor);
+        }
+    }
+
+    private Map<String, Object> getApiKeyDocument(String apiKeyId) {
+        final GetResponse getResponse = client().execute(GetAction.INSTANCE, new GetRequest(SECURITY_MAIN_ALIAS, apiKeyId)).actionGet();
+        return getResponse.getSource();
+    }
+
+    private ServiceWithNodeName getServiceWithNodeName() {
+        final var nodeName = internalCluster().getNodeNames()[0];
+        final var service = internalCluster().getInstance(ApiKeyService.class, nodeName);
+        return new ServiceWithNodeName(service, nodeName);
+    }
+
+    private record ServiceWithNodeName(ApiKeyService service, String nodeName) {}
+
     private Tuple<String, String> createApiKeyAndAuthenticateWithIt() throws IOException {
         Client client = client().filterWithHeader(
             Collections.singletonMap("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING))
@@ -1535,6 +1885,11 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         }
     }
 
+    private Tuple<CreateApiKeyResponse, Map<String, Object>> createApiKey(String user, TimeValue expiration) {
+        final Tuple<List<CreateApiKeyResponse>, List<Map<String, Object>>> res = createApiKeys(user, 1, expiration, "monitor");
+        return new Tuple<>(res.v1().get(0), res.v2().get(0));
+    }
+
     private Tuple<List<CreateApiKeyResponse>, List<Map<String, Object>>> createApiKeys(int noOfApiKeys, TimeValue expiration) {
         return createApiKeys(ES_TEST_ROOT_USER, noOfApiKeys, expiration, "monitor");
     }
@@ -1608,14 +1963,16 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
      * This new helper method creates the user in the native realm.
      */
     private void createUserWithRunAsRole() throws ExecutionException, InterruptedException {
+        createUserWithRunAsRole(Map.of("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING)));
+    }
+
+    private void createUserWithRunAsRole(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());
         PlainActionFuture<PutUserResponse> listener = new PlainActionFuture<>();
-        final Client client = client().filterWithHeader(
-            Map.of("Authorization", basicAuthHeaderValue(ES_TEST_ROOT_USER, TEST_PASSWORD_SECURE_STRING))
-        );
+        final Client client = client().filterWithHeader(authHeaders);
         client.execute(PutUserAction.INSTANCE, putUserRequest, listener);
         final PutUserResponse putUserResponse = listener.get();
         assertTrue(putUserResponse.created());

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

@@ -12,9 +12,11 @@ import org.apache.logging.log4j.Logger;
 import org.elasticsearch.ElasticsearchException;
 import org.elasticsearch.ElasticsearchSecurityException;
 import org.elasticsearch.ExceptionsHelper;
+import org.elasticsearch.ResourceNotFoundException;
 import org.elasticsearch.Version;
 import org.elasticsearch.action.ActionListener;
 import org.elasticsearch.action.ActionRunnable;
+import org.elasticsearch.action.DocWriteRequest;
 import org.elasticsearch.action.DocWriteResponse;
 import org.elasticsearch.action.bulk.BulkAction;
 import org.elasticsearch.action.bulk.BulkItemResponse;
@@ -73,6 +75,7 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xcontent.XContentLocation;
 import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.security.ScrollHelper;
@@ -85,6 +88,8 @@ import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.apikey.InvalidateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.UpdateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationField;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
@@ -124,6 +129,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.LongAdder;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
@@ -248,6 +254,21 @@ public class ApiKeyService {
                     apiKeyAuthCache.invalidateAll();
                 }
             });
+            cacheInvalidatorRegistry.registerCacheInvalidator("api_key_doc", new CacheInvalidatorRegistry.CacheInvalidator() {
+                @Override
+                public void invalidate(Collection<String> keys) {
+                    if (apiKeyDocCache != null) {
+                        apiKeyDocCache.invalidate(keys);
+                    }
+                }
+
+                @Override
+                public void invalidateAll() {
+                    if (apiKeyDocCache != null) {
+                        apiKeyDocCache.invalidateAll();
+                    }
+                }
+            });
         } else {
             this.apiKeyAuthCache = null;
             this.apiKeyDocCache = null;
@@ -332,6 +353,58 @@ public class ApiKeyService {
         }));
     }
 
+    public void updateApiKey(
+        final Authentication authentication,
+        final UpdateApiKeyRequest request,
+        final Set<RoleDescriptor> userRoles,
+        final ActionListener<UpdateApiKeyResponse> listener
+    ) {
+        ensureEnabled();
+
+        if (authentication == null) {
+            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"));
+            return;
+        }
+
+        logger.debug("Updating API key [{}]", request.getId());
+
+        findVersionedApiKeyDocsForSubject(authentication, new String[] { request.getId() }, ActionListener.wrap((versionedDocs) -> {
+            final var apiKeyId = request.getId();
+
+            if (versionedDocs.isEmpty()) {
+                throw new ResourceNotFoundException("no API key owned by requesting user found for ID [" + apiKeyId + "]");
+            }
+
+            validateCurrentApiKeyDocForUpdate(apiKeyId, authentication, single(apiKeyId, versionedDocs).doc());
+
+            executeBulkRequest(
+                buildBulkRequestForUpdate(versionedDocs, authentication, request, userRoles),
+                ActionListener.wrap(bulkResponse -> translateResponseAndClearCache(apiKeyId, bulkResponse, listener), listener::onFailure)
+            );
+        }, listener::onFailure));
+    }
+
+    // package-private for testing
+    void validateCurrentApiKeyDocForUpdate(String apiKeyId, Authentication authentication, ApiKeyDoc apiKeyDoc) {
+        assert authentication.getEffectiveSubject().getUser().principal().equals(apiKeyDoc.creator.get("principal"));
+
+        if (apiKeyDoc.invalidated) {
+            throw new IllegalArgumentException("cannot update invalidated API key [" + apiKeyId + "]");
+        }
+
+        boolean expired = apiKeyDoc.expirationTime != -1 && clock.instant().isAfter(Instant.ofEpochMilli(apiKeyDoc.expirationTime));
+        if (expired) {
+            throw new IllegalArgumentException("cannot update expired API key [" + apiKeyId + "]");
+        }
+
+        if (Strings.isNullOrEmpty(apiKeyDoc.name)) {
+            throw new IllegalArgumentException("cannot update legacy API key [" + apiKeyId + "] without name");
+        }
+    }
+
     /**
      * package-private for testing
      */
@@ -346,56 +419,72 @@ public class ApiKeyService {
         Version version,
         @Nullable Map<String, Object> metadata
     ) throws IOException {
-        XContentBuilder builder = XContentFactory.jsonBuilder();
+        final XContentBuilder builder = XContentFactory.jsonBuilder();
         builder.startObject()
             .field("doc_type", "api_key")
             .field("creation_time", created.toEpochMilli())
             .field("expiration_time", expiration == null ? null : expiration.toEpochMilli())
             .field("api_key_invalidated", false);
 
-        byte[] utf8Bytes = null;
-        try {
-            utf8Bytes = CharArrays.toUtf8Bytes(apiKeyHashChars);
-            builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length);
-        } finally {
-            if (utf8Bytes != null) {
-                Arrays.fill(utf8Bytes, (byte) 0);
-            }
-        }
+        addApiKeyHash(builder, apiKeyHashChars);
+        addRoleDescriptors(builder, keyRoles);
+        addLimitedByRoleDescriptors(builder, userRoles);
 
-        // Save role_descriptors
-        builder.startObject("role_descriptors");
-        if (keyRoles != null && keyRoles.isEmpty() == false) {
-            for (RoleDescriptor descriptor : keyRoles) {
-                builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true));
-            }
-        }
-        builder.endObject();
+        builder.field("name", name).field("version", version.id).field("metadata_flattened", metadata);
+        addCreator(builder, authentication);
 
-        // Save limited_by_role_descriptors
-        builder.startObject("limited_by_role_descriptors");
-        for (RoleDescriptor descriptor : userRoles) {
-            builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true));
+        return builder.endObject();
+    }
+
+    static XContentBuilder buildUpdatedDocument(
+        final ApiKeyDoc currentApiKeyDoc,
+        final Authentication authentication,
+        final Set<RoleDescriptor> userRoles,
+        final List<RoleDescriptor> keyRoles,
+        final Version version,
+        final Map<String, Object> metadata
+    ) throws IOException {
+        final XContentBuilder builder = XContentFactory.jsonBuilder();
+        builder.startObject()
+            .field("doc_type", "api_key")
+            .field("creation_time", currentApiKeyDoc.creationTime)
+            .field("expiration_time", currentApiKeyDoc.expirationTime == -1 ? null : currentApiKeyDoc.expirationTime)
+            .field("api_key_invalidated", false);
+
+        addApiKeyHash(builder, currentApiKeyDoc.hash.toCharArray());
+
+        if (keyRoles != null) {
+            logger.trace(() -> format("Building API key doc with updated role descriptors [{}]", keyRoles));
+            addRoleDescriptors(builder, keyRoles);
+        } else {
+            assert currentApiKeyDoc.roleDescriptorsBytes != null;
+            builder.rawField("role_descriptors", currentApiKeyDoc.roleDescriptorsBytes.streamInput(), XContentType.JSON);
         }
-        builder.endObject();
 
-        builder.field("name", name).field("version", version.id).field("metadata_flattened", metadata);
-        {
-            builder.startObject("creator")
-                .field("principal", authentication.getUser().principal())
-                .field("full_name", authentication.getUser().fullName())
-                .field("email", authentication.getUser().email())
-                .field("metadata", authentication.getUser().metadata())
-                .field("realm", authentication.getSourceRealm().getName())
-                .field("realm_type", authentication.getSourceRealm().getType());
-            if (authentication.getSourceRealm().getDomain() != null) {
-                builder.field("realm_domain", authentication.getSourceRealm().getDomain());
-            }
-            builder.endObject();
+        addLimitedByRoleDescriptors(builder, userRoles);
+
+        builder.field("name", currentApiKeyDoc.name).field("version", version.id);
+
+        assert currentApiKeyDoc.metadataFlattened == null
+            || MetadataUtils.containsReservedMetadata(
+                XContentHelper.convertToMap(currentApiKeyDoc.metadataFlattened, false, XContentType.JSON).v2()
+            ) == false : "API key doc to be updated contains reserved metadata";
+        if (metadata != null) {
+            logger.trace(() -> format("Building API key doc with updated metadata [{}]", metadata));
+            builder.field("metadata_flattened", metadata);
+        } else {
+            builder.rawField(
+                "metadata_flattened",
+                currentApiKeyDoc.metadataFlattened == null
+                    ? ApiKeyDoc.NULL_BYTES.streamInput()
+                    : currentApiKeyDoc.metadataFlattened.streamInput(),
+                XContentType.JSON
+            );
         }
-        builder.endObject();
 
-        return builder;
+        addCreator(builder, authentication);
+
+        return builder.endObject();
     }
 
     void tryAuthenticate(ThreadContext ctx, ApiKeyCredentials credentials, ActionListener<AuthenticationResult<User>> listener) {
@@ -908,6 +997,7 @@ public class ApiKeyService {
                 apiKeyIds,
                 true,
                 false,
+                ApiKeyService::convertSearchHitToApiKeyInfo,
                 ActionListener.wrap(apiKeys -> {
                     if (apiKeys.isEmpty()) {
                         logger.debug(
@@ -919,10 +1009,7 @@ public class ApiKeyService {
                         );
                         invalidateListener.onResponse(InvalidateApiKeyResponse.emptyResponse());
                     } else {
-                        invalidateAllApiKeys(
-                            apiKeys.stream().map(apiKey -> apiKey.getId()).collect(Collectors.toSet()),
-                            invalidateListener
-                        );
+                        invalidateAllApiKeys(apiKeys.stream().map(ApiKey::getId).collect(Collectors.toSet()), invalidateListener);
                     }
                 }, invalidateListener::onFailure)
             );
@@ -933,11 +1020,12 @@ public class ApiKeyService {
         indexInvalidation(apiKeyIds, invalidateListener, null);
     }
 
-    private void findApiKeys(
+    private <T> void findApiKeys(
         final BoolQueryBuilder boolQuery,
         boolean filterOutInvalidatedKeys,
         boolean filterOutExpiredKeys,
-        ActionListener<Collection<ApiKey>> listener
+        final Function<SearchHit, T> hitParser,
+        final ActionListener<Collection<T>> listener
     ) {
         if (filterOutInvalidatedKeys) {
             boolQuery.filter(QueryBuilders.termQuery("api_key_invalidated", false));
@@ -959,12 +1047,7 @@ public class ApiKeyService {
                 .request();
             securityIndex.checkIndexVersionThenExecute(
                 listener::onFailure,
-                () -> ScrollHelper.fetchAllByEntity(
-                    client,
-                    request,
-                    new ContextPreservingActionListener<>(supplier, listener),
-                    ApiKeyService::convertSearchHitToApiKeyInfo
-                )
+                () -> ScrollHelper.fetchAllByEntity(client, request, new ContextPreservingActionListener<>(supplier, listener), hitParser)
             );
         }
     }
@@ -985,14 +1068,33 @@ public class ApiKeyService {
         }
     }
 
-    private void findApiKeysForUserRealmApiKeyIdAndNameCombination(
+    private void findVersionedApiKeyDocsForSubject(
+        final Authentication authentication,
+        final String[] apiKeyIds,
+        final ActionListener<Collection<VersionedApiKeyDoc>> listener
+    ) {
+        assert authentication.isApiKey() == false;
+        findApiKeysForUserRealmApiKeyIdAndNameCombination(
+            getOwnersRealmNames(authentication),
+            authentication.getEffectiveSubject().getUser().principal(),
+            null,
+            apiKeyIds,
+            false,
+            false,
+            ApiKeyService::convertSearchHitToVersionedApiKeyDoc,
+            listener
+        );
+    }
+
+    private <T> void findApiKeysForUserRealmApiKeyIdAndNameCombination(
         String[] realmNames,
         String userName,
         String apiKeyName,
         String[] apiKeyIds,
         boolean filterOutInvalidatedKeys,
         boolean filterOutExpiredKeys,
-        ActionListener<Collection<ApiKey>> listener
+        Function<SearchHit, T> hitParser,
+        ActionListener<Collection<T>> listener
     ) {
         final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze();
         if (frozenSecurityIndex.indexExists() == false) {
@@ -1019,7 +1121,7 @@ public class ApiKeyService {
                 boolQuery.filter(QueryBuilders.idsQuery().addIds(apiKeyIds));
             }
 
-            findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, listener);
+            findApiKeys(boolQuery, filterOutInvalidatedKeys, filterOutExpiredKeys, hitParser, listener);
         }
     }
 
@@ -1096,9 +1198,153 @@ public class ApiKeyService {
         }
     }
 
+    private void translateResponseAndClearCache(
+        final String apiKeyId,
+        final BulkResponse bulkResponse,
+        final ActionListener<UpdateApiKeyResponse> listener
+    ) {
+        final BulkItemResponse[] elements = bulkResponse.getItems();
+        assert elements.length == 1 : "expected single item in bulk index response for API key update";
+        final var bulkItemResponse = elements[0];
+        if (bulkItemResponse.isFailed()) {
+            listener.onFailure(bulkItemResponse.getFailure().getCause());
+        } else {
+            assert bulkItemResponse.getResponse().getId().equals(apiKeyId);
+            // Since we made an index request against an existing document, we can't get a NOOP or CREATED here
+            assert bulkItemResponse.getResponse().getResult() == DocWriteResponse.Result.UPDATED;
+            clearApiKeyDocCache(apiKeyId, new UpdateApiKeyResponse(true), listener);
+        }
+    }
+
+    private static VersionedApiKeyDoc single(final String apiKeyId, final Collection<VersionedApiKeyDoc> elements) {
+        if (elements.size() != 1) {
+            final var message = "expected single API key doc with ID ["
+                + apiKeyId
+                + "] to be found for update but found ["
+                + elements.size()
+                + "]";
+            assert false : message;
+            throw new IllegalStateException(message);
+        }
+        return elements.iterator().next();
+    }
+
+    private BulkRequest buildBulkRequestForUpdate(
+        final Collection<VersionedApiKeyDoc> currentVersionedDocs,
+        final Authentication authentication,
+        final UpdateApiKeyRequest request,
+        final Set<RoleDescriptor> userRoles
+    ) throws IOException {
+        assert currentVersionedDocs.isEmpty() == false;
+        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 [{}]",
+                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()
+            );
+        }
+        bulkRequestBuilder.setRefreshPolicy(RefreshPolicy.WAIT_UNTIL);
+        return bulkRequestBuilder.request();
+    }
+
+    private void executeBulkRequest(final BulkRequest bulkRequest, final ActionListener<BulkResponse> listener) {
+        securityIndex.prepareIndexIfNeededThenExecute(
+            listener::onFailure,
+            () -> executeAsyncWithOrigin(client.threadPool().getThreadContext(), SECURITY_ORIGIN, bulkRequest, listener, client::bulk)
+        );
+    }
+
+    private static void addLimitedByRoleDescriptors(final XContentBuilder builder, final Set<RoleDescriptor> userRoles) throws IOException {
+        assert userRoles != null;
+        builder.startObject("limited_by_role_descriptors");
+        for (RoleDescriptor descriptor : userRoles) {
+            builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true));
+        }
+        builder.endObject();
+    }
+
+    private static void addApiKeyHash(final XContentBuilder builder, final char[] apiKeyHashChars) throws IOException {
+        byte[] utf8Bytes = null;
+        try {
+            utf8Bytes = CharArrays.toUtf8Bytes(apiKeyHashChars);
+            builder.field("api_key_hash").utf8Value(utf8Bytes, 0, utf8Bytes.length);
+        } finally {
+            if (utf8Bytes != null) {
+                Arrays.fill(utf8Bytes, (byte) 0);
+            }
+        }
+    }
+
+    private static void addCreator(final XContentBuilder builder, final Authentication authentication) throws IOException {
+        final var user = authentication.getEffectiveSubject().getUser();
+        final var sourceRealm = authentication.getEffectiveSubject().getRealm();
+        builder.startObject("creator")
+            .field("principal", user.principal())
+            .field("full_name", user.fullName())
+            .field("email", user.email())
+            .field("metadata", user.metadata())
+            .field("realm", sourceRealm.getName())
+            .field("realm_type", sourceRealm.getType());
+        if (sourceRealm.getDomain() != null) {
+            builder.field("realm_domain", sourceRealm.getDomain());
+        }
+        builder.endObject();
+    }
+
+    private static void addRoleDescriptors(final XContentBuilder builder, final List<RoleDescriptor> keyRoles) throws IOException {
+        builder.startObject("role_descriptors");
+        if (keyRoles != null && keyRoles.isEmpty() == false) {
+            for (RoleDescriptor descriptor : keyRoles) {
+                builder.field(descriptor.getName(), (contentBuilder, params) -> descriptor.toXContent(contentBuilder, params, true));
+            }
+        }
+        builder.endObject();
+    }
+
     private void clearCache(InvalidateApiKeyResponse result, ActionListener<InvalidateApiKeyResponse> listener) {
-        final ClearSecurityCacheRequest clearApiKeyCacheRequest = new ClearSecurityCacheRequest().cacheName("api_key")
-            .keys(result.getInvalidatedApiKeys().toArray(String[]::new));
+        executeClearCacheRequest(
+            result,
+            listener,
+            new ClearSecurityCacheRequest().cacheName("api_key").keys(result.getInvalidatedApiKeys().toArray(String[]::new))
+        );
+    }
+
+    private void clearApiKeyDocCache(String apiKeyId, UpdateApiKeyResponse result, ActionListener<UpdateApiKeyResponse> listener) {
+        executeClearCacheRequest(result, listener, new ClearSecurityCacheRequest().cacheName("api_key_doc").keys(apiKeyId));
+    }
+
+    private <T> void executeClearCacheRequest(T result, ActionListener<T> listener, ClearSecurityCacheRequest clearApiKeyCacheRequest) {
         executeAsyncWithOrigin(client, SECURITY_ORIGIN, ClearSecurityCacheAction.INSTANCE, clearApiKeyCacheRequest, new ActionListener<>() {
             @Override
             public void onResponse(ClearSecurityCacheResponse nodes) {
@@ -1107,7 +1353,7 @@ public class ApiKeyService {
 
             @Override
             public void onFailure(Exception e) {
-                logger.error("unable to clear API key cache", e);
+                logger.error(() -> format("unable to clear API key cache [{}]", clearApiKeyCacheRequest.cacheName()), e);
                 listener.onFailure(new ElasticsearchException("clearing the API key cache failed; please clear the caches manually", e));
             }
         });
@@ -1193,6 +1439,7 @@ public class ApiKeyService {
             apiKeyIds,
             false,
             false,
+            ApiKeyService::convertSearchHitToApiKeyInfo,
             ActionListener.wrap(apiKeyInfos -> {
                 if (apiKeyInfos.isEmpty()) {
                     logger.debug(
@@ -1274,6 +1521,18 @@ public class ApiKeyService {
         );
     }
 
+    private static VersionedApiKeyDoc convertSearchHitToVersionedApiKeyDoc(SearchHit hit) {
+        try (
+            XContentParser parser = XContentHelper.createParser(XContentParserConfiguration.EMPTY, hit.getSourceRef(), XContentType.JSON)
+        ) {
+            return new VersionedApiKeyDoc(ApiKeyDoc.fromXContent(parser), hit.getSeqNo(), hit.getPrimaryTerm());
+        } catch (IOException ex) {
+            throw new UncheckedIOException(ex);
+        }
+    }
+
+    private record VersionedApiKeyDoc(ApiKeyDoc doc, long seqNo, long primaryTerm) {}
+
     private RemovalListener<String, ListenableFuture<CachedApiKeyHashResult>> getAuthCacheRemovalListener(int maximumWeight) {
         return notification -> {
             if (RemovalReason.EVICTED == notification.getRemovalReason() && getApiKeyAuthCache().count() >= maximumWeight) {
@@ -1449,7 +1708,6 @@ public class ApiKeyService {
             Map<String, Object> creator,
             @Nullable BytesReference metadataFlattened
         ) {
-
             this.docType = docType;
             this.creationTime = creationTime;
             this.expirationTime = expirationTime;

+ 128 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java

@@ -58,6 +58,7 @@ import org.elasticsearch.threadpool.ThreadPool;
 import org.elasticsearch.xcontent.NamedXContentRegistry;
 import org.elasticsearch.xcontent.ToXContent;
 import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.json.JsonXContent;
 import org.elasticsearch.xpack.core.XPackSettings;
@@ -82,6 +83,7 @@ import org.elasticsearch.xpack.core.security.user.User;
 import org.elasticsearch.xpack.security.authc.ApiKeyService.ApiKeyCredentials;
 import org.elasticsearch.xpack.security.authc.ApiKeyService.ApiKeyDoc;
 import org.elasticsearch.xpack.security.authc.ApiKeyService.CachedApiKeyHashResult;
+import org.elasticsearch.xpack.security.authz.RoleDescriptorTests;
 import org.elasticsearch.xpack.security.authz.store.NativePrivilegeStore;
 import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
 import org.elasticsearch.xpack.security.support.FeatureNotEnabledException;
@@ -102,6 +104,7 @@ import java.util.Base64;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -1639,6 +1642,121 @@ public class ApiKeyServiceTests extends ESTestCase {
         assertEquals("bar", ((Map<String, Object>) creator.get("metadata")).get("foo"));
     }
 
+    public void testValidateApiKeyDocBeforeUpdate() throws IOException {
+        final var apiKeyId = randomAlphaOfLength(12);
+        final var apiKey = randomAlphaOfLength(16);
+        final var hasher = getFastStoredHashAlgoForTests();
+        final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
+
+        final var apiKeyService = createApiKeyService();
+        final var apiKeyDocWithNullName = buildApiKeyDoc(hash, -1, false, null, Version.V_8_2_0.id);
+        final var auth = Authentication.newRealmAuthentication(
+            new User("test_user", "role"),
+            new Authentication.RealmRef("realm1", "realm_type1", "node")
+        );
+
+        var ex = expectThrows(
+            IllegalArgumentException.class,
+            () -> apiKeyService.validateCurrentApiKeyDocForUpdate(apiKeyId, auth, apiKeyDocWithNullName)
+        );
+        assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name"));
+
+        final var apiKeyDocWithEmptyName = buildApiKeyDoc(hash, -1, false, "", Version.V_8_2_0.id);
+        ex = expectThrows(
+            IllegalArgumentException.class,
+            () -> apiKeyService.validateCurrentApiKeyDocForUpdate(apiKeyId, auth, apiKeyDocWithEmptyName)
+        );
+        assertThat(ex.getMessage(), containsString("cannot update legacy API key [" + apiKeyId + "] without name"));
+    }
+
+    public void testBuildUpdatedDocument() throws IOException {
+        final var apiKey = randomAlphaOfLength(16);
+        final var hasher = getFastStoredHashAlgoForTests();
+        final char[] hash = hasher.hash(new SecureString(apiKey.toCharArray()));
+
+        final var oldApiKeyDoc = buildApiKeyDoc(hash, randomBoolean() ? -1 : Instant.now().toEpochMilli(), false);
+
+        final Set<RoleDescriptor> newUserRoles = randomBoolean() ? Set.of() : Set.of(RoleDescriptorTests.randomRoleDescriptor());
+
+        final boolean nullKeyRoles = randomBoolean();
+        final List<RoleDescriptor> newKeyRoles;
+        if (nullKeyRoles) {
+            newKeyRoles = null;
+        } else {
+            newKeyRoles = List.of(RoleDescriptorTests.randomRoleDescriptor());
+        }
+
+        final var metadata = ApiKeyTests.randomMetadata();
+        final var version = Version.CURRENT;
+        final var authentication = randomValueOtherThanMany(
+            Authentication::isApiKey,
+            () -> AuthenticationTestHelper.builder().user(new User("user", "role")).build(false)
+        );
+
+        final var keyDocSource = ApiKeyService.buildUpdatedDocument(
+            oldApiKeyDoc,
+            authentication,
+            newUserRoles,
+            newKeyRoles,
+            version,
+            metadata
+        );
+        final var updatedApiKeyDoc = ApiKeyDoc.fromXContent(
+            XContentHelper.createParser(XContentParserConfiguration.EMPTY, BytesReference.bytes(keyDocSource), XContentType.JSON)
+        );
+
+        assertEquals(oldApiKeyDoc.docType, updatedApiKeyDoc.docType);
+        assertEquals(oldApiKeyDoc.name, updatedApiKeyDoc.name);
+        assertEquals(oldApiKeyDoc.hash, updatedApiKeyDoc.hash);
+        assertEquals(oldApiKeyDoc.expirationTime, updatedApiKeyDoc.expirationTime);
+        assertEquals(oldApiKeyDoc.creationTime, updatedApiKeyDoc.creationTime);
+        assertEquals(oldApiKeyDoc.invalidated, updatedApiKeyDoc.invalidated);
+
+        final var service = createApiKeyService(Settings.EMPTY);
+        final var actualUserRoles = service.parseRoleDescriptorsBytes(
+            "",
+            updatedApiKeyDoc.limitedByRoleDescriptorsBytes,
+            RoleReference.ApiKeyRoleType.LIMITED_BY
+        );
+        assertEquals(newUserRoles.size(), actualUserRoles.size());
+        assertEquals(new HashSet<>(newUserRoles), new HashSet<>(actualUserRoles));
+
+        final var actualKeyRoles = service.parseRoleDescriptorsBytes(
+            "",
+            updatedApiKeyDoc.roleDescriptorsBytes,
+            RoleReference.ApiKeyRoleType.ASSIGNED
+        );
+        if (nullKeyRoles) {
+            assertEquals(
+                service.parseRoleDescriptorsBytes("", oldApiKeyDoc.roleDescriptorsBytes, RoleReference.ApiKeyRoleType.ASSIGNED),
+                actualKeyRoles
+            );
+        } else {
+            assertEquals(newKeyRoles.size(), actualKeyRoles.size());
+            assertEquals(new HashSet<>(newKeyRoles), new HashSet<>(actualKeyRoles));
+        }
+        if (metadata == null) {
+            assertEquals(oldApiKeyDoc.metadataFlattened, updatedApiKeyDoc.metadataFlattened);
+        } else {
+            assertEquals(metadata, XContentHelper.convertToMap(updatedApiKeyDoc.metadataFlattened, true, XContentType.JSON).v2());
+        }
+
+        assertEquals(authentication.getEffectiveSubject().getUser().principal(), updatedApiKeyDoc.creator.getOrDefault("principal", null));
+        assertEquals(authentication.getEffectiveSubject().getUser().fullName(), updatedApiKeyDoc.creator.getOrDefault("fullName", null));
+        assertEquals(authentication.getEffectiveSubject().getUser().email(), updatedApiKeyDoc.creator.getOrDefault("email", null));
+        assertEquals(authentication.getEffectiveSubject().getUser().metadata(), updatedApiKeyDoc.creator.getOrDefault("metadata", null));
+        RealmRef realm = authentication.getEffectiveSubject().getRealm();
+        assertEquals(realm.getName(), updatedApiKeyDoc.creator.getOrDefault("realm", null));
+        assertEquals(realm.getType(), updatedApiKeyDoc.creator.getOrDefault("realm_type", null));
+        if (realm.getDomain() != null) {
+            @SuppressWarnings("unchecked")
+            final var actualDomain = (Map<String, Object>) updatedApiKeyDoc.creator.getOrDefault("realm_domain", null);
+            assertEquals(realm.getDomain().name(), actualDomain.get("name"));
+        } else {
+            assertFalse(updatedApiKeyDoc.creator.containsKey("realm_domain"));
+        }
+    }
+
     public void testApiKeyDocDeserializationWithNullValues() throws IOException {
         final String apiKeyDocumentSource = """
             {
@@ -1857,6 +1975,14 @@ public class ApiKeyServiceTests extends ESTestCase {
     }
 
     private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated) throws IOException {
+        return buildApiKeyDoc(hash, expirationTime, invalidated, randomAlphaOfLength(12));
+    }
+
+    private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated, String name) throws IOException {
+        return buildApiKeyDoc(hash, expirationTime, invalidated, name, 0);
+    }
+
+    private ApiKeyDoc buildApiKeyDoc(char[] hash, long expirationTime, boolean invalidated, String name, int version) throws IOException {
         final BytesReference metadataBytes = XContentTestUtils.convertToXContent(ApiKeyTests.randomMetadata(), XContentType.JSON);
         return new ApiKeyDoc(
             "api_key",
@@ -1864,8 +1990,8 @@ public class ApiKeyServiceTests extends ESTestCase {
             expirationTime,
             invalidated,
             new String(hash),
-            randomAlphaOfLength(12),
-            0,
+            name,
+            version,
             new BytesArray("{\"a role\": {\"cluster\": [\"all\"]}}"),
             new BytesArray("{\"limited role\": {\"cluster\": [\"all\"]}}"),
             Map.of(

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

@@ -626,7 +626,7 @@ public class RoleDescriptorTests extends ESTestCase {
         }
     }
 
-    private RoleDescriptor randomRoleDescriptor() {
+    public static RoleDescriptor randomRoleDescriptor() {
         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()