Browse Source

Add expiration time to update api key api (#103453)

* Add expiration time to update api key api
Johannes Fredén 1 year ago
parent
commit
f6a305afd7
29 changed files with 514 additions and 161 deletions
  1. 5 0
      docs/changelog/103453.yaml
  2. 8 3
      docs/reference/rest-api/security/bulk-update-api-keys.asciidoc
  3. 4 1
      docs/reference/rest-api/security/update-api-key.asciidoc
  4. 4 1
      docs/reference/rest-api/security/update-cross-cluster-api-key.asciidoc
  5. 1 0
      server/src/main/java/org/elasticsearch/TransportVersions.java
  6. 21 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseBulkUpdateApiKeyRequest.java
  7. 20 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseSingleUpdateApiKeyRequest.java
  8. 22 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseUpdateApiKeyRequest.java
  9. 11 4
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java
  10. 5 3
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequest.java
  11. 4 2
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequest.java
  12. 5 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/ApiKeyTests.java
  13. 71 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestSerializationTests.java
  14. 6 35
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTests.java
  15. 72 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestSerializationTests.java
  16. 4 36
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateApiKeyRequestTests.java
  17. 7 5
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequestTests.java
  18. 4 2
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
  19. 76 21
      x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java
  20. 61 17
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/ApiKeyIntegTests.java
  21. 8 1
      x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java
  22. 6 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyAction.java
  23. 19 7
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
  24. 8 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestBulkUpdateApiKeyAction.java
  25. 10 4
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateApiKeyAction.java
  26. 9 3
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyAction.java
  27. 12 2
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyActionTests.java
  28. 7 3
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java
  29. 24 5
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/ApiKeyServiceTests.java

+ 5 - 0
docs/changelog/103453.yaml

@@ -0,0 +1,5 @@
+pr: 103453
+summary: Add expiration time to update api key api
+area: Security
+type: enhancement
+issues: []

+ 8 - 3
docs/reference/rest-api/security/bulk-update-api-keys.asciidoc

@@ -30,7 +30,7 @@ This operation can greatly improve performance over making individual updates.
 
 It's not possible to update expired or <<security-api-invalidate-api-key,invalidated>> API keys.
 
-This API supports updates to API key access scope and metadata.
+This API supports updates to API key access scope, metadata and expiration.
 The access scope of each API key is derived from the <<security-api-bulk-update-api-keys-api-key-role-descriptors,`role_descriptors`>> you specify in the request, and a snapshot of the owner user's permissions at the time of the request.
 The snapshot of the owner's permissions is updated automatically on every call.
 
@@ -63,6 +63,9 @@ The structure of a role descriptor is the same as the request for the <<api-key-
 Within the `metadata` object, top-level keys beginning with an underscore (`_`) are reserved for system usage.
 Any information specified with this parameter fully replaces metadata previously associated with the API key.
 
+`expiration`::
+(Optional, string) Expiration time for the API keys. By default, API keys never expire. Can be omitted to leave unchanged.
+
 [[security-api-bulk-update-api-keys-response-body]]
 ==== {api-response-body-title}
 
@@ -166,7 +169,8 @@ Further, assume that the owner user's permissions are:
 --------------------------------------------------
 // NOTCONSOLE
 
-The following example updates the API keys created above, assigning them new role descriptors and metadata.
+The following example updates the API keys created above, assigning them new role descriptors, metadata and updates
+their expiration time.
 
 [source,console]
 ----
@@ -192,7 +196,8 @@ POST /_security/api_key/_bulk_update
        "trusted": true,
        "tags": ["production"]
     }
-  }
+  },
+  "expiration": "30d"
 }
 ----
 // TEST[skip:api key ids not available]

+ 4 - 1
docs/reference/rest-api/security/update-api-key.asciidoc

@@ -30,7 +30,7 @@ If you need to apply the same update to many API keys, you can use <<security-ap
 
 It's not possible to update expired API keys, or API keys that have been invalidated by <<security-api-invalidate-api-key,invalidate API Key>>.
 
-This API supports updates to an API key's access scope and metadata.
+This API supports updates to an API key's access scope, metadata and expiration.
 The access scope of an API key is derived from the <<security-api-update-api-key-api-key-role-descriptors,`role_descriptors`>> you specify in the request, and a snapshot of the owner user's permissions at the time of the request.
 The snapshot of the owner's permissions is updated automatically on every call.
 
@@ -67,6 +67,9 @@ It supports nested data structure.
 Within the `metadata` object, top-level keys beginning with `_` are reserved for system usage.
 When specified, this fully replaces metadata previously associated with the API key.
 
+`expiration`::
+(Optional, string) Expiration time for the API key. By default, API keys never expire. Can be omitted to leave unchanged.
+
 [[security-api-update-api-key-response-body]]
 ==== {api-response-body-title}
 

+ 4 - 1
docs/reference/rest-api/security/update-cross-cluster-api-key.asciidoc

@@ -34,7 +34,7 @@ Use this API to update cross-cluster API keys created by the <<security-api-crea
 It's not possible to update expired API keys, or API keys that have been invalidated by
 <<security-api-invalidate-api-key,invalidate API Key>>.
 
-This API supports updates to an API key's access scope and metadata.
+This API supports updates to an API key's access scope, metadata and expiration.
 The owner user's information, e.g. `username`, `realm`, is also updated automatically on every call.
 
 NOTE: This API cannot update <<security-api-create-api-key,REST API keys>>, which should be updated by
@@ -66,6 +66,9 @@ It supports nested data structure.
 Within the `metadata` object, top-level keys beginning with `_` are reserved for system usage.
 When specified, this fully replaces metadata previously associated with the API key.
 
+`expiration`::
+(Optional, string) Expiration time for the API key. By default, API keys never expire. Can be omitted to leave unchanged.
+
 [[security-api-update-cross-cluster-api-key-response-body]]
 ==== {api-response-body-title}
 

+ 1 - 0
server/src/main/java/org/elasticsearch/TransportVersions.java

