Browse Source

A new search API for API keys - core search function (#75335)

This PR adds a new API for searching API keys. The API supports searching API
keys with a controlled list of field names and a subset of Query DSL. It also
provides a translation layer between the field names used in the REST layer and
those in the index layer. This is to prevent tight coupling between the user
facing request and index mappings so that they can evolve separately.

Compared to the Get API key API, this new search API automatically applies
calling user's security context similar to regular searches, e.g. if the user
has only manage_own_api_key privilege, only keys owned by the user are returned
in the search response.

Relates: #71023
Yang Wang 4 years ago
parent
commit
8c09fc8f9f
22 changed files with 1436 additions and 12 deletions
  1. 16 3
      server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java
  2. 21 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java
  3. 60 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java
  4. 82 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java
  5. 0 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java
  6. 4 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java
  7. 61 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java
  8. 72 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java
  9. 6 7
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java
  10. 16 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java
  11. 1 0
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  12. 2 0
      x-pack/plugin/security/qa/security-basic/build.gradle
  13. 294 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java
  14. 11 0
      x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml
  15. 5 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  16. 50 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java
  17. 23 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java
  18. 15 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java
  19. 71 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java
  20. 226 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java
  21. 114 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java
  22. 286 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java

+ 16 - 3
server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java

@@ -107,6 +107,7 @@ public class SearchExecutionContext extends QueryRewriteContext {
     private NestedScope nestedScope;
     private final ValuesSourceRegistry valuesSourceRegistry;
     private final Map<String, MappedFieldType> runtimeMappings;
+    private Predicate<String> allowedFields;
 
     /**
      * Build a {@linkplain SearchExecutionContext}.
@@ -154,7 +155,8 @@ public class SearchExecutionContext extends QueryRewriteContext {
             ),
             allowExpensiveQueries,
             valuesSourceRegistry,
-            parseRuntimeMappings(runtimeMappings, mapperService)
+            parseRuntimeMappings(runtimeMappings, mapperService),
+            null
         );
     }
 
@@ -177,7 +179,8 @@ public class SearchExecutionContext extends QueryRewriteContext {
             source.fullyQualifiedIndex,
             source.allowExpensiveQueries,
             source.valuesSourceRegistry,
-            source.runtimeMappings
+            source.runtimeMappings,
+            source.allowedFields
         );
     }
 
@@ -199,7 +202,8 @@ public class SearchExecutionContext extends QueryRewriteContext {
                                    Index fullyQualifiedIndex,
                                    BooleanSupplier allowExpensiveQueries,
                                    ValuesSourceRegistry valuesSourceRegistry,
-                                   Map<String, MappedFieldType> runtimeMappings) {
+                                   Map<String, MappedFieldType> runtimeMappings,
+                                   Predicate<String> allowedFields) {
         super(xContentRegistry, namedWriteableRegistry, client, nowInMillis);
         this.shardId = shardId;
         this.shardRequestIndex = shardRequestIndex;
@@ -218,6 +222,7 @@ public class SearchExecutionContext extends QueryRewriteContext {
         this.allowExpensiveQueries = allowExpensiveQueries;
         this.valuesSourceRegistry = valuesSourceRegistry;
         this.runtimeMappings = runtimeMappings;
+        this.allowedFields = allowedFields;
     }
 
     private void reset() {
@@ -352,6 +357,10 @@ public class SearchExecutionContext extends QueryRewriteContext {
     }
 
     private MappedFieldType fieldType(String name) {
+        // If the field is not allowed, behave as if it is not mapped
+        if (allowedFields != null && false == allowedFields.test(name)) {
+            return null;
+        }
         MappedFieldType fieldType = runtimeMappings.get(name);
         return fieldType == null ? mappingLookup.getFieldType(name) : fieldType;
     }
@@ -419,6 +428,10 @@ public class SearchExecutionContext extends QueryRewriteContext {
         this.mapUnmappedFieldAsString = mapUnmappedFieldAsString;
     }
 
+    public void setAllowedFields(Predicate<String> allowedFields) {
+        this.allowedFields = allowedFields;
+    }
+
     MappedFieldType failIfFieldMappingNotFound(String name, MappedFieldType fieldMapping) {
         if (fieldMapping != null || allowUnmappedFields) {
             return fieldMapping;

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

@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionType;
+
+public final class QueryApiKeyAction extends ActionType<QueryApiKeyResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/api_key/query";
+    public static final QueryApiKeyAction INSTANCE = new QueryApiKeyAction();
+
+    private QueryApiKeyAction() {
+        super(NAME, QueryApiKeyResponse::new);
+    }
+
+}

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

@@ -0,0 +1,60 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionRequestValidationException;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.QueryBuilder;
+
+import java.io.IOException;
+
+public final class QueryApiKeyRequest extends ActionRequest {
+
+    @Nullable
+    private final QueryBuilder queryBuilder;
+    private boolean filterForCurrentUser;
+
+    public QueryApiKeyRequest() {
+        this((QueryBuilder) null);
+    }
+
+    public QueryApiKeyRequest(QueryBuilder queryBuilder) {
+        this.queryBuilder = queryBuilder;
+    }
+
+    public QueryApiKeyRequest(StreamInput in) throws IOException {
+        super(in);
+        queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class);
+    }
+
+    public QueryBuilder getQueryBuilder() {
+        return queryBuilder;
+    }
+
+    public boolean isFilterForCurrentUser() {
+        return filterForCurrentUser;
+    }
+
+    public void setFilterForCurrentUser() {
+        filterForCurrentUser = true;
+    }
+
+    @Override
+    public ActionRequestValidationException validate() {
+        return null;
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeOptionalNamedWriteable(queryBuilder);
+    }
+}

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

@@ -0,0 +1,82 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.xpack.core.security.action.ApiKey;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+
+/**
+ * Response for search API keys.<br>
+ * The result contains information about the API keys that were found.
+ */
+public final class QueryApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable {
+
+    private final ApiKey[] foundApiKeysInfo;
+
+    public QueryApiKeyResponse(StreamInput in) throws IOException {
+        super(in);
+        this.foundApiKeysInfo = in.readArray(ApiKey::new, ApiKey[]::new);
+    }
+
+    public QueryApiKeyResponse(Collection<ApiKey> foundApiKeysInfo) {
+        Objects.requireNonNull(foundApiKeysInfo, "found_api_keys_info must be provided");
+        this.foundApiKeysInfo = foundApiKeysInfo.toArray(new ApiKey[0]);
+    }
+
+    public static QueryApiKeyResponse emptyResponse() {
+        return new QueryApiKeyResponse(Collections.emptyList());
+    }
+
+    public ApiKey[] getApiKeyInfos() {
+        return foundApiKeysInfo;
+    }
+
+    @Override
+    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject()
+            .array("api_keys", (Object[]) foundApiKeysInfo);
+        return builder.endObject();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        out.writeArray(foundApiKeysInfo);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o)
+            return true;
+        if (o == null || getClass() != o.getClass())
+            return false;
+        QueryApiKeyResponse that = (QueryApiKeyResponse) o;
+        return Arrays.equals(foundApiKeysInfo, that.foundApiKeysInfo);
+    }
+
+    @Override
+    public int hashCode() {
+        return Arrays.hashCode(foundApiKeysInfo);
+    }
+
+    @Override
+    public String toString() {
+        return "QueryApiKeyResponse [foundApiKeysInfo=" + foundApiKeysInfo + "]";
+    }
+
+}

+ 0 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java

