diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java index 7182d02987cc0..e04ea8a22e8fe 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/ApiKey.java @@ -108,20 +108,28 @@ public Map getMetadata() { @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { - builder.startObject() - .field("id", id) - .field("name", name) - .field("creation", creation.toEpochMilli()); + builder.startObject(); + innerToXContent(builder, params); + return builder.endObject(); + } + + public XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException { + builder + .field("id", id) + .field("name", name) + .field("creation", creation.toEpochMilli()); if (expiration != null) { builder.field("expiration", expiration.toEpochMilli()); } - builder.field("invalidated", invalidated) - .field("username", username) - .field("realm", realm) - .field("metadata", (metadata == null ? Map.of() : metadata)); - return builder.endObject(); + builder + .field("invalidated", invalidated) + .field("username", username) + .field("realm", realm) + .field("metadata", (metadata == null ? Map.of() : metadata)); + return builder; } + @Override public void writeTo(StreamOutput out) throws IOException { if (out.getVersion().onOrAfter(Version.V_7_5_0)) { 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 index e5dd5eedc9284..b5dec8d6d0631 100644 --- 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 @@ -13,13 +13,26 @@ import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.core.Nullable; import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.search.searchafter.SearchAfterBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.action.ValidateActions.addValidationError; public final class QueryApiKeyRequest extends ActionRequest { @Nullable private final QueryBuilder queryBuilder; + @Nullable + private final Integer from; + @Nullable + private final Integer size; + @Nullable + private final List fieldSortBuilders; + @Nullable + private final SearchAfterBuilder searchAfterBuilder; private boolean filterForCurrentUser; public QueryApiKeyRequest() { @@ -27,18 +40,56 @@ public QueryApiKeyRequest() { } public QueryApiKeyRequest(QueryBuilder queryBuilder) { + this(queryBuilder, null, null, null, null); + } + + public QueryApiKeyRequest( + @Nullable QueryBuilder queryBuilder, + @Nullable Integer from, + @Nullable Integer size, + @Nullable List fieldSortBuilders, + @Nullable SearchAfterBuilder searchAfterBuilder + ) { this.queryBuilder = queryBuilder; + this.from = from; + this.size = size; + this.fieldSortBuilders = fieldSortBuilders; + this.searchAfterBuilder = searchAfterBuilder; } public QueryApiKeyRequest(StreamInput in) throws IOException { super(in); - queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class); + this.queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class); + this.from = in.readOptionalVInt(); + this.size = in.readOptionalVInt(); + if (in.readBoolean()) { + this.fieldSortBuilders = in.readList(FieldSortBuilder::new); + } else { + this.fieldSortBuilders = null; + } + this.searchAfterBuilder = in.readOptionalWriteable(SearchAfterBuilder::new); } public QueryBuilder getQueryBuilder() { return queryBuilder; } + public Integer getFrom() { + return from; + } + + public Integer getSize() { + return size; + } + + public List getFieldSortBuilders() { + return fieldSortBuilders; + } + + public SearchAfterBuilder getSearchAfterBuilder() { + return searchAfterBuilder; + } + public boolean isFilterForCurrentUser() { return filterForCurrentUser; } @@ -49,12 +100,28 @@ public void setFilterForCurrentUser() { @Override public ActionRequestValidationException validate() { - return null; + ActionRequestValidationException validationException = null; + if (from != null && from < 0) { + validationException = addValidationError("[from] parameter cannot be negative but was [" + from + "]", validationException); + } + if (size != null && size < 0) { + validationException = addValidationError("[size] parameter cannot be negative but was [" + size + "]", validationException); + } + return validationException; } @Override public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); out.writeOptionalNamedWriteable(queryBuilder); + out.writeOptionalVInt(from); + out.writeOptionalVInt(size); + if (fieldSortBuilders == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + out.writeList(fieldSortBuilders); + } + out.writeOptionalWriteable(searchAfterBuilder); } } 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 index 0ab1cc2e1f210..df17364dc1570 100644 --- 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 @@ -11,8 +11,10 @@ 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.lucene.Lucene; import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.core.Nullable; import org.elasticsearch.xpack.core.security.action.ApiKey; import java.io.IOException; @@ -27,36 +29,46 @@ */ public final class QueryApiKeyResponse extends ActionResponse implements ToXContentObject, Writeable { - private final ApiKey[] foundApiKeysInfo; + private final long total; + private final Item[] items; public QueryApiKeyResponse(StreamInput in) throws IOException { super(in); - this.foundApiKeysInfo = in.readArray(ApiKey::new, ApiKey[]::new); + this.total = in.readLong(); + this.items = in.readArray(Item::new, Item[]::new); } - public QueryApiKeyResponse(Collection foundApiKeysInfo) { - Objects.requireNonNull(foundApiKeysInfo, "found_api_keys_info must be provided"); - this.foundApiKeysInfo = foundApiKeysInfo.toArray(new ApiKey[0]); + public QueryApiKeyResponse(long total, Collection items) { + this.total = total; + Objects.requireNonNull(items, "items must be provided"); + this.items = items.toArray(new Item[0]); } public static QueryApiKeyResponse emptyResponse() { - return new QueryApiKeyResponse(Collections.emptyList()); + return new QueryApiKeyResponse(0, Collections.emptyList()); } - public ApiKey[] getApiKeyInfos() { - return foundApiKeysInfo; + public long getTotal() { + return total; + } + + public Item[] getItems() { + return items; } @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject() - .array("api_keys", (Object[]) foundApiKeysInfo); + .field("total", total) + .field("count", items.length) + .array("api_keys", (Object[]) items); return builder.endObject(); } @Override public void writeTo(StreamOutput out) throws IOException { - out.writeArray(foundApiKeysInfo); + out.writeLong(total); + out.writeArray(items); } @Override @@ -66,17 +78,81 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; QueryApiKeyResponse that = (QueryApiKeyResponse) o; - return Arrays.equals(foundApiKeysInfo, that.foundApiKeysInfo); + return total == that.total && Arrays.equals(items, that.items); } @Override public int hashCode() { - return Arrays.hashCode(foundApiKeysInfo); + int result = Objects.hash(total); + result = 31 * result + Arrays.hashCode(items); + return result; } @Override public String toString() { - return "QueryApiKeyResponse [foundApiKeysInfo=" + foundApiKeysInfo + "]"; + return "QueryApiKeyResponse{" + "total=" + total + ", items=" + Arrays.toString(items) + '}'; } + public static class Item implements ToXContentObject, Writeable { + private final ApiKey apiKey; + @Nullable + private final Object[] sortValues; + + public Item(ApiKey apiKey, @Nullable Object[] sortValues) { + this.apiKey = apiKey; + this.sortValues = sortValues; + } + + public Item(StreamInput in) throws IOException { + this.apiKey = new ApiKey(in); + this.sortValues = in.readOptionalArray(Lucene::readSortValue, Object[]::new); + } + + public ApiKey getApiKey() { + return apiKey; + } + + public Object[] getSortValues() { + return sortValues; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + apiKey.writeTo(out); + out.writeOptionalArray(Lucene::writeSortValue, sortValues); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + apiKey.innerToXContent(builder, params); + if (sortValues != null && sortValues.length > 0) { + builder.array("_sort", sortValues); + } + builder.endObject(); + return builder; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Item item = (Item) o; + return Objects.equals(apiKey, item.apiKey) && Arrays.equals(sortValues, item.sortValues); + } + + @Override + public int hashCode() { + int result = Objects.hash(apiKey); + result = 31 * result + Arrays.hashCode(sortValues); + return result; + } + + @Override + public String toString() { + return "Item{" + "apiKey=" + apiKey + ", sortValues=" + Arrays.toString(sortValues) + '}'; + } + } } 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 index 8c799631737e0..d40fc2cca3c64 100644 --- 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 @@ -14,14 +14,19 @@ import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.MatchAllQueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.SearchModule; +import org.elasticsearch.search.searchafter.SearchAfterBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.SortOrder; import org.elasticsearch.test.ESTestCase; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.List; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; @@ -57,5 +62,39 @@ public void testReadWrite() throws IOException { assertThat((BoolQueryBuilder) deserialized.getQueryBuilder(), equalTo(boolQueryBuilder2)); } } + + final QueryApiKeyRequest request3 = new QueryApiKeyRequest( + QueryBuilders.matchAllQuery(), + 42, + 20, + List.of(new FieldSortBuilder("name"), + new FieldSortBuilder("creation_time").setFormat("strict_date_time").order(SortOrder.DESC), + new FieldSortBuilder("username")), + new SearchAfterBuilder().setSortValues(new String[] { "key-2048", "2021-07-01T00:00:59.000Z" })); + try (BytesStreamOutput out = new BytesStreamOutput()) { + request3.writeTo(out); + try (StreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), writableRegistry())) { + final QueryApiKeyRequest deserialized = new QueryApiKeyRequest(in); + assertThat(deserialized.getQueryBuilder().getClass(), is(MatchAllQueryBuilder.class)); + assertThat(deserialized.getFrom(), equalTo(request3.getFrom())); + assertThat(deserialized.getSize(), equalTo(request3.getSize())); + assertThat(deserialized.getFieldSortBuilders(), equalTo(request3.getFieldSortBuilders())); + assertThat(deserialized.getSearchAfterBuilder(), equalTo(request3.getSearchAfterBuilder())); + } + } + } + + public void testValidate() { + final QueryApiKeyRequest request1 = + new QueryApiKeyRequest(null, randomIntBetween(0, Integer.MAX_VALUE), randomIntBetween(0, Integer.MAX_VALUE), null, null); + assertThat(request1.validate(), nullValue()); + + final QueryApiKeyRequest request2 = + new QueryApiKeyRequest(null, randomIntBetween(Integer.MIN_VALUE, -1), randomIntBetween(0, Integer.MAX_VALUE), null, null); + assertThat(request2.validate().getMessage(), containsString("[from] parameter cannot be negative")); + + final QueryApiKeyRequest request3 = + new QueryApiKeyRequest(null, randomIntBetween(0, Integer.MAX_VALUE), randomIntBetween(Integer.MIN_VALUE, -1), null, null); + assertThat(request3.validate().getMessage(), containsString("[size] parameter cannot be negative")); } } 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 index 87681c8e74afd..14170e355e349 100644 --- 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 @@ -29,36 +29,42 @@ protected Writeable.Reader instanceReader() { @Override protected QueryApiKeyResponse createTestInstance() { - final List apiKeys = randomList(0, 3, this::randomApiKeyInfo); - return new QueryApiKeyResponse(apiKeys); + final List items = randomList(0, 3, this::randomItem); + return new QueryApiKeyResponse(randomIntBetween(items.size(), 100), items); } @Override protected QueryApiKeyResponse mutateInstance(QueryApiKeyResponse instance) throws IOException { - final ArrayList apiKeyInfos = - Arrays.stream(instance.getApiKeyInfos()).collect(Collectors.toCollection(ArrayList::new)); - switch (randomIntBetween(0, 2)) { + final List items = + Arrays.stream(instance.getItems()).collect(Collectors.toCollection(ArrayList::new)); + switch (randomIntBetween(0, 3)) { case 0: - apiKeyInfos.add(randomApiKeyInfo()); - return new QueryApiKeyResponse(apiKeyInfos); + items.add(randomItem()); + return new QueryApiKeyResponse(instance.getTotal(), items); case 1: - if (false == apiKeyInfos.isEmpty()) { - return new QueryApiKeyResponse(apiKeyInfos.subList(1, apiKeyInfos.size())); + if (false == items.isEmpty()) { + return new QueryApiKeyResponse(instance.getTotal(), items.subList(1, items.size())); } else { - apiKeyInfos.add(randomApiKeyInfo()); - return new QueryApiKeyResponse(apiKeyInfos); + items.add(randomItem()); + return new QueryApiKeyResponse(instance.getTotal(), items); } - default: - if (false == apiKeyInfos.isEmpty()) { - final int index = randomIntBetween(0, apiKeyInfos.size() - 1); - apiKeyInfos.set(index, randomApiKeyInfo()); + case 2: + if (false == items.isEmpty()) { + final int index = randomIntBetween(0, items.size() - 1); + items.set(index, randomItem()); } else { - apiKeyInfos.add(randomApiKeyInfo()); + items.add(randomItem()); } - return new QueryApiKeyResponse(apiKeyInfos); + return new QueryApiKeyResponse(instance.getTotal(), items); + default: + return new QueryApiKeyResponse(instance.getTotal() + 1, items); } } + private QueryApiKeyResponse.Item randomItem() { + return new QueryApiKeyResponse.Item(randomApiKeyInfo(), randomSortValues()); + } + private ApiKey randomApiKeyInfo() { final String name = randomAlphaOfLengthBetween(3, 8); final String id = randomAlphaOfLength(22); @@ -69,4 +75,12 @@ private ApiKey randomApiKeyInfo() { final Map metadata = ApiKeyTests.randomMetadata(); return new ApiKey(name, id, creation, expiration, false, username, realm_name, metadata); } + + private Object[] randomSortValues() { + if (randomBoolean()) { + return null; + } else { + return randomArray(1, 3, Object[]::new, () -> randomFrom(42, 42L, "key-1", "2021-01-01T00:00:00.177Z", randomBoolean())); + } + } } 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 b5a9b04036af2..3dac93a9d0fc3 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,8 +17,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.function.Consumer; @@ -27,7 +29,11 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.oneOf; public class QueryApiKeyIT extends SecurityInBasicRestTestCase { @@ -47,6 +53,7 @@ public void testQuery() throws IOException { 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")); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); // An empty request body means search for all keys @@ -62,6 +69,7 @@ public void testQuery() throws IOException { assertThat( apiKeys.stream().map(k -> k.get("name")).collect(Collectors.toList()), containsInAnyOrder("my-org/ingest-key-1", "my-org/management-key-1")); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); } ); @@ -69,12 +77,14 @@ public void testQuery() throws IOException { "{\"query\":{\"terms\":{\"metadata.tags\":[\"prod\",\"east\"]}}}", apiKeys -> { assertThat(apiKeys.size(), equalTo(5)); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); assertQuery(API_KEY_ADMIN_AUTH_HEADER, - "{\"query\":{\"range\":{\"creation_time\":{\"lt\":\"now\"}}}}", + "{\"query\":{\"range\":{\"creation\":{\"lt\":\"now\"}}}}", apiKeys -> { assertThat(apiKeys.size(), equalTo(6)); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); // Search for keys belong to an user @@ -84,6 +94,7 @@ public void testQuery() throws IOException { 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"))); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); // Search for keys belong to users from a realm @@ -91,6 +102,7 @@ public void testQuery() throws IOException { "{ \"query\": { \"term\": {\"realm_name\": \"default_file\"} } }", apiKeys -> { assertThat(apiKeys.size(), equalTo(6)); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); // search using explicit IDs try { @@ -100,6 +112,7 @@ public void testQuery() throws IOException { + subset.stream().map(m -> "\"" + m.get("id") + "\"").collect(Collectors.joining(",")) + "] } } }", keys -> { assertThat(keys, hasSize(subset.size())); + keys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); } catch (IOException e) { throw new RuntimeException(e); @@ -129,6 +142,7 @@ public void testQuery() throws IOException { 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")); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); assertQuery(API_KEY_USER_AUTH_HEADER, @@ -136,11 +150,31 @@ public void testQuery() throws IOException { apiKeys -> { assertThat(apiKeys.size(), equalTo(1)); assertThat(apiKeys.get(0).get("name"), equalTo("my-alert-key-2")); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); // 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*\"} } }"); + + // Invalidated API keys are returned by default, but can be filtered out + final String authHeader = randomFrom(API_KEY_ADMIN_AUTH_HEADER, API_KEY_USER_AUTH_HEADER); + final String invalidatedApiKeyId1 = createAndInvalidateApiKey("temporary-key-1", authHeader); + final String queryString = randomFrom("{ \"query\": { \"term\": {\"name\": \"temporary-key-1\"} } }", + "{\"query\":{\"bool\":{\"must\":[{\"term\":{\"name\":{\"value\":\"temporary-key-1\"}}}," + + "{\"term\":{\"invalidated\":{\"value\":\"" + randomBoolean() + "\"}}}]}}}"); + + assertQuery(authHeader, queryString, apiKeys -> { + if (queryString.contains("\"invalidated\":{\"value\":\"false\"")) { + assertThat(apiKeys, empty()); + } else { + assertThat(apiKeys.size(), equalTo(1)); + assertThat(apiKeys.get(0).get("name"), equalTo("temporary-key-1")); + assertThat(apiKeys.get(0).get("id"), equalTo(invalidatedApiKeyId1)); + assertThat(apiKeys.get(0).get("invalidated"), is(true)); + } + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); + }); } public void testQueryShouldRespectOwnerIdentityWithApiKeyAuth() throws IOException { @@ -166,6 +200,7 @@ public void testQueryShouldRespectOwnerIdentityWithApiKeyAuth() throws IOExcepti 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"))); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); // limitKey gets only keys owned by the original user, not including the derived keys since they are not @@ -175,7 +210,132 @@ public void testQueryShouldRespectOwnerIdentityWithApiKeyAuth() throws IOExcepti assertThat( apiKeys.stream().map(m -> (String) m.get("name")).collect(Collectors.toUnmodifiableSet()), equalTo(Set.of("power-key-1", "limit-key-1"))); + apiKeys.forEach(k -> assertThat(k, not(hasKey("_sort")))); }); + + } + + public void testPagination() throws IOException { + final String authHeader = randomFrom(API_KEY_ADMIN_AUTH_HEADER, API_KEY_USER_AUTH_HEADER); + final int total = randomIntBetween(8, 12); + final List apiKeyIds = new ArrayList<>(total); + for (int i = 0; i < total; i++) { + apiKeyIds.add(createApiKey(String.format(Locale.ROOT, "k-%02d", i), null, authHeader).v1()); + } + + final int from = randomIntBetween(0, 3); + final int size = randomIntBetween(2, 5); + final int remaining = total - from; + final List sortFields = List.of(randomFrom("name", "creation"), "_doc"); + final String sortFieldsString = sortFields.stream().map(f -> "\"" + f + "\"").collect(Collectors.joining(",")); + + final List> apiKeyInfos = new ArrayList<>(remaining); + final Request request1 = new Request("GET", "/_security/_query/api_key"); + request1.setOptions(request1.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader)); + request1.setJsonEntity("{\"from\":" + from + ",\"size\":" + size + ",\"sort\":[" + sortFieldsString + "]}"); + collectApiKeys(apiKeyInfos, request1, total, size); + + while (apiKeyInfos.size() < remaining) { + final Request request2 = new Request("GET", "/_security/_query/api_key"); + request2.setOptions(request2.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader)); + final StringBuilder searchAfter = new StringBuilder(); + @SuppressWarnings("unchecked") + final List sortValues = (List) apiKeyInfos.get(apiKeyInfos.size() - 1).get("_sort"); + if (sortFields.get(0).equals("name")) { + assertThat(String.format(Locale.ROOT, "k-%02d", from + apiKeyInfos.size() - 1), equalTo(sortValues.get(0))); + searchAfter.append("\"").append(sortValues.get(0)).append("\""); + } else { + assertThat(apiKeyInfos.get(apiKeyInfos.size() - 1).get("creation"), equalTo(sortValues.get(0))); + searchAfter.append(sortValues.get(0)); + } + searchAfter.append(",").append(sortValues.get(1)); + request2.setJsonEntity("{\"size\":" + size + ",\"sort\":[" + sortFieldsString + "],\"search_after\":[" + searchAfter + "]}"); + collectApiKeys(apiKeyInfos, request2, total, size); + } + + for (int i = from; i < total; i++) { + assertThat(apiKeyInfos.get(i - from).get("id"), equalTo(apiKeyIds.get(i))); + } + + // size can be zero, but total should still reflect the number of keys matched + final Request request2 = new Request("GET", "/_security/_query/api_key"); + request2.setOptions(request2.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader)); + request2.setJsonEntity("{\"size\":0}"); + final Response response2 = client().performRequest(request2); + assertOK(response2); + final Map responseMap2 = responseAsMap(response2); + assertThat(responseMap2.get("total"), equalTo(total)); + assertThat(responseMap2.get("count"), equalTo(0)); + } + + @SuppressWarnings("unchecked") + public void testSort() throws IOException { + final String authHeader = randomFrom(API_KEY_ADMIN_AUTH_HEADER, API_KEY_USER_AUTH_HEADER); + final List apiKeyIds = new ArrayList<>(3); + apiKeyIds.add(createApiKey("k2", Map.of("letter", "a", "symbol", "2"), authHeader).v1()); + apiKeyIds.add(createApiKey("k1", Map.of("letter", "b", "symbol", "2"), authHeader).v1()); + apiKeyIds.add(createApiKey("k0", Map.of("letter", "c", "symbol", "1"), authHeader).v1()); + + assertQuery(authHeader, "{\"sort\":[{\"creation\":{\"order\":\"desc\"}}]}", apiKeys -> { + assertThat(apiKeys.size(), equalTo(3)); + for (int i = 2, j = 0; i >=0; i--, j++) { + assertThat(apiKeys.get(i).get("id"), equalTo(apiKeyIds.get(j))); + assertThat(apiKeys.get(i).get("creation"), equalTo(((List) apiKeys.get(i).get("_sort")).get(0))); + } + }); + + assertQuery(authHeader, "{\"sort\":[{\"name\":{\"order\":\"asc\"}}]}", apiKeys -> { + assertThat(apiKeys.size(), equalTo(3)); + for (int i = 2, j = 0; i >=0; i--, j++) { + assertThat(apiKeys.get(i).get("id"), equalTo(apiKeyIds.get(j))); + assertThat(apiKeys.get(i).get("name"), equalTo(((List) apiKeys.get(i).get("_sort")).get(0))); + } + }); + + assertQuery(authHeader, "{\"sort\":[\"metadata.letter\"]}", apiKeys -> { + assertThat(apiKeys.size(), equalTo(3)); + for (int i = 0; i < 3; i++) { + assertThat(apiKeys.get(i).get("id"), equalTo(apiKeyIds.get(i))); + } + }); + + assertQuery(authHeader, "{\"sort\":[\"metadata.symbol\",\"metadata.letter\"]}", apiKeys -> { + assertThat(apiKeys.size(), equalTo(3)); + assertThat(apiKeys.get(0).get("id"), equalTo(apiKeyIds.get(2))); + assertThat(apiKeys.get(1).get("id"), equalTo(apiKeyIds.get(0))); + assertThat(apiKeys.get(2).get("id"), equalTo(apiKeyIds.get(1))); + apiKeys.forEach(k -> { + final Map metadata = (Map) k.get("metadata"); + assertThat(metadata.get("symbol"), equalTo(((List) k.get("_sort")).get(0))); + assertThat(metadata.get("letter"), equalTo(((List) k.get("_sort")).get(1))); + }); + }); + + assertQuery(authHeader, "{\"sort\":[\"_doc\"]}", apiKeys -> { + assertThat(apiKeys.size(), equalTo(3)); + for (int i = 0; i < 3; i++) { + assertThat(apiKeys.get(i).get("id"), equalTo(apiKeyIds.get(i))); + assertThat(apiKeys.get(i).get("_sort"), notNullValue()); + } + }); + + final String invalidFieldName = randomFrom("doc_type", "api_key_invalidated", "metadata_flattened.letter"); + assertQueryError(authHeader, 400, "{\"sort\":[\"" + invalidFieldName + "\"]}"); + } + + private void collectApiKeys(List> apiKeyInfos, Request request, int total, int size) throws IOException { + final Response response = client().performRequest(request); + assertOK(response); + final Map responseMap = responseAsMap(response); + final int before = apiKeyInfos.size(); + @SuppressWarnings("unchecked") + final List> apiKeysMap = (List>) responseMap.get("api_keys"); + apiKeyInfos.addAll(apiKeysMap); + assertThat(responseMap.get("total"), equalTo(total)); + assertThat(responseMap.get("count"), equalTo(apiKeyInfos.size() - before)); + if (before == 0) { + assertThat(responseMap.get("count"), equalTo(size)); + } } private void assertQueryError(String authHeader, int statusCode, String body) throws IOException { @@ -197,8 +357,8 @@ private void assertQuery(String authHeader, String body, assertOK(response); final Map responseMap = responseAsMap(response); @SuppressWarnings("unchecked") - final List> api_keys = (List>) responseMap.get("api_keys"); - apiKeysVerifier.accept(api_keys); + final List> apiKeys = (List>) responseMap.get("api_keys"); + apiKeysVerifier.accept(apiKeys); } private void createApiKeys() throws IOException { @@ -292,6 +452,15 @@ private Tuple createApiKey(String name, return new Tuple<>((String) m.get("id"), (String) m.get("api_key")); } + private String createAndInvalidateApiKey(String name, String authHeader) throws IOException { + final Tuple tuple = createApiKey(name, null, authHeader); + final Request request = new Request("DELETE", "/_security/api_key"); + request.setOptions(request.getOptions().toBuilder().addHeader(HttpHeaders.AUTHORIZATION, authHeader)); + request.setJsonEntity("{\"ids\": [\"" + tuple.v1() + "\"],\"owner\":true}"); + assertOK(client().performRequest(request)); + return tuple.v1(); + } + private void createUser(String name) throws IOException { final Request request = new Request("POST", "/_security/user/" + name); request.setJsonEntity("{\"password\":\"super-strong-password\",\"roles\":[]}"); diff --git a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java index c4db969082772..02e3b0bc2290a 100644 --- a/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java +++ b/x-pack/plugin/security/src/internalClusterTest/java/org/elasticsearch/xpack/security/authc/apikey/ApiKeySingleNodeTests.java @@ -18,6 +18,9 @@ import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyRequest; import org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse; +import java.time.Instant; + +import static org.hamcrest.Matchers.emptyArray; import static org.hamcrest.Matchers.equalTo; public class ApiKeySingleNodeTests extends SecuritySingleNodeTestCase { @@ -40,9 +43,14 @@ public void testQueryWithExpiredKeys() throws InterruptedException { .getId(); Thread.sleep(10); // just to be 100% sure that the 1st key is expired when we search for it - final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest(QueryBuilders.idsQuery().addIds(id1, id2)); + final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest( + QueryBuilders.boolQuery() + .filter(QueryBuilders.idsQuery().addIds(id1, id2)) + .filter(QueryBuilders.rangeQuery("expiration").from(Instant.now().toEpochMilli()))); final QueryApiKeyResponse queryApiKeyResponse = client().execute(QueryApiKeyAction.INSTANCE, queryApiKeyRequest).actionGet(); - assertThat(queryApiKeyResponse.getApiKeyInfos().length, equalTo(1)); - assertThat(queryApiKeyResponse.getApiKeyInfos()[0].getId(), equalTo(id2)); + assertThat(queryApiKeyResponse.getItems().length, equalTo(1)); + assertThat(queryApiKeyResponse.getItems()[0].getApiKey().getId(), equalTo(id2)); + assertThat(queryApiKeyResponse.getItems()[0].getApiKey().getName(), equalTo("long-lived")); + assertThat(queryApiKeyResponse.getItems()[0].getSortValues(), emptyArray()); } } 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 index a4fb6d8a4bf32..a240504b9d602 100644 --- 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 @@ -8,9 +8,12 @@ package org.elasticsearch.xpack.security.action.apikey; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.search.builder.SearchSourceBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; import org.elasticsearch.xpack.core.security.SecurityContext; @@ -20,6 +23,11 @@ import org.elasticsearch.xpack.core.security.authc.Authentication; import org.elasticsearch.xpack.security.authc.ApiKeyService; import org.elasticsearch.xpack.security.support.ApiKeyBoolQueryBuilder; +import org.elasticsearch.xpack.security.support.ApiKeyFieldNameTranslators; + +import java.util.List; + +import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; public final class TransportQueryApiKeyAction extends HandledTransportAction { @@ -41,10 +49,66 @@ protected void doExecute(Task task, QueryApiKeyRequest request, ActionListener fieldSortBuilders, SearchSourceBuilder searchSourceBuilder) { + fieldSortBuilders.forEach(fieldSortBuilder -> { + if (fieldSortBuilder.getNestedSort() != null) { + throw new IllegalArgumentException("nested sorting is not supported for API Key query"); + } + if (FieldSortBuilder.DOC_FIELD_NAME.equals(fieldSortBuilder.getFieldName())) { + searchSourceBuilder.sort(fieldSortBuilder); + } else { + final String translatedFieldName = ApiKeyFieldNameTranslators.translate(fieldSortBuilder.getFieldName()); + if (translatedFieldName.equals(fieldSortBuilder.getFieldName())) { + searchSourceBuilder.sort(fieldSortBuilder); + } else { + final FieldSortBuilder translatedFieldSortBuilder = + new FieldSortBuilder(translatedFieldName) + .order(fieldSortBuilder.order()) + .missing(fieldSortBuilder.missing()) + .unmappedType(fieldSortBuilder.unmappedType()) + .setFormat(fieldSortBuilder.getFormat()); + + if (fieldSortBuilder.sortMode() != null) { + translatedFieldSortBuilder.sortMode(fieldSortBuilder.sortMode()); + } + if (fieldSortBuilder.getNestedSort() != null) { + translatedFieldSortBuilder.setNestedSort(fieldSortBuilder.getNestedSort()); + } + if (fieldSortBuilder.getNumericType() != null) { + translatedFieldSortBuilder.setNumericType(fieldSortBuilder.getNumericType()); + } + searchSourceBuilder.sort(translatedFieldSortBuilder); + } + } + }); + } } 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 9e59769db2317..2b13cbe997471 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 @@ -27,6 +27,7 @@ import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.index.IndexRequest; import org.elasticsearch.action.index.IndexResponse; +import org.elasticsearch.action.search.SearchAction; import org.elasticsearch.action.search.SearchRequest; import org.elasticsearch.action.support.ContextPreservingActionListener; import org.elasticsearch.action.support.WriteRequest.RefreshPolicy; @@ -90,7 +91,6 @@ 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; @@ -909,23 +909,7 @@ private void findApiKeys(final BoolQueryBuilder boolQuery, boolean filterOutInva .request(); securityIndex.checkIndexVersionThenExecute(listener::onFailure, () -> ScrollHelper.fetchAllByEntity(client, request, new ContextPreservingActionListener<>(supplier, listener), - (SearchHit hit) -> { - Map source = hit.getSourceAsMap(); - String name = (String) source.get("name"); - String id = hit.getId(); - Long creation = (Long) source.get("creation_time"); - Long expiration = (Long) source.get("expiration_time"); - Boolean invalidated = (Boolean) source.get("api_key_invalidated"); - @SuppressWarnings("unchecked") - String username = (String) ((Map) source.get("creator")).get("principal"); - @SuppressWarnings("unchecked") - String realm = (String) ((Map) source.get("creator")).get("realm"); - @SuppressWarnings("unchecked") - Map metadata = (Map) source.get("metadata_flattened"); - return new ApiKey(name, id, Instant.ofEpochMilli(creation), - (expiration != null) ? Instant.ofEpochMilli(expiration) : null, - invalidated, username, realm, metadata); - })); + ApiKeyService::convertSearchHitToApiKeyInfo)); } } @@ -1125,27 +1109,66 @@ public void getApiKeys(String realmName, String username, String apiKeyName, Str }, listener::onFailure)); } - public void queryApiKeys(ApiKeyBoolQueryBuilder apiKeyBoolQueryBuilder, ActionListener listener) { + public void queryApiKeys(SearchRequest searchRequest, 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()); + logger.debug("security index does not exist"); + listener.onResponse(QueryApiKeyResponse.emptyResponse()); } else if (frozenSecurityIndex.isAvailable() == false) { - wrappedListener.onFailure(frozenSecurityIndex.getUnavailableReason()); + listener.onFailure(frozenSecurityIndex.getUnavailableReason()); } else { - findApiKeys(apiKeyBoolQueryBuilder, true, true, wrappedListener); + securityIndex.checkIndexVersionThenExecute(listener::onFailure, + () -> executeAsyncWithOrigin(client, + SECURITY_ORIGIN, + SearchAction.INSTANCE, + searchRequest, + ActionListener.wrap(searchResponse -> { + final long total = searchResponse.getHits().getTotalHits().value; + if (total == 0) { + logger.debug("No api keys found for query [{}]", searchRequest.source().query()); + listener.onResponse(QueryApiKeyResponse.emptyResponse()); + return; + } + final List apiKeyItem = Arrays.stream(searchResponse.getHits().getHits()) + .map(ApiKeyService::convertSearchHitToQueryItem) + .collect(Collectors.toUnmodifiableList()); + listener.onResponse(new QueryApiKeyResponse(total, apiKeyItem)); + }, listener::onFailure))); } } + private static QueryApiKeyResponse.Item convertSearchHitToQueryItem(SearchHit hit) { + return new QueryApiKeyResponse.Item(convertSearchHitToApiKeyInfo(hit), hit.getSortValues()); + } + + private static ApiKey convertSearchHitToApiKeyInfo(SearchHit hit) { + Map source = hit.getSourceAsMap(); + String name = (String) source.get("name"); + String id = hit.getId(); + Long creation = (Long) source.get("creation_time"); + Long expiration = (Long) source.get("expiration_time"); + Boolean invalidated = (Boolean) source.get("api_key_invalidated"); + @SuppressWarnings("unchecked") + String username = (String) ((Map) source.get("creator")).get("principal"); + @SuppressWarnings("unchecked") + String realm = (String) ((Map) source.get("creator")).get("realm"); + @SuppressWarnings("unchecked") + Map metadata = (Map) source.get("metadata_flattened"); + + return new ApiKey( + name, + id, + Instant.ofEpochMilli(creation), + (expiration != null) ? Instant.ofEpochMilli(expiration) : null, + invalidated, + username, + realm, + metadata + ); + } + 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/rest/action/apikey/RestQueryApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestQueryApiKeyAction.java index d7c3d5011d8c8..598aa5a16fb65 100644 --- 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 @@ -10,11 +10,16 @@ import org.elasticsearch.client.node.NodeClient; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ObjectParser; import org.elasticsearch.common.xcontent.ParseField; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentParserUtils; 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.search.searchafter.SearchAfterBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; 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; @@ -32,12 +37,30 @@ */ public final class RestQueryApiKeyAction extends SecurityBaseRestHandler { + @SuppressWarnings("unchecked") private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( "query_api_key_request", - a -> new QueryApiKeyRequest((QueryBuilder) a[0])); + a -> new QueryApiKeyRequest((QueryBuilder) a[0], (Integer) a[1], (Integer) a[2], + (List) a[3], (SearchAfterBuilder) a[4])); static { PARSER.declareObject(optionalConstructorArg(), (p, c) -> parseInnerQueryBuilder(p), new ParseField("query")); + PARSER.declareInt(optionalConstructorArg(), new ParseField("from")); + PARSER.declareInt(optionalConstructorArg(), new ParseField("size")); + PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> { + if (p.currentToken() == XContentParser.Token.VALUE_STRING) { + return new FieldSortBuilder(p.text()); + } else if (p.currentToken() == XContentParser.Token.START_OBJECT) { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, p.nextToken(), p); + final FieldSortBuilder fieldSortBuilder = FieldSortBuilder.fromXContent(p, p.currentName()); + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, p.nextToken(), p); + return fieldSortBuilder; + } else { + throw new IllegalArgumentException("mal-formatted sort object"); + } + }, new ParseField("sort")); + PARSER.declareField(optionalConstructorArg(), (p, c) -> SearchAfterBuilder.fromXContent(p), + new ParseField("search_after"), ObjectParser.ValueType.VALUE_ARRAY); } /** 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 19bb7204b9d39..e89a9c53f7f82 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 @@ -25,9 +25,7 @@ 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 { @@ -85,28 +83,28 @@ private static QueryBuilder doProcess(QueryBuilder qb) { return qb; } else if (qb instanceof TermQueryBuilder) { final TermQueryBuilder query = (TermQueryBuilder) qb; - final String translatedFieldName = FieldNameTranslators.translate(query.fieldName()); + final String translatedFieldName = ApiKeyFieldNameTranslators.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()); + final String translatedFieldName = ApiKeyFieldNameTranslators.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()); + final String translatedFieldName = ApiKeyFieldNameTranslators.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()); + final String translatedFieldName = ApiKeyFieldNameTranslators.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()); + final String translatedFieldName = ApiKeyFieldNameTranslators.translate(query.fieldName()); if (query.relation() != null) { throw new IllegalArgumentException("range query with relation is not supported for API Key query"); } @@ -150,77 +148,4 @@ static boolean isIndexFieldNameAllowed(String fieldName) { || 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/main/java/org/elasticsearch/xpack/security/support/ApiKeyFieldNameTranslators.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyFieldNameTranslators.java new file mode 100644 index 0000000000000..70d064a63f00e --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/support/ApiKeyFieldNameTranslators.java @@ -0,0 +1,85 @@ +/* + * 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 java.util.List; +import java.util.function.Function; + +/** + * A class to translate query level field names to index level field names. + */ +public class ApiKeyFieldNameTranslators { + 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(s -> "creation_time", "creation"), + new ExactFieldNameTranslator(s -> "expiration_time", "expiration"), + new ExactFieldNameTranslator(s -> "api_key_invalidated", "invalidated"), + 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. + */ + public 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/action/apikey/TransportQueryApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyActionTests.java new file mode 100644 index 0000000000000..8be82a390aebf --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/apikey/TransportQueryApiKeyActionTests.java @@ -0,0 +1,98 @@ +/* + * 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.search.builder.SearchSourceBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.NestedSortBuilder; +import org.elasticsearch.search.sort.SortMode; +import org.elasticsearch.search.sort.SortOrder; +import org.elasticsearch.test.ESTestCase; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.equalTo; + +public class TransportQueryApiKeyActionTests extends ESTestCase { + + public void testTranslateFieldSortBuilders() { + final List fieldNames = List.of( + "_doc", + "username", + "realm_name", + "name", + "creation", + "expiration", + "invalidated", + "metadata." + randomAlphaOfLengthBetween(3, 8)); + + final List originals = + fieldNames.stream().map(this::randomFieldSortBuilderWithName).collect(Collectors.toUnmodifiableList()); + + final SearchSourceBuilder searchSourceBuilder = SearchSourceBuilder.searchSource(); + TransportQueryApiKeyAction.translateFieldSortBuilders(originals, searchSourceBuilder); + + IntStream.range(0, originals.size()).forEach(i -> { + final FieldSortBuilder original = originals.get(i); + final FieldSortBuilder translated = (FieldSortBuilder) searchSourceBuilder.sorts().get(i); + if (Set.of("_doc", "name").contains(original.getFieldName())) { + assertThat(translated, equalTo(original)); + } else { + if ("username".equals(original.getFieldName())) { + assertThat(translated.getFieldName(), equalTo("creator.principal")); + } else if ("realm_name".equals(original.getFieldName())) { + assertThat(translated.getFieldName(), equalTo("creator.realm")); + } else if ("creation".equals(original.getFieldName())) { + assertThat(translated.getFieldName(), equalTo("creation_time")); + } else if ("expiration".equals(original.getFieldName())) { + assertThat(translated.getFieldName(), equalTo("expiration_time")); + } else if ("invalidated".equals(original.getFieldName())) { + assertThat(translated.getFieldName(), equalTo("api_key_invalidated")); + } else if (original.getFieldName().startsWith("metadata.")) { + assertThat(translated.getFieldName(), equalTo("metadata_flattened." + original.getFieldName().substring(9))); + } else { + fail("unrecognized field name: [" + original.getFieldName() + "]"); + } + assertThat(translated.order(), equalTo(original.order())); + assertThat(translated.missing(), equalTo(original.missing())); + assertThat(translated.unmappedType(), equalTo(original.unmappedType())); + assertThat(translated.getNumericType(), equalTo(original.getNumericType())); + assertThat(translated.getFormat(), equalTo(original.getFormat())); + assertThat(translated.sortMode(), equalTo(original.sortMode())); + } + }); + } + + public void testNestedSortingIsNotAllowed() { + final FieldSortBuilder fieldSortBuilder = new FieldSortBuilder("name"); + fieldSortBuilder.setNestedSort(new NestedSortBuilder("name")); + final IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> TransportQueryApiKeyAction.translateFieldSortBuilders(List.of(fieldSortBuilder), SearchSourceBuilder.searchSource())); + assertThat(e.getMessage(), equalTo("nested sorting is not supported for API Key query")); + } + + private FieldSortBuilder randomFieldSortBuilderWithName(String name) { + final FieldSortBuilder fieldSortBuilder = new FieldSortBuilder(name); + fieldSortBuilder.order(randomBoolean() ? SortOrder.ASC : SortOrder.DESC); + fieldSortBuilder.setFormat(randomBoolean() ? randomAlphaOfLengthBetween(3, 16) : null); + if (randomBoolean()) { + fieldSortBuilder.setNumericType(randomFrom("long", "double", "date", "date_nanos")); + } + if (randomBoolean()) { + fieldSortBuilder.missing(randomAlphaOfLengthBetween(3, 8)); + } + if (randomBoolean()) { + fieldSortBuilder.sortMode(randomFrom(SortMode.values())); + } + return fieldSortBuilder; + } +} 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 index d07a1d45fa751..3c0b66bbe18a9 100644 --- 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 @@ -19,6 +19,7 @@ import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.env.Environment; import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.MatchAllQueryBuilder; import org.elasticsearch.index.query.PrefixQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.TermsQueryBuilder; @@ -27,6 +28,9 @@ import org.elasticsearch.rest.RestChannel; import org.elasticsearch.rest.RestResponse; import org.elasticsearch.search.SearchModule; +import org.elasticsearch.search.searchafter.SearchAfterBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.SortOrder; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.rest.FakeRestRequest; import org.elasticsearch.threadpool.ThreadPool; @@ -100,7 +104,60 @@ void doExecute(ActionType action, Request request, ActionListener 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(MatchAllQueryBuilder.class)); + assertThat(queryApiKeyRequest.getFrom(), equalTo(42)); + assertThat(queryApiKeyRequest.getSize(), equalTo(20)); + final List fieldSortBuilders = queryApiKeyRequest.getFieldSortBuilders(); + assertThat(fieldSortBuilders.size(), equalTo(3)); + + assertThat(fieldSortBuilders.get(0), equalTo(new FieldSortBuilder("name"))); + assertThat( + fieldSortBuilders.get(1), + equalTo(new FieldSortBuilder("creation_time").setFormat("strict_date_time").order(SortOrder.DESC))); + assertThat(fieldSortBuilders.get(2), equalTo(new FieldSortBuilder("username"))); + + final SearchAfterBuilder searchAfterBuilder = queryApiKeyRequest.getSearchAfterBuilder(); + assertThat( + searchAfterBuilder, + equalTo(new SearchAfterBuilder().setSortValues(new String[] { "key-2048", "2021-07-01T00:00:59.000Z" }))); + + listener.onResponse((Response) new QueryApiKeyResponse(0, List.of())); } }) { final RestQueryApiKeyAction restQueryApiKeyAction = new RestQueryApiKeyAction(Settings.EMPTY, mockLicenseState); 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 2a7120eac52f6..e32033c70366e 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 @@ -36,7 +36,7 @@ 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.elasticsearch.xpack.security.support.ApiKeyFieldNameTranslators.FIELD_NAME_TRANSLATORS; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; @@ -52,7 +52,7 @@ 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 QueryBuilder q1 = randomSimpleQuery("name"); final ApiKeyBoolQueryBuilder apiKeyQb1 = ApiKeyBoolQueryBuilder.build(q1, authentication); assertCommonFilterQueries(apiKeyQb1, authentication); final List mustQueries = apiKeyQb1.must(); @@ -119,6 +119,19 @@ public void testFieldNameTranslation() { final ApiKeyBoolQueryBuilder apiKeyQb3 = ApiKeyBoolQueryBuilder.build(q3, authentication); assertCommonFilterQueries(apiKeyQb3, authentication); assertThat(apiKeyQb3.must().get(0), equalTo(QueryBuilders.wildcardQuery("creator.realm", q3.value()))); + + + // creation_time + final TermQueryBuilder q4 = QueryBuilders.termQuery("creation", randomLongBetween(0, Long.MAX_VALUE)); + final ApiKeyBoolQueryBuilder apiKeyQb4 = ApiKeyBoolQueryBuilder.build(q4, authentication); + assertCommonFilterQueries(apiKeyQb4, authentication); + assertThat(apiKeyQb4.must().get(0), equalTo(QueryBuilders.termQuery("creation_time", q4.value()))); + + // expiration_time + final TermQueryBuilder q5 = QueryBuilders.termQuery("expiration", randomLongBetween(0, Long.MAX_VALUE)); + final ApiKeyBoolQueryBuilder apiKeyQb5 = ApiKeyBoolQueryBuilder.build(q5, authentication); + assertCommonFilterQueries(apiKeyQb5, authentication); + assertThat(apiKeyQb5.must().get(0), equalTo(QueryBuilders.termQuery("expiration_time", q5.value()))); } public void testAllowListOfFieldNames() { @@ -155,7 +168,7 @@ public void testTermsLookupIsNotAllowed() { public void testRangeQueryWithRelationIsNotAllowed() { final Authentication authentication = randomBoolean() ? AuthenticationTests.randomAuthentication(null, null) : null; - final RangeQueryBuilder q1 = QueryBuilders.rangeQuery("creation_time").relation("contains"); + final RangeQueryBuilder q1 = QueryBuilders.rangeQuery("creation").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"));