From f4cbe14e1b7871155161d08ec1a23990534c706b Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Wed, 26 Feb 2020 22:45:45 +1100 Subject: [PATCH 1/6] Create API Key on behalf of other user This change adds a "grant API key action" POST /_security/api_key/grant that creates a new API key using the privileges of one user ("the system user") to execute the action, but creates the API key with the roles of the second user ("the end user"). This allows a system (such as Kibana) to create API keys representing the identity and access of an authenticated user without requiring that user to have permission to create API keys on their own. --- .../org/elasticsearch/test/ESTestCase.java | 6 + .../elasticsearch/test/XContentTestUtils.java | 7 + .../action/CreateApiKeyRequestBuilder.java | 14 +- .../security/action/GrantApiKeyAction.java | 24 ++ .../security/action/GrantApiKeyRequest.java | 171 +++++++++++++ .../security/SecurityInBasicRestTestCase.java | 98 ++++++++ .../security/SecurityWithBasicLicenseIT.java | 23 +- .../xpack/security/apikey/ApiKeyRestIT.java | 80 ++++++ .../xpack/security/Security.java | 5 + .../action/TransportCreateApiKeyAction.java | 32 +-- .../action/TransportGrantApiKeyAction.java | 86 +++++++ .../xpack/security/authc/ApiKeyService.java | 4 +- .../xpack/security/authc/TokenService.java | 31 ++- .../authc/support/ApiKeyGenerator.java | 58 +++++ .../action/apikey/RestGrantApiKeyAction.java | 128 ++++++++++ .../TransportGrantApiKeyActionTests.java | 230 ++++++++++++++++++ .../security/authc/TokenServiceMock.java | 70 ++++++ .../authc/support/ApiKeyGeneratorTests.java | 86 +++++++ .../xpack/security/test/SecurityMocks.java | 28 +++ 19 files changed, 1123 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyAction.java create mode 100644 x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java create mode 100644 x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java create mode 100644 x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyActionTests.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceMock.java create mode 100644 x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGeneratorTests.java diff --git a/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java b/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java index 21078a085b017..75e577680274a 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java @@ -728,6 +728,12 @@ public static String randomRealisticUnicodeOfCodepointLength(int codePoints) { return RandomizedTest.randomRealisticUnicodeOfCodepointLength(codePoints); } + /** + * @param maxArraySize The maximum number of elements in the random array + * @param stringSize The length of each String in the array + * @param allowNull Whether the returned array may be null + * @param allowEmpty Whether the returned array may be empty (have zero elements) + */ public static String[] generateRandomStringArray(int maxArraySize, int stringSize, boolean allowNull, boolean allowEmpty) { if (allowNull && random().nextBoolean()) { return null; diff --git a/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java b/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java index 7e80810d7dd27..1449252d024ac 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java +++ b/test/framework/src/main/java/org/elasticsearch/test/XContentTestUtils.java @@ -58,6 +58,13 @@ public static Map convertToMap(ToXContent part) throws IOExcepti return XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2(); } + public static BytesReference convertToXContent(Map map, XContentType xContentType) throws IOException { + try (XContentBuilder builder = XContentFactory.contentBuilder(xContentType)) { + builder.map(map); + return BytesReference.bytes(builder); + } + } + /** * Compares two maps generated from XContentObjects. The order of elements in arrays is ignored. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java index c31dbeb7e4859..7c92960f0318c 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java @@ -32,10 +32,10 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder PARSER = new ConstructingObjectParser<>( - "api_key_request", false, (args, v) -> { - return new CreateApiKeyRequest((String) args[0], (List) args[1], - TimeValue.parseTimeValue((String) args[2], null, "expiration")); - }); + "api_key_request", false, (args, v) -> { + return new CreateApiKeyRequest((String) args[0], (List) args[1], + TimeValue.parseTimeValue((String) args[2], null, "expiration")); + }); static { PARSER.declareString(constructorArg(), new ParseField("name")); @@ -74,11 +74,15 @@ public CreateApiKeyRequestBuilder source(BytesReference source, XContentType xCo final NamedXContentRegistry registry = NamedXContentRegistry.EMPTY; try (InputStream stream = source.streamInput(); XContentParser parser = xContentType.xContent().createParser(registry, LoggingDeprecationHandler.INSTANCE, stream)) { - CreateApiKeyRequest createApiKeyRequest = PARSER.parse(parser, null); + CreateApiKeyRequest createApiKeyRequest = parse(parser); setName(createApiKeyRequest.getName()); setRoleDescriptors(createApiKeyRequest.getRoleDescriptors()); setExpiration(createApiKeyRequest.getExpiration()); } return this; } + + public static CreateApiKeyRequest parse(XContentParser parser) throws IOException { + return PARSER.parse(parser, null); + } } diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyAction.java new file mode 100644 index 0000000000000..07e5c3bbf2521 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyAction.java @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionType; + +/** + * ActionType for the creation of an API key on behalf of another user + * This returns the {@link CreateApiKeyResponse} because the REST output is intended to be identical to the {@link CreateApiKeyAction}. + */ +public final class GrantApiKeyAction extends ActionType { + + public static final String NAME = "cluster:admin/xpack/security/api_key/grant"; + public static final GrantApiKeyAction INSTANCE = new GrantApiKeyAction(); + + private GrantApiKeyAction() { + super(NAME, CreateApiKeyResponse::new); + } + +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java new file mode 100644 index 0000000000000..becf6b59f04d5 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.action.support.WriteRequest; +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.settings.SecureString; + +import java.io.IOException; +import java.util.Objects; + +import static org.elasticsearch.action.ValidateActions.addValidationError; + +/** + * Request class used for the creation of an API key on behalf of another user. + * Logically this is similar to {@link CreateApiKeyRequest}, but is for cases when the user that has permission to call this action + * is different to the user for whom the API key should be created + */ +public final class GrantApiKeyRequest extends ActionRequest { + + public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL; + public static final String PASSWORD_GRANT_TYPE = "password"; + public static final String ACCESS_TOKEN_GRANT_TYPE = "access_token"; + + /** + * Fields related to the end user authentication + */ + public static class Grant implements Writeable { + private String type; + private String username; + private SecureString password; + private SecureString accessToken; + + public Grant() { + } + + public Grant(StreamInput in) throws IOException { + this.type = in.readString(); + this.username = in.readOptionalString(); + this.password = in.readOptionalSecureString(); + this.accessToken = in.readOptionalSecureString(); + } + + public void writeTo(StreamOutput out) throws IOException { + out.writeString(type); + out.writeOptionalString(username); + out.writeOptionalSecureString(password); + out.writeOptionalSecureString(accessToken); + } + + public String getType() { + return type; + } + + public String getUsername() { + return username; + } + + public SecureString getPassword() { + return password; + } + + public SecureString getAccessToken() { + return accessToken; + } + + public void setType(String type) { + this.type = type; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(SecureString password) { + this.password = password; + } + + public void setAccessToken(SecureString accessToken) { + this.accessToken = accessToken; + } + } + + private final Grant grant; + private CreateApiKeyRequest apiKey; + private WriteRequest.RefreshPolicy refreshPolicy; + + public GrantApiKeyRequest() { + this.grant = new Grant(); + this.apiKey = new CreateApiKeyRequest(); + this.refreshPolicy = DEFAULT_REFRESH_POLICY; + } + + public GrantApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.grant = new Grant(in); + this.apiKey = new CreateApiKeyRequest(in); + this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + grant.writeTo(out); + apiKey.writeTo(out); + refreshPolicy.writeTo(out); + } + + public WriteRequest.RefreshPolicy getRefreshPolicy() { + return refreshPolicy; + } + + public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { + this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); + } + + public Grant getGrant() { + return grant; + } + + public CreateApiKeyRequest getApiKeyRequest() { + return apiKey; + } + + public void setApiKeyRequest(CreateApiKeyRequest apiKeyRequest) { + this.apiKey = Objects.requireNonNull(apiKeyRequest, "Cannot set a null api_key"); + } + + @Override + public ActionRequestValidationException validate() { + ActionRequestValidationException validationException = apiKey.validate(); + if (grant.type == null) { + validationException = addValidationError("[grant_type] is required", validationException); + } else if (grant.type.equals(PASSWORD_GRANT_TYPE)) { + validationException = validateRequiredField("username", grant.username, validationException); + validationException = validateRequiredField("password", grant.password, validationException); + validationException = validateUnsupportedField("access_token", grant.accessToken, validationException); + } else if (grant.type.equals(ACCESS_TOKEN_GRANT_TYPE)) { + validationException = validateRequiredField("access_token", grant.accessToken, validationException); + validationException = validateUnsupportedField("username", grant.username, validationException); + validationException = validateUnsupportedField("password", grant.password, validationException); + } else { + validationException = addValidationError("grant_type [" + grant.type + "] is not supported", validationException); + } + return validationException; + } + + private ActionRequestValidationException validateRequiredField(String fieldName, CharSequence fieldValue, + ActionRequestValidationException validationException) { + if (fieldValue == null || fieldValue.length() == 0) { + return addValidationError("[" + fieldName + "] is required for grant_type [" + grant.type + "]", validationException); + } + return validationException; + } + + private ActionRequestValidationException validateUnsupportedField(String fieldName, CharSequence fieldValue, + ActionRequestValidationException validationException) { + if (fieldValue != null && fieldValue.length() > 0) { + return addValidationError("[" + fieldName + "] is not supported for grant_type [" + grant.type + "]", validationException); + } + return validationException; + } +} diff --git a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java new file mode 100644 index 0000000000000..831f70e3b972f --- /dev/null +++ b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.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; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security; + +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.client.security.DeleteRoleRequest; +import org.elasticsearch.client.security.DeleteUserRequest; +import org.elasticsearch.client.security.GetApiKeyRequest; +import org.elasticsearch.client.security.GetApiKeyResponse; +import org.elasticsearch.client.security.InvalidateApiKeyRequest; +import org.elasticsearch.client.security.PutRoleRequest; +import org.elasticsearch.client.security.PutUserRequest; +import org.elasticsearch.client.security.RefreshPolicy; +import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.client.security.user.User; +import org.elasticsearch.client.security.user.privileges.Role; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.hamcrest.Matchers; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; + +public abstract class SecurityInBasicRestTestCase extends ESRestTestCase { + private RestHighLevelClient highLevelAdminClient; + + @Override + protected Settings restAdminSettings() { + String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray())); + return Settings.builder() + .put(ThreadContext.PREFIX + ".Authorization", token) + .build(); + } + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("security_test_user", new SecureString("security-test-password".toCharArray())); + return Settings.builder() + .put(ThreadContext.PREFIX + ".Authorization", token) + .build(); + } + + protected void createUser(String username, SecureString password, List roles) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().putUser(PutUserRequest.withPassword(new User(username, roles), password.getChars(), true, + RefreshPolicy.WAIT_UNTIL), RequestOptions.DEFAULT); + } + + protected void createRole(String name, Collection clusterPrivileges) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final Role role = Role.builder().name(name).clusterPrivileges(clusterPrivileges).build(); + client.security().putRole(new PutRoleRequest(role, null), RequestOptions.DEFAULT); + } + + protected void deleteUser(String username) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().deleteUser(new DeleteUserRequest(username), RequestOptions.DEFAULT); + } + + protected void deleteRole(String name) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().deleteRole(new DeleteRoleRequest(name), RequestOptions.DEFAULT); + } + + protected void invalidateApiKeysForUser(String username) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().invalidateApiKey(InvalidateApiKeyRequest.usingUserName(username), RequestOptions.DEFAULT); + } + + protected ApiKey getApiKey(String id) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final GetApiKeyResponse response = client.security().getApiKey(GetApiKeyRequest.usingApiKeyId(id, false), RequestOptions.DEFAULT); + assertThat(response.getApiKeyInfos(), Matchers.iterableWithSize(1)); + return response.getApiKeyInfos().get(0); + } + + private RestHighLevelClient getHighLevelAdminClient() { + if (highLevelAdminClient == null) { + highLevelAdminClient = new RestHighLevelClient( + adminClient(), + ignore -> { + }, + List.of()) { + }; + } + return highLevelAdminClient; + } +} diff --git a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityWithBasicLicenseIT.java b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityWithBasicLicenseIT.java index 837421f6000d5..98f92c750951c 100644 --- a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityWithBasicLicenseIT.java +++ b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityWithBasicLicenseIT.java @@ -11,10 +11,6 @@ import org.elasticsearch.client.Response; import org.elasticsearch.client.ResponseException; import org.elasticsearch.common.collect.Tuple; -import org.elasticsearch.common.settings.SecureString; -import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.common.util.concurrent.ThreadContext; -import org.elasticsearch.test.rest.ESRestTestCase; import org.elasticsearch.test.rest.yaml.ObjectPath; import org.elasticsearch.xpack.security.authc.InternalRealms; @@ -24,29 +20,12 @@ import java.util.Base64; import java.util.Map; -import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; -public class SecurityWithBasicLicenseIT extends ESRestTestCase { - - @Override - protected Settings restAdminSettings() { - String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray())); - return Settings.builder() - .put(ThreadContext.PREFIX + ".Authorization", token) - .build(); - } - - @Override - protected Settings restClientSettings() { - String token = basicAuthHeaderValue("security_test_user", new SecureString("security-test-password".toCharArray())); - return Settings.builder() - .put(ThreadContext.PREFIX + ".Authorization", token) - .build(); - } +public class SecurityWithBasicLicenseIT extends SecurityInBasicRestTestCase { public void testWithBasicLicense() throws Exception { checkLicenseType("basic"); diff --git a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java new file mode 100644 index 0000000000000..dfd2194cd2137 --- /dev/null +++ b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.apikey; + +import org.elasticsearch.client.Request; +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.Response; +import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.XContentTestUtils; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.security.SecurityInBasicRestTestCase; +import org.junit.After; +import org.junit.Before; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.notNullValue; + +/** + * This IT runs in the "security-basic" QA test because it has a working cluster with + * API keys enabled, and there's no reason to have a dedicated QA project for API keys. + */ +public class ApiKeyRestIT extends SecurityInBasicRestTestCase { + + private static final String SYSTEM_USER = "system_user"; + private static final SecureString SYSTEM_USER_PASSWORD = new SecureString("sys-pass".toCharArray()); + private static final String END_USER = "end_user"; + private static final SecureString END_USER_PASSWORD = new SecureString("user-pass".toCharArray()); + + @Before + public void createUsers() throws IOException { + createUser(SYSTEM_USER, SYSTEM_USER_PASSWORD, List.of("system_role")); + createRole("system_role", Set.of("manage_api_key")); + createUser(END_USER, END_USER_PASSWORD, List.of("user_role")); + createRole("user_role", Set.of("monitor")); + } + + @After + public void cleanUp() throws IOException { + deleteUser("system_user"); + deleteUser("end_user"); + deleteRole("system_role"); + deleteRole("user_role"); + invalidateApiKeysForUser(END_USER); + } + + public void testGrantApiKeyForOtherUser() throws IOException { + Request request = new Request("POST", "_security/api_key/grant"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", + UsernamePasswordToken.basicAuthHeaderValue(SYSTEM_USER, SYSTEM_USER_PASSWORD))); + final Map requestBody = Map.ofEntries( + Map.entry("grant_type", "password"), + Map.entry("username", END_USER), + Map.entry("password", END_USER_PASSWORD.toString()), + Map.entry("api_key", Map.of("name", "test_api_key")) + ); + request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString()); + + final Response response = client().performRequest(request); + final Map responseBody = entityAsMap(response); + + assertThat(responseBody.get("name"), equalTo("test_api_key")); + assertThat(responseBody.get("id"), notNullValue()); + assertThat(responseBody.get("id"), instanceOf(String.class)); + + ApiKey apiKey = getApiKey((String)responseBody.get("id")); + assertThat(apiKey.getUsername(), equalTo(END_USER)); + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java index d8a818c42c318..51f40465d3598 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -81,6 +81,7 @@ import org.elasticsearch.xpack.core.security.action.CreateApiKeyAction; import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction; import org.elasticsearch.xpack.core.security.action.GetApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction; import org.elasticsearch.xpack.core.security.action.InvalidateApiKeyAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; @@ -142,6 +143,7 @@ import org.elasticsearch.xpack.security.action.TransportCreateApiKeyAction; import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticationAction; import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction; +import org.elasticsearch.xpack.security.action.TransportGrantApiKeyAction; import org.elasticsearch.xpack.security.action.TransportInvalidateApiKeyAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction; @@ -206,6 +208,7 @@ import org.elasticsearch.xpack.security.rest.action.RestDelegatePkiAuthenticationAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestGetApiKeyAction; +import org.elasticsearch.xpack.security.rest.action.apikey.RestGrantApiKeyAction; import org.elasticsearch.xpack.security.rest.action.apikey.RestInvalidateApiKeyAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction; @@ -753,6 +756,7 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class), new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class), new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class), + new ActionHandler<>(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class), new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class), new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class), new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class), @@ -808,6 +812,7 @@ public List getRestHandlers(Settings settings, RestController restC new RestPutPrivilegesAction(settings, getLicenseState()), new RestDeletePrivilegesAction(settings, getLicenseState()), new RestCreateApiKeyAction(settings, getLicenseState()), + new RestGrantApiKeyAction(settings, getLicenseState()), new RestInvalidateApiKeyAction(settings, getLicenseState()), new RestGetApiKeyAction(settings, getLicenseState()), new RestDelegatePkiAuthenticationAction(settings, getLicenseState()) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java index 72a92516e59ed..730affc7ea953 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportCreateApiKeyAction.java @@ -6,12 +6,10 @@ package org.elasticsearch.xpack.security.action; -import org.elasticsearch.ElasticsearchException; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.ActionFilters; import org.elasticsearch.action.support.HandledTransportAction; import org.elasticsearch.common.inject.Inject; -import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.xcontent.NamedXContentRegistry; import org.elasticsearch.tasks.Task; import org.elasticsearch.transport.TransportService; @@ -20,32 +18,24 @@ import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.authc.Authentication; -import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; -import org.elasticsearch.xpack.core.security.authz.support.DLSRoleQueryValidator; import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator; import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; -import java.util.Arrays; -import java.util.HashSet; - /** * Implementation of the action needed to create an API key */ public final class TransportCreateApiKeyAction extends HandledTransportAction { - private final ApiKeyService apiKeyService; + private final ApiKeyGenerator generator; private final SecurityContext securityContext; - private final CompositeRolesStore rolesStore; - private final NamedXContentRegistry xContentRegistry; @Inject public TransportCreateApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService, SecurityContext context, CompositeRolesStore rolesStore, NamedXContentRegistry xContentRegistry) { - super(CreateApiKeyAction.NAME, transportService, actionFilters, (Writeable.Reader) CreateApiKeyRequest::new); - this.apiKeyService = apiKeyService; + super(CreateApiKeyAction.NAME, transportService, actionFilters, CreateApiKeyRequest::new); + this.generator = new ApiKeyGenerator(apiKeyService, rolesStore, xContentRegistry); this.securityContext = context; - this.rolesStore = rolesStore; - this.xContentRegistry = xContentRegistry; } @Override @@ -54,19 +44,7 @@ protected void doExecute(Task task, CreateApiKeyRequest request, ActionListener< if (authentication == null) { listener.onFailure(new IllegalStateException("authentication is required")); } else { - rolesStore.getRoleDescriptors(new HashSet<>(Arrays.asList(authentication.getUser().roles())), - ActionListener.wrap(roleDescriptors -> { - for (RoleDescriptor rd : roleDescriptors) { - try { - DLSRoleQueryValidator.validateQueryField(rd.getIndicesPrivileges(), xContentRegistry); - } catch (ElasticsearchException | IllegalArgumentException e) { - listener.onFailure(e); - return; - } - } - apiKeyService.createApiKey(authentication, request, roleDescriptors, listener); - }, - listener::onFailure)); + generator.generateApiKey(authentication, request, listener); } } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java new file mode 100644 index 0000000000000..b8c89df14d56d --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportMessage; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authc.AuthenticationService; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; + +/** + * Implementation of the action needed to create an API key on behalf of another user (using an OAuth style "grant") + */ +public final class TransportGrantApiKeyAction extends HandledTransportAction { + + private final ThreadContext threadContext; + private final ApiKeyGenerator generator; + private final AuthenticationService authenticationService; + private final TokenService tokenService; + + @Inject + public TransportGrantApiKeyAction(TransportService transportService, ActionFilters actionFilters, ThreadPool threadPool, + ApiKeyService apiKeyService, AuthenticationService authenticationService, TokenService tokenService, + CompositeRolesStore rolesStore, NamedXContentRegistry xContentRegistry) { + this(transportService, actionFilters, threadPool.getThreadContext(), + new ApiKeyGenerator(apiKeyService, rolesStore, xContentRegistry), authenticationService, tokenService + ); + } + + // Constructor for testing + TransportGrantApiKeyAction(TransportService transportService, ActionFilters actionFilters, ThreadContext threadContext, + ApiKeyGenerator generator, AuthenticationService authenticationService, TokenService tokenService) { + super(GrantApiKeyAction.NAME, transportService, actionFilters, GrantApiKeyRequest::new); + this.threadContext = threadContext; + this.generator = generator; + this.authenticationService = authenticationService; + this.tokenService = tokenService; + } + + @Override + protected void doExecute(Task task, GrantApiKeyRequest request, ActionListener listener) { + try (ThreadContext.StoredContext ignore = threadContext.stashContext()) { + resolveAuthentication(request.getGrant(), request, ActionListener.wrap( + authentication -> generator.generateApiKey(authentication, request.getApiKeyRequest(), listener), + listener::onFailure + )); + } + } + + private void resolveAuthentication(GrantApiKeyRequest.Grant grant, TransportMessage message, ActionListener listener) { + switch (grant.getType()) { + case GrantApiKeyRequest.PASSWORD_GRANT_TYPE: + final UsernamePasswordToken token = new UsernamePasswordToken(grant.getUsername(), grant.getPassword()); + authenticationService.authenticate(super.actionName, message, token, listener); + return; + case GrantApiKeyRequest.ACCESS_TOKEN_GRANT_TYPE: + tokenService.authenticateToken(grant.getAccessToken(), listener); + return; + default: + listener.onFailure(new ElasticsearchSecurityException("the grant type [{}] is not supported", grant.getType())); + return; + } + } + + +} 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 11ba9a6b23b0d..45a4ba6b5f67f 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 @@ -568,11 +568,11 @@ private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { } } - private boolean isEnabled() { + public boolean isEnabled() { return enabled && licenseState.isApiKeyServiceAllowed(); } - private void ensureEnabled() { + public void ensureEnabled() { if (licenseState.isApiKeyServiceAllowed() == false) { throw LicenseUtils.newComplianceException("api keys"); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index 3680afb98c774..64d1496bc79ec 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -394,6 +394,32 @@ void getAndValidateToken(ThreadContext ctx, ActionListener listener) } } + /** + * Decodes the provided token, and validates it (for format, expiry and invalidation). + * If valid, the token's {@link Authentication} (see {@link UserToken#getAuthentication()} is provided to the listener. + * If the token is invalid (expired etc), then {@link ActionListener#onFailure(Exception)} will be called. + * If tokens are not enabled, or the token does not exist, {@link ActionListener#onResponse} will be called with a + * {@code null} authentication object. + */ + public void authenticateToken(SecureString tokenString, ActionListener listener) { + if (isEnabled()) { + decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { + if (userToken != null) { + checkIfTokenIsValid(userToken, ActionListener.wrap( + token -> { + listener.onResponse(token == null ? null : token.getAuthentication()); + }, + listener::onFailure + )); + } else { + listener.onResponse(null); + } + }, listener::onFailure)); + } else { + listener.onResponse(null); + } + } + /** * Reads the authentication and metadata from the given token. * This method does not validate whether the token is expired or not. @@ -421,8 +447,9 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action logger.warn("failed to get access token [{}] because index [{}] is not available", userTokenId, tokensIndex.aliasName()); listener.onResponse(null); } else { - final GetRequest getRequest = client.prepareGet(tokensIndex.aliasName(), - getTokenDocumentId(userTokenId)).request(); + final String index = tokensIndex.aliasName(); + final String documentId = getTokenDocumentId(userTokenId); + final GetRequest getRequest = client.prepareGet(index, documentId).request(); final Consumer onFailure = ex -> listener.onFailure(traceLog("get token from id", userTokenId, ex)); tokensIndex.checkIndexVersionThenExecute( ex -> listener.onFailure(traceLog("prepare tokens index [" + tokensIndex.aliasName() +"]", userTokenId, ex)), diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java new file mode 100644 index 0000000000000..1e1e4c0339c3d --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc.support; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.authz.support.DLSRoleQueryValidator; +import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; + +import java.util.Arrays; +import java.util.HashSet; + +public class ApiKeyGenerator { + + private final ApiKeyService apiKeyService; + private final CompositeRolesStore rolesStore; + private final NamedXContentRegistry xContentRegistry; + + public ApiKeyGenerator(ApiKeyService apiKeyService, CompositeRolesStore rolesStore, NamedXContentRegistry xContentRegistry) { + this.apiKeyService = apiKeyService; + this.rolesStore = rolesStore; + this.xContentRegistry = xContentRegistry; + } + + public void generateApiKey(Authentication authentication, CreateApiKeyRequest request, ActionListener listener) { + if (authentication == null) { + listener.onFailure(new ElasticsearchSecurityException("no authentication available to generate API key")); + return; + } + apiKeyService.ensureEnabled(); + rolesStore.getRoleDescriptors(new HashSet<>(Arrays.asList(authentication.getUser().roles())), + ActionListener.wrap(roleDescriptors -> { + for (RoleDescriptor rd : roleDescriptors) { + try { + DLSRoleQueryValidator.validateQueryField(rd.getIndicesPrivileges(), xContentRegistry); + } catch (ElasticsearchException | IllegalArgumentException e) { + listener.onFailure(e); + return; + } + } + apiKeyService.createApiKey(authentication, request, roleDescriptors, listener); + }, + listener::onFailure)); + + } + +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java new file mode 100644 index 0000000000000..f9ea2558db509 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.rest.action.apikey; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.WriteRequest; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.rest.BytesRestResponse; +import org.elasticsearch.rest.RestChannel; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; +import static org.elasticsearch.rest.RestRequest.Method.PUT; + +/** + * Rest action to create an API key on behalf of another user. Loosely mimics the API of + * {@link org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction} combined with {@link RestCreateApiKeyAction} + */ +public final class RestGrantApiKeyAction extends ApiKeyBaseRestHandler { + + final Logger logger = LogManager.getLogger(); + + static final ObjectParser PARSER = new ObjectParser<>("grant_api_key_request", GrantApiKeyRequest::new); + static { + PARSER.declareString((req, str) -> req.getGrant().setType(str), new ParseField("grant_type")); + PARSER.declareString((req, str) -> req.getGrant().setUsername(str), new ParseField("username")); + PARSER.declareField((req, secStr) -> req.getGrant().setPassword(secStr), RestGrantApiKeyAction::getSecureString, + new ParseField("password"), ObjectParser.ValueType.STRING); + PARSER.declareField((req, secStr) -> req.getGrant().setAccessToken(secStr), RestGrantApiKeyAction::getSecureString, + new ParseField("access_token"), ObjectParser.ValueType.STRING); + PARSER.declareObject((req, api) -> req.setApiKeyRequest(api), (parser, ignore) -> CreateApiKeyRequestBuilder.parse(parser), + new ParseField("api_key")); + } + + private static SecureString getSecureString(XContentParser parser) throws IOException { + return new SecureString( + Arrays.copyOfRange(parser.textCharacters(), parser.textOffset(), parser.textOffset() + parser.textLength())); + } + + public RestGrantApiKeyAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + } + + @Override + public List routes() { + return List.of( + new Route(POST, "/_security/api_key/grant"), + new Route(PUT, "/_security/api_key/grant")); + } + + @Override + public String getName() { + return "xpack_security_grant_api_key"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException { + String refresh = request.param("refresh"); + try (XContentParser parser = request.contentParser()) { + final GrantApiKeyRequest grantRequest = PARSER.parse(parser, null); + if (refresh != null) { + grantRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.parse(refresh)); + } + return channel -> client.execute(GrantApiKeyAction.INSTANCE, grantRequest, new ResponseListener(channel)); + } + } + + private class ResponseListener implements ActionListener { + private final RestChannel channel; + + ResponseListener(RestChannel channel) { + this.channel = channel; + } + + @Override + public void onResponse(CreateApiKeyResponse response) { + try (XContentBuilder builder = channel.newBuilder()) { + channel.sendResponse(new BytesRestResponse(RestStatus.OK, response.toXContent(builder, channel.request()))); + } catch (IOException e) { + sendFailure(e); + } + } + + @Override + public void onFailure(Exception e) { + RestStatus status = ExceptionsHelper.status(e); + if (status == RestStatus.UNAUTHORIZED) { + sendFailure(new ElasticsearchSecurityException("Failed to authenticate api key grant", RestStatus.FORBIDDEN, e)); + } else { + sendFailure(e); + } + } + + void sendFailure(Exception e) { + try { + channel.sendResponse(new BytesRestResponse(channel, e)); + } catch (Exception inner) { + inner.addSuppressed(e); + logger.error("failed to send failure response", inner); + } + } + + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyActionTests.java new file mode 100644 index 0000000000000..8d7faf15371f0 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyActionTests.java @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.action; + +import org.elasticsearch.ElasticsearchSecurityException; +import org.elasticsearch.ElasticsearchStatusException; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.TestThreadPool; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.AuthenticationService; +import org.elasticsearch.xpack.security.authc.TokenServiceMock; +import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator; +import org.elasticsearch.xpack.security.test.SecurityMocks; +import org.junit.After; +import org.junit.Before; + +import java.util.List; + +import static org.elasticsearch.test.TestMatchers.throwableWithMessage; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyZeroInteractions; + +public class TransportGrantApiKeyActionTests extends ESTestCase { + + private TransportGrantApiKeyAction action; + private ApiKeyGenerator apiKeyGenerator; + private AuthenticationService authenticationService; + private TokenServiceMock tokenServiceMock; + private ThreadPool threadPool; + + @Before + public void setupMocks() throws Exception { + apiKeyGenerator = mock(ApiKeyGenerator.class); + authenticationService = mock(AuthenticationService.class); + + threadPool = new TestThreadPool("TP-" + getTestName()); + tokenServiceMock = SecurityMocks.tokenService(true, threadPool); + final ThreadContext threadContext = threadPool.getThreadContext(); + + action = new TransportGrantApiKeyAction(mock(TransportService.class), mock(ActionFilters.class), threadContext, + apiKeyGenerator, authenticationService, tokenServiceMock.tokenService); + } + + @After + public void cleanup() { + threadPool.shutdown(); + } + + public void testGrantApiKeyWithUsernamePassword() throws Exception { + final String username = randomAlphaOfLengthBetween(4, 12); + final SecureString password = new SecureString(randomAlphaOfLengthBetween(8, 24).toCharArray()); + final Authentication authentication = buildAuthentication(username); + + final GrantApiKeyRequest request = mockRequest(); + request.getGrant().setType("password"); + request.getGrant().setUsername(username); + request.getGrant().setPassword(password); + + final CreateApiKeyResponse response = mockResponse(request); + + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(4)); + + assertThat(args[0], equalTo(GrantApiKeyAction.NAME)); + assertThat(args[1], sameInstance(request)); + assertThat(args[2], instanceOf(UsernamePasswordToken.class)); + UsernamePasswordToken token = (UsernamePasswordToken) args[2]; + assertThat(token.principal(), equalTo(username)); + assertThat(token.credentials(), equalTo(password)); + + ActionListener listener = (ActionListener) args[args.length - 1]; + listener.onResponse(authentication); + + return null; + }).when(authenticationService) + .authenticate(eq(GrantApiKeyAction.NAME), same(request), any(UsernamePasswordToken.class), any(ActionListener.class)); + + setupApiKeyGenerator(authentication, request, response); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(null, request, future); + + assertThat(future.actionGet(), sameInstance(response)); + } + + public void testGrantApiKeyWithInvalidUsernamePassword() throws Exception { + final String username = randomAlphaOfLengthBetween(4, 12); + final SecureString password = new SecureString(randomAlphaOfLengthBetween(8, 24).toCharArray()); + final Authentication authentication = buildAuthentication(username); + + final GrantApiKeyRequest request = mockRequest(); + request.getGrant().setType("password"); + request.getGrant().setUsername(username); + request.getGrant().setPassword(password); + + final CreateApiKeyResponse response = mockResponse(request); + + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(4)); + + assertThat(args[0], equalTo(GrantApiKeyAction.NAME)); + assertThat(args[1], sameInstance(request)); + assertThat(args[2], instanceOf(UsernamePasswordToken.class)); + UsernamePasswordToken token = (UsernamePasswordToken) args[2]; + assertThat(token.principal(), equalTo(username)); + assertThat(token.credentials(), equalTo(password)); + + ActionListener listener = (ActionListener) args[args.length - 1]; + listener.onFailure(new ElasticsearchSecurityException("authentication failed for testing")); + + return null; + }).when(authenticationService) + .authenticate(eq(GrantApiKeyAction.NAME), same(request), any(UsernamePasswordToken.class), any(ActionListener.class)); + + setupApiKeyGenerator(authentication, request, response); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(null, request, future); + + final ElasticsearchStatusException exception = expectThrows(ElasticsearchStatusException.class, future::actionGet); + assertThat(exception, throwableWithMessage("authentication failed for testing")); + + verifyZeroInteractions(apiKeyGenerator); + } + + public void testGrantApiKeyWithAccessToken() throws Exception { + final String username = randomAlphaOfLengthBetween(4, 12); + final TokenServiceMock.MockToken token = tokenServiceMock.mockAccessToken(); + final Authentication authentication = buildAuthentication(username); + + final GrantApiKeyRequest request = mockRequest(); + request.getGrant().setType("access_token"); + request.getGrant().setAccessToken(token.encodedToken); + + final CreateApiKeyResponse response = mockResponse(request); + + tokenServiceMock.defineToken(token, authentication); + setupApiKeyGenerator(authentication, request, response); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(null, request, future); + + assertThat(future.actionGet(), sameInstance(response)); + verifyZeroInteractions(authenticationService); + } + + public void testGrantApiKeyWithInvalidatedAccessToken() throws Exception { + final String username = randomAlphaOfLengthBetween(4, 12); + final TokenServiceMock.MockToken token = tokenServiceMock.mockAccessToken(); + final Authentication authentication = buildAuthentication(username); + + final GrantApiKeyRequest request = mockRequest(); + request.getGrant().setType("access_token"); + request.getGrant().setAccessToken(token.encodedToken); + + final CreateApiKeyResponse response = mockResponse(request); + + tokenServiceMock.defineToken(token, authentication, false); + setupApiKeyGenerator(authentication, request, response); + + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(null, request, future); + + final ElasticsearchStatusException exception = expectThrows(ElasticsearchStatusException.class, future::actionGet); + assertThat(exception, throwableWithMessage("token expired")); + + verifyZeroInteractions(authenticationService); + verifyZeroInteractions(apiKeyGenerator); + } + + private Authentication buildAuthentication(String username) { + return new Authentication(new User(username), + new Authentication.RealmRef("realm_name", "realm_type", "node_name"), null); + } + + private CreateApiKeyResponse mockResponse(GrantApiKeyRequest request) { + return new CreateApiKeyResponse(request.getApiKeyRequest().getName(), + randomAlphaOfLength(12), new SecureString(randomAlphaOfLength(18).toCharArray()), null); + } + + private GrantApiKeyRequest mockRequest() { + final String keyName = randomAlphaOfLengthBetween(6, 32); + final GrantApiKeyRequest request = new GrantApiKeyRequest(); + request.setApiKeyRequest(new CreateApiKeyRequest(keyName, List.of(), null)); + return request; + } + + private void setupApiKeyGenerator(Authentication authentication, GrantApiKeyRequest request, CreateApiKeyResponse response) { + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(3)); + + assertThat(args[0], equalTo(authentication)); + assertThat(args[1], sameInstance(request.getApiKeyRequest())); + + ActionListener listener = (ActionListener) args[args.length - 1]; + listener.onResponse(response); + + return null; + }).when(apiKeyGenerator).generateApiKey(any(Authentication.class), any(CreateApiKeyRequest.class), any(ActionListener.class)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceMock.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceMock.java new file mode 100644 index 0000000000000..76e321f69c439 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceMock.java @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc; + +import org.elasticsearch.Version; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.XContentTestUtils; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames; +import org.elasticsearch.xpack.security.test.SecurityMocks; + +import java.io.IOException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Because {@link TokenService} is {@code final}, we can't mock it. + * Instead, we use this class to control the client that underlies the token service and trigger certain conditions + */ +public class TokenServiceMock { + public final TokenService tokenService; + public final Client client; + + public final class MockToken { + public final String baseToken; + public final SecureString encodedToken; + public final String hashedToken; + + public MockToken(String baseToken, SecureString encodedToken, String hashedToken) { + this.baseToken = baseToken; + this.encodedToken = encodedToken; + this.hashedToken = hashedToken; + } + } + + public TokenServiceMock(TokenService tokenService, Client client) { + this.tokenService = tokenService; + this.client = client; + } + + public MockToken mockAccessToken() throws Exception { + final String uuid = UUIDs.randomBase64UUID(); + final SecureString encoded = new SecureString(tokenService.prependVersionAndEncodeAccessToken(Version.CURRENT, uuid).toCharArray()); + final String hashedToken = TokenService.hashTokenString(uuid); + return new MockToken(uuid, encoded, hashedToken); + } + + public void defineToken(MockToken token, Authentication authentication) throws IOException { + defineToken(token, authentication, true); + } + + public void defineToken(MockToken token, Authentication authentication, boolean valid) throws IOException { + Instant expiration = Instant.now().plusSeconds(TimeUnit.MINUTES.toSeconds(20)); + final UserToken userToken = new UserToken(token.hashedToken, Version.CURRENT, authentication, expiration, Map.of()); + final Map document = new HashMap<>(); + document.put("access_token", Map.of("user_token", userToken, "invalidated", valid == false)); + + SecurityMocks.mockGetRequest(client, RestrictedIndicesNames.SECURITY_TOKENS_ALIAS, "token_" + token.hashedToken, + XContentTestUtils.convertToXContent(document, XContentType.JSON)); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGeneratorTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGeneratorTests.java new file mode 100644 index 0000000000000..6d9a92d3a625d --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGeneratorTests.java @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security.authc.support; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.security.authc.ApiKeyService; +import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anySetOf; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +public class ApiKeyGeneratorTests extends ESTestCase { + + public void testGenerateApiKeySuccessfully() { + final ApiKeyService apiKeyService = mock(ApiKeyService.class); + final CompositeRolesStore rolesStore = mock(CompositeRolesStore.class); + final ApiKeyGenerator generator = new ApiKeyGenerator(apiKeyService, rolesStore, NamedXContentRegistry.EMPTY); + final Set userRoleNames = Sets.newHashSet(randomArray(1, 4, String[]::new, () -> randomAlphaOfLengthBetween(3, 12))); + final Authentication authentication = new Authentication( + new User("test", userRoleNames.toArray(String[]::new)), + new Authentication.RealmRef("realm-name", "realm-type", "node-name"), + null); + final CreateApiKeyRequest request = new CreateApiKeyRequest("name", null, null); + + final Set roleDescriptors = randomSubsetOf(userRoleNames).stream() + .map(name -> new RoleDescriptor(name, generateRandomStringArray(3, 6, false), null, null)) + .collect(Collectors.toUnmodifiableSet()); + + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(2)); + + Set roleNames = (Set) args[0]; + assertThat(roleNames, equalTo(userRoleNames)); + + ActionListener> listener = (ActionListener>) args[args.length - 1]; + listener.onResponse(roleDescriptors); + return null; + }).when(rolesStore).getRoleDescriptors(anySetOf(String.class), any(ActionListener.class)); + + CreateApiKeyResponse response = new CreateApiKeyResponse( + "name", randomAlphaOfLength(18), new SecureString(randomAlphaOfLength(24).toCharArray()), null); + doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(4)); + + assertThat(args[0], sameInstance(authentication)); + assertThat(args[1], sameInstance(request)); + assertThat(args[2], sameInstance(roleDescriptors)); + + ActionListener listener = (ActionListener) args[args.length - 1]; + listener.onResponse(response); + + return null; + }).when(apiKeyService).createApiKey(same(authentication), same(request), anySetOf(RoleDescriptor.class), any(ActionListener.class)); + + final PlainActionFuture future = new PlainActionFuture<>(); + generator.generateApiKey(authentication, request, future); + + assertThat(future.actionGet(), sameInstance(response)); + } + +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java index 0270a07d9f97c..c9bb7879147b6 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/test/SecurityMocks.java @@ -17,17 +17,29 @@ import org.elasticsearch.action.index.IndexRequestBuilder; import org.elasticsearch.action.index.IndexResponse; import org.elasticsearch.client.Client; +import org.elasticsearch.cluster.service.ClusterService; import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.get.GetResult; import org.elasticsearch.index.shard.ShardId; +import org.elasticsearch.license.XPackLicenseState; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.security.authc.TokenService; +import org.elasticsearch.xpack.security.authc.TokenServiceMock; import org.elasticsearch.xpack.security.support.SecurityIndexManager; import org.junit.Assert; +import java.security.GeneralSecurityException; +import java.time.Clock; +import java.time.Instant; import java.util.function.Consumer; import static java.util.Collections.emptyMap; import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_MAIN_ALIAS; +import static org.elasticsearch.xpack.core.security.index.RestrictedIndicesNames.SECURITY_TOKENS_ALIAS; import static org.hamcrest.Matchers.arrayWithSize; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; @@ -129,4 +141,20 @@ public static void mockIndexRequest(Client client, String indexAliasName, Consum return null; }).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class)); } + + public static TokenServiceMock tokenService(boolean enabled, ThreadPool threadPool) throws GeneralSecurityException { + final Settings settings = Settings.builder().put(XPackSettings.TOKEN_SERVICE_ENABLED_SETTING.getKey(), enabled).build(); + final Instant now = Instant.now(); + final Clock clock = Clock.fixed(now, ESTestCase.randomZone()); + final Client client = mock(Client.class); + when(client.threadPool()).thenReturn(threadPool); + final XPackLicenseState licenseState = mock(XPackLicenseState.class); + when(licenseState.isTokenServiceAllowed()).thenReturn(true); + final ClusterService clusterService = mock(ClusterService.class); + + final SecurityContext securityContext = new SecurityContext(settings, threadPool.getThreadContext()); + final TokenService service = new TokenService(settings, clock, client, licenseState, securityContext, + mockSecurityIndexManager(SECURITY_MAIN_ALIAS), mockSecurityIndexManager(SECURITY_TOKENS_ALIAS), clusterService); + return new TokenServiceMock(service, client); + } } From 21c16fff2c58f16e66e127cd3cfe61bb5956d31f Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Fri, 28 Feb 2020 00:03:14 +1100 Subject: [PATCH 2/6] Revert formatting change --- .../core/security/action/CreateApiKeyRequestBuilder.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java index 7c92960f0318c..67307ad88cf3b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/CreateApiKeyRequestBuilder.java @@ -32,10 +32,10 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder PARSER = new ConstructingObjectParser<>( - "api_key_request", false, (args, v) -> { - return new CreateApiKeyRequest((String) args[0], (List) args[1], - TimeValue.parseTimeValue((String) args[2], null, "expiration")); - }); + "api_key_request", false, (args, v) -> { + return new CreateApiKeyRequest((String) args[0], (List) args[1], + TimeValue.parseTimeValue((String) args[2], null, "expiration")); + }); static { PARSER.declareString(constructorArg(), new ParseField("name")); From 17d292f52d9fd1d9ff51a1944cf230d2df28c955 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Fri, 28 Feb 2020 00:09:07 +1100 Subject: [PATCH 3/6] Revert unnecessary changes --- .../elasticsearch/xpack/security/authc/ApiKeyService.java | 2 +- .../org/elasticsearch/xpack/security/authc/TokenService.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 45a4ba6b5f67f..723095ef0ef08 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 @@ -568,7 +568,7 @@ private Instant getApiKeyExpiration(Instant now, CreateApiKeyRequest request) { } } - public boolean isEnabled() { + private boolean isEnabled() { return enabled && licenseState.isApiKeyServiceAllowed(); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index 64d1496bc79ec..fbe39af0cbd08 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -447,9 +447,8 @@ private void getUserTokenFromId(String userTokenId, Version tokenVersion, Action logger.warn("failed to get access token [{}] because index [{}] is not available", userTokenId, tokensIndex.aliasName()); listener.onResponse(null); } else { - final String index = tokensIndex.aliasName(); - final String documentId = getTokenDocumentId(userTokenId); - final GetRequest getRequest = client.prepareGet(index, documentId).request(); + final GetRequest getRequest = client.prepareGet(tokensIndex.aliasName(), + getTokenDocumentId(userTokenId)).request(); final Consumer onFailure = ex -> listener.onFailure(traceLog("get token from id", userTokenId, ex)); tokensIndex.checkIndexVersionThenExecute( ex -> listener.onFailure(traceLog("prepare tokens index [" + tokensIndex.aliasName() +"]", userTokenId, ex)), From f5808256c01fc4c0ce767b8ebc414be74162e584 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Thu, 5 Mar 2020 12:12:34 +1100 Subject: [PATCH 4/6] Move API key test to new QA/security-trial project This test was piggy-backing on the security-in-basic QA project we had, but the grant-api-key endpoint has the ability to use tokens, which are not a basic licensed feature. This creates a new QA project for security on trial licenses and run the API key tests there --- .../security/SecurityInBasicRestTestCase.java | 49 -------- .../security/qa/security-trial/build.gradle | 28 +++++ .../SecurityOnTrialLicenseRestTestCase.java | 111 ++++++++++++++++++ .../xpack/security/apikey/ApiKeyRestIT.java | 54 +++++++-- .../src/test/resources/roles.yml | 8 ++ 5 files changed, 193 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugin/security/qa/security-trial/build.gradle create mode 100644 x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java rename x-pack/plugin/security/qa/{security-basic => security-trial}/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java (54%) create mode 100644 x-pack/plugin/security/qa/security-trial/src/test/resources/roles.yml diff --git a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java index 831f70e3b972f..7fcec7d6cae74 100644 --- a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java +++ b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java @@ -6,27 +6,12 @@ package org.elasticsearch.xpack.security; -import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.RestHighLevelClient; -import org.elasticsearch.client.security.DeleteRoleRequest; -import org.elasticsearch.client.security.DeleteUserRequest; -import org.elasticsearch.client.security.GetApiKeyRequest; -import org.elasticsearch.client.security.GetApiKeyResponse; -import org.elasticsearch.client.security.InvalidateApiKeyRequest; -import org.elasticsearch.client.security.PutRoleRequest; -import org.elasticsearch.client.security.PutUserRequest; -import org.elasticsearch.client.security.RefreshPolicy; -import org.elasticsearch.client.security.support.ApiKey; -import org.elasticsearch.client.security.user.User; -import org.elasticsearch.client.security.user.privileges.Role; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.test.rest.ESRestTestCase; -import org.hamcrest.Matchers; -import java.io.IOException; -import java.util.Collection; import java.util.List; import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; @@ -50,40 +35,6 @@ protected Settings restClientSettings() { .build(); } - protected void createUser(String username, SecureString password, List roles) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - client.security().putUser(PutUserRequest.withPassword(new User(username, roles), password.getChars(), true, - RefreshPolicy.WAIT_UNTIL), RequestOptions.DEFAULT); - } - - protected void createRole(String name, Collection clusterPrivileges) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - final Role role = Role.builder().name(name).clusterPrivileges(clusterPrivileges).build(); - client.security().putRole(new PutRoleRequest(role, null), RequestOptions.DEFAULT); - } - - protected void deleteUser(String username) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - client.security().deleteUser(new DeleteUserRequest(username), RequestOptions.DEFAULT); - } - - protected void deleteRole(String name) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - client.security().deleteRole(new DeleteRoleRequest(name), RequestOptions.DEFAULT); - } - - protected void invalidateApiKeysForUser(String username) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - client.security().invalidateApiKey(InvalidateApiKeyRequest.usingUserName(username), RequestOptions.DEFAULT); - } - - protected ApiKey getApiKey(String id) throws IOException { - final RestHighLevelClient client = getHighLevelAdminClient(); - final GetApiKeyResponse response = client.security().getApiKey(GetApiKeyRequest.usingApiKeyId(id, false), RequestOptions.DEFAULT); - assertThat(response.getApiKeyInfos(), Matchers.iterableWithSize(1)); - return response.getApiKeyInfos().get(0); - } - private RestHighLevelClient getHighLevelAdminClient() { if (highLevelAdminClient == null) { highLevelAdminClient = new RestHighLevelClient( diff --git a/x-pack/plugin/security/qa/security-trial/build.gradle b/x-pack/plugin/security/qa/security-trial/build.gradle new file mode 100644 index 0000000000000..e8e0b547c1828 --- /dev/null +++ b/x-pack/plugin/security/qa/security-trial/build.gradle @@ -0,0 +1,28 @@ +apply plugin: 'elasticsearch.testclusters' +apply plugin: 'elasticsearch.standalone-rest-test' +apply plugin: 'elasticsearch.rest-test' + +dependencies { + testCompile project(path: xpackModule('core'), configuration: 'default') + testCompile project(path: xpackModule('security'), configuration: 'testArtifacts') + testCompile project(path: xpackModule('core'), configuration: 'testArtifacts') +} + +testClusters.integTest { + testDistribution = 'DEFAULT' + numberOfNodes = 2 + + setting 'xpack.ilm.enabled', 'false' + setting 'xpack.ml.enabled', 'false' + setting 'xpack.license.self_generated.type', 'trial' + setting 'xpack.security.enabled', 'true' + setting 'xpack.security.ssl.diagnose.trust', 'true' + setting 'xpack.security.http.ssl.enabled', 'false' + setting 'xpack.security.transport.ssl.enabled', 'false' + setting 'xpack.security.authc.token.enabled', 'true' + setting 'xpack.security.authc.api_key.enabled', 'true' + + extraConfigFile 'roles.yml', file('src/test/resources/roles.yml') + user username: "admin_user", password: "admin-password" + user username: "security_test_user", password: "security-test-password", role: "security_test_role" +} diff --git a/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java new file mode 100644 index 0000000000000..2fe99bc8ad48d --- /dev/null +++ b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +package org.elasticsearch.xpack.security; + +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.client.security.CreateTokenRequest; +import org.elasticsearch.client.security.CreateTokenResponse; +import org.elasticsearch.client.security.DeleteRoleRequest; +import org.elasticsearch.client.security.DeleteUserRequest; +import org.elasticsearch.client.security.GetApiKeyRequest; +import org.elasticsearch.client.security.GetApiKeyResponse; +import org.elasticsearch.client.security.InvalidateApiKeyRequest; +import org.elasticsearch.client.security.PutRoleRequest; +import org.elasticsearch.client.security.PutUserRequest; +import org.elasticsearch.client.security.RefreshPolicy; +import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.client.security.user.User; +import org.elasticsearch.client.security.user.privileges.Role; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.concurrent.ThreadContext; +import org.elasticsearch.test.rest.ESRestTestCase; +import org.hamcrest.Matchers; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +import static org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken.basicAuthHeaderValue; + +public abstract class SecurityOnTrialLicenseRestTestCase extends ESRestTestCase { + private RestHighLevelClient highLevelAdminClient; + + @Override + protected Settings restAdminSettings() { + String token = basicAuthHeaderValue("admin_user", new SecureString("admin-password".toCharArray())); + return Settings.builder() + .put(ThreadContext.PREFIX + ".Authorization", token) + .build(); + } + + @Override + protected Settings restClientSettings() { + String token = basicAuthHeaderValue("security_test_user", new SecureString("security-test-password".toCharArray())); + return Settings.builder() + .put(ThreadContext.PREFIX + ".Authorization", token) + .build(); + } + + protected void createUser(String username, SecureString password, List roles) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().putUser(PutUserRequest.withPassword(new User(username, roles), password.getChars(), true, + RefreshPolicy.WAIT_UNTIL), RequestOptions.DEFAULT); + } + + protected void createRole(String name, Collection clusterPrivileges) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final Role role = Role.builder().name(name).clusterPrivileges(clusterPrivileges).build(); + client.security().putRole(new PutRoleRequest(role, null), RequestOptions.DEFAULT); + } + + /** + * @return A tuple of (access-token, refresh-token) + */ + protected Tuple createOAuthToken(String username, SecureString password) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final CreateTokenRequest request = CreateTokenRequest.passwordGrant(username, password.getChars()); + final CreateTokenResponse response = client.security().createToken(request, RequestOptions.DEFAULT); + return new Tuple(response.getAccessToken(), response.getRefreshToken()); + } + + protected void deleteUser(String username) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().deleteUser(new DeleteUserRequest(username), RequestOptions.DEFAULT); + } + + protected void deleteRole(String name) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().deleteRole(new DeleteRoleRequest(name), RequestOptions.DEFAULT); + } + + protected void invalidateApiKeysForUser(String username) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + client.security().invalidateApiKey(InvalidateApiKeyRequest.usingUserName(username), RequestOptions.DEFAULT); + } + + protected ApiKey getApiKey(String id) throws IOException { + final RestHighLevelClient client = getHighLevelAdminClient(); + final GetApiKeyResponse response = client.security().getApiKey(GetApiKeyRequest.usingApiKeyId(id, false), RequestOptions.DEFAULT); + assertThat(response.getApiKeyInfos(), Matchers.iterableWithSize(1)); + return response.getApiKeyInfos().get(0); + } + + private RestHighLevelClient getHighLevelAdminClient() { + if (highLevelAdminClient == null) { + highLevelAdminClient = new RestHighLevelClient( + adminClient(), + ignore -> { + }, + List.of()) { + }; + } + return highLevelAdminClient; + } +} diff --git a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java similarity index 54% rename from x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java rename to x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java index dfd2194cd2137..daa0cfc303142 100644 --- a/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java +++ b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java @@ -10,28 +10,33 @@ import org.elasticsearch.client.RequestOptions; import org.elasticsearch.client.Response; import org.elasticsearch.client.security.support.ApiKey; +import org.elasticsearch.common.collect.Tuple; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.xcontent.XContentType; import org.elasticsearch.test.XContentTestUtils; import org.elasticsearch.xpack.core.security.authc.support.UsernamePasswordToken; -import org.elasticsearch.xpack.security.SecurityInBasicRestTestCase; +import org.elasticsearch.xpack.security.SecurityOnTrialLicenseRestTestCase; import org.junit.After; import org.junit.Before; import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.Set; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.hamcrest.Matchers.notNullValue; /** - * This IT runs in the "security-basic" QA test because it has a working cluster with - * API keys enabled, and there's no reason to have a dedicated QA project for API keys. + * Integration Rest Tests relating to API Keys. + * Tested against a trial license */ -public class ApiKeyRestIT extends SecurityInBasicRestTestCase { +public class ApiKeyRestIT extends SecurityOnTrialLicenseRestTestCase { private static final String SYSTEM_USER = "system_user"; private static final SecureString SYSTEM_USER_PASSWORD = new SecureString("sys-pass".toCharArray()); @@ -55,7 +60,7 @@ public void cleanUp() throws IOException { invalidateApiKeysForUser(END_USER); } - public void testGrantApiKeyForOtherUser() throws IOException { + public void testGrantApiKeyForOtherUserWithPassword() throws IOException { Request request = new Request("POST", "_security/api_key/grant"); request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", UsernamePasswordToken.basicAuthHeaderValue(SYSTEM_USER, SYSTEM_USER_PASSWORD))); @@ -63,18 +68,51 @@ public void testGrantApiKeyForOtherUser() throws IOException { Map.entry("grant_type", "password"), Map.entry("username", END_USER), Map.entry("password", END_USER_PASSWORD.toString()), - Map.entry("api_key", Map.of("name", "test_api_key")) + Map.entry("api_key", Map.of("name", "test_api_key_password")) ); request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString()); final Response response = client().performRequest(request); final Map responseBody = entityAsMap(response); - assertThat(responseBody.get("name"), equalTo("test_api_key")); + assertThat(responseBody.get("name"), equalTo("test_api_key_password")); assertThat(responseBody.get("id"), notNullValue()); assertThat(responseBody.get("id"), instanceOf(String.class)); - ApiKey apiKey = getApiKey((String)responseBody.get("id")); + ApiKey apiKey = getApiKey((String) responseBody.get("id")); assertThat(apiKey.getUsername(), equalTo(END_USER)); } + + public void testGrantApiKeyForOtherUserWithAccessToken() throws IOException { + final Tuple token = super.createOAuthToken(END_USER, END_USER_PASSWORD); + final String accessToken = token.v1(); + + final Request request = new Request("POST", "_security/api_key/grant"); + request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", + UsernamePasswordToken.basicAuthHeaderValue(SYSTEM_USER, SYSTEM_USER_PASSWORD))); + final Map requestBody = Map.ofEntries( + Map.entry("grant_type", "access_token"), + Map.entry("access_token", accessToken), + Map.entry("api_key", Map.of("name", "test_api_key_token", "expiration", "2h")) + ); + request.setJsonEntity(XContentTestUtils.convertToXContent(requestBody, XContentType.JSON).utf8ToString()); + + final Instant before = Instant.now(); + final Response response = client().performRequest(request); + final Instant after = Instant.now(); + final Map responseBody = entityAsMap(response); + + assertThat(responseBody.get("name"), equalTo("test_api_key_token")); + assertThat(responseBody.get("id"), notNullValue()); + assertThat(responseBody.get("id"), instanceOf(String.class)); + + ApiKey apiKey = getApiKey((String) responseBody.get("id")); + assertThat(apiKey.getUsername(), equalTo(END_USER)); + + Instant minExpiry = before.plus(2, ChronoUnit.HOURS); + Instant maxExpiry = after.plus(2, ChronoUnit.HOURS); + assertThat(apiKey.getExpiration(), notNullValue()); + assertThat(apiKey.getExpiration(), greaterThanOrEqualTo(minExpiry)); + assertThat(apiKey.getExpiration(), lessThanOrEqualTo(maxExpiry)); + } } diff --git a/x-pack/plugin/security/qa/security-trial/src/test/resources/roles.yml b/x-pack/plugin/security/qa/security-trial/src/test/resources/roles.yml new file mode 100644 index 0000000000000..9b2171257fc61 --- /dev/null +++ b/x-pack/plugin/security/qa/security-trial/src/test/resources/roles.yml @@ -0,0 +1,8 @@ +# A basic role that is used to test security +security_test_role: + cluster: + - monitor + - "cluster:admin/xpack/license/*" + indices: + - names: [ "index_allowed" ] + privileges: [ "read", "write", "create_index" ] From 036c402945d26e524cf435853fc611a852f23312 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Wed, 11 Mar 2020 23:05:02 +1100 Subject: [PATCH 5/6] Address feedback --- .../security/action/GrantApiKeyRequest.java | 9 ++--- .../action/TransportGrantApiKeyAction.java | 2 ++ .../xpack/security/authc/TokenService.java | 35 ++++++++++--------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java index becf6b59f04d5..4daf3c84df61c 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java @@ -26,7 +26,6 @@ */ public final class GrantApiKeyRequest extends ActionRequest { - public static final WriteRequest.RefreshPolicy DEFAULT_REFRESH_POLICY = WriteRequest.RefreshPolicy.WAIT_UNTIL; public static final String PASSWORD_GRANT_TYPE = "password"; public static final String ACCESS_TOKEN_GRANT_TYPE = "access_token"; @@ -91,19 +90,16 @@ public void setAccessToken(SecureString accessToken) { private final Grant grant; private CreateApiKeyRequest apiKey; - private WriteRequest.RefreshPolicy refreshPolicy; public GrantApiKeyRequest() { this.grant = new Grant(); this.apiKey = new CreateApiKeyRequest(); - this.refreshPolicy = DEFAULT_REFRESH_POLICY; } public GrantApiKeyRequest(StreamInput in) throws IOException { super(in); this.grant = new Grant(in); this.apiKey = new CreateApiKeyRequest(in); - this.refreshPolicy = WriteRequest.RefreshPolicy.readFrom(in); } @Override @@ -111,15 +107,14 @@ public void writeTo(StreamOutput out) throws IOException { super.writeTo(out); grant.writeTo(out); apiKey.writeTo(out); - refreshPolicy.writeTo(out); } public WriteRequest.RefreshPolicy getRefreshPolicy() { - return refreshPolicy; + return apiKey.getRefreshPolicy(); } public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { - this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refresh policy may not be null"); + apiKey.setRefreshPolicy(refreshPolicy); } public Grant getGrant() { diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java index b8c89df14d56d..d41754266329c 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java @@ -64,6 +64,8 @@ protected void doExecute(Task task, GrantApiKeyRequest request, ActionListener generator.generateApiKey(authentication, request.getApiKeyRequest(), listener), listener::onFailure )); + } catch (Exception e) { + listener.onFailure(e); } } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java index b3c21d1aacbf4..c12b14477b8e7 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/TokenService.java @@ -404,22 +404,25 @@ void getAndValidateToken(ThreadContext ctx, ActionListener listener) * {@code null} authentication object. */ public void authenticateToken(SecureString tokenString, ActionListener listener) { - if (isEnabled()) { - decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { - if (userToken != null) { - checkIfTokenIsValid(userToken, ActionListener.wrap( - token -> { - listener.onResponse(token == null ? null : token.getAuthentication()); - }, - listener::onFailure - )); - } else { - listener.onResponse(null); - } - }, listener::onFailure)); - } else { - listener.onResponse(null); - } + ensureEnabled(); + decodeToken(tokenString.toString(), ActionListener.wrap(userToken -> { + if (userToken != null) { + checkIfTokenIsValid(userToken, ActionListener.wrap( + token -> { + if (token == null) { + // Typically this means that the index is unavailable, so _probably_ the token is invalid but the only + // this we can say for certain is that we couldn't validate it. The logs will be more explicit. + listener.onFailure(new IllegalArgumentException("Cannot validate access token")); + } else { + listener.onResponse(token.getAuthentication()); + } + }, + listener::onFailure + )); + } else { + listener.onFailure(new IllegalArgumentException("Cannot decode access token")); + } + }, listener::onFailure)); } /** From 04339c6a52e8f225e68c14ab66039a96dea43a66 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Thu, 12 Mar 2020 15:47:57 +1100 Subject: [PATCH 6/6] Address more feedback --- .../action/apikey/RestGrantApiKeyAction.java | 57 ++++--------------- 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java index f9ea2558db509..0ce0c6588e810 100644 --- a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java @@ -6,8 +6,6 @@ package org.elasticsearch.xpack.security.rest.action.apikey; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.ExceptionsHelper; import org.elasticsearch.action.ActionListener; @@ -17,15 +15,12 @@ import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.ObjectParser; -import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.license.XPackLicenseState; -import org.elasticsearch.rest.BytesRestResponse; -import org.elasticsearch.rest.RestChannel; import org.elasticsearch.rest.RestRequest; import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.rest.action.RestToXContentListener; import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequestBuilder; -import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; import org.elasticsearch.xpack.core.security.action.GrantApiKeyAction; import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest; @@ -42,8 +37,6 @@ */ public final class RestGrantApiKeyAction extends ApiKeyBaseRestHandler { - final Logger logger = LogManager.getLogger(); - static final ObjectParser PARSER = new ObjectParser<>("grant_api_key_request", GrantApiKeyRequest::new); static { PARSER.declareString((req, str) -> req.getGrant().setType(str), new ParseField("grant_type")); @@ -85,44 +78,16 @@ protected RestChannelConsumer innerPrepareRequest(final RestRequest request, fin if (refresh != null) { grantRequest.setRefreshPolicy(WriteRequest.RefreshPolicy.parse(refresh)); } - return channel -> client.execute(GrantApiKeyAction.INSTANCE, grantRequest, new ResponseListener(channel)); - } - } - - private class ResponseListener implements ActionListener { - private final RestChannel channel; - - ResponseListener(RestChannel channel) { - this.channel = channel; - } - - @Override - public void onResponse(CreateApiKeyResponse response) { - try (XContentBuilder builder = channel.newBuilder()) { - channel.sendResponse(new BytesRestResponse(RestStatus.OK, response.toXContent(builder, channel.request()))); - } catch (IOException e) { - sendFailure(e); - } + return channel -> client.execute(GrantApiKeyAction.INSTANCE, grantRequest, + ActionListener.delegateResponse(new RestToXContentListener<>(channel), (listener, ex) -> { + RestStatus status = ExceptionsHelper.status(ex); + if (status == RestStatus.UNAUTHORIZED) { + listener.onFailure( + new ElasticsearchSecurityException("Failed to authenticate api key grant", RestStatus.FORBIDDEN, ex)); + } else { + listener.onFailure(ex); + } + })); } - - @Override - public void onFailure(Exception e) { - RestStatus status = ExceptionsHelper.status(e); - if (status == RestStatus.UNAUTHORIZED) { - sendFailure(new ElasticsearchSecurityException("Failed to authenticate api key grant", RestStatus.FORBIDDEN, e)); - } else { - sendFailure(e); - } - } - - void sendFailure(Exception e) { - try { - channel.sendResponse(new BytesRestResponse(channel, e)); - } catch (Exception inner) { - inner.addSuppressed(e); - logger.error("failed to send failure response", inner); - } - } - } }