@@ -216,7 +216,6 @@ public class Role {
 
         public Builder cluster(Set<String> privilegeNames, Iterable<ConfigurableClusterPrivilege> configurableClusterPrivileges) {
             ClusterPermission.Builder builder = ClusterPermission.builder();
-            List<ClusterPermission> clusterPermissions = new ArrayList<>();
             if (privilegeNames.isEmpty() == false) {
                 for (String name : privilegeNames) {
                     builder = ClusterPrivilegeResolver.resolve(name).buildPermission(builder);

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

@@ -12,6 +12,7 @@ import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
 import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
@@ -75,6 +76,9 @@ public class ManageOwnApiKeyClusterPrivilege implements NamedClusterPrivilege {
                         invalidateApiKeyRequest.getUserName(), invalidateApiKeyRequest.getRealmName(),
                         invalidateApiKeyRequest.ownedByAuthenticatedUser()));
                 }
+            } else if (request instanceof QueryApiKeyRequest) {
+                final QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                return queryApiKeyRequest.isFilterForCurrentUser();
             }
             throw new IllegalArgumentException(
                 "manage own api key privilege only supports API key requests (not " + request.getClass().getName() + ")");

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

@@ -0,0 +1,61 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.core.security.action.apikey;
+
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.InputStreamStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+public class QueryApiKeyRequestTests extends ESTestCase {
+
+    @Override
+    protected NamedWriteableRegistry writableRegistry() {
+        final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
+        return new NamedWriteableRegistry(searchModule.getNamedWriteables());
+    }
+
+    public void testReadWrite() throws IOException {
+        final QueryApiKeyRequest request1 = new QueryApiKeyRequest();
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            request1.writeTo(out);
+            try (StreamInput in = new InputStreamStreamInput(new ByteArrayInputStream(out.bytes().array()))) {
+                assertThat(new QueryApiKeyRequest(in).getQueryBuilder(), nullValue());
+            }
+        }
+
+        final BoolQueryBuilder boolQueryBuilder2 = QueryBuilders.boolQuery()
+            .filter(QueryBuilders.termQuery("foo", "bar"))
+            .should(QueryBuilders.idsQuery().addIds("id1", "id2"))
+            .must(QueryBuilders.wildcardQuery("a.b", "t*y"))
+            .mustNot(QueryBuilders.prefixQuery("value", "prod"));
+        final QueryApiKeyRequest request2 = new QueryApiKeyRequest(boolQueryBuilder2);
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            request2.writeTo(out);
+            try (StreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), writableRegistry())) {
+                final QueryApiKeyRequest deserialized = new QueryApiKeyRequest(in);
+                assertThat(deserialized.getQueryBuilder().getClass(), is(BoolQueryBuilder.class));
+                assertThat((BoolQueryBuilder) deserialized.getQueryBuilder(), equalTo(boolQueryBuilder2));
+            }
+        }
+    }
+}

+ 72 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.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.common.io.stream.Writeable;
+import org.elasticsearch.test.AbstractWireSerializingTestCase;
+import org.elasticsearch.xpack.core.security.action.ApiKey;
+import org.elasticsearch.xpack.core.security.action.ApiKeyTests;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class QueryApiKeyResponseTests extends AbstractWireSerializingTestCase<QueryApiKeyResponse> {
+
+    @Override
+    protected Writeable.Reader<QueryApiKeyResponse> instanceReader() {
+        return QueryApiKeyResponse::new;
+    }
+
+    @Override
+    protected QueryApiKeyResponse createTestInstance() {
+        final List<ApiKey> apiKeys = randomList(0, 3, this::randomApiKeyInfo);
+        return new QueryApiKeyResponse(apiKeys);
+    }
+
+    @Override
+    protected QueryApiKeyResponse mutateInstance(QueryApiKeyResponse instance) throws IOException {
+        final ArrayList<ApiKey> apiKeyInfos =
+            Arrays.stream(instance.getApiKeyInfos()).collect(Collectors.toCollection(ArrayList::new));
+        switch (randomIntBetween(0, 2)) {
+            case 0:
+                apiKeyInfos.add(randomApiKeyInfo());
+                return new QueryApiKeyResponse(apiKeyInfos);
+            case 1:
+                if (false == apiKeyInfos.isEmpty()) {
+                    return new QueryApiKeyResponse(apiKeyInfos.subList(1, apiKeyInfos.size()));
+                } else {
+                    apiKeyInfos.add(randomApiKeyInfo());
+                    return new QueryApiKeyResponse(apiKeyInfos);
+                }
+            default:
+                if (false == apiKeyInfos.isEmpty()) {
+                    final int index = randomIntBetween(0, apiKeyInfos.size() - 1);
+                    apiKeyInfos.set(index, randomApiKeyInfo());
+                } else {
+                    apiKeyInfos.add(randomApiKeyInfo());
+                }
+                return new QueryApiKeyResponse(apiKeyInfos);
+        }
+    }
+
+    private ApiKey randomApiKeyInfo() {
+        final String name = randomAlphaOfLengthBetween(3, 8);
+        final String id = randomAlphaOfLength(22);
+        final String username = randomAlphaOfLengthBetween(3, 8);
+        final String realm_name = randomAlphaOfLengthBetween(3, 8);
+        final Instant creation = Instant.ofEpochMilli(randomMillisUpToYear9999());
+        final Instant expiration = randomBoolean() ? Instant.ofEpochMilli(randomMillisUpToYear9999()) : null;
+        final Map<String, Object> metadata = ApiKeyTests.randomMetadata();
+        return new ApiKey(name, id, creation, expiration, false, username, realm_name, metadata);
+    }
+}

+ 6 - 7
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java

