diff --git a/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java b/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java index a518ee74a797e..6eb8abe7dcdca 100644 --- a/server/src/main/java/org/elasticsearch/index/query/SearchExecutionContext.java +++ b/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 runtimeMappings; + private Predicate allowedFields; /** * Build a {@linkplain SearchExecutionContext}. @@ -154,7 +155,8 @@ public SearchExecutionContext( ), allowExpensiveQueries, valuesSourceRegistry, - parseRuntimeMappings(runtimeMappings, mapperService) + parseRuntimeMappings(runtimeMappings, mapperService), + null ); } @@ -177,7 +179,8 @@ public SearchExecutionContext(SearchExecutionContext source) { source.fullyQualifiedIndex, source.allowExpensiveQueries, source.valuesSourceRegistry, - source.runtimeMappings + source.runtimeMappings, + source.allowedFields ); } @@ -199,7 +202,8 @@ private SearchExecutionContext(int shardId, Index fullyQualifiedIndex, BooleanSupplier allowExpensiveQueries, ValuesSourceRegistry valuesSourceRegistry, - Map runtimeMappings) { + Map runtimeMappings, + Predicate allowedFields) { super(xContentRegistry, namedWriteableRegistry, client, nowInMillis); this.shardId = shardId; this.shardRequestIndex = shardRequestIndex; @@ -218,6 +222,7 @@ private SearchExecutionContext(int shardId, this.allowExpensiveQueries = allowExpensiveQueries; this.valuesSourceRegistry = valuesSourceRegistry; this.runtimeMappings = runtimeMappings; + this.allowedFields = allowedFields; } private void reset() { @@ -352,6 +357,10 @@ public boolean isFieldMapped(String name) { } 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 void setMapUnmappedFieldAsString(boolean mapUnmappedFieldAsString) { this.mapUnmappedFieldAsString = mapUnmappedFieldAsString; } + public void setAllowedFields(Predicate allowedFields) { + this.allowedFields = allowedFields; + } + MappedFieldType failIfFieldMappingNotFound(String name, MappedFieldType fieldMapping) { if (fieldMapping != null || allowUnmappedFields) { return fieldMapping; diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyAction.java new file mode 100644 index 0000000000000..e32dacd5e032c --- /dev/null +++ b/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 { + + 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); + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequest.java new file mode 100644 index 0000000000000..e5dd5eedc9284 --- /dev/null +++ b/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); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponse.java new file mode 100644 index 0000000000000..0ab1cc2e1f210 --- /dev/null +++ b/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.
+ * 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 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 + "]"; + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java index 355f4dbaa732d..e9a60ed2508e3 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/permission/Role.java @@ -216,7 +216,6 @@ private Builder(RoleDescriptor rd, @Nullable FieldPermissionsCache fieldPermissi public Builder cluster(Set privilegeNames, Iterable configurableClusterPrivileges) { ClusterPermission.Builder builder = ClusterPermission.builder(); - List clusterPermissions = new ArrayList<>(); if (privilegeNames.isEmpty() == false) { for (String name : privilegeNames) { builder = ClusterPrivilegeResolver.resolve(name).buildPermission(builder); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java index 22c94d6c30323..5df9ed4e60031 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilege.java @@ -12,6 +12,7 @@ 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 @@ protected boolean extendedCheck(String action, TransportRequest request, Authent 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() + ")"); diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyRequestTests.java new file mode 100644 index 0000000000000..8c799631737e0 --- /dev/null +++ b/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)); + } + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/apikey/QueryApiKeyResponseTests.java new file mode 100644 index 0000000000000..87681c8e74afd --- /dev/null +++ b/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 { + + @Override + protected Writeable.Reader instanceReader() { + return QueryApiKeyResponse::new; + } + + @Override + protected QueryApiKeyResponse createTestInstance() { + final List apiKeys = randomList(0, 3, this::randomApiKeyInfo); + return new QueryApiKeyResponse(apiKeys); + } + + @Override + protected QueryApiKeyResponse mutateInstance(QueryApiKeyResponse instance) throws IOException { + final ArrayList 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 metadata = ApiKeyTests.randomMetadata(); + return new ApiKey(name, id, creation, expiration, false, username, realm_name, metadata); + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java index 7a8cf46514e1f..1e102f8b49413 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/AuthenticationTests.java @@ -52,7 +52,7 @@ public void testCanAccessResourcesOf() { 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 @@ private void assertCannotAccessResources(Authentication authentication0, Authent 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 @@ private RealmRef mutateRealm(RealmRef original, String name, String type) { 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 @@ private Authentication randomAuthentication(User user, RealmRef realmRef) { } } - 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, diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java index 30f4c860d8f9d..e5afe013e6b7c 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authz/privilege/ManageOwnApiKeyClusterPrivilegeTests.java @@ -13,6 +13,8 @@ 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 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 void testGetAndInvalidateApiKeyWillRespectRunAsUser() { 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 metadata) { final User user = new User(username); diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 6bee9a5bc2bc0..b7036fe0aa9c6 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/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", diff --git a/x-pack/plugin/security/qa/security-basic/build.gradle b/x-pack/plugin/security/qa/security-basic/build.gradle index a2114e7d6c0ba..b45b38536ca0a 100644 --- a/x-pack/plugin/security/qa/security-basic/build.gradle +++ b/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" } diff --git a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/java/org/elasticsearch/xpack/security/QueryApiKeyIT.java new file mode 100644 index 0000000000000..540ddcc682b32 --- /dev/null +++ b/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 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 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>> 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 responseMap = responseAsMap(response); + @SuppressWarnings("unchecked") + final List> api_keys = (List>) 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 createApiKey(String name, Map metadata, String authHeader) throws IOException { + return createApiKey(name, null, metadata, authHeader); + } + + private Tuple createApiKey(String name, + Map roleDescriptors, + Map 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 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)); + } +} diff --git a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml b/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml index 9b2171257fc61..1069e3a38ebfa 100644 --- a/x-pack/plugin/security/qa/security-basic/src/javaRestTest/resources/roles.yml +++ b/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 diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index 5306b67e76ba9..4fcdb8d0553fc 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -96,6 +96,7 @@ 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.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.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 void onIndexModule(IndexModule module) { 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 List getRestHandlers(Settings settings, RestController restC 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()), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyAction.java new file mode 100644 index 0000000000000..a4fb6d8a4bf32 --- /dev/null +++ b/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 { + + 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 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); + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java index a879eb80eaaa9..a77dfbb1bf088 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/ApiKeyService.java +++ b/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.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 void getApiKeys(String realmName, String username, String apiKeyName, Str }, listener::onFailure)); } + public void queryApiKeys(ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder, ActionListener listener) { + ensureEnabled(); + final ActionListener> 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> getAuthCacheRemovalListener(int maximumWeight) { return notification -> { if (RemovalReason.EVICTED == notification.getRemovalReason() && getApiKeyAuthCache().count() >= maximumWeight) { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java index c2bd7a2d1e70d..642c89e7a0c9f 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authz/AuthorizationService.java @@ -42,6 +42,8 @@ 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 @@ private void authorizeAction(final RequestInfo requestInfo, final String request final String action = requestInfo.getAction(); final AuthorizationEngine authzEngine = getAuthorizationEngine(authentication); final AuditTrail auditTrail = auditTrailService.get(); + if (ClusterPrivilegeResolver.isClusterAction(action)) { final ActionListener 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> authorizedIndicesSupplier = new CachingAsyncSupplier<>(authzIndicesListener -> diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java new file mode 100644 index 0000000000000..d7c3d5011d8c8 --- /dev/null +++ b/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 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 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)); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilder.java new file mode 100644 index 0000000000000..bebefe38f9403 --- /dev/null +++ b/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 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 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 translationFunc; + + protected FieldNameTranslator(Function 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 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 translationFunc, String prefix) { + super(translationFunc); + this.prefix = prefix; + } + + @Override + boolean supports(String fieldName) { + return fieldName.startsWith(prefix); + } + } + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyActionTests.java new file mode 100644 index 0000000000000..93bda06846806 --- /dev/null +++ b/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 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 + void doExecute(ActionType action, Request request, ActionListener 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()); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/support/ApiKeyBoolQueryBuilderTests.java new file mode 100644 index 0000000000000..2a7120eac52f6 --- /dev/null +++ b/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 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> 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 predicate = (Predicate) 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 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 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()); + } + } +}