Browse Source

Support getting active API keys (#98259)

This PR adds a flag `active_only` to the [Get API key
API](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-get-api-key.html)
to optionally retrieve active-only (i.e., neither expired nor
invalidated) API keys. The flag defaults to false, i.e., by default,
expired and invalidate API keys **are** included in the response
(therefore we keep BWC). 

An example API call is:

```
GET /_security/api_key?active_only=true
```

The `active_only` flag can be used with realm and username based flags
(e.g., `active_only=true&username=X`) as well as `name` and `id`. In the
case it's used with `id` for an existing but non-active key the response
code is 404. This is consistent with the `owner` flag behavior.

Closes https://github.com/elastic/elasticsearch/issues/97995
Nikolaj Volgushev 2 years ago
parent
commit
edf47d29b6

+ 6 - 0
docs/changelog/98259.yaml

@@ -0,0 +1,6 @@
+pr: 98259
+summary: Support getting active-only API keys via Get API keys API
+area: Security
+type: enhancement
+issues:
+ - 97995

+ 2 - 1
server/src/main/java/org/elasticsearch/TransportVersion.java

@@ -176,9 +176,10 @@ public record TransportVersion(int id) implements VersionId<TransportVersion> {
     public static final TransportVersion V_8_500_051 = registerTransportVersion(8_500_051, "a28b43bc-bb5f-4406-afcf-26900aa98a71");
     public static final TransportVersion V_8_500_052 = registerTransportVersion(8_500_052, "2d382b3d-9838-4cce-84c8-4142113e5c2b");
     public static final TransportVersion V_8_500_053 = registerTransportVersion(8_500_053, "aa603bae-01e2-380a-8950-6604468e8c6d");
+    public static final TransportVersion V_8_500_054 = registerTransportVersion(8_500_054, "b76ef950-af03-4dda-85c2-6400ec442e7e");
 
     private static class CurrentHolder {
-        private static final TransportVersion CURRENT = findCurrent(V_8_500_053);
+        private static final TransportVersion CURRENT = findCurrent(V_8_500_054);
 
         // finds the pluggable current version, or uses the given fallback
         private static TransportVersion findCurrent(TransportVersion fallback) {

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

@@ -25,12 +25,15 @@ import static org.elasticsearch.action.ValidateActions.addValidationError;
  */
 public final class GetApiKeyRequest extends ActionRequest {
 
+    static TransportVersion API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION = TransportVersion.V_8_500_054;
+
     private final String realmName;
     private final String userName;
     private final String apiKeyId;
     private final String apiKeyName;
     private final boolean ownedByAuthenticatedUser;
     private final boolean withLimitedBy;
+    private final boolean activeOnly;
 
     public GetApiKeyRequest(StreamInput in) throws IOException {
         super(in);
@@ -48,6 +51,11 @@ public final class GetApiKeyRequest extends ActionRequest {
         } else {
             withLimitedBy = false;
         }
+        if (in.getTransportVersion().onOrAfter(API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION)) {
+            activeOnly = in.readBoolean();
+        } else {
+            activeOnly = false;
+        }
     }
 
     private GetApiKeyRequest(
@@ -56,7 +64,8 @@ public final class GetApiKeyRequest extends ActionRequest {
         @Nullable String apiKeyId,
         @Nullable String apiKeyName,
         boolean ownedByAuthenticatedUser,
-        boolean withLimitedBy
+        boolean withLimitedBy,
+        boolean activeOnly
     ) {
         this.realmName = textOrNull(realmName);
         this.userName = textOrNull(userName);
@@ -64,6 +73,7 @@ public final class GetApiKeyRequest extends ActionRequest {
         this.apiKeyName = textOrNull(apiKeyName);
         this.ownedByAuthenticatedUser = ownedByAuthenticatedUser;
         this.withLimitedBy = withLimitedBy;
+        this.activeOnly = activeOnly;
     }
 
     private static String textOrNull(@Nullable String arg) {
@@ -94,6 +104,10 @@ public final class GetApiKeyRequest extends ActionRequest {
         return withLimitedBy;
     }
 
+    public boolean activeOnly() {
+        return activeOnly;
+    }
+
     @Override
     public ActionRequestValidationException validate() {
         ActionRequestValidationException validationException = null;
@@ -132,6 +146,9 @@ public final class GetApiKeyRequest extends ActionRequest {
         if (out.getTransportVersion().onOrAfter(TransportVersion.V_8_5_0)) {
             out.writeBoolean(withLimitedBy);
         }
+        if (out.getTransportVersion().onOrAfter(API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION)) {
+            out.writeBoolean(activeOnly);
+        }
     }
 
     @Override
@@ -148,12 +165,13 @@ public final class GetApiKeyRequest extends ActionRequest {
             && Objects.equals(userName, that.userName)
             && Objects.equals(apiKeyId, that.apiKeyId)
             && Objects.equals(apiKeyName, that.apiKeyName)
-            && withLimitedBy == that.withLimitedBy;
+            && withLimitedBy == that.withLimitedBy
+            && activeOnly == that.activeOnly;
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(realmName, userName, apiKeyId, apiKeyName, ownedByAuthenticatedUser, withLimitedBy);
+        return Objects.hash(realmName, userName, apiKeyId, apiKeyName, ownedByAuthenticatedUser, withLimitedBy, activeOnly);
     }
 
     public static Builder builder() {
@@ -167,6 +185,7 @@ public final class GetApiKeyRequest extends ActionRequest {
         private String apiKeyName = null;
         private boolean ownedByAuthenticatedUser = false;
         private boolean withLimitedBy = false;
+        private boolean activeOnly = false;
 
         public Builder realmName(String realmName) {
             this.realmName = realmName;
@@ -206,8 +225,13 @@ public final class GetApiKeyRequest extends ActionRequest {
             return this;
         }
 
+        public Builder activeOnly(boolean activeOnly) {
+            this.activeOnly = activeOnly;
+            return this;
+        }
+
         public GetApiKeyRequest build() {
-            return new GetApiKeyRequest(realmName, userName, apiKeyId, apiKeyName, ownedByAuthenticatedUser, withLimitedBy);
+            return new GetApiKeyRequest(realmName, userName, apiKeyId, apiKeyName, ownedByAuthenticatedUser, withLimitedBy, activeOnly);
         }
     }
 }

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

@@ -14,6 +14,7 @@ import org.elasticsearch.common.io.stream.InputStreamStreamInput;
 import org.elasticsearch.common.io.stream.OutputStreamStreamOutput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.TransportVersionUtils;
 
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -21,6 +22,7 @@ import java.io.IOException;
 import java.util.function.Supplier;
 
 import static org.elasticsearch.test.TransportVersionUtils.randomVersionBetween;
+import static org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyRequest.API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.is;
@@ -38,13 +40,17 @@ public class GetApiKeyRequestTests extends ESTestCase {
         request = GetApiKeyRequest.builder().apiKeyName(randomAlphaOfLength(5)).ownedByAuthenticatedUser(randomBoolean()).build();
         ve = request.validate();
         assertNull(ve);
-        request = GetApiKeyRequest.builder().realmName(randomAlphaOfLength(5)).build();
+        request = GetApiKeyRequest.builder().realmName(randomAlphaOfLength(5)).activeOnly(randomBoolean()).build();
         ve = request.validate();
         assertNull(ve);
-        request = GetApiKeyRequest.builder().userName(randomAlphaOfLength(5)).build();
+        request = GetApiKeyRequest.builder().userName(randomAlphaOfLength(5)).activeOnly(randomBoolean()).build();
         ve = request.validate();
         assertNull(ve);
-        request = GetApiKeyRequest.builder().realmName(randomAlphaOfLength(5)).userName(randomAlphaOfLength(7)).build();
+        request = GetApiKeyRequest.builder()
+            .realmName(randomAlphaOfLength(5))
+            .userName(randomAlphaOfLength(7))
+            .activeOnly(randomBoolean())
+            .build();
         ve = request.validate();
         assertNull(ve);
     }
@@ -79,6 +85,7 @@ public class GetApiKeyRequestTests extends ESTestCase {
                 out.writeOptionalString(apiKeyName);
                 out.writeOptionalBoolean(ownedByAuthenticatedUser);
                 out.writeBoolean(randomBoolean());
+                out.writeBoolean(randomBoolean());
             }
         }
 
@@ -143,6 +150,7 @@ public class GetApiKeyRequestTests extends ESTestCase {
                 .apiKeyId(apiKeyId)
                 .ownedByAuthenticatedUser(true)
                 .withLimitedBy(randomBoolean())
+                .activeOnly(randomBoolean())
                 .build();
             ByteArrayOutputStream outBuffer = new ByteArrayOutputStream();
             OutputStreamStreamOutput out = new OutputStreamStreamOutput(outBuffer);
@@ -157,17 +165,48 @@ public class GetApiKeyRequestTests extends ESTestCase {
             assertThat(requestFromInputStream.ownedByAuthenticatedUser(), is(true));
             // old version so the default for `withLimitedBy` is false
             assertThat(requestFromInputStream.withLimitedBy(), is(false));
+            // old version so the default for `activeOnly` is false
+            assertThat(requestFromInputStream.activeOnly(), is(false));
         }
         {
-            final GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.builder().apiKeyId(apiKeyId).withLimitedBy(randomBoolean()).build();
+            final GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.builder()
+                .apiKeyId(apiKeyId)
+                .ownedByAuthenticatedUser(randomBoolean())
+                .withLimitedBy(randomBoolean())
+                .activeOnly(randomBoolean())
+                .build();
             ByteArrayOutputStream outBuffer = new ByteArrayOutputStream();
             OutputStreamStreamOutput out = new OutputStreamStreamOutput(outBuffer);
-            out.setTransportVersion(randomVersionBetween(random(), TransportVersion.V_8_5_0, TransportVersion.current()));
+            TransportVersion beforeActiveOnly = TransportVersionUtils.getPreviousVersion(API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION);
+            out.setTransportVersion(randomVersionBetween(random(), TransportVersion.V_8_5_0, beforeActiveOnly));
+            getApiKeyRequest.writeTo(out);
+
+            InputStreamStreamInput inputStreamStreamInput = new InputStreamStreamInput(new ByteArrayInputStream(outBuffer.toByteArray()));
+            inputStreamStreamInput.setTransportVersion(randomVersionBetween(random(), TransportVersion.V_8_5_0, beforeActiveOnly));
+            GetApiKeyRequest requestFromInputStream = new GetApiKeyRequest(inputStreamStreamInput);
+
+            assertThat(requestFromInputStream.getApiKeyId(), equalTo(getApiKeyRequest.getApiKeyId()));
+            assertThat(requestFromInputStream.ownedByAuthenticatedUser(), is(getApiKeyRequest.ownedByAuthenticatedUser()));
+            assertThat(requestFromInputStream.withLimitedBy(), is(getApiKeyRequest.withLimitedBy()));
+            // old version so the default for `activeOnly` is false
+            assertThat(requestFromInputStream.activeOnly(), is(false));
+        }
+        {
+            final GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.builder()
+                .apiKeyId(apiKeyId)
+                .withLimitedBy(randomBoolean())
+                .activeOnly(randomBoolean())
+                .build();
+            ByteArrayOutputStream outBuffer = new ByteArrayOutputStream();
+            OutputStreamStreamOutput out = new OutputStreamStreamOutput(outBuffer);
+            out.setTransportVersion(
+                randomVersionBetween(random(), API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION, TransportVersion.current())
+            );
             getApiKeyRequest.writeTo(out);
 
             InputStreamStreamInput inputStreamStreamInput = new InputStreamStreamInput(new ByteArrayInputStream(outBuffer.toByteArray()));
             inputStreamStreamInput.setTransportVersion(
-                randomVersionBetween(random(), TransportVersion.V_8_5_0, TransportVersion.current())
+                randomVersionBetween(random(), API_KEY_ACTIVE_ONLY_PARAM_TRANSPORT_VERSION, TransportVersion.current())
             );
             GetApiKeyRequest requestFromInputStream = new GetApiKeyRequest(inputStreamStreamInput);
 

+ 274 - 0
x-pack/plugin/security/qa/security-trial/src/javaRestTest/java/org/elasticsearch/xpack/security/apikey/GetApiKeysRestIT.java

@@ -0,0 +1,274 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.apikey;
+
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.util.EntityUtils;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.test.XContentTestUtils;
+import org.elasticsearch.transport.TcpTransport;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.core.security.action.apikey.ApiKey;
+import org.elasticsearch.xpack.core.security.action.apikey.GetApiKeyResponse;
+import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken;
+import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase;
+import org.junit.Before;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nullable;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.emptyArray;
+import static org.hamcrest.Matchers.equalTo;
+
+public class GetApiKeysRestIT extends SecurityOnTrialLicenseRestTestCase {
+    private static final SecureString END_USER_PASSWORD = new SecureString("end-user-password".toCharArray());
+    private static final String MANAGE_OWN_API_KEY_USER = "manage_own_api_key_user";
+    private static final String MANAGE_SECURITY_USER = "manage_security_user";
+
+    @Before
+    public void createUsers() throws IOException {
+        createUser(MANAGE_OWN_API_KEY_USER, END_USER_PASSWORD, List.of("manage_own_api_key_role"));
+        createRole("manage_own_api_key_role", Set.of("manage_own_api_key"));
+        createUser(MANAGE_SECURITY_USER, END_USER_PASSWORD, List.of("manage_security_role"));
+        createRole("manage_security_role", Set.of("manage_security"));
+    }
+
+    public void testGetApiKeysWithActiveOnlyFlag() throws Exception {
+        final String apiKeyId0 = createApiKey(MANAGE_SECURITY_USER, "key-0");
+        final String apiKeyId1 = createApiKey(MANAGE_SECURITY_USER, "key-1");
+        // Set short enough expiration for the API key to be expired by the time we query for it
+        final String apiKeyId2 = createApiKey(MANAGE_SECURITY_USER, "key-2", TimeValue.timeValueNanos(1));
+
+        // All API keys returned when flag false (implicitly or explicitly)
+        {
+            final Map<String, String> parameters = new HashMap<>();
+            if (randomBoolean()) {
+                parameters.put("active_only", "false");
+            }
+            assertResponseContainsApiKeyIds(getApiKeysWithRequestParams(parameters), apiKeyId0, apiKeyId1, apiKeyId2);
+        }
+
+        // Only active keys returned when flag true
+        assertResponseContainsApiKeyIds(getApiKeysWithRequestParams(Map.of("active_only", "true")), apiKeyId0, apiKeyId1);
+        // Also works with `name` filter
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", "true", "name", randomFrom("*", "key-*"))),
+            apiKeyId0,
+            apiKeyId1
+        );
+        // Also works with `realm_name` filter
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", "true", "realm_name", "default_native")),
+            apiKeyId0,
+            apiKeyId1
+        );
+
+        // Same applies to invalidated key
+        getSecurityClient().invalidateApiKeys(apiKeyId0);
+        {
+            final Map<String, String> parameters = new HashMap<>();
+            if (randomBoolean()) {
+                parameters.put("active_only", "false");
+            }
+            assertResponseContainsApiKeyIds(getApiKeysWithRequestParams(parameters), apiKeyId0, apiKeyId1, apiKeyId2);
+        }
+        assertResponseContainsApiKeyIds(getApiKeysWithRequestParams(Map.of("active_only", "true")), apiKeyId1);
+        // also works with name filter
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", "true", "name", randomFrom("*", "key-*", "key-1"))),
+            apiKeyId1
+        );
+
+        // We get an empty result when no API keys active
+        getSecurityClient().invalidateApiKeys(apiKeyId1);
+        assertThat(getApiKeysWithRequestParams(Map.of("active_only", "true")).getApiKeyInfos(), emptyArray());
+
+        {
+            // Using together with id parameter, returns 404 for inactive key
+            var ex = expectThrows(
+                ResponseException.class,
+                () -> getApiKeysWithRequestParams(Map.of("active_only", "true", "id", randomFrom(apiKeyId0, apiKeyId1, apiKeyId2)))
+            );
+            assertThat(ex.getResponse().getStatusLine().getStatusCode(), equalTo(404));
+        }
+
+        {
+            // manage_own_api_key prohibits owner=false, even if active_only is set
+            var ex = expectThrows(
+                ResponseException.class,
+                () -> getApiKeysWithRequestParams(MANAGE_OWN_API_KEY_USER, Map.of("active_only", "true", "owner", "false"))
+            );
+            assertThat(ex.getResponse().getStatusLine().getStatusCode(), equalTo(403));
+        }
+    }
+
+    public void testGetApiKeysWithActiveOnlyFlagAndMultipleUsers() throws Exception {
+        final String manageOwnApiKeyUserApiKeyId = createApiKey(MANAGE_OWN_API_KEY_USER, "key-0");
+        final String manageApiKeyUserApiKeyId = createApiKey(MANAGE_SECURITY_USER, "key-1");
+
+        // Both users' API keys are returned
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", Boolean.toString(randomBoolean()))),
+            manageOwnApiKeyUserApiKeyId,
+            manageApiKeyUserApiKeyId
+        );
+        // Filtering by username works (also via owner flag)
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", Boolean.toString(randomBoolean()), "username", MANAGE_SECURITY_USER)),
+            manageApiKeyUserApiKeyId
+        );
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", Boolean.toString(randomBoolean()), "username", MANAGE_OWN_API_KEY_USER)),
+            manageOwnApiKeyUserApiKeyId
+        );
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(MANAGE_SECURITY_USER, Map.of("active_only", Boolean.toString(randomBoolean()), "owner", "true")),
+            manageApiKeyUserApiKeyId
+        );
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(MANAGE_OWN_API_KEY_USER, Map.of("active_only", Boolean.toString(randomBoolean()), "owner", "true")),
+            manageOwnApiKeyUserApiKeyId
+        );
+
+        // One user's API key is active
+        invalidateApiKeysForUser(MANAGE_OWN_API_KEY_USER);
+
+        // Filtering by username still works (also via owner flag)
+        assertResponseContainsApiKeyIds(getApiKeysWithRequestParams(Map.of("active_only", "true")), manageApiKeyUserApiKeyId);
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(Map.of("active_only", "true", "username", MANAGE_SECURITY_USER)),
+            manageApiKeyUserApiKeyId
+        );
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(MANAGE_SECURITY_USER, Map.of("active_only", "true", "owner", "true")),
+            manageApiKeyUserApiKeyId
+        );
+        assertThat(
+            getApiKeysWithRequestParams(Map.of("active_only", "true", "username", MANAGE_OWN_API_KEY_USER)).getApiKeyInfos(),
+            emptyArray()
+        );
+        assertThat(
+            getApiKeysWithRequestParams(MANAGE_OWN_API_KEY_USER, Map.of("active_only", "true", "owner", "true")).getApiKeyInfos(),
+            emptyArray()
+        );
+
+        // No more active API keys
+        invalidateApiKeysForUser(MANAGE_SECURITY_USER);
+
+        assertThat(
+            getApiKeysWithRequestParams(
+                Map.of("active_only", "true", "username", randomFrom(MANAGE_SECURITY_USER, MANAGE_OWN_API_KEY_USER))
+            ).getApiKeyInfos(),
+            emptyArray()
+        );
+        assertThat(
+            getApiKeysWithRequestParams(
+                randomFrom(MANAGE_SECURITY_USER, MANAGE_OWN_API_KEY_USER),
+                Map.of("active_only", "true", "owner", "true")
+            ).getApiKeyInfos(),
+            emptyArray()
+        );
+        // With flag set to false, we get both inactive keys
+        assertResponseContainsApiKeyIds(
+            getApiKeysWithRequestParams(randomBoolean() ? Map.of() : Map.of("active_only", "false")),
+            manageOwnApiKeyUserApiKeyId,
+            manageApiKeyUserApiKeyId
+        );
+    }
+
+    private GetApiKeyResponse getApiKeysWithRequestParams(Map<String, String> requestParams) throws IOException {
+        return getApiKeysWithRequestParams(MANAGE_SECURITY_USER, requestParams);
+    }
+
+    private GetApiKeyResponse getApiKeysWithRequestParams(String userOnRequest, Map<String, String> requestParams) throws IOException {
+        final var request = new Request(HttpGet.METHOD_NAME, "/_security/api_key/");
+        request.addParameters(requestParams);
+        setUserForRequest(request, userOnRequest);
+        return GetApiKeyResponse.fromXContent(getParser(client().performRequest(request)));
+    }
+
+    private static void assertResponseContainsApiKeyIds(GetApiKeyResponse response, String... ids) {
+        assertThat(Arrays.stream(response.getApiKeyInfos()).map(ApiKey::getId).collect(Collectors.toList()), containsInAnyOrder(ids));
+    }
+
+    private static XContentParser getParser(Response response) throws IOException {
+        final byte[] responseBody = EntityUtils.toByteArray(response.getEntity());
+        return XContentType.JSON.xContent().createParser(XContentParserConfiguration.EMPTY, responseBody);
+    }
+
+    private String createApiKey(String creatorUser, String apiKeyName) throws IOException {
+        return createApiKey(creatorUser, apiKeyName, null);
+    }
+
+    /**
+     * Returns id of created API key.
+     */
+    private String createApiKey(String creatorUser, String apiKeyName, @Nullable TimeValue expiration) throws IOException {
+        // Sanity check to ensure API key name and creator name aren't flipped
+        assert creatorUser.equals(MANAGE_OWN_API_KEY_USER) || creatorUser.equals(MANAGE_SECURITY_USER);
+
+        // Exercise cross cluster keys, if viable (i.e., creator has enough privileges and feature flag is enabled)
+        final boolean createCrossClusterKey = creatorUser.equals(MANAGE_SECURITY_USER)
+            && TcpTransport.isUntrustedRemoteClusterEnabled()
+            && randomBoolean();
+        if (createCrossClusterKey) {
+            final Map<String, Object> createApiKeyRequestBody = expiration == null
+                ? Map.of("name", apiKeyName, "access", Map.of("search", List.of(Map.of("names", List.of("*")))))
+                : Map.of("name", apiKeyName, "expiration", expiration, "access", Map.of("search", List.of(Map.of("names", List.of("*")))));
+            final var createApiKeyRequest = new Request("POST", "/_security/cross_cluster/api_key");
+            createApiKeyRequest.setJsonEntity(
+                XContentTestUtils.convertToXContent(createApiKeyRequestBody, XContentType.JSON).utf8ToString()
+            );
+            setUserForRequest(createApiKeyRequest, creatorUser);
+
+            final Response createApiKeyResponse = client().performRequest(createApiKeyRequest);
+
+            assertOK(createApiKeyResponse);
+            final Map<String, Object> createApiKeyResponseMap = responseAsMap(createApiKeyResponse);
+            return (String) createApiKeyResponseMap.get("id");
+        } else {
+            final Map<String, Object> createApiKeyRequestBody = expiration == null
+                ? Map.of("name", apiKeyName)
+                : Map.of("name", apiKeyName, "expiration", expiration);
+            final var createApiKeyRequest = new Request("POST", "/_security/api_key");
+            createApiKeyRequest.setJsonEntity(
+                XContentTestUtils.convertToXContent(createApiKeyRequestBody, XContentType.JSON).utf8ToString()
+            );
+            setUserForRequest(createApiKeyRequest, creatorUser);
+
+            final Response createApiKeyResponse = client().performRequest(createApiKeyRequest);
+
+            assertOK(createApiKeyResponse);
+            final Map<String, Object> createApiKeyResponseMap = responseAsMap(createApiKeyResponse);
+            return (String) createApiKeyResponseMap.get("id");
+        }
+    }
+
+    private void setUserForRequest(Request request, String username) {
+        request.setOptions(
+            request.getOptions()
+                .toBuilder()
+                .removeHeader("Authorization")
+                .addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(username, END_USER_PASSWORD))
+        );
+    }
+}

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