@@ -177,6 +177,7 @@ public class TransportVersions {
     public static final TransportVersion ESQL_CLUSTER_ALIAS = def(8_565_00_0);
     public static final TransportVersion SNAPSHOTS_IN_PROGRESS_TRACKING_REMOVING_NODES_ADDED = def(8_566_00_0);
     public static final TransportVersion SMALLER_RELOAD_SECURE_SETTINGS_REQUEST = def(8_567_00_0);
+    public static final TransportVersion UPDATE_API_KEY_EXPIRATION_TIME_ADDED = def(8_568_00_0);
 
     /*
      * STOP! READ THIS FIRST! No, really,

+ 21 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BaseBulkUpdateApiKeyRequest.java

@@ -11,6 +11,7 @@ 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.core.TimeValue;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 
 import java.io.IOException;
@@ -27,9 +28,10 @@ public abstract class BaseBulkUpdateApiKeyRequest extends BaseUpdateApiKeyReques
     public BaseBulkUpdateApiKeyRequest(
         final List<String> ids,
         @Nullable final List<RoleDescriptor> roleDescriptors,
-        @Nullable final Map<String, Object> metadata
+        @Nullable final Map<String, Object> metadata,
+        @Nullable final TimeValue expiration
     ) {
-        super(roleDescriptors, metadata);
+        super(roleDescriptors, metadata, expiration);
         this.ids = Objects.requireNonNull(ids, "API key IDs must not be null");
     }
 
@@ -56,4 +58,21 @@ public abstract class BaseBulkUpdateApiKeyRequest extends BaseUpdateApiKeyReques
     public List<String> getIds() {
         return ids;
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass() || super.equals(o)) return false;
+
+        BaseBulkUpdateApiKeyRequest that = (BaseBulkUpdateApiKeyRequest) o;
+        return Objects.equals(getIds(), that.getIds())
+            && Objects.equals(metadata, that.metadata)
+            && Objects.equals(expiration, that.expiration)
+            && Objects.equals(roleDescriptors, that.roleDescriptors);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getIds(), expiration, metadata, roleDescriptors);
+    }
 }

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

@@ -10,6 +10,7 @@ package org.elasticsearch.xpack.core.security.action.apikey;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 
 import java.io.IOException;
@@ -24,9 +25,10 @@ public abstract class BaseSingleUpdateApiKeyRequest extends BaseUpdateApiKeyRequ
     public BaseSingleUpdateApiKeyRequest(
         @Nullable final List<RoleDescriptor> roleDescriptors,
         @Nullable final Map<String, Object> metadata,
+        @Nullable final TimeValue expiration,
         String id
     ) {
-        super(roleDescriptors, metadata);
+        super(roleDescriptors, metadata, expiration);
         this.id = Objects.requireNonNull(id, "API key ID must not be null");
     }
 
@@ -44,4 +46,21 @@ public abstract class BaseSingleUpdateApiKeyRequest extends BaseUpdateApiKeyRequ
     public String getId() {
         return id;
     }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass() || super.equals(o)) return false;
+
+        BaseSingleUpdateApiKeyRequest that = (BaseSingleUpdateApiKeyRequest) o;
+        return Objects.equals(getId(), that.getId())
+            && Objects.equals(metadata, that.metadata)
+            && Objects.equals(expiration, that.expiration)
+            && Objects.equals(roleDescriptors, that.roleDescriptors);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(getId(), expiration, metadata, roleDescriptors);
+    }
 }

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

@@ -7,11 +7,13 @@
 
 package org.elasticsearch.xpack.core.security.action.apikey;
 
+import org.elasticsearch.TransportVersions;
 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.core.TimeValue;
 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;
@@ -28,16 +30,28 @@ public abstract class BaseUpdateApiKeyRequest extends ActionRequest {
     protected final List<RoleDescriptor> roleDescriptors;
     @Nullable
     protected final Map<String, Object> metadata;
+    @Nullable
+    protected final TimeValue expiration;
 
-    public BaseUpdateApiKeyRequest(@Nullable final List<RoleDescriptor> roleDescriptors, @Nullable final Map<String, Object> metadata) {
+    public BaseUpdateApiKeyRequest(
+        @Nullable final List<RoleDescriptor> roleDescriptors,
+        @Nullable final Map<String, Object> metadata,
+        @Nullable final TimeValue expiration
+    ) {
         this.roleDescriptors = roleDescriptors;
         this.metadata = metadata;
+        this.expiration = expiration;
     }
 
     public BaseUpdateApiKeyRequest(StreamInput in) throws IOException {
         super(in);
         this.roleDescriptors = in.readOptionalCollectionAsList(RoleDescriptor::new);
         this.metadata = in.readMap();
+        if (in.getTransportVersion().onOrAfter(TransportVersions.UPDATE_API_KEY_EXPIRATION_TIME_ADDED)) {
+            expiration = in.readOptionalTimeValue();
+        } else {
+            expiration = null;
+        }
     }
 
     public Map<String, Object> getMetadata() {
@@ -48,6 +62,10 @@ public abstract class BaseUpdateApiKeyRequest extends ActionRequest {
         return roleDescriptors;
     }
 
+    public TimeValue getExpiration() {
+        return expiration;
+    }
+
     public abstract ApiKey.Type getType();
 
     @Override
@@ -72,5 +90,8 @@ public abstract class BaseUpdateApiKeyRequest extends ActionRequest {
         super.writeTo(out);
         out.writeOptionalCollection(roleDescriptors);
         out.writeGenericMap(metadata);
+        if (out.getTransportVersion().onOrAfter(TransportVersions.UPDATE_API_KEY_EXPIRATION_TIME_ADDED)) {
+            out.writeOptionalTimeValue(expiration);
+        }
     }
 }

+ 11 - 4
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequest.java

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.core.security.action.apikey;
 
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 
 import java.io.IOException;
@@ -19,19 +20,25 @@ import java.util.Map;
 public final class BulkUpdateApiKeyRequest extends BaseBulkUpdateApiKeyRequest {
 
     public static BulkUpdateApiKeyRequest usingApiKeyIds(String... ids) {
-        return new BulkUpdateApiKeyRequest(Arrays.stream(ids).toList(), null, null);
+        return new BulkUpdateApiKeyRequest(Arrays.stream(ids).toList(), null, null, null);
     }
 
     public static BulkUpdateApiKeyRequest wrap(final UpdateApiKeyRequest request) {
-        return new BulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata());
+        return new BulkUpdateApiKeyRequest(
+            List.of(request.getId()),
+            request.getRoleDescriptors(),
+            request.getMetadata(),
+            request.getExpiration()
+        );
     }
 
     public BulkUpdateApiKeyRequest(
         final List<String> ids,
         @Nullable final List<RoleDescriptor> roleDescriptors,
-        @Nullable final Map<String, Object> metadata
+        @Nullable final Map<String, Object> metadata,
+        @Nullable final TimeValue expiration
     ) {
-        super(ids, roleDescriptors, metadata);
+        super(ids, roleDescriptors, metadata, expiration);
     }
 
     public BulkUpdateApiKeyRequest(StreamInput in) throws IOException {

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

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.core.security.action.apikey;
 
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 
 import java.io.IOException;
@@ -17,15 +18,16 @@ import java.util.Map;
 
 public final class UpdateApiKeyRequest extends BaseSingleUpdateApiKeyRequest {
     public static UpdateApiKeyRequest usingApiKeyId(final String id) {
-        return new UpdateApiKeyRequest(id, null, null);
+        return new UpdateApiKeyRequest(id, null, null, null);
     }
 
     public UpdateApiKeyRequest(
         final String id,
         @Nullable final List<RoleDescriptor> roleDescriptors,
-        @Nullable final Map<String, Object> metadata
+        @Nullable final Map<String, Object> metadata,
+        @Nullable final TimeValue expiration
     ) {
-        super(roleDescriptors, metadata, id);
+        super(roleDescriptors, metadata, expiration, id);
     }
 
     public UpdateApiKeyRequest(StreamInput in) throws IOException {

+ 4 - 2
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequest.java

@@ -10,6 +10,7 @@ package org.elasticsearch.xpack.core.security.action.apikey;
 import org.elasticsearch.action.ActionRequestValidationException;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.core.Nullable;
+import org.elasticsearch.core.TimeValue;
 
 import java.io.IOException;
 import java.util.List;
@@ -22,9 +23,10 @@ public final class UpdateCrossClusterApiKeyRequest extends BaseSingleUpdateApiKe
     public UpdateCrossClusterApiKeyRequest(
         final String id,
         @Nullable CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder,
-        @Nullable final Map<String, Object> metadata
+        @Nullable final Map<String, Object> metadata,
+        @Nullable TimeValue expiration
     ) {
-        super(roleDescriptorBuilder == null ? null : List.of(roleDescriptorBuilder.build()), metadata, id);
+        super(roleDescriptorBuilder == null ? null : List.of(roleDescriptorBuilder.build()), metadata, expiration, id);
     }
 
     public UpdateCrossClusterApiKeyRequest(StreamInput in) throws IOException {

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

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.core.security.action.apikey;
 
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.xcontent.XContentHelper;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.test.XContentTestUtils;
 import org.elasticsearch.xcontent.ToXContent;
@@ -152,6 +153,10 @@ public class ApiKeyTests extends ESTestCase {
         return randomMetadata == null ? new HashMap<>() : new HashMap<>(randomMetadata);
     }
 
+    public static TimeValue randomFutureExpirationTime() {
+        return TimeValue.parseTimeValue(randomTimeValue(10, 20, "d", "h", "s", "m"), "expiration");
+    }
+
     public static ApiKey randomApiKeyInstance() {
         final String name = randomAlphaOfLengthBetween(4, 10);
         final String id = randomAlphaOfLength(20);

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

@@ -0,0 +1,71 @@
+/*
+ * 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.TransportVersions;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+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.nullValue;
+
+public class BulkUpdateApiKeyRequestSerializationTests extends AbstractWireSerializingTestCase<BulkUpdateApiKeyRequest> {
+    public void testSerializationBackwardsCompatibility() throws IOException {
+        BulkUpdateApiKeyRequest testInstance = createTestInstance();
+        BulkUpdateApiKeyRequest deserializedInstance = copyInstance(testInstance, TransportVersions.V_8_500_064);
+        try {
+            // Transport is on a version before expiration was introduced, so should always be null
+            assertThat(deserializedInstance.getExpiration(), nullValue());
+        } finally {
+            dispose(deserializedInstance);
+        }
+    }
+
+    @Override
+    protected BulkUpdateApiKeyRequest createTestInstance() {
+        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 ids = randomList(randomInt(5), () -> randomAlphaOfLength(10));
+        final var metadata = ApiKeyTests.randomMetadata();
+        final TimeValue expiration = ApiKeyTests.randomFutureExpirationTime();
+        return new BulkUpdateApiKeyRequest(ids, descriptorList, metadata, expiration);
+    }
+
+    @Override
+    protected Writeable.Reader<BulkUpdateApiKeyRequest> instanceReader() {
+        return BulkUpdateApiKeyRequest::new;
+    }
+
+    @Override
+    protected BulkUpdateApiKeyRequest mutateInstance(BulkUpdateApiKeyRequest instance) throws IOException {
+        Map<String, Object> metadata = ApiKeyTests.randomMetadata();
+        long days = randomValueOtherThan(instance.getExpiration().days(), () -> ApiKeyTests.randomFutureExpirationTime().getDays());
+        return new BulkUpdateApiKeyRequest(
+            instance.getIds(),
+            instance.getRoleDescriptors(),
+            metadata,
+            TimeValue.parseTimeValue((days + 1) + "d", null, "expiration")
+        );
+    }
+}

+ 6 - 35
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/BulkUpdateApiKeyRequestTests.java

@@ -8,13 +8,10 @@
 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.core.TimeValue;
 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;
 import java.util.Map;
 
@@ -23,42 +20,13 @@ import static org.hamcrest.Matchers.containsStringIgnoringCase;
 import static org.hamcrest.Matchers.equalTo;
 
 public class BulkUpdateApiKeyRequestTests extends ESTestCase {
-
-    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 List<String> ids = randomList(1, 5, () -> randomAlphaOfLength(10));
-        final Map<String, Object> metadata = ApiKeyTests.randomMetadata();
-        final var request = new BulkUpdateApiKeyRequest(ids, descriptorList, metadata);
-
-        try (BytesStreamOutput out = new BytesStreamOutput()) {
-            request.writeTo(out);
-            try (StreamInput in = out.bytes().streamInput()) {
-                final var serialized = new BulkUpdateApiKeyRequest(in);
-                assertEquals(ids, serialized.getIds());
-                assertEquals(descriptorList, serialized.getRoleDescriptors());
-                assertEquals(metadata, request.getMetadata());
-            }
-        }
-    }
-
     public void testNullValuesValidForNonIds() {
         final var request = BulkUpdateApiKeyRequest.usingApiKeyIds("id");
         assertNull(request.validate());
     }
 
     public void testEmptyIdsNotValid() {
-        final var request = new BulkUpdateApiKeyRequest(List.of(), null, null);
+        final var request = new BulkUpdateApiKeyRequest(List.of(), null, null, null);
         final ActionRequestValidationException ve = request.validate();
         assertNotNull(ve);
         assertThat(ve.validationErrors().size(), equalTo(1));
@@ -68,10 +36,12 @@ public class BulkUpdateApiKeyRequestTests extends ESTestCase {
     public void testMetadataKeyValidation() {
         final var reservedKey = "_" + randomAlphaOfLengthBetween(0, 10);
         final var metadataValue = randomAlphaOfLengthBetween(1, 10);
+        final TimeValue expiration = ApiKeyTests.randomFutureExpirationTime();
         final var request = new BulkUpdateApiKeyRequest(
             randomList(1, 5, () -> randomAlphaOfLength(10)),
             null,
-            Map.of(reservedKey, metadataValue)
+            Map.of(reservedKey, metadataValue),
+            expiration
         );
         final ActionRequestValidationException ve = request.validate();
         assertNotNull(ve);
@@ -103,6 +73,7 @@ public class BulkUpdateApiKeyRequestTests extends ESTestCase {
                     new RoleDescriptor.Restriction(unknownWorkflows)
                 )
             ),
+            null,
             null
         );
         final ActionRequestValidationException ve = request.validate();

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

@@ -0,0 +1,72 @@
+/*
+ * 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.TransportVersions;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+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.nullValue;
+
+public class UpdateApiKeyRequestSerializationTests extends AbstractWireSerializingTestCase<UpdateApiKeyRequest> {
+    public void testSerializationBackwardsCompatibility() throws IOException {
+        UpdateApiKeyRequest testInstance = createTestInstance();
+        UpdateApiKeyRequest deserializedInstance = copyInstance(testInstance, TransportVersions.V_8_500_064);
+        try {
+            // Transport is on a version before expiration was introduced, so should always be null
+            assertThat(deserializedInstance.getExpiration(), nullValue());
+        } finally {
+            dispose(deserializedInstance);
+        }
+    }
+
+    @Override
+    protected UpdateApiKeyRequest createTestInstance() {
+        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 TimeValue expiration = ApiKeyTests.randomFutureExpirationTime();
+        return new UpdateApiKeyRequest(id, descriptorList, metadata, expiration);
+    }
+
+    @Override
+    protected Writeable.Reader<UpdateApiKeyRequest> instanceReader() {
+        return UpdateApiKeyRequest::new;
+    }
+
+    @Override
+    protected UpdateApiKeyRequest mutateInstance(UpdateApiKeyRequest instance) throws IOException {
+        Map<String, Object> metadata = ApiKeyTests.randomMetadata();
+        long days = randomValueOtherThan(instance.getExpiration().days(), () -> ApiKeyTests.randomFutureExpirationTime().getDays());
+        return new UpdateApiKeyRequest(
+            instance.getId(),
+            instance.getRoleDescriptors(),
+            metadata,
+            TimeValue.parseTimeValue(days + "d", null, "expiration")
+        );
+    }
+
+}

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

@@ -8,13 +8,10 @@
 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;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.authz.restriction.WorkflowResolver;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -22,49 +19,19 @@ import java.util.Map;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.containsStringIgnoringCase;
 import static org.hamcrest.Matchers.equalTo;
-import static org.hamcrest.Matchers.is;
 
 public class UpdateApiKeyRequestTests extends ESTestCase {
 
     public void testNullValuesValidForNonIds() {
-        final var request = new UpdateApiKeyRequest("id", null, null);
+        final var request = new UpdateApiKeyRequest("id", null, 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);
-        assertThat(request.getType(), is(ApiKey.Type.REST));
-
-        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, serialized.getMetadata());
-                assertEquals(request.getType(), serialized.getType());
-            }
-        }
-    }
-
     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));
+
+        UpdateApiKeyRequest request = new UpdateApiKeyRequest(randomAlphaOfLength(10), null, Map.of(reservedKey, metadataValue), null);
         final ActionRequestValidationException ve = request.validate();
         assertNotNull(ve);
         assertThat(ve.validationErrors().size(), equalTo(1));
@@ -98,6 +65,7 @@ public class UpdateApiKeyRequestTests extends ESTestCase {
                     new RoleDescriptor.Restriction(workflows.toArray(String[]::new))
                 )
             ),
+            null,
             null
         );
         final ActionRequestValidationException ve1 = request1.validate();

+ 7 - 5
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/UpdateCrossClusterApiKeyRequestTests.java

@@ -10,6 +10,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.core.TimeValue;
 import org.elasticsearch.test.ESTestCase;
 
 import java.io.IOException;
@@ -25,15 +26,15 @@ public class UpdateCrossClusterApiKeyRequestTests extends ESTestCase {
 
     public void testSerialization() throws IOException {
         final var metadata = ApiKeyTests.randomMetadata();
-
+        final TimeValue expiration = ApiKeyTests.randomFutureExpirationTime();
         final CrossClusterApiKeyRoleDescriptorBuilder roleDescriptorBuilder;
-        if (metadata == null || randomBoolean()) {
+        if (randomBoolean()) {
             roleDescriptorBuilder = CrossClusterApiKeyRoleDescriptorBuilder.parse(randomCrossClusterApiKeyAccessField());
         } else {
             roleDescriptorBuilder = null;
         }
 
-        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), roleDescriptorBuilder, metadata);
+        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), roleDescriptorBuilder, metadata, expiration);
         assertThat(request.getType(), is(ApiKey.Type.CROSS_CLUSTER));
         assertThat(request.validate(), nullValue());
 
@@ -44,13 +45,14 @@ public class UpdateCrossClusterApiKeyRequestTests extends ESTestCase {
                 assertEquals(request.getId(), serialized.getId());
                 assertEquals(request.getRoleDescriptors(), serialized.getRoleDescriptors());
                 assertEquals(metadata, serialized.getMetadata());
+                assertEquals(expiration, serialized.getExpiration());
                 assertEquals(request.getType(), serialized.getType());
             }
         }
     }
 
     public void testNotEmptyUpdateValidation() {
-        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, null);
+        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, null, null);
         final ActionRequestValidationException ve = request.validate();
         assertThat(ve, notNullValue());
         assertThat(ve.validationErrors(), contains("must update either [access] or [metadata] for cross-cluster API keys"));
@@ -59,7 +61,7 @@ public class UpdateCrossClusterApiKeyRequestTests extends ESTestCase {
     public void testMetadataKeyValidation() {
         final var reservedKey = "_" + randomAlphaOfLengthBetween(0, 10);
         final var metadataValue = randomAlphaOfLengthBetween(1, 10);
-        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, Map.of(reservedKey, metadataValue));
+        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, Map.of(reservedKey, metadataValue), null);
         final ActionRequestValidationException ve = request.validate();
         assertThat(ve, notNullValue());
         assertThat(ve.validationErrors(), contains("API key metadata keys may not start with [_]"));

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

@@ -11,6 +11,7 @@ package org.elasticsearch.xpack.core.security.authz.privilege;
 
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.transport.TransportRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests;
 import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateCrossClusterApiKeyAction;
@@ -88,7 +89,7 @@ public class ManageOwnApiKeyClusterPrivilegeTests extends ESTestCase {
             .build();
         final List<String> apiKeyIds = randomList(1, 5, () -> randomAlphaOfLengthBetween(4, 7));
         final Authentication authentication = AuthenticationTestHelper.builder().build();
-        final TransportRequest bulkUpdateApiKeyRequest = new BulkUpdateApiKeyRequest(apiKeyIds, null, null);
+        final TransportRequest bulkUpdateApiKeyRequest = new BulkUpdateApiKeyRequest(apiKeyIds, null, null, null);
 
         assertTrue(clusterPermission.check("cluster:admin/xpack/security/api_key/update", bulkUpdateApiKeyRequest, authentication));
     }
@@ -315,7 +316,8 @@ public class ManageOwnApiKeyClusterPrivilegeTests extends ESTestCase {
         final UpdateCrossClusterApiKeyRequest request = new UpdateCrossClusterApiKeyRequest(
             randomAlphaOfLengthBetween(4, 7),
             null,
-            Map.of()
+            Map.of(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         assertFalse(clusterPermission.check(UpdateCrossClusterApiKeyAction.NAME, request, AuthenticationTestHelper.builder().build()));
     }

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

@@ -15,6 +15,7 @@ import org.elasticsearch.client.ResponseException;
 import org.elasticsearch.common.settings.SecureString;
 import org.elasticsearch.common.xcontent.XContentHelper;
 import org.elasticsearch.core.Strings;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.test.XContentTestUtils;
 import org.elasticsearch.test.rest.ObjectPath;
@@ -22,6 +23,7 @@ import org.elasticsearch.xcontent.XContentParser;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xcontent.json.JsonXContent;
 import org.elasticsearch.xpack.core.security.action.apikey.ApiKey;
+import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests;
 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;
@@ -34,6 +36,7 @@ import java.io.IOException;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -436,6 +439,23 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         doTestAuthenticationWithApiKey(apiKeyExpectingNoop.name, apiKeyExpectingNoop.id, apiKeyExpectingNoop.encoded);
     }
 
+    public void testBulkUpdateExpirationTimeApiKey() throws IOException {
+        final EncodedApiKey apiKey1 = createApiKey("my-api-key-name", Map.of());
+        final EncodedApiKey apiKey2 = createApiKey("my-other-api-key-name", Map.of());
+        final var bulkUpdateApiKeyRequest = new Request("POST", "_security/api_key/_bulk_update");
+        final TimeValue expiration = ApiKeyTests.randomFutureExpirationTime();
+        bulkUpdateApiKeyRequest.setJsonEntity(
+            XContentTestUtils.convertToXContent(Map.of("ids", List.of(apiKey1.id, apiKey2.id), "expiration", expiration), XContentType.JSON)
+                .utf8ToString()
+        );
+        final Response bulkUpdateApiKeyResponse = performRequestUsingRandomAuthMethod(bulkUpdateApiKeyRequest);
+        assertOK(bulkUpdateApiKeyResponse);
+        final Map<String, Object> response = responseAsMap(bulkUpdateApiKeyResponse);
+        assertEquals(List.of(apiKey1.id(), apiKey2.id()), response.get("updated"));
+        assertNull(response.get("errors"));
+        assertEquals(List.of(), response.get("noops"));
+    }
+
     public void testGrantTargetCanUpdateApiKey() throws IOException {
         final var request = new Request("POST", "_security/api_key/grant");
         request.setOptions(
@@ -923,7 +943,7 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         final ObjectPath createResponse = assertOKAndCreateObjectPath(client().performRequest(createRequest));
         final String apiKeyId = createResponse.evaluate("id");
 
-        // Update both access and metadata
+        // Update access, metadata and expiration
         final Request updateRequest1 = new Request("PUT", "/_security/cross_cluster/api_key/" + apiKeyId);
         updateRequest1.setJsonEntity("""
             {
@@ -940,7 +960,8 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
                   }
                 ]
               },
-              "metadata": { "tag": "shared", "points": 0 }
+              "metadata": { "tag": "shared", "points": 0 },
+              "expiration": "30d"
             }""");
         setUserForRequest(updateRequest1, MANAGE_SECURITY_USER, END_USER_PASSWORD);
         final ObjectPath updateResponse1 = assertOKAndCreateObjectPath(client().performRequest(updateRequest1));
@@ -966,6 +987,7 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
             fetchResponse1.evaluate("api_keys.0.role_descriptors"),
             equalTo(Map.of("cross_cluster", XContentTestUtils.convertToMap(updatedRoleDescriptor1)))
         );
+        assertThat(fetchResponse1.evaluate("api_keys.0.expiration"), notNullValue());
         assertThat(fetchResponse1.evaluate("api_keys.0.access"), equalTo(XContentHelper.convertToMap(JsonXContent.jsonXContent, """
             {
               "search": [
@@ -1465,6 +1487,40 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         assertThat(authenticate, hasEntry("api_key", Map.of("id", apiKeyId, "name", apiKeyName)));
     }
 
+    private static Map<String, Object> getRandomUpdateApiKeyRequestBody(
+        final Map<String, Object> oldMetadata,
+        boolean updateExpiration,
+        boolean updateMetadata
+    ) {
+        return getRandomUpdateApiKeyRequestBody(oldMetadata, updateExpiration, updateMetadata, List.of());
+    }
+
+    private static Map<String, Object> getRandomUpdateApiKeyRequestBody(
+        final Map<String, Object> oldMetadata,
+        boolean updateExpiration,
+        boolean updateMetadata,
+        List<String> ids
+    ) {
+        Map<String, Object> updateRequestBody = new HashMap<>();
+
+        if (updateMetadata) {
+            updateRequestBody.put("metadata", Map.of("not", "returned (changed)", "foo", "bar"));
+        } else if (oldMetadata != null) {
+            updateRequestBody.put("metadata", oldMetadata);
+        }
+
+        if (updateExpiration) {
+            updateRequestBody.put("expiration", ApiKeyTests.randomFutureExpirationTime());
+        }
+
+        if (ids.isEmpty() == false) {
+            updateRequestBody.put("ids", ids);
+        }
+
+        return updateRequestBody;
+    }
+
+    @SuppressWarnings({ "unchecked" })
     private void doTestUpdateApiKey(
         final String apiKeyName,
         final String apiKeyId,
@@ -1472,19 +1528,17 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         final Map<String, Object> oldMetadata
     ) throws IOException {
         final var updateApiKeyRequest = new Request("PUT", "_security/api_key/" + apiKeyId);
-        final boolean updated = randomBoolean();
-        final Map<String, Object> expectedApiKeyMetadata = updated ? Map.of("not", "returned (changed)", "foo", "bar") : oldMetadata;
-        final Map<String, Object> updateApiKeyRequestBody = expectedApiKeyMetadata == null
-            ? Map.of()
-            : Map.of("metadata", expectedApiKeyMetadata);
-        updateApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(updateApiKeyRequestBody, XContentType.JSON).utf8ToString());
+        final boolean updateExpiration = randomBoolean();
+        final boolean updateMetadata = randomBoolean();
+        final Map<String, Object> updateRequestBody = getRandomUpdateApiKeyRequestBody(oldMetadata, updateExpiration, updateMetadata);
+        updateApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(updateRequestBody, XContentType.JSON).utf8ToString());
 
         final Response updateApiKeyResponse = performRequestUsingRandomAuthMethod(updateApiKeyRequest);
 
         assertOK(updateApiKeyResponse);
         final Map<String, Object> updateApiKeyResponseMap = responseAsMap(updateApiKeyResponse);
-        assertEquals(updated, updateApiKeyResponseMap.get("updated"));
-        expectMetadata(apiKeyId, expectedApiKeyMetadata == null ? Map.of() : expectedApiKeyMetadata);
+        assertEquals(updateMetadata || updateExpiration, updateApiKeyResponseMap.get("updated"));
+        expectMetadata(apiKeyId, (Map<String, Object>) updateRequestBody.get("metadata"));
         // validate authentication still works after update
         doTestAuthenticationWithApiKey(apiKeyName, apiKeyId, apiKeyEncoded);
     }
@@ -1497,28 +1551,29 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         final Map<String, Object> oldMetadata
     ) throws IOException {
         final var bulkUpdateApiKeyRequest = new Request("POST", "_security/api_key/_bulk_update");
-        final boolean updated = randomBoolean();
-        final Map<String, Object> expectedApiKeyMetadata = updated ? Map.of("not", "returned (changed)", "foo", "bar") : oldMetadata;
-        final Map<String, Object> bulkUpdateApiKeyRequestBody = expectedApiKeyMetadata == null
-            ? Map.of("ids", List.of(apiKeyId))
-            : Map.of("ids", List.of(apiKeyId), "metadata", expectedApiKeyMetadata);
-        bulkUpdateApiKeyRequest.setJsonEntity(
-            XContentTestUtils.convertToXContent(bulkUpdateApiKeyRequestBody, XContentType.JSON).utf8ToString()
+        boolean updateMetadata = randomBoolean();
+        boolean updateExpiration = randomBoolean();
+        Map<String, Object> updateRequestBody = getRandomUpdateApiKeyRequestBody(
+            oldMetadata,
+            updateExpiration,
+            updateMetadata,
+            List.of(apiKeyId)
         );
+        bulkUpdateApiKeyRequest.setJsonEntity(XContentTestUtils.convertToXContent(updateRequestBody, XContentType.JSON).utf8ToString());
 
         final Response bulkUpdateApiKeyResponse = performRequestUsingRandomAuthMethod(bulkUpdateApiKeyRequest);
 
         assertOK(bulkUpdateApiKeyResponse);
         final Map<String, Object> bulkUpdateApiKeyResponseMap = responseAsMap(bulkUpdateApiKeyResponse);
         assertThat(bulkUpdateApiKeyResponseMap, not(hasKey("errors")));
-        if (updated) {
+        if (updateMetadata || updateExpiration) {
             assertThat((List<String>) bulkUpdateApiKeyResponseMap.get("noops"), empty());
             assertThat((List<String>) bulkUpdateApiKeyResponseMap.get("updated"), contains(apiKeyId));
         } else {
             assertThat((List<String>) bulkUpdateApiKeyResponseMap.get("updated"), empty());
             assertThat((List<String>) bulkUpdateApiKeyResponseMap.get("noops"), contains(apiKeyId));
         }
-        expectMetadata(apiKeyId, expectedApiKeyMetadata == null ? Map.of() : expectedApiKeyMetadata);
+        expectMetadata(apiKeyId, (Map<String, Object>) updateRequestBody.get("metadata"));
         // validate authentication still works after update
         doTestAuthenticationWithApiKey(apiKeyName, apiKeyId, apiKeyEncoded);
     }
@@ -1604,7 +1659,6 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         }
     }
 
-    @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);
@@ -1613,7 +1667,8 @@ public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase {
         try (XContentParser parser = responseAsParser(response)) {
             final var apiKeyResponse = GetApiKeyResponse.fromXContent(parser);
             assertThat(apiKeyResponse.getApiKeyInfos().length, equalTo(1));
-            assertThat(apiKeyResponse.getApiKeyInfos()[0].getMetadata(), equalTo(expectedMetadata));
+            // ApiKey metadata is set to empty Map if null
+            assertThat(apiKeyResponse.getApiKeyInfos()[0].getMetadata(), equalTo(expectedMetadata == null ? Map.of() : expectedMetadata));
         }
     }
 

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

@@ -1959,7 +1959,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
                 null
             )
         );
-        final var request = new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, ApiKeyTests.randomMetadata());
+        final var request = new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, ApiKeyTests.randomMetadata(), null);
 
         final UpdateApiKeyResponse response = updateSingleApiKeyMaybeUsingBulkAction(TEST_USER_NAME, request);
 
@@ -2030,7 +2030,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
 
         BulkUpdateApiKeyResponse response = executeBulkUpdateApiKey(
             TEST_USER_NAME,
-            new BulkUpdateApiKeyRequest(apiKeyIds, newRoleDescriptors, newMetadata)
+            new BulkUpdateApiKeyRequest(apiKeyIds, newRoleDescriptors, newMetadata, ApiKeyTests.randomFutureExpirationTime())
         );
 
         assertNotNull(response);
@@ -2070,7 +2070,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             () -> randomValueOtherThanMany(apiKeyIds::contains, () -> randomAlphaOfLength(10))
         );
         newIds.addAll(notFoundIds);
-        final BulkUpdateApiKeyRequest request = new BulkUpdateApiKeyRequest(shuffledList(newIds), newRoleDescriptors, newMetadata);
+        final BulkUpdateApiKeyRequest request = new BulkUpdateApiKeyRequest(shuffledList(newIds), newRoleDescriptors, newMetadata, null);
 
         response = executeBulkUpdateApiKey(TEST_USER_NAME, request);
 
@@ -2100,7 +2100,8 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         final BulkUpdateApiKeyRequest requestWithSomeErrors = new BulkUpdateApiKeyRequest(
             shuffledList(apiKeyIds),
             randomValueOtherThan(null, this::randomRoleDescriptors),
-            randomValueOtherThan(null, ApiKeyTests::randomMetadata)
+            randomValueOtherThan(null, ApiKeyTests::randomMetadata),
+            ApiKeyTests.randomFutureExpirationTime()
         );
 
         response = executeBulkUpdateApiKey(TEST_USER_NAME, requestWithSomeErrors);
@@ -2124,7 +2125,7 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
 
         BulkUpdateApiKeyResponse response = executeBulkUpdateApiKey(
             TEST_USER_NAME,
-            new BulkUpdateApiKeyRequest(idsWithDuplicates, newRoleDescriptors, newMetadata)
+            new BulkUpdateApiKeyRequest(idsWithDuplicates, newRoleDescriptors, newMetadata, ApiKeyTests.randomFutureExpirationTime())
         );
 
         assertNotNull(response);
@@ -2142,7 +2143,12 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
 
         response = executeBulkUpdateApiKey(
             TEST_USER_NAME,
-            new BulkUpdateApiKeyRequest(notFoundIdsWithDuplicates, newRoleDescriptors, newMetadata)
+            new BulkUpdateApiKeyRequest(
+                notFoundIdsWithDuplicates,
+                newRoleDescriptors,
+                newMetadata,
+                ApiKeyTests.randomFutureExpirationTime()
+            )
         );
 
         assertNotNull(response);
@@ -2317,7 +2323,12 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         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());
+        final var request = new UpdateApiKeyRequest(
+            apiKeyId,
+            List.of(expectedRoleDescriptor),
+            ApiKeyTests.randomMetadata(),
+            ApiKeyTests.randomFutureExpirationTime()
+        );
 
         // Validate can update own API key
         final UpdateApiKeyResponse response = updateSingleApiKeyMaybeUsingBulkAction(TEST_USER_NAME, request);
@@ -2326,12 +2337,24 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
 
         // Test not found exception on non-existent API key
         final var otherApiKeyId = randomValueOtherThan(apiKeyId, () -> randomAlphaOfLength(20));
-        doTestUpdateApiKeysNotFound(new UpdateApiKeyRequest(otherApiKeyId, request.getRoleDescriptors(), request.getMetadata()));
+        doTestUpdateApiKeysNotFound(
+            new UpdateApiKeyRequest(
+                otherApiKeyId,
+                request.getRoleDescriptors(),
+                request.getMetadata(),
+                ApiKeyTests.randomFutureExpirationTime()
+            )
+        );
 
         // 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);
         doTestUpdateApiKeysNotFound(
-            new UpdateApiKeyRequest(otherUsersApiKey.v1().getId(), request.getRoleDescriptors(), request.getMetadata())
+            new UpdateApiKeyRequest(
+                otherUsersApiKey.v1().getId(),
+                request.getRoleDescriptors(),
+                request.getMetadata(),
+                ApiKeyTests.randomFutureExpirationTime()
+            )
         );
 
         // Test not found exception on API key of user with the same username but from a different realm
@@ -2351,7 +2374,12 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             "all"
         ).v1().get(0);
         doTestUpdateApiKeysNotFound(
-            new UpdateApiKeyRequest(apiKeyForNativeRealmUser.getId(), request.getRoleDescriptors(), request.getMetadata())
+            new UpdateApiKeyRequest(
+                apiKeyForNativeRealmUser.getId(),
+                request.getRoleDescriptors(),
+                request.getMetadata(),
+                ApiKeyTests.randomFutureExpirationTime()
+            )
         );
     }
 
@@ -2364,7 +2392,12 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         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());
+        final var request = new UpdateApiKeyRequest(
+            apiKeyId,
+            List.of(roleDescriptor),
+            ApiKeyTests.randomMetadata(),
+            ApiKeyTests.randomFutureExpirationTime()
+        );
         final PlainActionFuture<UpdateApiKeyResponse> updateListener = new PlainActionFuture<>();
         client().filterWithHeader(
             Collections.singletonMap(
@@ -2465,7 +2498,8 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             List.of(new RoleDescriptor(randomAlphaOfLength(10), new String[] { "all" }, null, null)),
             // Ensure not `null` to set metadata since we use the initialRequest further down in the test to ensure that
             // metadata updates are non-noops
-            randomValueOtherThanMany(Objects::isNull, ApiKeyTests::randomMetadata)
+            randomValueOtherThanMany(Objects::isNull, ApiKeyTests::randomMetadata),
+            null // Expiration is relative current time, so must be null to cause noop
         );
         UpdateApiKeyResponse response = updateSingleApiKeyMaybeUsingBulkAction(TEST_USER_NAME, initialRequest);
         assertNotNull(response);
@@ -2501,14 +2535,17 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
                 () -> RoleDescriptorTests.randomRoleDescriptor(false)
             )
         );
-        response = updateSingleApiKeyMaybeUsingBulkAction(TEST_USER_NAME, new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, null));
+        response = updateSingleApiKeyMaybeUsingBulkAction(
+            TEST_USER_NAME,
+            new UpdateApiKeyRequest(apiKeyId, newRoleDescriptors, null, null)
+        );
         assertNotNull(response);
         assertTrue(response.isUpdated());
 
         // Update with re-ordered role descriptors is a noop
         response = updateSingleApiKeyMaybeUsingBulkAction(
             TEST_USER_NAME,
-            new UpdateApiKeyRequest(apiKeyId, List.of(newRoleDescriptors.get(1), newRoleDescriptors.get(0)), null)
+            new UpdateApiKeyRequest(apiKeyId, List.of(newRoleDescriptors.get(1), newRoleDescriptors.get(0)), null, null)
         );
         assertNotNull(response);
         assertFalse(response.isUpdated());
@@ -2519,7 +2556,8 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
             new UpdateApiKeyRequest(
                 apiKeyId,
                 null,
-                randomValueOtherThanMany(md -> md == null || md.equals(initialRequest.getMetadata()), ApiKeyTests::randomMetadata)
+                randomValueOtherThanMany(md -> md == null || md.equals(initialRequest.getMetadata()), ApiKeyTests::randomMetadata),
+                null
             )
         );
         assertNotNull(response);
@@ -2677,7 +2715,8 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
                 apiKey1.v1(),
                 List.of(),
                 // Set metadata to ensure update
-                Map.of(randomAlphaOfLength(5), randomAlphaOfLength(10))
+                Map.of(randomAlphaOfLength(5), randomAlphaOfLength(10)),
+                ApiKeyTests.randomFutureExpirationTime()
             )
         );
 
@@ -3251,7 +3290,12 @@ public class ApiKeyIntegTests extends SecurityIntegTestCase {
         if (useBulkAction) {
             final BulkUpdateApiKeyResponse response = executeBulkUpdateApiKey(
                 username,
-                new BulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata())
+                new BulkUpdateApiKeyRequest(
+                    List.of(request.getId()),
+                    request.getRoleDescriptors(),
+                    request.getMetadata(),
+                    request.getExpiration()
+                )
             );
             return toUpdateApiKeyResponse(request.getId(), response);
         } else {

+ 8 - 1
x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java

@@ -37,6 +37,7 @@ import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.XPackSettings;
 import org.elasticsearch.xpack.core.security.action.Grant;
 import org.elasticsearch.xpack.core.security.action.apikey.ApiKey;
+import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyRequestBuilder;
@@ -706,7 +707,13 @@ public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase {
             updateMetadata = null;
         }
 
-        final var updateApiKeyRequest = new UpdateCrossClusterApiKeyRequest(apiKeyId, roleDescriptorBuilder, updateMetadata);
+        final boolean shouldUpdateExpiration = randomBoolean();
+        TimeValue expiration = null;
+        if (shouldUpdateExpiration) {
+            ApiKeyTests.randomFutureExpirationTime();
+        }
+
+        final var updateApiKeyRequest = new UpdateCrossClusterApiKeyRequest(apiKeyId, roleDescriptorBuilder, updateMetadata, expiration);
         final UpdateApiKeyResponse updateApiKeyResponse = client().execute(UpdateCrossClusterApiKeyAction.INSTANCE, updateApiKeyRequest)
             .actionGet();
 

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

@@ -50,7 +50,12 @@ public final class TransportUpdateCrossClusterApiKeyAction extends TransportBase
     ) {
         apiKeyService.updateApiKeys(
             authentication,
-            new BaseBulkUpdateApiKeyRequest(List.of(request.getId()), request.getRoleDescriptors(), request.getMetadata()) {
+            new BaseBulkUpdateApiKeyRequest(
+                List.of(request.getId()),
+                request.getRoleDescriptors(),
+                request.getMetadata(),
+                request.getExpiration()
+            ) {
                 @Override
                 public ApiKey.Type getType() {
                     return ApiKey.Type.CROSS_CLUSTER;

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

@@ -410,7 +410,7 @@ public class ApiKeyService {
         ActionListener<CreateApiKeyResponse> listener
     ) {
         final Instant created = clock.instant();
-        final Instant expiration = getApiKeyExpiration(created, request);
+        final Instant expiration = getApiKeyExpiration(created, request.getExpiration());
         final SecureString apiKey = UUIDs.randomBase64UUIDSecureString();
         assert ApiKey.Type.CROSS_CLUSTER != request.getType() || API_KEY_SECRET_LENGTH == apiKey.length();
         final Version version = clusterService.state().nodes().getMinNodeVersion();
@@ -743,7 +743,8 @@ public class ApiKeyService {
         final Version targetDocVersion,
         final Authentication authentication,
         final BaseUpdateApiKeyRequest request,
-        final Set<RoleDescriptor> userRoleDescriptors
+        final Set<RoleDescriptor> userRoleDescriptors,
+        final Clock clock
     ) throws IOException {
         assert currentApiKeyDoc.type == request.getType();
         if (isNoop(apiKeyId, currentApiKeyDoc, targetDocVersion, authentication, request, userRoleDescriptors)) {
@@ -755,9 +756,14 @@ public class ApiKeyService {
             .field("doc_type", "api_key")
             .field("type", currentApiKeyDoc.type.value())
             .field("creation_time", currentApiKeyDoc.creationTime)
-            .field("expiration_time", currentApiKeyDoc.expirationTime == -1 ? null : currentApiKeyDoc.expirationTime)
             .field("api_key_invalidated", false);
 
+        if (request.getExpiration() != null) {
+            builder.field("expiration_time", getApiKeyExpiration(clock.instant(), request.getExpiration()).toEpochMilli());
+        } else {
+            builder.field("expiration_time", currentApiKeyDoc.expirationTime == -1 ? null : currentApiKeyDoc.expirationTime);
+        }
+
         addApiKeyHash(builder, currentApiKeyDoc.hash.toCharArray());
 
         final List<RoleDescriptor> keyRoles = request.getRoleDescriptors();
@@ -808,6 +814,11 @@ public class ApiKeyService {
             return false;
         }
 
+        if (request.getExpiration() != null) {
+            // Since expiration is relative current time, it's not likely that it matches the stored value to the ms, so assume update
+            return false;
+        }
+
         final Map<String, Object> currentCreator = apiKeyDoc.creator;
         final var user = authentication.getEffectiveSubject().getUser();
         final var sourceRealm = authentication.getEffectiveSubject().getRealm();
@@ -1280,9 +1291,9 @@ public class ApiKeyService {
         }));
     }
 
-    private static Instant getApiKeyExpiration(Instant now, AbstractCreateApiKeyRequest request) {
-        if (request.getExpiration() != null) {
-            return now.plusSeconds(request.getExpiration().getSeconds());
+    private static Instant getApiKeyExpiration(Instant now, @Nullable TimeValue expiration) {
+        if (expiration != null) {
+            return now.plusSeconds(expiration.getSeconds());
         } else {
             return null;
         }
@@ -1472,7 +1483,8 @@ public class ApiKeyService {
             targetDocVersion,
             authentication,
             request,
-            userRoleDescriptors
+            userRoleDescriptors,
+            clock
         );
         final boolean isNoop = builder == null;
         return isNoop

+ 8 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestBulkUpdateApiKeyAction.java

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.security.rest.action.apikey;
 
 import org.elasticsearch.client.internal.node.NodeClient;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.license.XPackLicenseState;
 import org.elasticsearch.rest.RestRequest;
 import org.elasticsearch.rest.Scope;
@@ -35,7 +36,12 @@ public final class RestBulkUpdateApiKeyAction extends ApiKeyBaseRestHandler {
     @SuppressWarnings("unchecked")
     static final ConstructingObjectParser<BulkUpdateApiKeyRequest, Void> PARSER = new ConstructingObjectParser<>(
         "bulk_update_api_key_request",
-        a -> new BulkUpdateApiKeyRequest((List<String>) a[0], (List<RoleDescriptor>) a[1], (Map<String, Object>) a[2])
+        a -> new BulkUpdateApiKeyRequest(
+            (List<String>) a[0],
+            (List<RoleDescriptor>) a[1],
+            (Map<String, Object>) a[2],
+            TimeValue.parseTimeValue((String) a[3], null, "expiration")
+        )
     );
 
     static {
@@ -45,6 +51,7 @@ public final class RestBulkUpdateApiKeyAction extends ApiKeyBaseRestHandler {
             return RoleDescriptor.parse(n, p, false);
         }, new ParseField("role_descriptors"));
         PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata"));
+        PARSER.declareString(optionalConstructorArg(), new ParseField("expiration"));
     }
 
     public RestBulkUpdateApiKeyAction(final Settings settings, final XPackLicenseState licenseState) {

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

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.security.rest.action.apikey;
 
 import org.elasticsearch.client.internal.node.NodeClient;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.license.XPackLicenseState;
 import org.elasticsearch.rest.RestRequest;
 import org.elasticsearch.rest.Scope;
@@ -33,7 +34,11 @@ public final class RestUpdateApiKeyAction extends ApiKeyBaseRestHandler {
     @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])
+        a -> new Payload(
+            (List<RoleDescriptor>) a[0],
+            (Map<String, Object>) a[1],
+            TimeValue.parseTimeValue((String) a[2], null, "expiration")
+        )
     );
 
     static {
@@ -42,6 +47,7 @@ public final class RestUpdateApiKeyAction extends ApiKeyBaseRestHandler {
             return RoleDescriptor.parse(n, p, false);
         }, new ParseField("role_descriptors"));
         PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata"));
+        PARSER.declareString(optionalConstructorArg(), new ParseField("expiration"));
     }
 
     public RestUpdateApiKeyAction(final Settings settings, final XPackLicenseState licenseState) {
@@ -64,13 +70,13 @@ public final class RestUpdateApiKeyAction extends ApiKeyBaseRestHandler {
         // `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);
+        final var payload = request.hasContent() == false ? new Payload(null, null, null) : PARSER.parse(request.contentParser(), null);
         return channel -> client.execute(
             UpdateApiKeyAction.INSTANCE,
-            new UpdateApiKeyRequest(apiKeyId, payload.roleDescriptors, payload.metadata),
+            new UpdateApiKeyRequest(apiKeyId, payload.roleDescriptors, payload.metadata, payload.expiration),
             new RestToXContentListener<>(channel)
         );
     }
 
-    record Payload(List<RoleDescriptor> roleDescriptors, Map<String, Object> metadata) {}
+    record Payload(List<RoleDescriptor> roleDescriptors, Map<String, Object> metadata, TimeValue expiration) {}
 }

+ 9 - 3
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestUpdateCrossClusterApiKeyAction.java

@@ -9,6 +9,7 @@ package org.elasticsearch.xpack.security.rest.action.apikey;
 
 import org.elasticsearch.client.internal.node.NodeClient;
 import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.TimeValue;
 import org.elasticsearch.license.LicenseUtils;
 import org.elasticsearch.license.XPackLicenseState;
 import org.elasticsearch.rest.RestRequest;
@@ -32,12 +33,17 @@ public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHand
     @SuppressWarnings("unchecked")
     static final ConstructingObjectParser<Payload, Void> PARSER = new ConstructingObjectParser<>(
         "update_cross_cluster_api_key_request_payload",
-        a -> new Payload((CrossClusterApiKeyRoleDescriptorBuilder) a[0], (Map<String, Object>) a[1])
+        a -> new Payload(
+            (CrossClusterApiKeyRoleDescriptorBuilder) a[0],
+            (Map<String, Object>) a[1],
+            TimeValue.parseTimeValue((String) a[2], null, "expiration")
+        )
     );
 
     static {
         PARSER.declareObject(optionalConstructorArg(), CrossClusterApiKeyRoleDescriptorBuilder.PARSER, new ParseField("access"));
         PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata"));
+        PARSER.declareString(optionalConstructorArg(), new ParseField("expiration"));
     }
 
     public RestUpdateCrossClusterApiKeyAction(final Settings settings, final XPackLicenseState licenseState) {
@@ -61,7 +67,7 @@ public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHand
 
         return channel -> client.execute(
             UpdateCrossClusterApiKeyAction.INSTANCE,
-            new UpdateCrossClusterApiKeyRequest(apiKeyId, payload.builder, payload.metadata),
+            new UpdateCrossClusterApiKeyRequest(apiKeyId, payload.builder, payload.metadata, payload.expiration),
             new RestToXContentListener<>(channel)
         );
     }
@@ -75,5 +81,5 @@ public final class RestUpdateCrossClusterApiKeyAction extends ApiKeyBaseRestHand
         }
     }
 
-    record Payload(CrossClusterApiKeyRoleDescriptorBuilder builder, Map<String, Object> metadata) {}
+    record Payload(CrossClusterApiKeyRoleDescriptorBuilder builder, Map<String, Object> metadata, TimeValue expiration) {}
 }

+ 12 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportUpdateCrossClusterApiKeyActionTests.java

@@ -74,7 +74,12 @@ public class TransportUpdateCrossClusterApiKeyActionTests extends ESTestCase {
         }
 
         final String id = randomAlphaOfLength(10);
-        final var request = new UpdateCrossClusterApiKeyRequest(id, roleDescriptorBuilder, metadata);
+        final var request = new UpdateCrossClusterApiKeyRequest(
+            id,
+            roleDescriptorBuilder,
+            metadata,
+            ApiKeyTests.randomFutureExpirationTime()
+        );
         final int updateStatus = randomIntBetween(0, 2); // 0 - success, 1 - noop, 2 - error
 
         doAnswer(invocation -> {
@@ -129,7 +134,12 @@ public class TransportUpdateCrossClusterApiKeyActionTests extends ESTestCase {
             mock(ApiKeyService.class),
             securityContext
         );
-        final var request = new UpdateCrossClusterApiKeyRequest(randomAlphaOfLength(10), null, Map.of());
+        final var request = new UpdateCrossClusterApiKeyRequest(
+            randomAlphaOfLength(10),
+            null,
+            Map.of(),
+            ApiKeyTests.randomFutureExpirationTime()
+        );
 
         // null authentication error
         when(securityContext.getAuthentication()).thenReturn(null);

+ 7 - 3
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/audit/logfile/LoggingAuditTrailTests.java

@@ -46,6 +46,7 @@ import org.elasticsearch.xcontent.NamedXContentRegistry;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentType;
 import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.action.apikey.ApiKeyTests;
 import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.apikey.BulkUpdateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.apikey.CreateApiKeyAction;
@@ -629,7 +630,8 @@ public class LoggingAuditTrailTests extends ESTestCase {
         final var updateApiKeyRequest = new UpdateApiKeyRequest(
             keyId,
             randomBoolean() ? null : keyRoleDescriptors,
-            metadataWithSerialization.metadata()
+            metadataWithSerialization.metadata(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         auditTrail.accessGranted(requestId, authentication, UpdateApiKeyAction.NAME, updateApiKeyRequest, authorizationInfo);
         final var expectedUpdateKeyAuditEventString = String.format(
@@ -661,7 +663,8 @@ public class LoggingAuditTrailTests extends ESTestCase {
         final var bulkUpdateApiKeyRequest = new BulkUpdateApiKeyRequest(
             keyIds,
             randomBoolean() ? null : keyRoleDescriptors,
-            metadataWithSerialization.metadata()
+            metadataWithSerialization.metadata(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         auditTrail.accessGranted(requestId, authentication, BulkUpdateApiKeyAction.NAME, bulkUpdateApiKeyRequest, authorizationInfo);
         final var expectedBulkUpdateKeyAuditEventString = String.format(
@@ -875,7 +878,8 @@ public class LoggingAuditTrailTests extends ESTestCase {
         final var updateRequest = new UpdateCrossClusterApiKeyRequest(
             createRequest.getId(),
             updateAccess,
-            updateMetadataWithSerialization.metadata()
+            updateMetadataWithSerialization.metadata(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         auditTrail.accessGranted(requestId, authentication, UpdateCrossClusterApiKeyAction.NAME, updateRequest, authorizationInfo);
 

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

@@ -2119,6 +2119,8 @@ public class ApiKeyServiceTests extends ESTestCase {
         } else {
             oldKeyRoles = randomList(3, RoleDescriptorTests::randomRoleDescriptor);
         }
+        final long now = randomMillisUpToYear9999();
+        when(clock.instant()).thenReturn(Instant.ofEpochMilli(now));
         final Map<String, Object> oldMetadata = ApiKeyTests.randomMetadata();
         final Version oldVersion = VersionUtils.randomVersion(random());
         final ApiKeyDoc oldApiKeyDoc = ApiKeyDoc.fromXContent(
@@ -2147,6 +2149,8 @@ public class ApiKeyServiceTests extends ESTestCase {
         final boolean changeMetadata = randomBoolean();
         final boolean changeVersion = randomBoolean();
         final boolean changeCreator = randomBoolean();
+        final boolean changeExpiration = randomBoolean();
+
         final Set<RoleDescriptor> newUserRoles = changeUserRoles
             ? randomValueOtherThan(oldUserRoles, () -> randomSet(0, 3, RoleDescriptorTests::randomRoleDescriptor))
             : oldUserRoles;
@@ -2180,11 +2184,14 @@ public class ApiKeyServiceTests extends ESTestCase {
                     .build(false)
             )
             : oldAuthentication;
+        final TimeValue newExpiration = changeExpiration ? randomFrom(ApiKeyTests.randomFutureExpirationTime()) : null;
         final String apiKeyId = randomAlphaOfLength(10);
         final BaseUpdateApiKeyRequest request = mock(BaseUpdateApiKeyRequest.class);
         when(request.getType()).thenReturn(type);
         when(request.getRoleDescriptors()).thenReturn(newKeyRoles);
         when(request.getMetadata()).thenReturn(newMetadata);
+        when(request.getExpiration()).thenReturn(newExpiration);
+
         final var service = createApiKeyService();
 
         final XContentBuilder builder = ApiKeyService.maybeBuildUpdatedDocument(
@@ -2193,10 +2200,16 @@ public class ApiKeyServiceTests extends ESTestCase {
             newVersion,
             newAuthentication,
             request,
-            newUserRoles
+            newUserRoles,
+            clock
         );
 
-        final boolean noop = (changeCreator || changeMetadata || changeKeyRoles || changeUserRoles || changeVersion) == false;
+        final boolean noop = (changeCreator
+            || changeMetadata
+            || changeKeyRoles
+            || changeUserRoles
+            || changeVersion
+            || changeExpiration) == false;
         if (noop) {
             assertNull(builder);
         } else {
@@ -2207,7 +2220,6 @@ public class ApiKeyServiceTests extends ESTestCase {
             assertEquals(oldApiKeyDoc.type, updatedApiKeyDoc.type);
             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);
             assertEquals(newVersion.id, updatedApiKeyDoc.version);
@@ -2237,6 +2249,11 @@ public class ApiKeyServiceTests extends ESTestCase {
             } else {
                 assertEquals(newMetadata, XContentHelper.convertToMap(updatedApiKeyDoc.metadataFlattened, true, XContentType.JSON).v2());
             }
+            if (newExpiration != null) {
+                assertEquals(clock.instant().plusSeconds(newExpiration.getSeconds()).toEpochMilli(), updatedApiKeyDoc.expirationTime);
+            } else {
+                assertEquals(oldApiKeyDoc.expirationTime, updatedApiKeyDoc.expirationTime);
+            }
             assertEquals(newAuthentication.getEffectiveSubject().getUser().principal(), updatedApiKeyDoc.creator.get("principal"));
             assertEquals(newAuthentication.getEffectiveSubject().getUser().fullName(), updatedApiKeyDoc.creator.get("full_name"));
             assertEquals(newAuthentication.getEffectiveSubject().getUser().email(), updatedApiKeyDoc.creator.get("email"));
@@ -2602,7 +2619,8 @@ public class ApiKeyServiceTests extends ESTestCase {
         final BulkUpdateApiKeyRequest updateRequest = new BulkUpdateApiKeyRequest(
             randomList(1, 3, () -> randomAlphaOfLengthBetween(3, 5)),
             roleDescriptorsWithWorkflowsRestriction,
-            Map.of()
+            Map.of(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         final PlainActionFuture<BulkUpdateApiKeyResponse> updateFuture = new PlainActionFuture<>();
         service.updateApiKeys(authentication, updateRequest, Set.of(), updateFuture);
@@ -2664,7 +2682,8 @@ public class ApiKeyServiceTests extends ESTestCase {
         final BulkUpdateApiKeyRequest updateRequest = new BulkUpdateApiKeyRequest(
             randomList(1, 3, () -> randomAlphaOfLengthBetween(3, 5)),
             requestRoleDescriptors,
-            Map.of()
+            Map.of(),
+            ApiKeyTests.randomFutureExpirationTime()
         );
         final PlainActionFuture<BulkUpdateApiKeyResponse> updateFuture = new PlainActionFuture<>();
         service.updateApiKeys(authentication, updateRequest, userRoleDescriptorsWithWorkflowsRestriction, updateFuture);