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..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 @@ -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..4daf3c84df61c --- /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 0000000000000..7fcec7d6cae74 --- /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 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-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-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 0000000000000..daa0cfc303142 --- /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.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.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; + +/** + * 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, 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 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 = 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_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 = 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" ] 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 885de69564c2b..1f62f6c341f87 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..d41754266329c --- /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 5cd9c062914bc..021984d073a23 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 @@ -574,7 +574,7 @@ private 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 24b40ba2922ae..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 @@ -396,6 +396,35 @@ 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) { + 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 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..0ce0c6588e810 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/apikey/RestGrantApiKeyAction.java @@ -0,0 +1,93 @@ +/* + * 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.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 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, + 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 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); + } }