@@ -57,7 +57,7 @@ public final class TransportGetApiKeyAction extends HandledTransportAction<GetAp
             realms = ApiKeyService.getOwnersRealmNames(authentication);
         }
 
-        apiKeyService.getApiKeys(realms, username, apiKeyName, apiKeyIds, request.withLimitedBy(), listener);
+        apiKeyService.getApiKeys(realms, username, apiKeyName, apiKeyIds, request.withLimitedBy(), request.activeOnly(), listener);
     }
 
 }

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

@@ -1893,6 +1893,7 @@ public class ApiKeyService {
         String apiKeyName,
         String[] apiKeyIds,
         boolean withLimitedBy,
+        boolean activeOnly,
         ActionListener<GetApiKeyResponse> listener
     ) {
         ensureEnabled();
@@ -1901,17 +1902,18 @@ public class ApiKeyService {
             username,
             apiKeyName,
             apiKeyIds,
-            false,
-            false,
+            activeOnly,
+            activeOnly,
             hit -> convertSearchHitToApiKeyInfo(hit, withLimitedBy),
             ActionListener.wrap(apiKeyInfos -> {
                 if (apiKeyInfos.isEmpty()) {
                     logger.debug(
-                        "No active api keys found for realms {}, user [{}], api key name [{}] and api key ids {}",
+                        "No API keys found for realms {}, user [{}], API key name [{}], API key IDs {}, and active_only flag [{}]",
                         Arrays.toString(realmNames),
                         username,
                         apiKeyName,
-                        Arrays.toString(apiKeyIds)
+                        Arrays.toString(apiKeyIds),
+                        activeOnly
                     );
                     listener.onResponse(GetApiKeyResponse.emptyResponse());
                 } else {

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

@@ -50,6 +50,7 @@ public final class RestGetApiKeyAction extends ApiKeyBaseRestHandler {
         final String realmName = request.param("realm_name");
         final boolean myApiKeysOnly = request.paramAsBoolean("owner", false);
         final boolean withLimitedBy = request.paramAsBoolean("with_limited_by", false);
+        final boolean activeOnly = request.paramAsBoolean("active_only", false);
         final GetApiKeyRequest getApiKeyRequest = GetApiKeyRequest.builder()
             .realmName(realmName)
             .userName(userName)
@@ -57,6 +58,7 @@ public final class RestGetApiKeyAction extends ApiKeyBaseRestHandler {
             .apiKeyName(apiKeyName)
             .ownedByAuthenticatedUser(myApiKeysOnly)
             .withLimitedBy(withLimitedBy)
+            .activeOnly(activeOnly)
             .build();
         return channel -> client.execute(GetApiKeyAction.INSTANCE, getApiKeyRequest, new RestBuilderListener<>(channel) {
             @Override

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

@@ -266,6 +266,8 @@ public class ApiKeyServiceTests extends ESTestCase {
 
     @SuppressWarnings("unchecked")
     public void testGetApiKeys() throws Exception {
+        final long now = randomMillisUpToYear9999();
+        when(clock.instant()).thenReturn(Instant.ofEpochMilli(now));
         final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), true).build();
         when(client.threadPool()).thenReturn(threadPool);
         SearchRequestBuilder searchRequestBuilder = Mockito.spy(new SearchRequestBuilder(client, SearchAction.INSTANCE));
@@ -283,7 +285,8 @@ public class ApiKeyServiceTests extends ESTestCase {
         String apiKeyName = randomFrom(randomAlphaOfLengthBetween(3, 8), null);
         String[] apiKeyIds = generateRandomStringArray(4, 4, true, true);
         PlainActionFuture<GetApiKeyResponse> getApiKeyResponsePlainActionFuture = new PlainActionFuture<>();
-        service.getApiKeys(realmNames, username, apiKeyName, apiKeyIds, randomBoolean(), getApiKeyResponsePlainActionFuture);
+        final boolean activeOnly = randomBoolean();
+        service.getApiKeys(realmNames, username, apiKeyName, apiKeyIds, randomBoolean(), activeOnly, getApiKeyResponsePlainActionFuture);
         final BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("doc_type", "api_key"));
         if (realmNames != null && realmNames.length > 0) {
             if (realmNames.length == 1) {
@@ -310,6 +313,13 @@ public class ApiKeyServiceTests extends ESTestCase {
         if (apiKeyIds != null && apiKeyIds.length > 0) {
             boolQuery.filter(QueryBuilders.idsQuery().addIds(apiKeyIds));
         }
+        if (activeOnly) {
+            boolQuery.filter(QueryBuilders.termQuery("api_key_invalidated", false));
+            final BoolQueryBuilder expiredQuery = QueryBuilders.boolQuery();
+            expiredQuery.should(QueryBuilders.rangeQuery("expiration_time").gt(now));
+            expiredQuery.should(QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery("expiration_time")));
+            boolQuery.filter(expiredQuery);
+        }
         verify(searchRequestBuilder).setQuery(eq(boolQuery));
         verify(searchRequestBuilder).setFetchSource(eq(true));
         assertThat(searchRequest.get().source().query(), is(boolQuery));
@@ -1157,7 +1167,7 @@ public class ApiKeyServiceTests extends ESTestCase {
         assertThat(roleWithoutRestriction.getRestriction().getWorkflows(), nullValue());
     }
 
-    public void testApiKeyServiceDisabled() throws Exception {
+    public void testApiKeyServiceDisabled() {
         final Settings settings = Settings.builder().put(XPackSettings.API_KEY_SERVICE_ENABLED_SETTING.getKey(), false).build();
         final ApiKeyService service = createApiKeyService(settings);
 
@@ -1169,6 +1179,7 @@ public class ApiKeyServiceTests extends ESTestCase {
                 null,
                 null,
                 randomBoolean(),
+                randomBoolean(),
                 new PlainActionFuture<>()
             )
         );