From cde8725e3cab43d27b517315d9d566bdc5d2f43b Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Mon, 23 Mar 2020 18:50:07 +1100 Subject: [PATCH] Create API Key on behalf of other user (#53943) 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. This also creates a new QA project for security on trial licenses and runs the API key tests there Backport of: #52886 --- .../org/elasticsearch/test/ESTestCase.java | 6 + .../elasticsearch/test/XContentTestUtils.java | 7 + .../action/CreateApiKeyRequestBuilder.java | 6 +- .../security/action/GrantApiKeyAction.java | 24 ++ .../security/action/GrantApiKeyRequest.java | 166 +++++++++++++ .../security/SecurityInBasicRestTestCase.java | 49 ++++ .../security/SecurityWithBasicLicenseIT.java | 23 +- .../security/qa/security-trial/build.gradle | 28 +++ .../SecurityOnTrialLicenseRestTestCase.java | 112 +++++++++ .../xpack/security/apikey/ApiKeyRestIT.java | 118 +++++++++ .../src/test/resources/roles.yml | 8 + .../xpack/security/Security.java | 5 + .../action/TransportCreateApiKeyAction.java | 42 +--- .../action/TransportGrantApiKeyAction.java | 88 +++++++ .../xpack/security/authc/ApiKeyService.java | 2 +- .../xpack/security/authc/TokenService.java | 29 +++ .../authc/support/ApiKeyGenerator.java | 72 ++++++ .../action/apikey/RestGrantApiKeyAction.java | 95 ++++++++ .../TransportGrantApiKeyActionTests.java | 230 ++++++++++++++++++ .../security/authc/TokenServiceMock.java | 73 ++++++ .../authc/support/ApiKeyGeneratorTests.java | 86 +++++++ .../xpack/security/test/SecurityMocks.java | 28 +++ 22 files changed, 1236 insertions(+), 61 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-trial/build.gradle create mode 100644 x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java create mode 100644 x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java create mode 100644 x-pack/plugin/security/qa/security-trial/src/test/resources/roles.yml 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 7d48f39956e..0fe77d337f8 100644 --- a/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java +++ b/test/framework/src/main/java/org/elasticsearch/test/ESTestCase.java @@ -758,6 +758,12 @@ public abstract class ESTestCase extends LuceneTestCase { 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 7e80810d7dd..1449252d024 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 final class XContentTestUtils { 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 c31dbeb7e48..67307ad88cf 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 @@ -74,11 +74,15 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder { + + 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 00000000000..4daf3c84df6 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/GrantApiKeyRequest.java @@ -0,0 +1,166 @@ +/* + * 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 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; + + public GrantApiKeyRequest() { + this.grant = new Grant(); + this.apiKey = new CreateApiKeyRequest(); + } + + public GrantApiKeyRequest(StreamInput in) throws IOException { + super(in); + this.grant = new Grant(in); + this.apiKey = new CreateApiKeyRequest(in); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + grant.writeTo(out); + apiKey.writeTo(out); + } + + public WriteRequest.RefreshPolicy getRefreshPolicy() { + return apiKey.getRefreshPolicy(); + } + + public void setRefreshPolicy(WriteRequest.RefreshPolicy refreshPolicy) { + apiKey.setRefreshPolicy(refreshPolicy); + } + + 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 00000000000..7fcec7d6cae --- /dev/null +++ b/x-pack/plugin/security/qa/security-basic/src/test/java/org/elasticsearch/xpack/security/SecurityInBasicRestTestCase.java @@ -0,0 +1,49 @@ +/* + * 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.RestHighLevelClient; +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 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(); + } + + 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 837421f6000..98f92c75095 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.RequestOptions; 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.Arrays; 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-trial/build.gradle b/x-pack/plugin/security/qa/security-trial/build.gradle new file mode 100644 index 00000000000..e8e0b547c18 --- /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 00000000000..a9e73214cfd --- /dev/null +++ b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/SecurityOnTrialLicenseRestTestCase.java @@ -0,0 +1,112 @@ +/* + * 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.Collections; +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 -> { + }, + Collections.emptyList()) { + }; + } + return highLevelAdminClient; + } +} diff --git a/x-pack/plugin/security/qa/security-trial/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 new file mode 100644 index 00000000000..16f0e87b047 --- /dev/null +++ b/x-pack/plugin/security/qa/security-trial/src/test/java/org/elasticsearch/xpack/security/apikey/ApiKeyRestIT.java @@ -0,0 +1,118 @@ +/* + * 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.collect.MapBuilder; +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.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.Collections; +import java.util.HashMap; +import java.util.Map; + +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; + +/** + * Integration Rest Tests relating to API Keys. + * Tested against a trial license + */ +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()); + 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, Collections.singletonList("system_role")); + createRole("system_role", Collections.singleton("manage_api_key")); + createUser(END_USER, END_USER_PASSWORD, Collections.singletonList("user_role")); + createRole("user_role", Collections.singleton("monitor")); + } + + @After + public void cleanUp() throws IOException { + deleteUser("system_user"); + deleteUser("end_user"); + deleteRole("system_role"); + deleteRole("user_role"); + invalidateApiKeysForUser(END_USER); + } + + 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))); + final Map requestBody = new HashMap<>(); + requestBody.put("grant_type", "password"); + requestBody.put("username", END_USER); + requestBody.put("password", END_USER_PASSWORD.toString()); + requestBody.put("api_key", Collections.singletonMap("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_password")); + 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)); + } + + 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 = new HashMap<>(); + requestBody.put("grant_type", "access_token"); + requestBody.put("access_token", accessToken); + requestBody.put("api_key", MapBuilder.newMapBuilder().put("name", "test_api_key_token").put("expiration", "2h").map()); + 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 00000000000..9b2171257fc --- /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" ] 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 add80930ff9..4a02503d290 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.SecuritySettings; 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.core.ssl.rest.RestGetCertificateInfoAction; 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.RestAuthenticateAction; 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; @@ -791,6 +794,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin, 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) @@ -848,6 +852,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin, 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 e96e0bdcdba..dc2237813eb 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.CreateApiKeyAction; 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,30 +44,8 @@ public final class TransportCreateApiKeyAction extends HandledTransportAction(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); } } - private boolean grantsAnyPrivileges(CreateApiKeyRequest request) { - return request.getRoleDescriptors() == null - || request.getRoleDescriptors().isEmpty() - || false == request.getRoleDescriptors().stream().allMatch(RoleDescriptor::isEmpty); - } } 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 00000000000..d4175426632 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/TransportGrantApiKeyAction.java @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 + )); + } catch (Exception e) { + listener.onFailure(e); + } + } + + 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 8969195b0cf..5e1d4deca28 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 @@ -583,7 +583,7 @@ public class ApiKeyService { 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 984feb59d7e..d342fd17c78 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 @@ -397,6 +397,35 @@ public final class TokenService { } } + /** + * 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) { + 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)); + } + /** * Reads the authentication and metadata from the given token. * This method does not validate whether the token is expired or not. 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 00000000000..4cd366abbc6 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/support/ApiKeyGenerator.java @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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; + +/** + * Utility class for generating API keys for a provided {@link Authentication}. + */ +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(); + if (Authentication.AuthenticationType.API_KEY == authentication.getAuthenticationType() && grantsAnyPrivileges(request)) { + listener.onFailure(new IllegalArgumentException( + "creating derived api keys requires an explicit role descriptor that is empty (has no privileges)")); + return; + } + 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)); + + } + + private boolean grantsAnyPrivileges(CreateApiKeyRequest request) { + return request.getRoleDescriptors() == null + || request.getRoleDescriptors().isEmpty() + || false == request.getRoleDescriptors().stream().allMatch(RoleDescriptor::isEmpty); + } + +} 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 00000000000..5881f92914e --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java @@ -0,0 +1,95 @@ +/* + * 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.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.XContentParser; +import org.elasticsearch.license.XPackLicenseState; +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.GrantApiKeyAction; +import org.elasticsearch.xpack.core.security.action.GrantApiKeyRequest; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +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 { + + 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 Collections.unmodifiableList(Arrays.asList( + 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, + 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); + } + })); + } + } +} 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 00000000000..910ebf97fa4 --- /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.Collections; + +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, Collections.emptyList(), 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 00000000000..991b2264ce5 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/TokenServiceMock.java @@ -0,0 +1,73 @@ +/* + * 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.collect.MapBuilder; +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.Collections; +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, Collections.emptyMap()); + final Map document = new HashMap<>(); + document.put("access_token", + MapBuilder.newMapBuilder().put("user_token", userToken).put("invalidated", valid == false).map()); + + 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 00000000000..b206ee15e86 --- /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(new String[0])), + 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.toSet()); + + 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 1e192361e9b..8fd33f47872 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,18 +17,30 @@ import org.elasticsearch.action.index.IndexRequest; 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.index.mapper.MapperService.SINGLE_MAPPING_NAME; 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; @@ -151,4 +163,20 @@ public final class SecurityMocks { 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); + } }