@@ -52,7 +52,7 @@ public class AuthenticationTests extends ESTestCase {
         checkCanAccessResources(randomAuthentication(user1, realm1), randomAuthentication(user1, realm1));
 
         // Different username is different no matter which realm it is from
-        final User user2 = randomValueOtherThanMany(u -> u.principal().equals(user1.principal()), this::randomUser);
+        final User user2 = randomValueOtherThanMany(u -> u.principal().equals(user1.principal()), AuthenticationTests::randomUser);
         // user 2 can be from either the same realm or a different realm
         final RealmRef realm2 = randomFrom(realm1, randomRealm());
         assertCannotAccessResources(randomAuthentication(user1, realm2), randomAuthentication(user2, realm2));
@@ -136,12 +136,12 @@ public class AuthenticationTests extends ESTestCase {
         assertFalse(authentication1.canAccessResourcesOf(authentication0));
     }
 
-    private User randomUser() {
+    public static User randomUser() {
         return new User(randomAlphaOfLengthBetween(3, 8),
             randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
     }
 
-    private RealmRef randomRealm() {
+    public static RealmRef randomRealm() {
         return new RealmRef(
             randomAlphaOfLengthBetween(3, 8),
             randomFrom(FileRealmSettings.TYPE, NativeRealmSettings.TYPE, randomAlphaOfLengthBetween(3, 8)),
@@ -155,10 +155,9 @@ public class AuthenticationTests extends ESTestCase {
             randomBoolean() ? original.getNodeName() : randomAlphaOfLengthBetween(3, 8));
     }
 
-    private Authentication randomAuthentication(User user, RealmRef realmRef) {
+    public static Authentication randomAuthentication(User user, RealmRef realmRef) {
         if (user == null) {
-            user = new User(randomAlphaOfLengthBetween(3, 8),
-                randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
+            user = randomUser();
         }
         if (realmRef == null) {
             realmRef = randomRealm();
@@ -181,7 +180,7 @@ public class AuthenticationTests extends ESTestCase {
         }
     }
 
-    private Authentication randomApiKeyAuthentication(User user, String apiKeyId) {
+    public static Authentication randomApiKeyAuthentication(User user, String apiKeyId) {
         final RealmRef apiKeyRealm = new RealmRef("_es_api_key", "_es_api_key", randomAlphaOfLengthBetween(3, 8));
         return new Authentication(user,
             apiKeyRealm,

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

@@ -13,6 +13,8 @@ import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.AuthenticationType;
 import org.elasticsearch.xpack.core.security.authz.permission.ClusterPermission;
@@ -20,6 +22,7 @@ import org.elasticsearch.xpack.core.security.user.User;
 
 import java.util.Map;
 
+import static org.hamcrest.Matchers.is;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -115,6 +118,19 @@ public class ManageOwnApiKeyClusterPrivilegeTests extends ESTestCase {
             InvalidateApiKeyRequest.usingRealmAndUserName("realm_b", "user_b"), authentication));
     }
 
+    public void testCheckQueryApiKeyRequest() {
+        final ClusterPermission clusterPermission =
+            ManageOwnApiKeyClusterPrivilege.INSTANCE.buildPermission(ClusterPermission.builder()).build();
+
+        final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest();
+        if (randomBoolean()) {
+            queryApiKeyRequest.setFilterForCurrentUser();
+        }
+        assertThat(
+            clusterPermission.check(QueryApiKeyAction.NAME, queryApiKeyRequest, mock(Authentication.class)),
+            is(queryApiKeyRequest.isFilterForCurrentUser()));
+    }
+
     private Authentication createMockAuthentication(String username, String realmName,
                                                     AuthenticationType authenticationType, Map<String, Object> metadata) {
         final User user = new User(username);

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

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

+ 2 - 0
x-pack/plugin/security/qa/security-basic/build.gradle

@@ -29,4 +29,6 @@ testClusters.all {
   extraConfigFile 'roles.yml', file('src/javaRestTest/resources/roles.yml')
   user username: "admin_user", password: "admin-password"
   user username: "security_test_user", password: "security-test-password", role: "security_test_role"
+  user username: "api_key_admin", password: "security-test-password", role: "api_key_admin_role"
+  user username: "api_key_user", password: "security-test-password", role: "api_key_user_role"
 }

+ 294 - 0
x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java

@@ -0,0 +1,294 @@
+/*
+ * 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;
+
+import org.apache.http.HttpHeaders;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Response;
+import org.elasticsearch.client.ResponseException;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.core.Tuple;
+import org.elasticsearch.test.XContentTestUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.oneOf;
+
+public class QueryApiKeyIT extends SecurityInBasicRestTestCase {
+
+    private static final String API_KEY_ADMIN_AUTH_HEADER = "Basic YXBpX2tleV9hZG1pbjpzZWN1cml0eS10ZXN0LXBhc3N3b3Jk";
+    private static final String API_KEY_USER_AUTH_HEADER = "Basic YXBpX2tleV91c2VyOnNlY3VyaXR5LXRlc3QtcGFzc3dvcmQ=";
+    private static final String TEST_USER_AUTH_HEADER = "Basic c2VjdXJpdHlfdGVzdF91c2VyOnNlY3VyaXR5LXRlc3QtcGFzc3dvcmQ=";
+
+    public void testQuery() throws IOException {
+        createApiKeys();
+        createUser("someone");
+
+        // Admin with manage_api_key can search for all keys
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(2));
+                assertThat(apiKeys.get(0).get("name"), oneOf("my-org/alert-key-1", "my-alert-key-2"));
+                assertThat(apiKeys.get(1).get("name"), oneOf("my-org/alert-key-1", "my-alert-key-2"));
+            });
+
+        // An empty request body means search for all keys
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            randomBoolean() ? "" : "{\"query\":{\"match_all\":{}}}",
+            apiKeys -> assertThat(apiKeys.size(), equalTo(6)));
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"bool\":{\"must\":[" +
+                "{\"prefix\":{\"metadata.application\":\"fleet\"}},{\"term\":{\"metadata.environment.os\":\"Cat\"}}]}}}",
+            apiKeys -> {
+                assertThat(apiKeys, hasSize(2));
+                assertThat(
+                    apiKeys.stream().map(k -> k.get("name")).collect(Collectors.toList()),
+                    containsInAnyOrder("my-org/ingest-key-1", "my-org/management-key-1"));
+            }
+        );
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"terms\":{\"metadata.tags\":[\"prod\",\"east\"]}}}",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(5));
+            });
+
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{\"query\":{\"range\":{\"creation_time\":{\"lt\":\"now\"}}}}",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(6));
+            });
+
+        // Search for keys belong to an user
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"username\": \"api_key_user\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(2));
+                assertThat(apiKeys.stream().map(m -> m.get("name")).collect(Collectors.toSet()),
+                    equalTo(Set.of("my-ingest-key-1", "my-alert-key-2")));
+            });
+
+        // Search for keys belong to users from a realm
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"realm_name\": \"default_file\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(6));
+                // search using explicit IDs
+                try {
+
+                    var subset = randomSubsetOf(randomIntBetween(1,5), apiKeys);
+                    assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+                        "{ \"query\": { \"ids\": { \"values\": ["
+                            + subset.stream().map(m -> "\"" + m.get("id") + "\"").collect(Collectors.joining(",")) + "] } } }",
+                        keys -> {
+                            assertThat(keys, hasSize(subset.size()));
+                        });
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            });
+
+        // Search for fields outside of the allowlist fails
+        assertQueryError(API_KEY_ADMIN_AUTH_HEADER, 400,
+            "{ \"query\": { \"prefix\": {\"api_key_hash\": \"{PBKDF2}10000$\"} } }");
+
+        // Search for fields that are not allowed in Query DSL but used internally by the service itself
+        final String fieldName = randomFrom("doc_type", "api_key_invalidated");
+        assertQueryError(API_KEY_ADMIN_AUTH_HEADER, 400,
+            "{ \"query\": { \"term\": {\"" + fieldName + "\": \"" + randomAlphaOfLengthBetween(3, 8) + "\"} } }");
+
+        // Search for api keys won't return other entities
+        assertQuery(API_KEY_ADMIN_AUTH_HEADER,
+            "{ \"query\": { \"term\": {\"name\": \"someone\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys, empty());
+            });
+
+        // User with manage_own_api_key will only see its own keys
+        assertQuery(API_KEY_USER_AUTH_HEADER,
+            randomBoolean() ? "" : "{\"query\":{\"match_all\":{}}}",
+            apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(2));
+            assertThat(apiKeys.stream().map(m -> m.get("name")).collect(Collectors.toSet()),
+                containsInAnyOrder("my-ingest-key-1", "my-alert-key-2"));
+        });
+
+        assertQuery(API_KEY_USER_AUTH_HEADER,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }",
+            apiKeys -> {
+                assertThat(apiKeys.size(), equalTo(1));
+                assertThat(apiKeys.get(0).get("name"), equalTo("my-alert-key-2"));
+            });
+
+        // User without manage_api_key or manage_own_api_key gets 403 trying to search API keys
+        assertQueryError(TEST_USER_AUTH_HEADER, 403,
+            "{ \"query\": { \"wildcard\": {\"name\": \"*alert*\"} } }");
+    }
+
+    public void testQueryShouldRespectOwnerIdentityWithApiKeyAuth() throws IOException {
+        final Tuple<String, String> powerKey = createApiKey("power-key-1", null, null, API_KEY_ADMIN_AUTH_HEADER);
+        final String powerKeyAuthHeader = "ApiKey " + Base64.getEncoder()
+            .encodeToString((powerKey.v1() + ":" + powerKey.v2()).getBytes(StandardCharsets.UTF_8));
+
+        final Tuple<String, String> limitKey = createApiKey("limit-key-1",
+            Map.of("a", Map.of("cluster", List.of("manage_own_api_key"))), null, API_KEY_ADMIN_AUTH_HEADER);
+        final String limitKeyAuthHeader = "ApiKey " + Base64.getEncoder()
+            .encodeToString((limitKey.v1() + ":" + limitKey.v2()).getBytes(StandardCharsets.UTF_8));
+
+        createApiKey("power-key-1-derived-1", Map.of("a", Map.of()), null, powerKeyAuthHeader);
+        createApiKey("limit-key-1-derived-1", Map.of("a", Map.of()), null, limitKeyAuthHeader);
+
+        createApiKey("user-key-1", Map.of(), API_KEY_USER_AUTH_HEADER);
+        createApiKey("user-key-2", Map.of(), API_KEY_USER_AUTH_HEADER);
+
+        // powerKey gets back all keys since it has manage_api_key privilege
+        assertQuery(powerKeyAuthHeader, "", apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(6));
+            assertThat(
+                apiKeys.stream().map(m -> (String) m.get("name")).collect(Collectors.toUnmodifiableSet()),
+                equalTo(Set.of("power-key-1", "limit-key-1", "power-key-1-derived-1", "limit-key-1-derived-1",
+                    "user-key-1", "user-key-2")));
+        });
+
+        // limitKey gets only keys owned by the original user, not including the derived keys since they are not
+        // owned by the user (realm_name is _es_api_key).
+        assertQuery(limitKeyAuthHeader, "", apiKeys -> {
+            assertThat(apiKeys.size(), equalTo(2));
+            assertThat(
+                apiKeys.stream().map(m -> (String) m.get("name")).collect(Collectors.toUnmodifiableSet()),
+                equalTo(Set.of("power-key-1", "limit-key-1")));
+        });
+    }
+
+    private void assertQueryError(String authHeader, int statusCode, String body) throws IOException {
+        final Request request = new Request("GET", "/_security/_query/api_key");
+        request.setJsonEntity(body);
+        request.setOptions(
+            request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final ResponseException responseException = expectThrows(ResponseException.class, () -> client().performRequest(request));
+        assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(statusCode));
+    }
+
+    private void assertQuery(String authHeader, String body,
+                             Consumer<List<Map<String, Object>>> apiKeysVerifier) throws IOException {
+        final Request request = new Request("GET", "/_security/_query/api_key");
+        request.setJsonEntity(body);
+        request.setOptions(
+            request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> responseMap = responseAsMap(response);
+        @SuppressWarnings("unchecked")
+        final List<Map<String, Object>> api_keys = (List<Map<String, Object>>) responseMap.get("api_keys");
+        apiKeysVerifier.accept(api_keys);
+    }
+
+    private void createApiKeys() throws IOException {
+        createApiKey(
+            "my-org/ingest-key-1",
+            Map.of(
+                "application", "fleet-agent",
+                "tags", List.of("prod", "east"),
+                "environment", Map.of(
+                    "os", "Cat", "level", 42, "system", false, "hostname", "my-org-host-1")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/ingest-key-2",
+            Map.of(
+                "application", "fleet-server",
+                "tags", List.of("staging", "east"),
+                "environment", Map.of(
+                    "os", "Dog", "level", 11, "system", true, "hostname", "my-org-host-2")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/management-key-1",
+            Map.of(
+                "application", "fleet-agent",
+                "tags", List.of("prod", "west"),
+                "environment", Map.of(
+                    "os", "Cat", "level", 11, "system", false, "hostname", "my-org-host-3")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-org/alert-key-1",
+            Map.of(
+                "application", "siem",
+                "tags", List.of("prod", "north", "upper"),
+                "environment", Map.of(
+                    "os", "Dog", "level", 3, "system", true, "hostname", "my-org-host-4")
+            ),
+            API_KEY_ADMIN_AUTH_HEADER);
+
+        createApiKey(
+            "my-ingest-key-1",
+            Map.of(
+                "application", "cli",
+                "tags", List.of("user", "test"),
+                "notes", Map.of(
+                    "sun", "hot", "earth", "blue")
+            ),
+            API_KEY_USER_AUTH_HEADER);
+
+        createApiKey(
+            "my-alert-key-2",
+            Map.of(
+                "application", "web",
+                "tags", List.of("app", "prod"),
+                "notes", Map.of(
+                    "shared", false, "weather", "sunny")
+            ),
+            API_KEY_USER_AUTH_HEADER);
+    }
+
+    private Tuple<String, String> createApiKey(String name, Map<String, Object> metadata, String authHeader) throws IOException {
+        return createApiKey(name, null, metadata, authHeader);
+    }
+
+    private Tuple<String, String> createApiKey(String name,
+                                               Map<String, Object> roleDescriptors,
+                                               Map<String, Object> metadata,
+                                               String authHeader) throws IOException {
+        final Request request = new Request("POST", "/_security/api_key");
+        final String roleDescriptorsString =
+            XContentTestUtils.convertToXContent(roleDescriptors == null ? Map.of() : roleDescriptors, XContentType.JSON).utf8ToString();
+        final String metadataString =
+            XContentTestUtils.convertToXContent(metadata == null ? Map.of() : metadata, XContentType.JSON).utf8ToString();
+        request.setJsonEntity("{\"name\":\"" + name
+            + "\", \"role_descriptors\":" + roleDescriptorsString
+            + ", \"metadata\":" + metadataString + "}");
+        request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader));
+        final Response response = client().performRequest(request);
+        assertOK(response);
+        final Map<String, Object> m = responseAsMap(response);
+        return new Tuple<>((String) m.get("id"), (String) m.get("api_key"));
+    }
+
+    private void createUser(String name) throws IOException {
+        final Request request = new Request("POST", "/_security/user/" + name);
+        request.setJsonEntity("{\"password\":\"super-strong-password\",\"roles\":[]}");
+        assertOK(adminClient().performRequest(request));
+    }
+}

+ 11 - 0
x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml

@@ -6,3 +6,14 @@ security_test_role:
   indices:
     - names: [ "index_allowed" ]
       privileges: [ "read", "write", "create_index" ]
+
+# The admin role also has the manage_own_api_key privilege to ensure this lesser privilege will not
+# interfere with the behaviour of the greater manage_api_key privilege
+api_key_admin_role:
+  cluster:
+    - manage_own_api_key
+    - manage_api_key
+
+api_key_user_role:
+  cluster:
+    - manage_own_api_key

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

@@ -96,6 +96,7 @@ import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAct
 import org.elasticsearch.xpack.core.security.action.GetApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
@@ -169,6 +170,7 @@ import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticatio
 import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportGrantApiKeyAction;
 import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.action.apikey.TransportQueryApiKeyAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.action.enrollment.TransportKibanaEnrollmentAction;
 import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
@@ -256,6 +258,7 @@ import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateApiKeyActio
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction;
+import org.elasticsearch.xpack.security.rest.action.apikey.RestQueryApiKeyAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction;
 import org.elasticsearch.xpack.security.rest.action.enrollment.RestKibanaEnrollAction;
 import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
@@ -899,6 +902,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new ActionHandler<>(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class),
                 new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class),
                 new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class),
+                new ActionHandler<>(QueryApiKeyAction.INSTANCE, TransportQueryApiKeyAction.class),
                 new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class),
                 new ActionHandler<>(CreateServiceAccountTokenAction.INSTANCE, TransportCreateServiceAccountTokenAction.class),
                 new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class),
@@ -968,6 +972,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new RestGrantApiKeyAction(settings, getLicenseState()),
                 new RestInvalidateApiKeyAction(settings, getLicenseState()),
                 new RestGetApiKeyAction(settings, getLicenseState()),
+                new RestQueryApiKeyAction(settings, getLicenseState()),
                 new RestDelegatePkiAuthenticationAction(settings, getLicenseState()),
                 new RestCreateServiceAccountTokenAction(settings, getLicenseState()),
                 new RestDeleteServiceAccountTokenAction(settings, getLicenseState()),

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

@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.action.apikey;
+
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.SecurityContext;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+import org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder;
+
+public final class TransportQueryApiKeyAction extends HandledTransportAction<QueryApiKeyRequest, QueryApiKeyResponse> {
+
+    private final ApiKeyService apiKeyService;
+    private final SecurityContext securityContext;
+
+    @Inject
+    public TransportQueryApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService,
+                                      SecurityContext context) {
+        super(QueryApiKeyAction.NAME, transportService, actionFilters, QueryApiKeyRequest::new);
+        this.apiKeyService = apiKeyService;
+        this.securityContext = context;
+    }
+
+    @Override
+    protected void doExecute(Task task, QueryApiKeyRequest request, ActionListener<QueryApiKeyResponse> listener) {
+        final Authentication authentication = securityContext.getAuthentication();
+        if (authentication == null) {
+            listener.onFailure(new IllegalStateException("authentication is required"));
+        }
+
+        final ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder =
+            ApiKeyBoolQueryBuilder.build(request.getQueryBuilder(), request.isFilterForCurrentUser() ? authentication : null);
+
+        apiKeyService.queryApiKeys(apiKeyBoolQueryBuilder, listener);
+    }
+
+}

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

@@ -85,12 +85,14 @@ import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.GetApiKeyResponse;
 import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyResponse;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
 import org.elasticsearch.xpack.core.security.authc.Authentication;
 import org.elasticsearch.xpack.core.security.authc.Authentication.RealmRef;
 import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
 import org.elasticsearch.xpack.core.security.authc.support.Hasher;
 import org.elasticsearch.xpack.core.security.authz.RoleDescriptor;
 import org.elasticsearch.xpack.core.security.user.User;
+import org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder;
 import org.elasticsearch.xpack.security.support.CacheInvalidatorRegistry;
 import org.elasticsearch.xpack.security.support.FeatureNotEnabledException;
 import org.elasticsearch.xpack.security.support.FeatureNotEnabledException.Feature;
@@ -1130,6 +1132,27 @@ public class ApiKeyService {
             }, listener::onFailure));
     }
 
+    public void queryApiKeys(ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder, ActionListener<QueryApiKeyResponse> listener) {
+        ensureEnabled();
+        final ActionListener<Collection<ApiKey>> wrappedListener = ActionListener.wrap(apiKeyInfos -> {
+            if (apiKeyInfos.isEmpty()) {
+                logger.debug("No active api keys found for query [{}]", apiKeyBoolQueryBuilder);
+                listener.onResponse(QueryApiKeyResponse.emptyResponse());
+            } else {
+                listener.onResponse(new QueryApiKeyResponse(apiKeyInfos));
+            }
+        }, listener::onFailure);
+
+        final SecurityIndexManager frozenSecurityIndex = securityIndex.freeze();
+        if (frozenSecurityIndex.indexExists() == false) {
+            wrappedListener.onResponse(Collections.emptyList());
+        } else if (frozenSecurityIndex.isAvailable() == false) {
+            wrappedListener.onFailure(frozenSecurityIndex.getUnavailableReason());
+        } else {
+            findApiKeys(apiKeyBoolQueryBuilder, true, true, wrappedListener);
+        }
+    }
+
     private RemovalListener<String, ListenableFuture<CachedApiKeyHashResult>> getAuthCacheRemovalListener(int maximumWeight) {
         return notification -> {
             if (RemovalReason.EVICTED == notification.getRemovalReason() && getApiKeyAuthCache().count() >= maximumWeight) {

+ 15 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java

@@ -42,6 +42,8 @@ import org.elasticsearch.transport.TransportActionProxy;
 import org.elasticsearch.transport.TransportRequest;
 import org.elasticsearch.xpack.core.MigrateToDataStreamAction;
 import org.elasticsearch.xpack.core.action.CreateDataStreamAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesRequest;
 import org.elasticsearch.xpack.core.security.action.user.GetUserPrivilegesResponse;
 import org.elasticsearch.xpack.core.security.action.user.HasPrivilegesRequest;
@@ -273,13 +275,25 @@ public class AuthorizationService {
         final String action = requestInfo.getAction();
         final AuthorizationEngine authzEngine = getAuthorizationEngine(authentication);
         final AuditTrail auditTrail = auditTrailService.get();
+
         if (ClusterPrivilegeResolver.isClusterAction(action)) {
             final ActionListener<AuthorizationResult> clusterAuthzListener =
                 wrapPreservingContext(new AuthorizationResultListener<>(result -> {
                         threadContext.putTransient(INDICES_PERMISSIONS_KEY, IndicesAccessControl.ALLOW_ALL);
                         listener.onResponse(null);
                     }, listener::onFailure, requestInfo, requestId, authzInfo), threadContext);
-            authzEngine.authorizeClusterAction(requestInfo, authzInfo, clusterAuthzListener);
+            authzEngine.authorizeClusterAction(requestInfo, authzInfo, ActionListener.wrap(result -> {
+                if (false == result.isGranted() && QueryApiKeyAction.NAME.equals(action)) {
+                    assert request instanceof QueryApiKeyRequest : "request does not match action";
+                    final QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                    if (false == queryApiKeyRequest.isFilterForCurrentUser()) {
+                        queryApiKeyRequest.setFilterForCurrentUser();
+                        authzEngine.authorizeClusterAction(requestInfo, authzInfo, clusterAuthzListener);
+                        return;
+                    }
+                }
+                clusterAuthzListener.onResponse(result);
+            }, clusterAuthzListener::onFailure));
         } else if (isIndexAction(action)) {
             final Metadata metadata = clusterService.state().metadata();
             final AsyncSupplier<Set<String>> authorizedIndicesSupplier = new CachingAsyncSupplier<>(authzIndicesListener ->

+ 71 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.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.security.rest.action.apikey;
+
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ParseField;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.RestRequest;
+import org.elasticsearch.rest.action.RestToXContentListener;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyAction;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+import static org.elasticsearch.index.query.AbstractQueryBuilder.parseInnerQueryBuilder;
+import static org.elasticsearch.rest.RestRequest.Method.GET;
+import static org.elasticsearch.rest.RestRequest.Method.POST;
+
+/**
+ * Rest action to search for API keys
+ */
+public final class RestQueryApiKeyAction extends SecurityBaseRestHandler {
+
+    private static final ConstructingObjectParser<QueryApiKeyRequest, Void> PARSER = new ConstructingObjectParser<>(
+        "query_api_key_request",
+        a -> new QueryApiKeyRequest((QueryBuilder) a[0]));
+
+    static {
+        PARSER.declareObject(optionalConstructorArg(), (p, c) -> parseInnerQueryBuilder(p), new ParseField("query"));
+    }
+
+    /**
+     * @param settings the node's settings
+     * @param licenseState the license state that will be used to determine if
+     * security is licensed
+     */
+    public RestQueryApiKeyAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override
+    public List<Route> routes() {
+        return List.of(
+            new Route(GET, "/_security/_query/api_key"),
+            new Route(POST, "/_security/_query/api_key"));
+    }
+
+    @Override
+    public String getName() {
+        return "xpack_security_query_api_key";
+    }
+
+    @Override
+    protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException {
+        final QueryApiKeyRequest queryApiKeyRequest =
+            request.hasContentOrSourceParam() ? PARSER.parse(request.contentOrSourceParamParser(), null) : new QueryApiKeyRequest();
+
+        return channel -> client.execute(QueryApiKeyAction.INSTANCE, queryApiKeyRequest, new RestToXContentListener<>(channel));
+    }
+}

+ 226 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java

@@ -0,0 +1,226 @@
+/*
+ * 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.support;
+
+import org.apache.lucene.search.Query;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.IdsQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.QueryRewriteContext;
+import org.elasticsearch.index.query.RangeQueryBuilder;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+
+public class ApiKeyBoolQueryBuilder extends BoolQueryBuilder {
+
+    // Field names allowed at the index level
+    private static final Set<String> ALLOWED_EXACT_INDEX_FIELD_NAMES =
+        Set.of("doc_type", "name", "api_key_invalidated", "creation_time", "expiration_time");
+
+    private ApiKeyBoolQueryBuilder() {}
+
+    /**
+     * Build a bool query that is specialised for query API keys information from the security index.
+     * The method processes the given QueryBuilder to ensure:
+     *   * Only fields from an allowlist are queried
+     *   * Only query types from an allowlist are used
+     *   * Field names used in the Query DSL get translated into corresponding names used at the index level.
+     *     This helps decouple the user facing and implementation level changes.
+     *   * User's security context gets applied when necessary
+     *   * Not exposing any other types of documents stored in the same security index
+     *
+     * @param queryBuilder This represents the query parsed directly from the user input. It is validated
+     *                     and transformed (see above).
+     * @param authentication The user's authentication object. If present, it will be used to filter the results
+     *                       to only include API keys owned by the user.
+     * @return A specialised query builder for API keys that is safe to run on the security index.
+     */
+    public static ApiKeyBoolQueryBuilder build(QueryBuilder queryBuilder, @Nullable Authentication authentication) {
+        final ApiKeyBoolQueryBuilder finalQuery = new ApiKeyBoolQueryBuilder();
+        if (queryBuilder != null) {
+            QueryBuilder processedQuery = doProcess(queryBuilder);
+            finalQuery.must(processedQuery);
+        }
+        finalQuery.filter(QueryBuilders.termQuery("doc_type", "api_key"));
+
+        if (authentication != null) {
+            finalQuery
+                .filter(QueryBuilders.termQuery("creator.principal", authentication.getUser().principal()))
+                .filter(QueryBuilders.termQuery("creator.realm", ApiKeyService.getCreatorRealmName(authentication)));
+        }
+        return finalQuery;
+    }
+
+    private static QueryBuilder doProcess(QueryBuilder qb) {
+        if (qb instanceof BoolQueryBuilder) {
+            final BoolQueryBuilder query = (BoolQueryBuilder) qb;
+            final BoolQueryBuilder newQuery =
+                QueryBuilders.boolQuery().minimumShouldMatch(query.minimumShouldMatch()).adjustPureNegative(query.adjustPureNegative());
+            query.must().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::must);
+            query.should().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::should);
+            query.mustNot().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::mustNot);
+            query.filter().stream().map(ApiKeyBoolQueryBuilder::doProcess).forEach(newQuery::filter);
+            return newQuery;
+        } else if (qb instanceof MatchAllQueryBuilder) {
+            return qb;
+        } else if (qb instanceof IdsQueryBuilder) {
+            return qb;
+        } else if (qb instanceof TermQueryBuilder) {
+            final TermQueryBuilder query = (TermQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.termQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof TermsQueryBuilder) {
+            final TermsQueryBuilder query = (TermsQueryBuilder) qb;
+            if (query.termsLookup() != null) {
+                throw new IllegalArgumentException("terms query with terms lookup is not supported for API Key query");
+            }
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.termsQuery(translatedFieldName, query.getValues());
+        } else if (qb instanceof PrefixQueryBuilder) {
+            final PrefixQueryBuilder query = (PrefixQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.prefixQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive());
+        } else if (qb instanceof WildcardQueryBuilder) {
+            final WildcardQueryBuilder query = (WildcardQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            return QueryBuilders.wildcardQuery(translatedFieldName, query.value())
+                .caseInsensitive(query.caseInsensitive())
+                .rewrite(query.rewrite());
+        } else if (qb instanceof RangeQueryBuilder) {
+            final RangeQueryBuilder query = (RangeQueryBuilder) qb;
+            final String translatedFieldName = FieldNameTranslators.translate(query.fieldName());
+            if (query.relation() != null) {
+                throw new IllegalArgumentException("range query with relation is not supported for API Key query");
+            }
+            final RangeQueryBuilder newQuery = QueryBuilders.rangeQuery(translatedFieldName);
+            if (query.format() != null) {
+                newQuery.format(query.format());
+            }
+            if (query.timeZone() != null) {
+                newQuery.timeZone(query.timeZone());
+            }
+            if (query.from() != null) {
+                newQuery.from(query.from()).includeLower(query.includeLower());
+            }
+            if (query.to() != null) {
+                newQuery.to(query.to()).includeUpper(query.includeUpper());
+            }
+            return newQuery.boost(query.boost());
+        } else {
+            throw new IllegalArgumentException("Query type [" + qb.getName() + "] is not supported for API Key query");
+        }
+    }
+
+
+    @Override
+    protected Query doToQuery(SearchExecutionContext context) throws IOException {
+        context.setAllowedFields(ApiKeyBoolQueryBuilder::isIndexFieldNameAllowed);
+        return super.doToQuery(context);
+    }
+
+    @Override
+    protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException {
+        if (queryRewriteContext instanceof SearchExecutionContext) {
+            ((SearchExecutionContext) queryRewriteContext).setAllowedFields(ApiKeyBoolQueryBuilder::isIndexFieldNameAllowed);
+        }
+        return super.doRewrite(queryRewriteContext);
+    }
+
+    static boolean isIndexFieldNameAllowed(String fieldName) {
+        return ALLOWED_EXACT_INDEX_FIELD_NAMES.contains(fieldName)
+            || fieldName.startsWith("metadata_flattened.")
+            || fieldName.startsWith("creator.");
+    }
+
+    /**
+     * A class to translate query level field names to index level field names.
+     */
+    static class FieldNameTranslators {
+        static final List<FieldNameTranslator> FIELD_NAME_TRANSLATORS;
+
+        static {
+            FIELD_NAME_TRANSLATORS = List.of(
+                new ExactFieldNameTranslator(s -> "creator.principal", "username"),
+                new ExactFieldNameTranslator(s -> "creator.realm", "realm_name"),
+                new ExactFieldNameTranslator(Function.identity(), "name"),
+                new ExactFieldNameTranslator(Function.identity(), "creation_time"),
+                new ExactFieldNameTranslator(Function.identity(), "expiration_time"),
+                new PrefixFieldNameTranslator(s -> "metadata_flattened" + s.substring(8), "metadata.")
+            );
+        }
+
+        /**
+         * Translate the query level field name to index level field names.
+         * It throws an exception if the field name is not explicitly allowed.
+         */
+        static String translate(String fieldName) {
+            for (FieldNameTranslator translator : FIELD_NAME_TRANSLATORS) {
+                if (translator.supports(fieldName)) {
+                    return translator.translate(fieldName);
+                }
+            }
+            throw new IllegalArgumentException("Field [" + fieldName + "] is not allowed for API Key query");
+        }
+
+        abstract static class FieldNameTranslator {
+
+            private final Function<String, String> translationFunc;
+
+            protected FieldNameTranslator(Function<String, String> translationFunc) {
+                this.translationFunc = translationFunc;
+            }
+
+            String translate(String fieldName) {
+                return translationFunc.apply(fieldName);
+            }
+
+            abstract boolean supports(String fieldName);
+        }
+
+        static class ExactFieldNameTranslator extends FieldNameTranslator {
+            private final String name;
+
+            ExactFieldNameTranslator(Function<String, String> translationFunc, String name) {
+                super(translationFunc);
+                this.name = name;
+            }
+
+            @Override
+            public boolean supports(String fieldName) {
+                return name.equals(fieldName);
+            }
+        }
+
+        static class PrefixFieldNameTranslator extends FieldNameTranslator {
+            private final String prefix;
+
+            PrefixFieldNameTranslator(Function<String, String> translationFunc, String prefix) {
+                super(translationFunc);
+                this.prefix = prefix;
+            }
+
+            @Override
+            boolean supports(String fieldName) {
+                return fieldName.startsWith(prefix);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,114 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.security.rest.action.apikey;
+
+import org.apache.lucene.util.SetOnce;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionRequest;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.ActionType;
+import org.elasticsearch.client.node.NodeClient;
+import org.elasticsearch.common.bytes.BytesArray;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.NamedXContentRegistry;
+import org.elasticsearch.common.xcontent.XContentType;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.AbstractRestChannel;
+import org.elasticsearch.rest.RestChannel;
+import org.elasticsearch.rest.RestResponse;
+import org.elasticsearch.search.SearchModule;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.rest.FakeRestRequest;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest;
+import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse;
+
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class RestQueryApiKeyActionTests extends ESTestCase {
+
+    private final XPackLicenseState mockLicenseState = mock(XPackLicenseState.class);
+    private Settings settings;
+    private ThreadPool threadPool;
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        settings = Settings.builder().put("path.home", createTempDir().toString()).put("node.name", "test-" + getTestName())
+            .put(Environment.PATH_HOME_SETTING.getKey(), createTempDir().toString()).build();
+        when(mockLicenseState.isSecurityEnabled()).thenReturn(true);
+        threadPool = new ThreadPool(settings);
+    }
+
+    @Override
+    public void tearDown() throws Exception {
+        super.tearDown();
+        terminate(threadPool);
+    }
+
+    @Override
+    protected NamedXContentRegistry xContentRegistry() {
+        final SearchModule searchModule = new SearchModule(Settings.EMPTY, List.of());
+        return new NamedXContentRegistry(searchModule.getNamedXContents());
+    }
+
+    public void testQueryParsing() throws Exception {
+        final String query1 = "{\"query\":{\"bool\":{\"must\":[{\"terms\":{\"name\":[\"k1\",\"k2\"]}}]," +
+            "\"should\":[{\"prefix\":{\"metadata.environ\":\"prod\"}}]}}}";
+        final FakeRestRequest restRequest = new FakeRestRequest.Builder(xContentRegistry())
+            .withContent(new BytesArray(query1), XContentType.JSON)
+            .build();
+
+        final SetOnce<RestResponse> responseSetOnce = new SetOnce<>();
+        final RestChannel restChannel = new AbstractRestChannel(restRequest, randomBoolean()) {
+            @Override
+            public void sendResponse(RestResponse restResponse) {
+                responseSetOnce.set(restResponse);
+            }
+        };
+
+        try (NodeClient client = new NodeClient(Settings.EMPTY, threadPool) {
+            @SuppressWarnings("unchecked")
+            @Override
+            public <Request extends ActionRequest, Response extends ActionResponse>
+            void doExecute(ActionType<Response> action, Request request, ActionListener<Response> listener) {
+                QueryApiKeyRequest queryApiKeyRequest = (QueryApiKeyRequest) request;
+                final QueryBuilder queryBuilder = queryApiKeyRequest.getQueryBuilder();
+                assertNotNull(queryBuilder);
+                assertThat(queryBuilder.getClass(), is(BoolQueryBuilder.class));
+                final BoolQueryBuilder boolQueryBuilder = (BoolQueryBuilder) queryBuilder;
+                assertTrue(boolQueryBuilder.filter().isEmpty());
+                assertTrue(boolQueryBuilder.mustNot().isEmpty());
+                assertThat(boolQueryBuilder.must().size(), equalTo(1));
+                final QueryBuilder mustQueryBuilder = boolQueryBuilder.must().get(0);
+                assertThat(mustQueryBuilder.getClass(), is(TermsQueryBuilder.class));
+                assertThat(((TermsQueryBuilder) mustQueryBuilder).fieldName(), equalTo("name"));
+                assertThat(boolQueryBuilder.should().size(), equalTo(1));
+                final QueryBuilder shouldQueryBuilder = boolQueryBuilder.should().get(0);
+                assertThat(shouldQueryBuilder.getClass(), is(PrefixQueryBuilder.class));
+                assertThat(((PrefixQueryBuilder) shouldQueryBuilder).fieldName(), equalTo("metadata.environ"));
+                listener.onResponse((Response) new QueryApiKeyResponse(List.of()));
+            }
+        }) {
+            final RestQueryApiKeyAction restQueryApiKeyAction = new RestQueryApiKeyAction(Settings.EMPTY, mockLicenseState);
+            restQueryApiKeyAction.handleRequest(restRequest, restChannel, client);
+        }
+
+        assertNotNull(responseSetOnce.get());
+    }
+}

+ 286 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java

@@ -0,0 +1,286 @@
+/*
+ * 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.support;
+
+import org.elasticsearch.index.query.AbstractQueryBuilder;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.DistanceFeatureQueryBuilder;
+import org.elasticsearch.index.query.IdsQueryBuilder;
+import org.elasticsearch.index.query.MatchAllQueryBuilder;
+import org.elasticsearch.index.query.MultiTermQueryBuilder;
+import org.elasticsearch.index.query.PrefixQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.elasticsearch.index.query.RangeQueryBuilder;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.index.query.SpanQueryBuilder;
+import org.elasticsearch.index.query.TermQueryBuilder;
+import org.elasticsearch.index.query.TermsQueryBuilder;
+import org.elasticsearch.index.query.WildcardQueryBuilder;
+import org.elasticsearch.indices.TermsLookup;
+import org.elasticsearch.script.Script;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.core.security.authc.Authentication;
+import org.elasticsearch.xpack.core.security.authc.AuthenticationTests;
+import org.elasticsearch.xpack.security.authc.ApiKeyService;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder.FieldNameTranslators.FIELD_NAME_TRANSLATORS;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+public class ApiKeyBoolQueryBuilderTests extends ESTestCase {
+
+    public void testBuildFromSimpleQuery() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final QueryBuilder q1 = randomSimpleQuery(randomFrom("name", "creation_time", "expiration_time"));
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(q1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+        final List<QueryBuilder> mustQueries = apiKeyQb1.must();
+        assertThat(mustQueries.size(), equalTo(1));
+        assertThat(mustQueries.get(0), equalTo(q1));
+        assertTrue(apiKeyQb1.should().isEmpty());
+        assertTrue(apiKeyQb1.mustNot().isEmpty());
+    }
+
+    public void testBuildFromBoolQuery() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final BoolQueryBuilder bq1 = QueryBuilders.boolQuery();
+
+        if (randomBoolean()) {
+            bq1.must(QueryBuilders.prefixQuery("name", "prod-"));
+        }
+        if (randomBoolean()) {
+            bq1.should(QueryBuilders.wildcardQuery("name", "*-east-*"));
+        }
+        if (randomBoolean()) {
+            bq1.filter(QueryBuilders.termsQuery("name",
+                randomArray(3, 8, String[]::new, () -> "prod-" + randomInt() + "-east-" + randomInt())));
+        }
+        if (randomBoolean()) {
+            bq1.mustNot(QueryBuilders.idsQuery().addIds(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(22))));
+        }
+        if (randomBoolean()) {
+            bq1.minimumShouldMatch(randomIntBetween(1, 2));
+        }
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(bq1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+
+        assertThat(apiKeyQb1.must(), hasSize(1));
+        assertThat(apiKeyQb1.should(), empty());
+        assertThat(apiKeyQb1.mustNot(), empty());
+        assertThat(apiKeyQb1.filter(), hasItem(QueryBuilders.termQuery("doc_type", "api_key")));
+        assertThat(apiKeyQb1.must().get(0).getClass(), is(BoolQueryBuilder.class));
+        final BoolQueryBuilder processed = (BoolQueryBuilder) apiKeyQb1.must().get(0);
+        assertThat(processed.must(), equalTo(bq1.must()));
+        assertThat(processed.should(), equalTo(bq1.should()));
+        assertThat(processed.mustNot(), equalTo(bq1.mustNot()));
+        assertThat(processed.minimumShouldMatch(), equalTo(bq1.minimumShouldMatch()));
+        assertThat(processed.filter(), equalTo(bq1.filter()));
+    }
+
+    public void testFieldNameTranslation() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        // metadata
+        final String metadataKey = randomAlphaOfLengthBetween(3, 8);
+        final TermQueryBuilder q1 = QueryBuilders.termQuery("metadata." + metadataKey, randomAlphaOfLengthBetween(3, 8));
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(q1, authentication);
+        assertCommonFilterQueries(apiKeyQb1, authentication);
+        assertThat(apiKeyQb1.must().get(0), equalTo(QueryBuilders.termQuery("metadata_flattened." + metadataKey, q1.value())));
+
+        // username
+        final PrefixQueryBuilder q2 = QueryBuilders.prefixQuery("username", randomAlphaOfLength(3));
+        final ApiKeyBoolQueryBuilder apiKeyQb2 = ApiKeyBoolQueryBuilder.build(q2, authentication);
+        assertCommonFilterQueries(apiKeyQb2, authentication);
+        assertThat(apiKeyQb2.must().get(0), equalTo(QueryBuilders.prefixQuery("creator.principal", q2.value())));
+
+        // realm name
+        final WildcardQueryBuilder q3 = QueryBuilders.wildcardQuery("realm_name", "*" + randomAlphaOfLength(3));
+        final ApiKeyBoolQueryBuilder apiKeyQb3 = ApiKeyBoolQueryBuilder.build(q3, authentication);
+        assertCommonFilterQueries(apiKeyQb3, authentication);
+        assertThat(apiKeyQb3.must().get(0), equalTo(QueryBuilders.wildcardQuery("creator.realm", q3.value())));
+    }
+
+    public void testAllowListOfFieldNames() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        final String randomFieldName = randomValueOtherThanMany(s -> FIELD_NAME_TRANSLATORS.stream().anyMatch(t -> t.supports(s)),
+            () -> randomAlphaOfLengthBetween(3, 20));
+        final String fieldName = randomFrom(
+                        randomFieldName,
+                        "api_key_hash",
+                        "api_key_invalidated",
+                        "doc_type",
+                        "role_descriptors",
+                        "limited_by_role_descriptors",
+                        "version",
+            "creator", "creator.metadata");
+
+        final QueryBuilder q1 = randomValueOtherThanMany(
+            q -> q.getClass() == IdsQueryBuilder.class || q.getClass() == MatchAllQueryBuilder.class,
+            () -> randomSimpleQuery(fieldName));
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+
+        assertThat(e1.getMessage(), containsString("Field [" + fieldName + "] is not allowed for API Key query"));
+    }
+
+    public void testTermsLookupIsNotAllowed() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final TermsQueryBuilder q1 = QueryBuilders.termsLookupQuery("name", new TermsLookup("lookup", "1", "names"));
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("terms query with terms lookup is not supported for API Key query"));
+    }
+
+    public void testRangeQueryWithRelationIsNotAllowed() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+        final RangeQueryBuilder q1 = QueryBuilders.rangeQuery("creation_time").relation("contains");
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("range query with relation is not supported for API Key query"));
+    }
+
+    public void testDisallowedQueryTypes() {
+        final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null;
+
+        final AbstractQueryBuilder<? extends AbstractQueryBuilder<?>> q1 = randomFrom(
+            QueryBuilders.matchQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.constantScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.existsQuery(randomAlphaOfLength(5)),
+            QueryBuilders.boostingQuery(mock(QueryBuilder.class), mock(QueryBuilder.class)),
+            QueryBuilders.queryStringQuery("q=a:42"),
+            QueryBuilders.simpleQueryStringQuery(randomAlphaOfLength(5)),
+            QueryBuilders.combinedFieldsQuery(randomAlphaOfLength(5)),
+            QueryBuilders.disMaxQuery(),
+            QueryBuilders.distanceFeatureQuery(randomAlphaOfLength(5),
+                mock(DistanceFeatureQueryBuilder.Origin.class),
+                randomAlphaOfLength(5)),
+            QueryBuilders.fieldMaskingSpanQuery(mock(SpanQueryBuilder.class), randomAlphaOfLength(5)),
+            QueryBuilders.functionScoreQuery(mock(QueryBuilder.class)),
+            QueryBuilders.fuzzyQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.wrapperQuery(randomAlphaOfLength(5)),
+            QueryBuilders.matchBoolPrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhraseQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.matchPhrasePrefixQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.moreLikeThisQuery(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(5))),
+            QueryBuilders.regexpQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanTermQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.spanOrQuery(mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanContainingQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.spanFirstQuery(mock(SpanQueryBuilder.class), randomIntBetween(1, 3)),
+            QueryBuilders.spanMultiTermQueryBuilder(mock(MultiTermQueryBuilder.class)),
+            QueryBuilders.spanNotQuery(mock(SpanQueryBuilder.class), mock(SpanQueryBuilder.class)),
+            QueryBuilders.scriptQuery(new Script(randomAlphaOfLength(5))),
+            QueryBuilders.scriptScoreQuery(mock(QueryBuilder.class), new Script(randomAlphaOfLength(5))),
+            QueryBuilders.geoWithinQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoBoundingBoxQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoDisjointQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoDistanceQuery(randomAlphaOfLength(5)),
+            QueryBuilders.geoIntersectionQuery(randomAlphaOfLength(5), randomAlphaOfLength(5)),
+            QueryBuilders.geoShapeQuery(randomAlphaOfLength(5), randomAlphaOfLength(5))
+        );
+
+        final IllegalArgumentException e1 =
+            expectThrows(IllegalArgumentException.class, () -> ApiKeyBoolQueryBuilder.build(q1, authentication));
+        assertThat(e1.getMessage(), containsString("Query type [" + q1.getName() + "] is not supported for API Key query"));
+    }
+
+    public void testWillSetAllowedFields() throws IOException {
+        final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(randomSimpleQuery("name"),
+            randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null);
+
+        final SearchExecutionContext context1 = mock(SearchExecutionContext.class);
+        doAnswer(invocationOnMock -> {
+            final Object[] args = invocationOnMock.getArguments();
+            @SuppressWarnings("unchecked")
+            final Predicate<String> predicate = (Predicate<String>) args[0];
+            assertTrue(predicate.getClass().getName().startsWith(ApiKeyBoolQueryBuilder.class.getName()));
+            testAllowedIndexFieldName(predicate);
+            return null;
+        }).when(context1).setAllowedFields(any());
+        try {
+            if (randomBoolean()) {
+                apiKeyQb1.doToQuery(context1);
+            } else {
+                apiKeyQb1.doRewrite(context1);
+            }
+        } catch (Exception e) {
+            // just ignore any exception from superclass since we only need verify the allowedFields are set
+        } finally {
+            verify(context1).setAllowedFields(any());
+        }
+    }
+
+    private void testAllowedIndexFieldName(Predicate<String> predicate) {
+        final String allowedField = randomFrom(
+            "doc_type",
+            "name",
+            "api_key_invalidated",
+            "creation_time",
+            "expiration_time",
+            "metadata_flattened." + randomAlphaOfLengthBetween(1, 10),
+            "creator." + randomAlphaOfLengthBetween(1, 10));
+        assertTrue(predicate.test(allowedField));
+
+        final String disallowedField = randomBoolean() ? (randomAlphaOfLengthBetween(1, 3) + allowedField) : (allowedField.substring(1));
+        assertFalse(predicate.test(disallowedField));
+    }
+
+    private void assertCommonFilterQueries(ApiKeyBoolQueryBuilder qb, Authentication authentication) {
+        final List<TermQueryBuilder> tqb = qb.filter()
+            .stream()
+            .filter(q -> q.getClass() == TermQueryBuilder.class)
+            .map(q -> (TermQueryBuilder) q)
+            .collect(Collectors.toUnmodifiableList());
+        assertTrue(tqb.stream().anyMatch(q -> q.equals(QueryBuilders.termQuery("doc_type", "api_key"))));
+        if (authentication == null) {
+            return;
+        }
+        assertTrue(tqb.stream()
+            .anyMatch(q -> q.equals(QueryBuilders.termQuery("creator.principal", authentication.getUser().principal()))));
+        assertTrue(tqb.stream()
+            .anyMatch(q -> q.equals(QueryBuilders.termQuery("creator.realm", ApiKeyService.getCreatorRealmName(authentication)))));
+    }
+
+    private QueryBuilder randomSimpleQuery(String name) {
+        switch (randomIntBetween(0, 6)) {
+            case 0:
+                return QueryBuilders.termQuery(name, randomAlphaOfLengthBetween(3, 8));
+            case 1:
+                return QueryBuilders.termsQuery(name, randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8)));
+            case 2:
+                return QueryBuilders.idsQuery().addIds(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(22)));
+            case 3:
+                return QueryBuilders.prefixQuery(name, "prod-");
+            case 4:
+                return QueryBuilders.wildcardQuery(name, "prod-*-east-*");
+            case 5:
+                return QueryBuilders.matchAllQuery();
+            default:
+                return QueryBuilders.rangeQuery(name)
+                    .from(Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli(), randomBoolean())
+                    .to(Instant.now().toEpochMilli(), randomBoolean());
+        }
+    }
+}