diff --git a/docs/changelog/87229.yaml b/docs/changelog/87229.yaml new file mode 100644 index 000000000000..445b5785e885 --- /dev/null +++ b/docs/changelog/87229.yaml @@ -0,0 +1,5 @@ +pr: 87229 +summary: Support exists query for API key query +area: Security +type: enhancement +issues: [] 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 index e111935b7f9d..aa3b84d04a8b 100644 --- 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 @@ -17,6 +17,7 @@ import org.elasticsearch.xcontent.XContentType; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.List; @@ -367,6 +368,105 @@ public class QueryApiKeyIT extends SecurityInBasicRestTestCase { assertQueryError(authHeader, 400, "{\"sort\":[\"" + invalidFieldName + "\"]}"); } + public void testExistsQuery() throws IOException, InterruptedException { + final String authHeader = randomFrom(API_KEY_ADMIN_AUTH_HEADER, API_KEY_USER_AUTH_HEADER); + + // No expiration + createApiKey("test-exists-1", null, null, Map.of("value", 42), authHeader); + // A short-lived key + createApiKey("test-exists-2", "1ms", null, Map.of("label", "prod"), authHeader); + createApiKey("test-exists-3", "1d", null, Map.of("value", 42, "label", "prod"), authHeader); + + final long startTime = Instant.now().toEpochMilli(); + + assertQuery( + authHeader, + """ + {"query": {"exists": {"field": "expiration" }}}""", + apiKeys -> { + assertThat( + apiKeys.stream().map(k -> (String) k.get("name")).toList(), + containsInAnyOrder("test-exists-2", "test-exists-3") + ); + } + ); + + assertQuery( + authHeader, + """ + {"query": {"exists": {"field": "metadata.value" }}}""", + apiKeys -> { + assertThat( + apiKeys.stream().map(k -> (String) k.get("name")).toList(), + containsInAnyOrder("test-exists-1", "test-exists-3") + ); + } + ); + + assertQuery( + authHeader, + """ + {"query": {"exists": {"field": "metadata.label" }}}""", + apiKeys -> { + assertThat( + apiKeys.stream().map(k -> (String) k.get("name")).toList(), + containsInAnyOrder("test-exists-2", "test-exists-3") + ); + } + ); + + // Create an invalidated API key + createAndInvalidateApiKey("test-exists-4", authHeader); + + // Ensure the short-lived key is expired + final long elapsed = Instant.now().toEpochMilli() - startTime; + if (elapsed < 10) { + Thread.sleep(10 - elapsed); + } + + // Find valid API keys (not invalidated nor expired) + assertQuery( + authHeader, + """ + { + "query": { + "bool": { + "must": { + "term": { + "invalidated": false + } + }, + "should": [ + { + "range": { + "expiration": { + "gte": "now" + } + } + }, + { + "bool": { + "must_not": { + "exists": { + "field": "expiration" + } + } + } + } + ], + "minimum_should_match": 1 + } + } + }""", + apiKeys -> { + assertThat( + apiKeys.stream().map(k -> (String) k.get("name")).toList(), + containsInAnyOrder("test-exists-1", "test-exists-3") + ); + } + ); + } + @SuppressWarnings("unchecked") private List extractSortValues(Map apiKeyInfo) { return (List) apiKeyInfo.get("_sort"); @@ -481,6 +581,16 @@ public class QueryApiKeyIT extends SecurityInBasicRestTestCase { Map roleDescriptors, Map metadata, String authHeader + ) throws IOException { + return createApiKey(name, randomFrom("10d", null), roleDescriptors, metadata, authHeader); + } + + private Tuple createApiKey( + String name, + String expiration, + Map roleDescriptors, + Map metadata, + String authHeader ) throws IOException { final Request request = new Request("POST", "/_security/api_key"); final String roleDescriptorsString = XContentTestUtils.convertToXContent( @@ -489,13 +599,13 @@ public class QueryApiKeyIT extends SecurityInBasicRestTestCase { ).utf8ToString(); final String metadataString = XContentTestUtils.convertToXContent(metadata == null ? Map.of() : metadata, XContentType.JSON) .utf8ToString(); - if (randomBoolean()) { + if (expiration == null) { request.setJsonEntity(""" {"name":"%s", "role_descriptors":%s, "metadata":%s}""".formatted(name, roleDescriptorsString, metadataString)); } else { request.setJsonEntity(""" - {"name":"%s", "expiration": "10d", "role_descriptors":%s,\ - "metadata":%s}""".formatted(name, roleDescriptorsString, metadataString)); + {"name":"%s", "expiration": "%s", "role_descriptors":%s,\ + "metadata":%s}""".formatted(name, expiration, roleDescriptorsString, metadataString)); } request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader)); final Response response = client().performRequest(request); 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 index f804ed39d694..fe450f523e90 100644 --- 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 @@ -10,6 +10,7 @@ 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.ExistsQueryBuilder; import org.elasticsearch.index.query.IdsQueryBuilder; import org.elasticsearch.index.query.MatchAllQueryBuilder; import org.elasticsearch.index.query.PrefixQueryBuilder; @@ -99,6 +100,9 @@ public class ApiKeyBoolQueryBuilder extends BoolQueryBuilder { } else if (qb instanceof final TermQueryBuilder query) { final String translatedFieldName = ApiKeyFieldNameTranslators.translate(query.fieldName()); return QueryBuilders.termQuery(translatedFieldName, query.value()).caseInsensitive(query.caseInsensitive()); + } else if (qb instanceof final ExistsQueryBuilder query) { + final String translatedFieldName = ApiKeyFieldNameTranslators.translate(query.fieldName()); + return QueryBuilders.existsQuery(translatedFieldName); } else if (qb instanceof final TermsQueryBuilder query) { if (query.termsLookup() != null) { throw new IllegalArgumentException("terms query with terms lookup is not supported for API Key query"); 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 index e4dca2725595..c9a5e7457f6a 100644 --- 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 @@ -219,7 +219,6 @@ public class ApiKeyBoolQueryBuilderTests extends ESTestCase { 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)), @@ -337,13 +336,14 @@ public class ApiKeyBoolQueryBuilderTests extends ESTestCase { } private QueryBuilder randomSimpleQuery(String name) { - return switch (randomIntBetween(0, 6)) { + return switch (randomIntBetween(0, 7)) { case 0 -> QueryBuilders.termQuery(name, randomAlphaOfLengthBetween(3, 8)); case 1 -> QueryBuilders.termsQuery(name, randomArray(1, 3, String[]::new, () -> randomAlphaOfLengthBetween(3, 8))); case 2 -> QueryBuilders.idsQuery().addIds(randomArray(1, 3, String[]::new, () -> randomAlphaOfLength(22))); case 3 -> QueryBuilders.prefixQuery(name, "prod-"); case 4 -> QueryBuilders.wildcardQuery(name, "prod-*-east-*"); case 5 -> QueryBuilders.matchAllQuery(); + case 6 -> QueryBuilders.existsQuery(name); default -> QueryBuilders.rangeQuery(name) .from(Instant.now().minus(1, ChronoUnit.DAYS).toEpochMilli(), randomBoolean()) .to(Instant.now().toEpochMilli(), randomBoolean());