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
This commit is contained in:
parent
412e163cf6
commit
cde8725e3c
|
@ -758,6 +758,12 @@ public abstract class ESTestCase extends LuceneTestCase {
|
||||||
return RandomizedTest.randomRealisticUnicodeOfCodepointLength(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) {
|
public static String[] generateRandomStringArray(int maxArraySize, int stringSize, boolean allowNull, boolean allowEmpty) {
|
||||||
if (allowNull && random().nextBoolean()) {
|
if (allowNull && random().nextBoolean()) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -58,6 +58,13 @@ public final class XContentTestUtils {
|
||||||
return XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2();
|
return XContentHelper.convertToMap(BytesReference.bytes(builder), false, builder.contentType()).v2();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static BytesReference convertToXContent(Map<String, ?> 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.
|
* Compares two maps generated from XContentObjects. The order of elements in arrays is ignored.
|
||||||
|
|
|
@ -74,11 +74,15 @@ public final class CreateApiKeyRequestBuilder extends ActionRequestBuilder<Creat
|
||||||
final NamedXContentRegistry registry = NamedXContentRegistry.EMPTY;
|
final NamedXContentRegistry registry = NamedXContentRegistry.EMPTY;
|
||||||
try (InputStream stream = source.streamInput();
|
try (InputStream stream = source.streamInput();
|
||||||
XContentParser parser = xContentType.xContent().createParser(registry, LoggingDeprecationHandler.INSTANCE, stream)) {
|
XContentParser parser = xContentType.xContent().createParser(registry, LoggingDeprecationHandler.INSTANCE, stream)) {
|
||||||
CreateApiKeyRequest createApiKeyRequest = PARSER.parse(parser, null);
|
CreateApiKeyRequest createApiKeyRequest = parse(parser);
|
||||||
setName(createApiKeyRequest.getName());
|
setName(createApiKeyRequest.getName());
|
||||||
setRoleDescriptors(createApiKeyRequest.getRoleDescriptors());
|
setRoleDescriptors(createApiKeyRequest.getRoleDescriptors());
|
||||||
setExpiration(createApiKeyRequest.getExpiration());
|
setExpiration(createApiKeyRequest.getExpiration());
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static CreateApiKeyRequest parse(XContentParser parser) throws IOException {
|
||||||
|
return PARSER.parse(parser, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<CreateApiKeyResponse> {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,10 +11,6 @@ import org.elasticsearch.client.RequestOptions;
|
||||||
import org.elasticsearch.client.Response;
|
import org.elasticsearch.client.Response;
|
||||||
import org.elasticsearch.client.ResponseException;
|
import org.elasticsearch.client.ResponseException;
|
||||||
import org.elasticsearch.common.collect.Tuple;
|
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.test.rest.yaml.ObjectPath;
|
||||||
import org.elasticsearch.xpack.security.authc.InternalRealms;
|
import org.elasticsearch.xpack.security.authc.InternalRealms;
|
||||||
|
|
||||||
|
@ -24,29 +20,12 @@ import java.util.Arrays;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Map;
|
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.contains;
|
||||||
import static org.hamcrest.Matchers.containsString;
|
import static org.hamcrest.Matchers.containsString;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.hamcrest.Matchers.notNullValue;
|
import static org.hamcrest.Matchers.notNullValue;
|
||||||
|
|
||||||
public class SecurityWithBasicLicenseIT extends ESRestTestCase {
|
public class SecurityWithBasicLicenseIT extends SecurityInBasicRestTestCase {
|
||||||
|
|
||||||
@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 void testWithBasicLicense() throws Exception {
|
public void testWithBasicLicense() throws Exception {
|
||||||
checkLicenseType("basic");
|
checkLicenseType("basic");
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
|
@ -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<String> 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<String> 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<String, String> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String, Object> 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<String, Object> 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<String, String> 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<String, Object> 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<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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" ]
|
|
@ -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.CreateApiKeyAction;
|
||||||
import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction;
|
import org.elasticsearch.xpack.core.security.action.DelegatePkiAuthenticationAction;
|
||||||
import org.elasticsearch.xpack.core.security.action.GetApiKeyAction;
|
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.InvalidateApiKeyAction;
|
||||||
import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
|
import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction;
|
||||||
import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction;
|
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.TransportCreateApiKeyAction;
|
||||||
import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticationAction;
|
import org.elasticsearch.xpack.security.action.TransportDelegatePkiAuthenticationAction;
|
||||||
import org.elasticsearch.xpack.security.action.TransportGetApiKeyAction;
|
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.TransportInvalidateApiKeyAction;
|
||||||
import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
|
import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
|
||||||
import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction;
|
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.RestDelegatePkiAuthenticationAction;
|
||||||
import org.elasticsearch.xpack.security.rest.action.apikey.RestCreateApiKeyAction;
|
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.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.apikey.RestInvalidateApiKeyAction;
|
||||||
import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
|
import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction;
|
||||||
import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction;
|
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<>(PutPrivilegesAction.INSTANCE, TransportPutPrivilegesAction.class),
|
||||||
new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class),
|
new ActionHandler<>(DeletePrivilegesAction.INSTANCE, TransportDeletePrivilegesAction.class),
|
||||||
new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class),
|
new ActionHandler<>(CreateApiKeyAction.INSTANCE, TransportCreateApiKeyAction.class),
|
||||||
|
new ActionHandler<>(GrantApiKeyAction.INSTANCE, TransportGrantApiKeyAction.class),
|
||||||
new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class),
|
new ActionHandler<>(InvalidateApiKeyAction.INSTANCE, TransportInvalidateApiKeyAction.class),
|
||||||
new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class),
|
new ActionHandler<>(GetApiKeyAction.INSTANCE, TransportGetApiKeyAction.class),
|
||||||
new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class)
|
new ActionHandler<>(DelegatePkiAuthenticationAction.INSTANCE, TransportDelegatePkiAuthenticationAction.class)
|
||||||
|
@ -848,6 +852,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
|
||||||
new RestPutPrivilegesAction(settings, getLicenseState()),
|
new RestPutPrivilegesAction(settings, getLicenseState()),
|
||||||
new RestDeletePrivilegesAction(settings, getLicenseState()),
|
new RestDeletePrivilegesAction(settings, getLicenseState()),
|
||||||
new RestCreateApiKeyAction(settings, getLicenseState()),
|
new RestCreateApiKeyAction(settings, getLicenseState()),
|
||||||
|
new RestGrantApiKeyAction(settings, getLicenseState()),
|
||||||
new RestInvalidateApiKeyAction(settings, getLicenseState()),
|
new RestInvalidateApiKeyAction(settings, getLicenseState()),
|
||||||
new RestGetApiKeyAction(settings, getLicenseState()),
|
new RestGetApiKeyAction(settings, getLicenseState()),
|
||||||
new RestDelegatePkiAuthenticationAction(settings, getLicenseState())
|
new RestDelegatePkiAuthenticationAction(settings, getLicenseState())
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
|
|
||||||
package org.elasticsearch.xpack.security.action;
|
package org.elasticsearch.xpack.security.action;
|
||||||
|
|
||||||
import org.elasticsearch.ElasticsearchException;
|
|
||||||
import org.elasticsearch.action.ActionListener;
|
import org.elasticsearch.action.ActionListener;
|
||||||
import org.elasticsearch.action.support.ActionFilters;
|
import org.elasticsearch.action.support.ActionFilters;
|
||||||
import org.elasticsearch.action.support.HandledTransportAction;
|
import org.elasticsearch.action.support.HandledTransportAction;
|
||||||
import org.elasticsearch.common.inject.Inject;
|
import org.elasticsearch.common.inject.Inject;
|
||||||
import org.elasticsearch.common.io.stream.Writeable;
|
|
||||||
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
|
import org.elasticsearch.common.xcontent.NamedXContentRegistry;
|
||||||
import org.elasticsearch.tasks.Task;
|
import org.elasticsearch.tasks.Task;
|
||||||
import org.elasticsearch.transport.TransportService;
|
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.CreateApiKeyRequest;
|
||||||
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
|
import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse;
|
||||||
import org.elasticsearch.xpack.core.security.authc.Authentication;
|
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.ApiKeyService;
|
||||||
|
import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator;
|
||||||
import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore;
|
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
|
* Implementation of the action needed to create an API key
|
||||||
*/
|
*/
|
||||||
public final class TransportCreateApiKeyAction extends HandledTransportAction<CreateApiKeyRequest, CreateApiKeyResponse> {
|
public final class TransportCreateApiKeyAction extends HandledTransportAction<CreateApiKeyRequest, CreateApiKeyResponse> {
|
||||||
|
|
||||||
private final ApiKeyService apiKeyService;
|
private final ApiKeyGenerator generator;
|
||||||
private final SecurityContext securityContext;
|
private final SecurityContext securityContext;
|
||||||
private final CompositeRolesStore rolesStore;
|
|
||||||
private final NamedXContentRegistry xContentRegistry;
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public TransportCreateApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService,
|
public TransportCreateApiKeyAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService,
|
||||||
SecurityContext context, CompositeRolesStore rolesStore, NamedXContentRegistry xContentRegistry) {
|
SecurityContext context, CompositeRolesStore rolesStore, NamedXContentRegistry xContentRegistry) {
|
||||||
super(CreateApiKeyAction.NAME, transportService, actionFilters, (Writeable.Reader<CreateApiKeyRequest>) CreateApiKeyRequest::new);
|
super(CreateApiKeyAction.NAME, transportService, actionFilters, CreateApiKeyRequest::new);
|
||||||
this.apiKeyService = apiKeyService;
|
this.generator = new ApiKeyGenerator(apiKeyService, rolesStore, xContentRegistry);
|
||||||
this.securityContext = context;
|
this.securityContext = context;
|
||||||
this.rolesStore = rolesStore;
|
|
||||||
this.xContentRegistry = xContentRegistry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -54,30 +44,8 @@ public final class TransportCreateApiKeyAction extends HandledTransportAction<Cr
|
||||||
if (authentication == null) {
|
if (authentication == null) {
|
||||||
listener.onFailure(new IllegalStateException("authentication is required"));
|
listener.onFailure(new IllegalStateException("authentication is required"));
|
||||||
} else {
|
} else {
|
||||||
if (Authentication.AuthenticationType.API_KEY == authentication.getAuthenticationType() && grantsAnyPrivileges(request)) {
|
generator.generateApiKey(authentication, request, listener);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<GrantApiKeyRequest, CreateApiKeyResponse> {
|
||||||
|
|
||||||
|
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<CreateApiKeyResponse> 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<Authentication> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -583,7 +583,7 @@ public class ApiKeyService {
|
||||||
return enabled && licenseState.isApiKeyServiceAllowed();
|
return enabled && licenseState.isApiKeyServiceAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureEnabled() {
|
public void ensureEnabled() {
|
||||||
if (licenseState.isApiKeyServiceAllowed() == false) {
|
if (licenseState.isApiKeyServiceAllowed() == false) {
|
||||||
throw LicenseUtils.newComplianceException("api keys");
|
throw LicenseUtils.newComplianceException("api keys");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Authentication> 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.
|
* Reads the authentication and metadata from the given token.
|
||||||
* This method does not validate whether the token is expired or not.
|
* This method does not validate whether the token is expired or not.
|
||||||
|
|
|
@ -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<CreateApiKeyResponse> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<GrantApiKeyRequest, Void> 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<Route> 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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Authentication> listener = (ActionListener<Authentication>) 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<CreateApiKeyResponse> 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<Authentication> listener = (ActionListener<Authentication>) 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<CreateApiKeyResponse> 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<CreateApiKeyResponse> 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<CreateApiKeyResponse> 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<CreateApiKeyResponse> listener = (ActionListener<CreateApiKeyResponse>) args[args.length - 1];
|
||||||
|
listener.onResponse(response);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}).when(apiKeyGenerator).generateApiKey(any(Authentication.class), any(CreateApiKeyRequest.class), any(ActionListener.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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<String, Object> document = new HashMap<>();
|
||||||
|
document.put("access_token",
|
||||||
|
MapBuilder.<String, Object>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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String> 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<RoleDescriptor> 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<String> roleNames = (Set<String>) args[0];
|
||||||
|
assertThat(roleNames, equalTo(userRoleNames));
|
||||||
|
|
||||||
|
ActionListener<Set<RoleDescriptor>> listener = (ActionListener<Set<RoleDescriptor>>) 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<CreateApiKeyResponse> listener = (ActionListener<CreateApiKeyResponse>) args[args.length - 1];
|
||||||
|
listener.onResponse(response);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}).when(apiKeyService).createApiKey(same(authentication), same(request), anySetOf(RoleDescriptor.class), any(ActionListener.class));
|
||||||
|
|
||||||
|
final PlainActionFuture<CreateApiKeyResponse> future = new PlainActionFuture<>();
|
||||||
|
generator.generateApiKey(authentication, request, future);
|
||||||
|
|
||||||
|
assertThat(future.actionGet(), sameInstance(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -17,18 +17,30 @@ import org.elasticsearch.action.index.IndexRequest;
|
||||||
import org.elasticsearch.action.index.IndexRequestBuilder;
|
import org.elasticsearch.action.index.IndexRequestBuilder;
|
||||||
import org.elasticsearch.action.index.IndexResponse;
|
import org.elasticsearch.action.index.IndexResponse;
|
||||||
import org.elasticsearch.client.Client;
|
import org.elasticsearch.client.Client;
|
||||||
|
import org.elasticsearch.cluster.service.ClusterService;
|
||||||
import org.elasticsearch.common.bytes.BytesReference;
|
import org.elasticsearch.common.bytes.BytesReference;
|
||||||
|
import org.elasticsearch.common.settings.Settings;
|
||||||
import org.elasticsearch.index.get.GetResult;
|
import org.elasticsearch.index.get.GetResult;
|
||||||
import org.elasticsearch.index.shard.ShardId;
|
import org.elasticsearch.index.shard.ShardId;
|
||||||
|
import org.elasticsearch.license.XPackLicenseState;
|
||||||
import org.elasticsearch.test.ESTestCase;
|
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.elasticsearch.xpack.security.support.SecurityIndexManager;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
|
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import static java.util.Collections.emptyMap;
|
import static java.util.Collections.emptyMap;
|
||||||
import static org.elasticsearch.index.mapper.MapperService.SINGLE_MAPPING_NAME;
|
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_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.arrayWithSize;
|
||||||
import static org.hamcrest.Matchers.equalTo;
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.hamcrest.Matchers.instanceOf;
|
import static org.hamcrest.Matchers.instanceOf;
|
||||||
|
@ -151,4 +163,20 @@ public final class SecurityMocks {
|
||||||
return null;
|
return null;
|
||||||
}).when(client).execute(eq(IndexAction.INSTANCE), any(IndexRequest.class), any(ActionListener.class));
|
}).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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue