diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java index 7d053a69e8915..5ae98c8847285 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java @@ -76,6 +76,8 @@ import org.elasticsearch.client.security.PutRoleResponse; import org.elasticsearch.client.security.PutUserRequest; import org.elasticsearch.client.security.PutUserResponse; +import org.elasticsearch.client.security.QueryApiKeyRequest; +import org.elasticsearch.client.security.QueryApiKeyResponse; import java.io.IOException; @@ -1050,7 +1052,7 @@ public Cancellable createApiKeyAsync(final CreateApiKeyRequest request, final Re * * @param request the request to retrieve API key(s) * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized - * @return the response from the create API key call + * @return the response from the get API key call * @throws IOException in case there is a problem sending the request or parsing back the response */ public GetApiKeyResponse getApiKey(final GetApiKeyRequest request, final RequestOptions options) throws IOException { @@ -1137,6 +1139,37 @@ public Cancellable grantApiKeyAsync(final GrantApiKeyRequest request, final Requ CreateApiKeyResponse::fromXContent, listener, emptySet()); } + /** + * Query and retrieve API Key(s) information.
+ * See + * the docs for more. + * + * @param request the request to query and retrieve API key(s) + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @return the response from the query API key call + * @throws IOException in case there is a problem sending the request or parsing back the response + */ + public QueryApiKeyResponse queryApiKey(final QueryApiKeyRequest request, final RequestOptions options) throws IOException { + return restHighLevelClient.performRequestAndParseEntity(request, SecurityRequestConverters::queryApiKey, options, + QueryApiKeyResponse::fromXContent, emptySet()); + } + + /** + * Asynchronously query and retrieve API Key(s) information.
+ * See + * the docs for more. + * + * @param request the request to query and retrieve API key(s) + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @param listener the listener to be notified upon request completion + * @return cancellable that may be used to cancel the request + */ + public Cancellable queryApiKeyAsync(final QueryApiKeyRequest request, final RequestOptions options, + final ActionListener listener) { + return restHighLevelClient.performRequestAsyncAndParseEntity(request, SecurityRequestConverters::queryApiKey, options, + QueryApiKeyResponse::fromXContent, listener, emptySet()); + } + /** * Get a service account, or list of service accounts synchronously. * See diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java index a5c1e8a37f2dd..624ff74d802ab 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityRequestConverters.java @@ -44,6 +44,7 @@ import org.elasticsearch.client.security.PutRoleMappingRequest; import org.elasticsearch.client.security.PutRoleRequest; import org.elasticsearch.client.security.PutUserRequest; +import org.elasticsearch.client.security.QueryApiKeyRequest; import org.elasticsearch.client.security.SetUserEnabledRequest; import org.elasticsearch.common.Strings; @@ -346,6 +347,12 @@ static Request invalidateApiKey(final InvalidateApiKeyRequest invalidateApiKeyRe return request; } + static Request queryApiKey(final QueryApiKeyRequest queryApiKeyRequest) throws IOException { + final Request request = new Request(HttpGet.METHOD_NAME, "/_security/_query/api_key"); + request.setEntity(createEntity(queryApiKeyRequest, REQUEST_BODY_CONTENT_TYPE)); + return request; + } + static Request getServiceAccounts(final GetServiceAccountsRequest getServiceAccountsRequest) { final RequestConverters.EndpointBuilder endpointBuilder = new RequestConverters.EndpointBuilder() .addPathPartAsIs("_security/service"); diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyRequest.java new file mode 100644 index 0000000000000..03b51388408cb --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyRequest.java @@ -0,0 +1,158 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.Validatable; +import org.elasticsearch.client.ValidationException; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; +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 java.util.Objects; +import java.util.Optional; + +public final class QueryApiKeyRequest implements Validatable, ToXContentObject { + + @Nullable + private QueryBuilder queryBuilder; + private Integer from; + private Integer size; + @Nullable + private List fieldSortBuilders; + @Nullable + private SearchAfterBuilder searchAfterBuilder; + + public QueryApiKeyRequest() { + this(null, 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 QueryBuilder getQueryBuilder() { + return queryBuilder; + } + + public int getFrom() { + return from; + } + + public int getSize() { + return size; + } + + public List getFieldSortBuilders() { + return fieldSortBuilders; + } + + public SearchAfterBuilder getSearchAfterBuilder() { + return searchAfterBuilder; + } + + public QueryApiKeyRequest queryBuilder(QueryBuilder queryBuilder) { + this.queryBuilder = queryBuilder; + return this; + } + + public QueryApiKeyRequest from(int from) { + this.from = from; + return this; + } + + public QueryApiKeyRequest size(int size) { + this.size = size; + return this; + } + + public QueryApiKeyRequest fieldSortBuilders(List fieldSortBuilders) { + this.fieldSortBuilders = fieldSortBuilders; + return this; + } + + public QueryApiKeyRequest searchAfterBuilder(SearchAfterBuilder searchAfterBuilder) { + this.searchAfterBuilder = searchAfterBuilder; + return this; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + if (queryBuilder != null) { + builder.field("query"); + queryBuilder.toXContent(builder, params); + } + if (from != null) { + builder.field("from", from); + } + if (size != null) { + builder.field("size", size); + } + if (fieldSortBuilders != null && false == fieldSortBuilders.isEmpty()) { + builder.field("sort", fieldSortBuilders); + } + if (searchAfterBuilder != null) { + builder.array(SearchAfterBuilder.SEARCH_AFTER.getPreferredName(), searchAfterBuilder.getSortValues()); + } + return builder.endObject(); + } + + @Override + public Optional validate() { + ValidationException validationException = null; + if (from != null && from < 0) { + validationException = addValidationError(validationException, "from must be non-negative"); + } + if (size != null && size < 0) { + validationException = addValidationError(validationException, "size must be non-negative"); + } + return validationException == null ? Optional.empty() : Optional.of(validationException); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + QueryApiKeyRequest that = (QueryApiKeyRequest) o; + return Objects.equals(queryBuilder, that.queryBuilder) && Objects.equals(from, that.from) && Objects.equals( + size, + that.size) && Objects.equals(fieldSortBuilders, that.fieldSortBuilders) && Objects.equals( + searchAfterBuilder, + that.searchAfterBuilder); + } + + @Override + public int hashCode() { + return Objects.hash(queryBuilder, from, size, fieldSortBuilders, searchAfterBuilder); + } + + private ValidationException addValidationError(ValidationException validationException, String message) { + if (validationException == null) { + validationException = new ValidationException(); + } + validationException.addValidationError(message); + return validationException; + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyResponse.java new file mode 100644 index 0000000000000..811fc62976400 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/QueryApiKeyResponse.java @@ -0,0 +1,68 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ParseField; +import org.elasticsearch.common.xcontent.XContentParser; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + +public final class QueryApiKeyResponse { + + private final long total; + private final List apiKeys; + + public QueryApiKeyResponse(long total, List apiKeys) { + this.total = total; + this.apiKeys = apiKeys; + } + + public long getTotal() { + return total; + } + + public int getCount() { + return apiKeys.size(); + } + + public List getApiKeys() { + return apiKeys; + } + + public static QueryApiKeyResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } + + static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + "query_api_key_response", + args -> { + final long total = (long) args[0]; + final int count = (int) args[1]; + @SuppressWarnings("unchecked") + final List items = (List) args[2]; + if (count != items.size()) { + throw new IllegalArgumentException("count [" + count + "] is not equal to number of items [" + + items.size() + "]"); + } + return new QueryApiKeyResponse(total, items); + } + ); + + static { + PARSER.declareLong(constructorArg(), new ParseField("total")); + PARSER.declareInt(constructorArg(), new ParseField("count")); + PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> ApiKey.fromXContent(p), new ParseField("api_keys")); + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java index 8989c92e20f36..65fa26edd3f23 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/support/ApiKey.java @@ -12,9 +12,12 @@ import org.elasticsearch.common.xcontent.ConstructingObjectParser; import org.elasticsearch.common.xcontent.ObjectParser; import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.core.Nullable; import java.io.IOException; import java.time.Instant; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -34,9 +37,16 @@ public final class ApiKey { private final String username; private final String realm; private final Map metadata; + @Nullable + private final Object[] sortValues; public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm, Map metadata) { + this(name, id, creation, expiration, invalidated, username, realm, metadata, null); + } + + public ApiKey(String name, String id, Instant creation, Instant expiration, boolean invalidated, String username, String realm, + Map metadata, @Nullable Object[] sortValues) { this.name = name; this.id = id; // As we do not yet support the nanosecond precision when we serialize to JSON, @@ -48,6 +58,7 @@ public ApiKey(String name, String id, Instant creation, Instant expiration, bool this.username = username; this.realm = realm; this.metadata = metadata; + this.sortValues = sortValues; } public String getId() { @@ -98,9 +109,21 @@ public Map getMetadata() { return metadata; } + /** + * API keys can be retrieved with either {@link org.elasticsearch.client.security.GetApiKeyRequest} + * or {@link org.elasticsearch.client.security.QueryApiKeyRequest}. When sorting is specified for + * QueryApiKeyRequest, the sort values for each key is returned along with each API key. + * + * @return Sort values for this API key if it is retrieved with QueryApiKeyRequest and sorting is + * required. Otherwise, it is null. + */ + public Object[] getSortValues() { + return sortValues; + } + @Override public int hashCode() { - return Objects.hash(name, id, creation, expiration, invalidated, username, realm, metadata); + return Objects.hash(name, id, creation, expiration, invalidated, username, realm, metadata, Arrays.hashCode(sortValues)); } @Override @@ -122,14 +145,22 @@ public boolean equals(Object obj) { && Objects.equals(invalidated, other.invalidated) && Objects.equals(username, other.username) && Objects.equals(realm, other.realm) - && Objects.equals(metadata, other.metadata); + && Objects.equals(metadata, other.metadata) + && Arrays.equals(sortValues, other.sortValues); } @SuppressWarnings("unchecked") static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>("api_key", args -> { + final Object[] sortValues; + if (args[8] == null) { + sortValues = null; + } else { + final List arg8 = (List) args[8]; + sortValues = arg8.isEmpty() ? null : arg8.toArray(); + } return new ApiKey((String) args[0], (String) args[1], Instant.ofEpochMilli((Long) args[2]), (args[3] == null) ? null : Instant.ofEpochMilli((Long) args[3]), (Boolean) args[4], (String) args[5], (String) args[6], - (Map) args[7]); + (Map) args[7], sortValues); }); static { PARSER.declareField(optionalConstructorArg(), (p, c) -> p.textOrNull(), new ParseField("name"), @@ -141,6 +172,7 @@ public boolean equals(Object obj) { PARSER.declareString(constructorArg(), new ParseField("username")); PARSER.declareString(constructorArg(), new ParseField("realm")); PARSER.declareObject(optionalConstructorArg(), (p, c) -> p.map(), new ParseField("metadata")); + PARSER.declareObjectArray(optionalConstructorArg(), (p, c) -> p.objectText(), new ParseField("_sort")); } public static ApiKey fromXContent(XContentParser parser) throws IOException { @@ -150,6 +182,6 @@ public static ApiKey fromXContent(XContentParser parser) throws IOException { @Override public String toString() { return "ApiKey [name=" + name + ", id=" + id + ", creation=" + creation + ", expiration=" + expiration + ", invalidated=" - + invalidated + ", username=" + username + ", realm=" + realm + "]"; + + invalidated + ", username=" + username + ", realm=" + realm + ", _sort=" + Arrays.toString(sortValues) + "]"; } } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java index 997a057e56318..26de24048f360 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityRequestConvertersTests.java @@ -38,6 +38,8 @@ import org.elasticsearch.client.security.PutRoleMappingRequest; import org.elasticsearch.client.security.PutRoleRequest; import org.elasticsearch.client.security.PutUserRequest; +import org.elasticsearch.client.security.QueryApiKeyRequest; +import org.elasticsearch.client.security.QueryApiKeyRequestTests; import org.elasticsearch.client.security.RefreshPolicy; import org.elasticsearch.client.security.support.expressiondsl.RoleMapperExpression; import org.elasticsearch.client.security.support.expressiondsl.expressions.AnyRoleMapperExpression; @@ -504,6 +506,19 @@ public void testInvalidateApiKey() throws IOException { assertToXContentBody(invalidateApiKeyRequest, request.getEntity()); } + public void testQueryApiKey() throws IOException { + final QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest( + QueryApiKeyRequestTests.randomQueryBuilder(), + randomIntBetween(0, 100), + randomIntBetween(0, 100), + QueryApiKeyRequestTests.randomFieldSortBuilders(), + QueryApiKeyRequestTests.randomSearchAfterBuilder()); + final Request request = SecurityRequestConverters.queryApiKey(queryApiKeyRequest); + assertEquals(HttpGet.METHOD_NAME, request.getMethod()); + assertEquals("/_security/_query/api_key", request.getEndpoint()); + assertToXContentBody(queryApiKeyRequest, request.getEntity()); + } + public void testGetServiceAccounts() throws IOException { final String namespace = randomBoolean() ? randomAlphaOfLengthBetween(3, 8) : null; final String serviceName = namespace == null ? null : randomAlphaOfLengthBetween(3, 8); diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index 13c0ab4968960..9c707035b1628 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -83,6 +83,8 @@ import org.elasticsearch.client.security.PutRoleResponse; import org.elasticsearch.client.security.PutUserRequest; import org.elasticsearch.client.security.PutUserResponse; +import org.elasticsearch.client.security.QueryApiKeyRequest; +import org.elasticsearch.client.security.QueryApiKeyResponse; import org.elasticsearch.client.security.RefreshPolicy; import org.elasticsearch.client.security.TemplateRoleName; import org.elasticsearch.client.security.support.ApiKey; @@ -107,6 +109,10 @@ import org.elasticsearch.core.TimeValue; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.index.query.QueryBuilders; +import org.elasticsearch.search.searchafter.SearchAfterBuilder; +import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.SortOrder; import org.hamcrest.Matchers; import javax.crypto.SecretKeyFactory; @@ -129,7 +135,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import static org.hamcrest.Matchers.contains; @@ -2553,6 +2561,131 @@ public void onFailure(Exception e) { } + public void testQueryApiKey() throws IOException, ExecutionException, InterruptedException, TimeoutException { + RestHighLevelClient client = highLevelClient(); + final CreateApiKeyRequest createApiKeyRequest1 = new CreateApiKeyRequest("key-10000", org.elasticsearch.core.List.of(), + randomBoolean() ? TimeValue.timeValueHours(24) : null, + RefreshPolicy.WAIT_UNTIL, org.elasticsearch.core.Map.of("environment", "east-production")); + final CreateApiKeyResponse createApiKeyResponse1 = client.security().createApiKey(createApiKeyRequest1, RequestOptions.DEFAULT); + final CreateApiKeyRequest createApiKeyRequest2 = new CreateApiKeyRequest("key-20000", org.elasticsearch.core.List.of(), + randomBoolean() ? TimeValue.timeValueHours(24) : null, + RefreshPolicy.WAIT_UNTIL, org.elasticsearch.core.Map.of("environment", "east-staging")); + final CreateApiKeyResponse createApiKeyResponse2 = client.security().createApiKey(createApiKeyRequest2, RequestOptions.DEFAULT); + + { + // tag::query-api-key-default-request + QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest(); + // end::query-api-key-default-request + + // tag::query-api-key-execute + QueryApiKeyResponse queryApiKeyResponse = client.security().queryApiKey(queryApiKeyRequest, RequestOptions.DEFAULT); + // end::query-api-key-execute + + assertThat(queryApiKeyResponse.getTotal(), equalTo(2L)); + assertThat(queryApiKeyResponse.getCount(), equalTo(2)); + assertThat(queryApiKeyResponse.getApiKeys().stream().map(ApiKey::getName).collect(Collectors.toSet()), + equalTo(org.elasticsearch.core.Set.of("key-10000", "key-20000"))); + assertThat(queryApiKeyResponse.getApiKeys().stream().map(ApiKey::getId).collect(Collectors.toSet()), + equalTo(org.elasticsearch.core.Set.of(createApiKeyResponse1.getId(), createApiKeyResponse2.getId()))); + } + + { + // tag::query-api-key-query-request + QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest().queryBuilder( + QueryBuilders.boolQuery() + .must(QueryBuilders.prefixQuery("metadata.environment", "east-")) + .mustNot(QueryBuilders.termQuery("name", "key-20000"))); + // end::query-api-key-query-request + + QueryApiKeyResponse queryApiKeyResponse = client.security().queryApiKey(queryApiKeyRequest, RequestOptions.DEFAULT); + assertThat(queryApiKeyResponse.getTotal(), equalTo(1L)); + assertThat(queryApiKeyResponse.getCount(), equalTo(1)); + assertThat(queryApiKeyResponse.getApiKeys().get(0).getName(), equalTo(createApiKeyResponse1.getName())); + assertThat(queryApiKeyResponse.getApiKeys().get(0).getId(), equalTo(createApiKeyResponse1.getId())); + } + + { + // tag::query-api-key-from-size-sort-request + QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest() + .from(1) + .size(100) + .fieldSortBuilders(org.elasticsearch.core.List.of(new FieldSortBuilder("name").order(SortOrder.DESC))); + // end::query-api-key-from-size-sort-request + + QueryApiKeyResponse queryApiKeyResponse = client.security().queryApiKey(queryApiKeyRequest, RequestOptions.DEFAULT); + + // tag::query-api-key-from-size-sort-response + final long total = queryApiKeyResponse.getTotal(); // <1> + final int count = queryApiKeyResponse.getCount(); // <2> + final List apiKeys = queryApiKeyResponse.getApiKeys(); // <3> + final Object[] sortValues = apiKeys.get(apiKeys.size()-1).getSortValues(); // <4> + // end::query-api-key-from-size-sort-response + + assertThat(total, equalTo(2L)); + assertThat(count, equalTo(1)); + assertThat(apiKeys.get(0).getName(), equalTo(createApiKeyResponse1.getName())); + assertThat(apiKeys.get(0).getId(), equalTo(createApiKeyResponse1.getId())); + assertThat(sortValues.length, equalTo(1)); + assertThat(sortValues[0], equalTo(createApiKeyResponse1.getName())); + } + + { + // tag::query-api-key-search-after-request + QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest() + .fieldSortBuilders(org.elasticsearch.core.List.of(new FieldSortBuilder("name"))) + .searchAfterBuilder(new SearchAfterBuilder().setSortValues(new String[] {"key-10000"})); + // end::query-api-key-search-after-request + + QueryApiKeyResponse queryApiKeyResponse = client.security().queryApiKey(queryApiKeyRequest, RequestOptions.DEFAULT); + assertThat(queryApiKeyResponse.getTotal(), equalTo(2L)); + assertThat(queryApiKeyResponse.getCount(), equalTo(1)); + assertThat(queryApiKeyResponse.getApiKeys().get(0).getName(), equalTo(createApiKeyResponse2.getName())); + assertThat(queryApiKeyResponse.getApiKeys().get(0).getId(), equalTo(createApiKeyResponse2.getId())); + } + + { + QueryApiKeyRequest queryApiKeyRequest = new QueryApiKeyRequest(); + + ActionListener listener; + // tag::query-api-key-execute-listener + listener = new ActionListener() { + @Override + public void onResponse(QueryApiKeyResponse queryApiKeyResponse) { + // <1> + } + + @Override + public void onFailure(Exception e) { + // <2> + } + }; + // end::query-api-key-execute-listener + + // Avoid unused variable warning + assertNotNull(listener); + + // Replace the empty listener by a blocking listener in test + final PlainActionFuture future = new PlainActionFuture<>(); + listener = future; + + // tag::query-api-key-execute-async + client.security().queryApiKeyAsync(queryApiKeyRequest, RequestOptions.DEFAULT, listener); // <1> + // end::query-api-key-execute-async + + final QueryApiKeyResponse queryApiKeyResponse = future.get(30, TimeUnit.SECONDS); + assertNotNull(queryApiKeyResponse); + + assertThat(queryApiKeyResponse.getTotal(), equalTo(2L)); + assertThat(queryApiKeyResponse.getCount(), equalTo(2)); + assertThat(queryApiKeyResponse.getApiKeys(), is(notNullValue())); + assertThat(queryApiKeyResponse.getApiKeys().size(), is(2)); + assertThat(queryApiKeyResponse.getApiKeys().stream().map(ApiKey::getName).collect(Collectors.toSet()), + equalTo(org.elasticsearch.core.Set.of("key-10000", "key-20000"))); + assertThat(queryApiKeyResponse.getApiKeys().stream().map(ApiKey::getId).collect(Collectors.toSet()), + equalTo(org.elasticsearch.core.Set.of(createApiKeyResponse1.getId(), createApiKeyResponse2.getId()))); + } + } + public void testGetServiceAccounts() throws IOException { RestHighLevelClient client = highLevelClient(); { diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyRequestTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyRequestTests.java new file mode 100644 index 0000000000000..27fa8338683dc --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyRequestTests.java @@ -0,0 +1,144 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.ValidationException; +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +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.EqualsHashCodeTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +public class QueryApiKeyRequestTests extends ESTestCase { + + public void testNewInstance() { + final QueryBuilder queryBuilder = randomQueryBuilder(); + final int from = randomIntBetween(0, 100); + final int size = randomIntBetween(0, 100); + final List fieldSortBuilders = randomFieldSortBuilders(); + final SearchAfterBuilder searchAfterBuilder = randomSearchAfterBuilder(); + final QueryApiKeyRequest request = new QueryApiKeyRequest(queryBuilder, from, size, fieldSortBuilders, searchAfterBuilder); + + assertThat(request.getQueryBuilder(), equalTo(queryBuilder)); + assertThat(request.getFrom(), equalTo(from)); + assertThat(request.getSize(), equalTo(size)); + assertThat(request.getFieldSortBuilders(), equalTo(fieldSortBuilders)); + assertThat(request.getSearchAfterBuilder(), equalTo(searchAfterBuilder)); + } + + public void testEqualsHashCode() { + final QueryApiKeyRequest request = new QueryApiKeyRequest(randomQueryBuilder(), + randomIntBetween(0, 100), + randomIntBetween(0, 100), + randomFieldSortBuilders(), + randomSearchAfterBuilder()); + + EqualsHashCodeTestUtils.checkEqualsAndHashCode(request, original -> new QueryApiKeyRequest(original.getQueryBuilder(), + original.getFrom(), + original.getSize(), + original.getFieldSortBuilders(), + original.getSearchAfterBuilder()), this::mutateInstance); + } + + public void testValidation() { + final QueryApiKeyRequest request1 = new QueryApiKeyRequest(null, randomIntBetween(0, 100), randomIntBetween(0, 100), null, null); + final Optional validationException1 = request1.validate(); + assertThat(validationException1.isPresent(), is(false)); + + final QueryApiKeyRequest request2 = new QueryApiKeyRequest(null, randomIntBetween(-100, -1), randomIntBetween(0, 100), null, null); + final Optional validationException2 = request2.validate(); + assertThat(validationException2.get().getMessage(), containsString("from must be non-negative")); + + final QueryApiKeyRequest request3 = new QueryApiKeyRequest(null, randomIntBetween(0, 100), randomIntBetween(-100, -1), null, null); + final Optional validationException3 = request3.validate(); + assertThat(validationException3.get().getMessage(), containsString("size must be non-negative")); + } + + private QueryApiKeyRequest mutateInstance(QueryApiKeyRequest request) { + switch (randomIntBetween(0, 5)) { + case 0: + return new QueryApiKeyRequest(randomValueOtherThan(request.getQueryBuilder(), QueryApiKeyRequestTests::randomQueryBuilder), + request.getFrom(), + request.getSize(), + request.getFieldSortBuilders(), + request.getSearchAfterBuilder()); + case 1: + return new QueryApiKeyRequest(request.getQueryBuilder(), + request.getFrom() + 1, + request.getSize(), + request.getFieldSortBuilders(), + request.getSearchAfterBuilder()); + case 2: + return new QueryApiKeyRequest(request.getQueryBuilder(), + request.getFrom(), + request.getSize() + 1, + request.getFieldSortBuilders(), + request.getSearchAfterBuilder()); + case 3: + return new QueryApiKeyRequest(request.getQueryBuilder(), + request.getFrom(), + request.getSize(), + randomValueOtherThan(request.getFieldSortBuilders(), QueryApiKeyRequestTests::randomFieldSortBuilders), + request.getSearchAfterBuilder()); + default: + return new QueryApiKeyRequest(request.getQueryBuilder(), + request.getFrom(), + request.getSize(), + request.getFieldSortBuilders(), + randomValueOtherThan(request.getSearchAfterBuilder(), QueryApiKeyRequestTests::randomSearchAfterBuilder)); + + } + } + + public static QueryBuilder randomQueryBuilder() { + switch (randomIntBetween(0, 5)) { + case 0: + return QueryBuilders.matchAllQuery(); + case 1: + return QueryBuilders.termQuery(randomAlphaOfLengthBetween(3, 8), + randomFrom(randomAlphaOfLength(8), randomInt(), randomLong(), randomDouble(), randomFloat())); + case 2: + return QueryBuilders.idsQuery().addIds(randomArray(1, 5, String[]::new, () -> randomAlphaOfLength(20))); + case 3: + return QueryBuilders.prefixQuery(randomAlphaOfLengthBetween(3, 8), randomAlphaOfLengthBetween(3, 8)); + case 4: + return QueryBuilders.wildcardQuery(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(0, 3) + "*" + randomAlphaOfLengthBetween(0, 3)); + case 5: + return QueryBuilders.rangeQuery(randomAlphaOfLengthBetween(3, 8)).from(randomNonNegativeLong()).to(randomNonNegativeLong()); + default: + return null; + } + } + + public static List randomFieldSortBuilders() { + if (randomBoolean()) { + return randomList(1, 2, () -> new FieldSortBuilder(randomAlphaOfLengthBetween(3, 8)).order(randomFrom(SortOrder.values()))); + } else { + return null; + } + } + + public static SearchAfterBuilder randomSearchAfterBuilder() { + if (randomBoolean()) { + return new SearchAfterBuilder().setSortValues(randomArray(1, 2, String[]::new, () -> randomAlphaOfLengthBetween(3, 8))); + } else { + return null; + } + } +} diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyResponseTests.java new file mode 100644 index 0000000000000..c6076c4a9f9c4 --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/QueryApiKeyResponseTests.java @@ -0,0 +1,88 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.client.AbstractResponseTestCase; +import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.hamcrest.Matchers.equalTo; + +public class QueryApiKeyResponseTests + extends AbstractResponseTestCase { + + @Override + protected org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse createServerTestInstance(XContentType xContentType) { + final int count = randomIntBetween(0, 5); + final int total = randomIntBetween(count, count + 5); + final int nSortValues = randomIntBetween(0, 3); + return new org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse(total, + IntStream.range(0, count) + .mapToObj(i -> new org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse.Item( + randomApiKeyInfo(), + randSortValues(nSortValues))) + .collect(Collectors.toList())); + } + + @Override + protected QueryApiKeyResponse doParseToClientInstance(XContentParser parser) throws IOException { + return QueryApiKeyResponse.fromXContent(parser); + } + + @Override + protected void assertInstances( + org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse serverTestInstance, QueryApiKeyResponse clientInstance) { + assertThat(serverTestInstance.getTotal(), equalTo(clientInstance.getTotal())); + assertThat(serverTestInstance.getCount(), equalTo(clientInstance.getCount())); + for (int i = 0; i < serverTestInstance.getItems().length; i++) { + assertApiKeyInfo(serverTestInstance.getItems()[i], clientInstance.getApiKeys().get(i)); + } + } + + private void assertApiKeyInfo( + org.elasticsearch.xpack.core.security.action.apikey.QueryApiKeyResponse.Item serverItem, ApiKey clientApiKeyInfo) { + assertThat(serverItem.getApiKey().getId(), equalTo(clientApiKeyInfo.getId())); + assertThat(serverItem.getApiKey().getName(), equalTo(clientApiKeyInfo.getName())); + assertThat(serverItem.getApiKey().getUsername(), equalTo(clientApiKeyInfo.getUsername())); + assertThat(serverItem.getApiKey().getRealm(), equalTo(clientApiKeyInfo.getRealm())); + assertThat(serverItem.getApiKey().getCreation(), equalTo(clientApiKeyInfo.getCreation())); + assertThat(serverItem.getApiKey().getExpiration(), equalTo(clientApiKeyInfo.getExpiration())); + assertThat(serverItem.getApiKey().getMetadata(), equalTo(clientApiKeyInfo.getMetadata())); + assertThat(serverItem.getSortValues(), equalTo(clientApiKeyInfo.getSortValues())); + } + + private org.elasticsearch.xpack.core.security.action.ApiKey randomApiKeyInfo() { + final Instant creation = Instant.now(); + return new org.elasticsearch.xpack.core.security.action.ApiKey(randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLength(20), + creation, + randomFrom(creation.plus(randomLongBetween(1, 10), ChronoUnit.DAYS), null), + randomBoolean(), + randomAlphaOfLengthBetween(3, 8), + randomAlphaOfLengthBetween(3, 8), + CreateApiKeyRequestTests.randomMetadata() + ); + } + + private Object[] randSortValues(int nSortValues) { + if (nSortValues > 0) { + return randomArray(nSortValues, nSortValues, Object[]::new, + () -> randomFrom(randomInt(Integer.MAX_VALUE), randomAlphaOfLength(8), randomBoolean())); + } else { + return null; + } + } +} diff --git a/docs/java-rest/high-level/security/query-api-key.asciidoc b/docs/java-rest/high-level/security/query-api-key.asciidoc new file mode 100644 index 0000000000000..88a39acb7c929 --- /dev/null +++ b/docs/java-rest/high-level/security/query-api-key.asciidoc @@ -0,0 +1,86 @@ +-- +:api: query-api-key +:request: QueryApiKeyRequest +:response: QueryApiKeyResponse +-- +[role="xpack"] +[id="{upid}-{api}"] +=== Query API Key information API + +API Key(s) information can be queried and retrieved in a paginated +fashion using this API. + +[id="{upid}-{api}-request"] +==== Query API Key Request +The +{request}+ supports query and retrieving API key information using +Elasticsearch's {ref}/query-dsl.html[Query DSL] with +{ref}/paginate-search-results.html[pagination]. +It supports only a subset of available query types, including: + +. {ref}/query-dsl-bool-query.html[Boolean query] + +. {ref}/query-dsl-match-all-query.html[Match all query] + +. {ref}/query-dsl-term-query.html[Term query] + +. {ref}/query-dsl-terms-query.html[Terms query] + +. {ref}/query-dsl-ids-query.html[IDs Query] + +. {ref}/query-dsl-prefix-query.html[Prefix query] + +. {ref}/query-dsl-wildcard-query.html[Wildcard query] + +. {ref}/query-dsl-range-query.html[Range query] + +===== Query for all API keys +In its most basic form, the request selects all API keys that the user +has access to. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[query-api-key-default-request] +-------------------------------------------------- + +===== Query API keys with Query DSL +The following query selects API keys owned by the user and also satisfy following criteria: +* The API key name must begin with the word `key` +* The API key name must *not* be `key-20000` + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[query-api-key-query-request] +-------------------------------------------------- + +===== Retrieve API keys with explicitly configured sort and paging +The following request sort the API keys by their names in descending order. +It also retrieves the API keys from index 1 (zero-based) and in a page size of 100. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[query-api-key-from-size-sort-request] +-------------------------------------------------- + +===== Deep pagination can be achieved with search after + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[query-api-key-search-after-request] +-------------------------------------------------- + +include::../execution.asciidoc[] + +[id="{upid}-{api}-response"] +==== Query API Key information API Response + +The returned +{response}+ contains the information regarding the API keys that were +requested. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[query-api-key-from-size-sort-response] +-------------------------------------------------- +<1> Total number of API keys matched by the query +<2> Number of API keys returned in this response +<3> The list of API keys +<4> If sorting is requested, each API key in the response contains its sort values. diff --git a/docs/java-rest/high-level/supported-apis.asciidoc b/docs/java-rest/high-level/supported-apis.asciidoc index c07ce4af944b2..668f8991fa691 100644 --- a/docs/java-rest/high-level/supported-apis.asciidoc +++ b/docs/java-rest/high-level/supported-apis.asciidoc @@ -545,6 +545,7 @@ include::security/create-api-key.asciidoc[] include::security/grant-api-key.asciidoc[] include::security/get-api-key.asciidoc[] include::security/invalidate-api-key.asciidoc[] +include::security/query-api-key.asciidoc[] include::security/get-service-accounts.asciidoc[] include::security/create-service-account-token.asciidoc[] include::security/delete-service-account-token.asciidoc[] 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 df17364dc1570..fac10e5d3a82e 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 @@ -56,6 +56,10 @@ public Item[] getItems() { return items; } + public int getCount() { + return items.length; + } + @Override public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject()