diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java index 5c6057a04caa7..14d9d87bad8dd 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java @@ -22,6 +22,8 @@ import org.elasticsearch.client.security.ClearSecurityCacheResponse; import org.elasticsearch.client.security.CreateApiKeyRequest; import org.elasticsearch.client.security.CreateApiKeyResponse; +import org.elasticsearch.client.security.CreateEnrollmentTokenRequest; +import org.elasticsearch.client.security.CreateEnrollmentTokenResponse; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.CreateTokenResponse; import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest; @@ -1133,6 +1135,28 @@ public Cancellable delegatePkiAuthenticationAsync(DelegatePkiAuthenticationReque DelegatePkiAuthenticationResponse::fromXContent, listener, emptySet()); } + /** + * Create an Enrollment Token used to enroll a client to a secured cluster using the Enroll Client API + * + */ + public CreateEnrollmentTokenResponse createEnrollmentToken(RequestOptions options) throws IOException { + return restHighLevelClient.performRequestAndParseEntity(CreateEnrollmentTokenRequest.INSTANCE, + CreateEnrollmentTokenRequest::getRequest, options, + CreateEnrollmentTokenResponse::fromXContent, emptySet()); + } + + /** + * Asynchronously create an Enrollment Token used to enroll a client to a secured cluster using the Enroll Client API + * + * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized + * @param listener the listener to be notified upon request completion + */ + public Cancellable createEnrollmentTokenAsync(RequestOptions options, ActionListener listener) { + return restHighLevelClient.performRequestAsyncAndParseEntity(CreateEnrollmentTokenRequest.INSTANCE, + CreateEnrollmentTokenRequest::getRequest, options, + CreateEnrollmentTokenResponse::fromXContent, listener, emptySet()); + } + /** * Allows a node to join to a cluster with security features enabled using the Enroll Node API. @@ -1140,6 +1164,7 @@ public Cancellable delegatePkiAuthenticationAsync(DelegatePkiAuthenticationReque * @return the response * @throws IOException in case there is a problem sending the request or parsing back the response */ + public NodeEnrollmentResponse enrollNode(RequestOptions options) throws IOException { return restHighLevelClient.performRequestAndParseEntity( NodeEnrollmentRequest.INSTANCE, NodeEnrollmentRequest::getRequest, diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenRequest.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenRequest.java new file mode 100644 index 0000000000000..0537c3ef52693 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenRequest.java @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.apache.http.client.methods.HttpPut; +import org.elasticsearch.client.Request; +import org.elasticsearch.client.Validatable; + +public class CreateEnrollmentTokenRequest implements Validatable { + + public CreateEnrollmentTokenRequest() { + } + + public static final CreateEnrollmentTokenRequest INSTANCE = new CreateEnrollmentTokenRequest(); + + + public Request getRequest() { + return new Request(HttpPut.METHOD_NAME, "/_security/enrollment_token"); + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponse.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponse.java new file mode 100644 index 0000000000000..2af865a3699b0 --- /dev/null +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponse.java @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentParser; + +import java.io.IOException; +import java.util.Objects; + +public class CreateEnrollmentTokenResponse { + private String enrollmentToken; + + public CreateEnrollmentTokenResponse(String enrollmentToken) { + this.enrollmentToken = enrollmentToken; + } + + public String getEnrollmentToken() { + return enrollmentToken; + } + + private static final ParseField ENROLLMENT_TOKEN = new ParseField("enrollment_token"); + + @SuppressWarnings("unchecked") + private static final ConstructingObjectParser PARSER = + new ConstructingObjectParser<>(CreateEnrollmentTokenResponse.class.getName(), true, + a -> new CreateEnrollmentTokenResponse((String) a[0])); + + static { + PARSER.declareString(ConstructingObjectParser.constructorArg(), ENROLLMENT_TOKEN); + } + + public static CreateEnrollmentTokenResponse fromXContent(XContentParser parser) throws IOException { + return PARSER.apply(parser, null); + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CreateEnrollmentTokenResponse that = (CreateEnrollmentTokenResponse) o; + return enrollmentToken.equals(that.enrollmentToken); + } + + @Override public int hashCode() { + return Objects.hash(enrollmentToken); + } +} diff --git a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/Role.java b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/Role.java index 111f66bad5f91..9f649570dda50 100644 --- a/client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/Role.java +++ b/client/rest-high-level/src/main/java/org/elasticsearch/client/security/user/privileges/Role.java @@ -326,11 +326,13 @@ public static class ClusterPrivilegeName { public static final String MANAGE_ILM = "manage_ilm"; public static final String READ_ILM = "read_ilm"; public static final String MANAGE_ENRICH = "manage_enrich"; + public static final String MANAGE_ENROLLMENT = "manage_enrollment"; + public static final String ENROLL = "enroll"; public static final String[] ALL_ARRAY = new String[] { NONE, ALL, MONITOR, MONITOR_TRANSFORM_DEPRECATED, MONITOR_TRANSFORM, MONITOR_ML, MONITOR_TEXT_STRUCTURE, MONITOR_WATCHER, MONITOR_ROLLUP, MANAGE, MANAGE_TRANSFORM_DEPRECATED, MANAGE_TRANSFORM, MANAGE_ML, MANAGE_WATCHER, MANAGE_ROLLUP, MANAGE_INDEX_TEMPLATES, MANAGE_INGEST_PIPELINES, READ_PIPELINE, TRANSPORT_CLIENT, MANAGE_SECURITY, MANAGE_SAML, MANAGE_OIDC, MANAGE_TOKEN, MANAGE_PIPELINE, MANAGE_AUTOSCALING, MANAGE_CCR, - READ_CCR, MANAGE_ILM, READ_ILM, MANAGE_ENRICH }; + READ_CCR, MANAGE_ILM, READ_ILM, MANAGE_ENRICH, MANAGE_ENROLLMENT, ENROLL }; } /** diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java index 185312d89750e..eca91f9c87d54 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java @@ -11,6 +11,7 @@ import org.apache.http.client.methods.HttpDelete; import org.elasticsearch.ElasticsearchStatusException; import org.elasticsearch.client.security.AuthenticateResponse; +import org.elasticsearch.client.security.CreateEnrollmentTokenResponse; import org.elasticsearch.client.security.DeleteRoleRequest; import org.elasticsearch.client.security.DeleteRoleResponse; import org.elasticsearch.client.security.DeleteUserRequest; @@ -33,8 +34,11 @@ import org.elasticsearch.client.security.user.privileges.IndicesPrivilegesTests; import org.elasticsearch.client.security.user.privileges.Role; import org.elasticsearch.common.CharArrays; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -42,7 +46,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; +import static org.elasticsearch.client.RequestOptions.DEFAULT; import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -142,7 +148,7 @@ public void testPutRole() throws Exception { assertThat(createRoleResponse.isCreated(), is(true)); final GetRolesRequest getRoleRequest = new GetRolesRequest(role.getName()); - final GetRolesResponse getRoleResponse = securityClient.getRoles(getRoleRequest, RequestOptions.DEFAULT); + final GetRolesResponse getRoleResponse = securityClient.getRoles(getRoleRequest, DEFAULT); // assert role is equal assertThat(getRoleResponse.getRoles(), contains(role)); @@ -151,11 +157,30 @@ public void testPutRole() throws Exception { assertThat(updateRoleResponse.isCreated(), is(false)); final DeleteRoleRequest deleteRoleRequest = new DeleteRoleRequest(role.getName()); - final DeleteRoleResponse deleteRoleResponse = securityClient.deleteRole(deleteRoleRequest, RequestOptions.DEFAULT); + final DeleteRoleResponse deleteRoleResponse = securityClient.deleteRole(deleteRoleRequest, DEFAULT); // assert role deleted assertThat(deleteRoleResponse.isFound(), is(true)); } + @AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys") + public void testCreateEnrollmentTokenClient() throws Exception { + final SecurityClient securityClient = highLevelClient().security(); + final Map info; + CreateEnrollmentTokenResponse clientResponse = + execute(securityClient::createEnrollmentToken, securityClient::createEnrollmentTokenAsync, DEFAULT); + assertThat(clientResponse, notNullValue()); + String jsonString = new String(Base64.getDecoder().decode(clientResponse.getEnrollmentToken()), StandardCharsets.UTF_8); + try (XContentParser parser = createParser(JsonXContent.jsonXContent, jsonString)) { + info = parser.map(); + assertNotEquals(info, null); + info.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().toString())); + } + assertThat(info.get("adr"), notNullValue()); + assertEquals("598a35cd831ee6bb90e79aa80d6b073cda88b41d", info.get("fgr")); + assertThat(info.get("key"), notNullValue()); + } + @AwaitsFix(bugUrl = "Determine behavior for keystore with multiple keys") public void testEnrollNode() throws Exception { final NodeEnrollmentResponse nodeEnrollmentResponse = @@ -239,7 +264,7 @@ private static String basicAuthHeader(String username, char[] password) { } private static RequestOptions authorizationRequestOptions(String authorizationHeader) { - final RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder(); + final RequestOptions.Builder builder = DEFAULT.toBuilder(); builder.addHeader("Authorization", authorizationHeader); return builder.build(); } diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java index 8e610c6a6e337..73ba8c8169202 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java @@ -30,6 +30,7 @@ import org.elasticsearch.client.security.CreateApiKeyRequest; import org.elasticsearch.client.security.CreateApiKeyRequestTests; import org.elasticsearch.client.security.CreateApiKeyResponse; +import org.elasticsearch.client.security.CreateEnrollmentTokenResponse; import org.elasticsearch.client.security.CreateTokenRequest; import org.elasticsearch.client.security.CreateTokenResponse; import org.elasticsearch.client.security.DelegatePkiAuthenticationRequest; @@ -2609,6 +2610,45 @@ public void onFailure(Exception e) { } } + @AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys") + public void testCreateEnrollmentToken() throws Exception { + RestHighLevelClient client = highLevelClient(); + + { + // tag::create-enrollment-token-execute + CreateEnrollmentTokenResponse response = client.security().createEnrollmentToken(RequestOptions.DEFAULT); + // end::create-enrollment-token-execute + + // tag::create-enrollment-token-response + String enrollmentToken = response.getEnrollmentToken(); // <1> + // end::create-enrollment-token-response + } + + { + // tag::create-enrollment-token-execute-listener + ActionListener listener = + new ActionListener() { + @Override + public void onResponse(CreateEnrollmentTokenResponse response) { + // <1> + } + + @Override + public void onFailure(Exception e) { + // <2> + }}; + // end::create-enrollment-token-execute-listener + + final CountDownLatch latch = new CountDownLatch(1); + listener = new LatchedActionListener<>(listener, latch); + + // tag::create-enrollment-token-execute-async + client.security().createEnrollmentTokenAsync(RequestOptions.DEFAULT, listener); + // end::create-enrollment-token-execute-async + assertTrue(latch.await(30L, TimeUnit.SECONDS)); + } + } + private X509Certificate readCertForPkiDelegation(String certificateName) throws Exception { Path path = getDataPath("/org/elasticsearch/client/security/delegate_pki/" + certificateName); try (InputStream in = Files.newInputStream(path)) { diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponseTests.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponseTests.java new file mode 100644 index 0000000000000..f1a4b162b66fc --- /dev/null +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/security/CreateEnrollmentTokenResponseTests.java @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +package org.elasticsearch.client.security; + +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; + +import static org.hamcrest.Matchers.equalTo; + +public class CreateEnrollmentTokenResponseTests extends ESTestCase { + + public void testFromXContent() throws IOException { + final SecureString enrollment_token = UUIDs.randomBase64UUIDSecureString(); + + final XContentType xContentType = randomFrom(XContentType.values()); + final XContentBuilder builder = XContentFactory.contentBuilder(xContentType); + builder.startObject() + .field("enrollment_token", enrollment_token.toString()); + builder.endObject(); + BytesReference xContent = BytesReference.bytes(builder); + + final CreateEnrollmentTokenResponse response = CreateEnrollmentTokenResponse.fromXContent(createParser(xContentType.xContent(), + xContent)); + assertThat(response.getEnrollmentToken(), equalTo(enrollment_token.toString())); + } +} diff --git a/docs/java-rest/high-level/security/create-enrollment-token.asciidoc b/docs/java-rest/high-level/security/create-enrollment-token.asciidoc new file mode 100644 index 0000000000000..968154878504a --- /dev/null +++ b/docs/java-rest/high-level/security/create-enrollment-token.asciidoc @@ -0,0 +1,40 @@ +-- +:api: create-enrollment-token +:request: CreateEnrollmentTokenRequest +:response: CreateEnrollmentTokenResponse +-- +[role="xpack"] +[id="{upid}-{api}"] +=== Create Enrollment Token API + +Enrollment Tokens can be created using this API. + +[id="{upid}-{api}-request"] +==== Create Enrollment Token Request + +A +{request}+ contains no parameters. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-request] +-------------------------------------------------- + +include::../execution.asciidoc[] + +[id="{upid}-{api}-response"] +==== Create Enrollment Token Response + +The returned +{response}+ contains a string with the enrollment token with which user can enroll a new node +in an existing secured elasticsearch cluster, or a client can configure itself to +communicate with a secured elasticsearch cluster. + +["source","java",subs="attributes,callouts,macros"] +-------------------------------------------------- +include-tagged::{doc-tests-file}[{api}-response] +-------------------------------------------------- +<1> The enrollment token contains the following information: +- IP Address and port number for the interface where the Elasticsearch node is listening for HTTP connections; +- The fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS on the HTTP layer; +- An API key which allows a holder of the token to authenticate themself to the elasticsearch node; + +as Base64 encoded string. diff --git a/rest-api-spec/src/main/resources/rest-api-spec/api/security.create_enrollment_token.json b/rest-api-spec/src/main/resources/rest-api-spec/api/security.create_enrollment_token.json new file mode 100644 index 0000000000000..449427f30ecaa --- /dev/null +++ b/rest-api-spec/src/main/resources/rest-api-spec/api/security.create_enrollment_token.json @@ -0,0 +1,25 @@ +{ + "security.create_enrollment_token":{ + "documentation":{ + "url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/security-create-enrollment-token.html", + "description":"Create an enrollment token to allow a new node to enroll in an existing secured elasticsearch cluster, or a client to configure itself to communicate with a secured elasticsearch cluster." + }, + "stability":"stable", + "visibility":"public", + "headers":{ + "accept": [ "application/json"], + "content_type": ["application/json"] + }, + "url":{ + "paths":[ + { + "path":"/_security/enrollment_token", + "methods":[ + "PUT", + "POST" + ] + } + ] + } + } +} diff --git a/server/src/main/java/org/elasticsearch/common/settings/ClusterSettings.java b/server/src/main/java/org/elasticsearch/common/settings/ClusterSettings.java index bbbf3a8c2b974..e933838d00dd9 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/ClusterSettings.java +++ b/server/src/main/java/org/elasticsearch/common/settings/ClusterSettings.java @@ -478,7 +478,8 @@ public void apply(Settings value, Settings current, Settings previous) { FsHealthService.REFRESH_INTERVAL_SETTING, FsHealthService.SLOW_PATH_LOGGING_THRESHOLD_SETTING, IndexingPressure.MAX_INDEXING_BYTES, - ShardLimitValidator.SETTING_CLUSTER_MAX_SHARDS_PER_NODE_FROZEN); + ShardLimitValidator.SETTING_CLUSTER_MAX_SHARDS_PER_NODE_FROZEN, + Node.ENROLLMENT_ENABLED); static List> BUILT_IN_SETTING_UPGRADERS = Collections.emptyList(); diff --git a/server/src/main/java/org/elasticsearch/node/Node.java b/server/src/main/java/org/elasticsearch/node/Node.java index 1b3559a6cbce7..7bd168883d597 100644 --- a/server/src/main/java/org/elasticsearch/node/Node.java +++ b/server/src/main/java/org/elasticsearch/node/Node.java @@ -230,6 +230,10 @@ public class Node implements Closeable { public static final Setting INITIAL_STATE_TIMEOUT_SETTING = Setting.positiveTimeSetting("discovery.initial_state_timeout", TimeValue.timeValueSeconds(30), Property.NodeScope); + /** Setting for enabling or disabling enrollment mode. Defaults to false. */ + public static final Setting ENROLLMENT_ENABLED = Setting.boolSetting("node.enrollment.enabled", false, + Setting.Property.NodeScope); + private static final String CLIENT_TYPE = "node"; private final Lifecycle lifecycle = new Lifecycle(); diff --git a/x-pack/docs/en/rest-api/security.asciidoc b/x-pack/docs/en/rest-api/security.asciidoc index bdc6e9a16caa9..d315d7fcd4fbc 100644 --- a/x-pack/docs/en/rest-api/security.asciidoc +++ b/x-pack/docs/en/rest-api/security.asciidoc @@ -122,6 +122,12 @@ realm when using a custom web application other than Kibana [[security-enrollment-apis]] === Enrollment +You can use the following API to generate a token with which a user can enroll a new node +in an existing secured elasticsearch cluster, or a client can configure itself to +communicate with a secured elasticsearch cluster. + +* <> + You can use the following APIs to allow new nodes to join an existing cluster with security enabled or to allow a client to configure itself to communicate with a secured {es} cluster @@ -150,6 +156,7 @@ include::security/delete-service-token.asciidoc[] include::security/delete-users.asciidoc[] include::security/disable-users.asciidoc[] include::security/enable-users.asciidoc[] +include::security/create-enrollment-token.asciidoc[] include::security/enroll-node.asciidoc[] include::security/get-api-keys.asciidoc[] include::security/get-app-privileges.asciidoc[] diff --git a/x-pack/docs/en/rest-api/security/create-enrollment-token.asciidoc b/x-pack/docs/en/rest-api/security/create-enrollment-token.asciidoc new file mode 100644 index 0000000000000..ed70c3b23bba8 --- /dev/null +++ b/x-pack/docs/en/rest-api/security/create-enrollment-token.asciidoc @@ -0,0 +1,62 @@ +[[create-enrollment-token]] +=== Create Enrollment Token API +++++ +Create Enrollment Token +++++ + +Create an enrollment token to allow a new node to enroll in an existing secured elasticsearch cluster, or a client to configure itself to +communicate with a secured elasticsearch cluster. + +[[cluster-create-enrollment-token-api-request]] +==== {api-request-title} + +`POST /_security/enrollment_token` + +`PUT /_security/enrollment_token` + +[[cluster-create-enrollment-token-api-prereqs]] +==== {api-prereq-title} + +* You must have the `manage_enrollment` <> to use this API. +[[cluster-create-enrollment-token-api-desc]] +==== {api-description-title} + +The purpose of the create enrollment token API is to generate an enrollment with which user can enroll a new node +in an existing secured elasticsearch cluster, or a client can configure itself to +communicate with a secured elasticsearch cluster. + +[[cluster-create-enrollment-token-api-examples]] +==== {api-examples-title} + +[source,console] +-------------------------------------------------- +POST /_security/enrollment_token +-------------------------------------------------- +// TEST[skip:Determine behavior for keystore with multiple keys] + +The API returns a response such as + +[source,console-result] +-------------------------------------------------- +{ + "enrollment_token" : "eyJhZHIiOiIxOTQ2ZHY0JDZGJrU....UTm1zeWFrdzl0dk5udyJ9Cg==" <1> +} +-------------------------------------------------- + +<1> The enrollment token contains the following information: + - IP Address and port number for the interface where the Elasticsearch node is listening for HTTP connections; + - The fingerprint of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS on the HTTP layer; + - An API key which allows a holder of the token to authenticate themself to the elasticsearch node. + +as a Base64 encoded string. + +A decoded enrollment token is shown below (spaces and new lines added for clarity) : +[source,console-result] +-------------------------------------------------- +{ +"adr": "192.168.1.43:9201", +"fgr": "48:CC:6C:F8:76:43:3C:97:85:....0A:C8:8D:AE:5C:4D", +"key": "VuaCfGcBCdbkQm-e5aOx:ui2lp2axTNmsyakw9tvNnw" +} +-------------------------------------------------- + diff --git a/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc b/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc index 36043cffd5eeb..3a93218708144 100644 --- a/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc +++ b/x-pack/docs/en/rest-api/security/get-builtin-privileges.asciidoc @@ -66,6 +66,7 @@ A successful call returns an object with "cluster" and "index" fields. "cancel_task", "create_snapshot", "delegate_pki", + "enroll", "grant_api_key", "manage", "manage_api_key", @@ -73,6 +74,7 @@ A successful call returns an object with "cluster" and "index" fields. "manage_ccr", "manage_data_frame_transforms", "manage_enrich", + "manage_enrollment", "manage_ilm", "manage_index_templates", "manage_ingest_pipelines", diff --git a/x-pack/docs/en/security/authorization/privileges.asciidoc b/x-pack/docs/en/security/authorization/privileges.asciidoc index 2698ca9cbfb3b..21cb1e1564ee0 100644 --- a/x-pack/docs/en/security/authorization/privileges.asciidoc +++ b/x-pack/docs/en/security/authorization/privileges.asciidoc @@ -20,6 +20,9 @@ See <> API for more informations. Privileges to create snapshots for existing repositories. Can also list and view details on existing repositories and snapshots. +`enroll`:: +Privilege to join new nodes to a cluster with security features enabled and to configure clients to communicate with a cluster with security features enabled. + `grant_api_key`:: Privileges to create {es} API keys on behalf of other users. @@ -54,6 +57,9 @@ patterns. It also includes the authority to grant the privileges necessary to manage follower indices and auto-follow patterns. This privilege is necessary only on clusters that contain follower indices. +`manage_enrollment`:: +Privilege to create enrollment tokens that can be used to join new nodes to a cluster with security features enabled and to configure clients to communicate with a cluster with security features enabled. `enroll` is implicitly granted by `manage_enrollment`. + `manage_ilm`:: All {Ilm} operations related to managing policies. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenAction.java new file mode 100644 index 0000000000000..2ef6fcb0964dd --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenAction.java @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.enrollment; + +import org.elasticsearch.action.ActionType; + +/** + * ActionType for creating an enrollment new token + */ +public class CreateEnrollmentTokenAction extends ActionType { + public static final String NAME = "cluster:admin/xpack/security/enrollment/create"; + public static final CreateEnrollmentTokenAction INSTANCE = + new CreateEnrollmentTokenAction(); + + private CreateEnrollmentTokenAction() { + super(NAME, CreateEnrollmentTokenResponse::new); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenRequest.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenRequest.java new file mode 100644 index 0000000000000..38d8e7d2f39d9 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenRequest.java @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.enrollment; + +import org.elasticsearch.action.ActionRequest; +import org.elasticsearch.action.ActionRequestValidationException; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; + +import java.io.IOException; + +/** + * Represents a request to create an enrollment token based on the provided information. + */ + +public class CreateEnrollmentTokenRequest extends ActionRequest { + + public CreateEnrollmentTokenRequest(StreamInput in) throws IOException { + super(in); + } + + public CreateEnrollmentTokenRequest() { + super(); + } + + @Override + public ActionRequestValidationException validate() { + return null; + } + + @Override + public String toString() { + return "get_enrollment_token"; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + super.writeTo(out); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenResponse.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenResponse.java new file mode 100644 index 0000000000000..32b4e79170547 --- /dev/null +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/CreateEnrollmentTokenResponse.java @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action.enrollment; + +import org.elasticsearch.action.ActionResponse; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; +import java.util.Objects; + +/** + * Response containing the enrollment token string that was generated from an enrollment token creation request. + */ +public class CreateEnrollmentTokenResponse extends ActionResponse implements ToXContentObject { + private static final ParseField ENROLLMENT_TOKEN = new ParseField("enrollment_token"); + + private String enrollmentTokenString; + + public CreateEnrollmentTokenResponse(StreamInput in) throws IOException { + super(in); + enrollmentTokenString = in.readString(); + } + + public CreateEnrollmentTokenResponse(String enrollmentTokenString) { + this.enrollmentTokenString = Objects.requireNonNull(enrollmentTokenString); + } + + public String getEnrollmentToken() { + return enrollmentTokenString; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(enrollmentTokenString); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CreateEnrollmentTokenResponse that = (CreateEnrollmentTokenResponse) o; + return Objects.equals(enrollmentTokenString, that.enrollmentTokenString); + } + + @Override + public int hashCode() { + return Objects.hash(enrollmentTokenString); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(ENROLLMENT_TOKEN.getPreferredName(), enrollmentTokenString); + return builder.endObject(); + } +} diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java index fce84423e6339..e25a40529621b 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/ClusterPrivilegeResolver.java @@ -122,7 +122,7 @@ public class ClusterPrivilegeResolver { public static final NamedClusterPrivilege TRANSPORT_CLIENT = new ActionClusterPrivilege("transport_client", TRANSPORT_CLIENT_PATTERN); public static final NamedClusterPrivilege MANAGE_SECURITY = new ActionClusterPrivilege("manage_security", ALL_SECURITY_PATTERN, - Set.of(DelegatePkiAuthenticationAction.NAME)); + Set.of(DelegatePkiAuthenticationAction.NAME, "cluster:admin/xpack/security/enrollment*")); public static final NamedClusterPrivilege MANAGE_SAML = new ActionClusterPrivilege("manage_saml", MANAGE_SAML_PATTERN); public static final NamedClusterPrivilege MANAGE_OIDC = new ActionClusterPrivilege("manage_oidc", MANAGE_OIDC_PATTERN); public static final NamedClusterPrivilege MANAGE_API_KEY = new ActionClusterPrivilege("manage_api_key", MANAGE_API_KEY_PATTERN); @@ -135,6 +135,7 @@ public class ClusterPrivilegeResolver { "manage_autoscaling", Set.of("cluster:admin/autoscaling/*") ); + public static final NamedClusterPrivilege MANAGE_CCR = new ActionClusterPrivilege("manage_ccr", MANAGE_CCR_PATTERN); public static final NamedClusterPrivilege READ_CCR = new ActionClusterPrivilege("read_ccr", READ_CCR_PATTERN); public static final NamedClusterPrivilege CREATE_SNAPSHOT = new ActionClusterPrivilege("create_snapshot", CREATE_SNAPSHOT_PATTERN); @@ -155,6 +156,11 @@ public class ClusterPrivilegeResolver { public static final NamedClusterPrivilege CANCEL_TASK = new ActionClusterPrivilege("cancel_task", Set.of(CancelTasksAction.NAME + "*")); + public static final NamedClusterPrivilege MANAGE_ENROLLMENT = new ActionClusterPrivilege("manage_enrollment", + Set.of("cluster:admin/xpack/security/enrollment/*")); + public static final NamedClusterPrivilege ENROLL = new ActionClusterPrivilege("enroll", + Set.of("cluster:admin/xpack/security/enrollment/enroll*")); + private static final Map VALUES = sortByAccessLevel(List.of( NONE, ALL, @@ -196,7 +202,9 @@ public class ClusterPrivilegeResolver { MANAGE_OWN_API_KEY, MANAGE_ENRICH, MANAGE_LOGSTASH_PIPELINES, - CANCEL_TASK)); + CANCEL_TASK, + MANAGE_ENROLLMENT, + ENROLL)); /** * Resolves a {@link NamedClusterPrivilege} from a given name if it exists. diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java index 329fe845886c1..51f8fa3b7bc14 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java @@ -127,6 +127,20 @@ Collection certificates(Environment environment) throws General return certificates; } + public Collection x509Certificates(Environment environment) throws GeneralSecurityException, IOException { + final KeyStore trustStore = getStore(CertParsingUtils.resolvePath(keyStorePath, environment), keyStoreType, keyStorePassword); + final List certificates = new ArrayList<>(); + final Enumeration aliases = trustStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + final Certificate certificate = trustStore.getCertificate(alias); + if (certificate instanceof X509Certificate) { + certificates.add((X509Certificate) certificate); + } + } + return certificates; + } + @Override List filesToMonitor(@Nullable Environment environment) { if (keyStorePath == null) { diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateEnrollmentTokenResponseTests.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateEnrollmentTokenResponseTests.java new file mode 100644 index 0000000000000..090a0226cedc8 --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/CreateEnrollmentTokenResponseTests.java @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.core.security.action; + +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.test.AbstractXContentTestCase; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class CreateEnrollmentTokenResponseTests extends AbstractXContentTestCase { + private static final ParseField ENROLLMENT_TOKEN = new ParseField("enrollment_token"); + + public void testSerialization() throws Exception { + CreateEnrollmentTokenResponse response = createTestInstance(); + try (BytesStreamOutput out = new BytesStreamOutput()) { + response.writeTo(out); + try (StreamInput in = out.bytes().streamInput()) { + CreateEnrollmentTokenResponse serialized = new CreateEnrollmentTokenResponse(in); + assertEquals(response.getEnrollmentToken(), serialized.getEnrollmentToken()); + } + } + } + + @Override + protected CreateEnrollmentTokenResponse createTestInstance() { + final String jsonString = "{\"adr\":\"192.168.1.43:9201\",\"fgr\":\"" + + "48:CC:6C:F8:76:43:3C:97:85:B6:24:45:5B:FF:BD:40:4B:D6:35:81:51:E7:A9:99:60:E4:0A:C8:8D:AE:5C:4D\",\"key\":\"" + + "VuaCfGcBCdbkQm-e5aOx:ui2lp2axTNmsyakw9tvNnw\" }"; + + final String token = Base64.getEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)); + return new CreateEnrollmentTokenResponse(token); + } + + @Override + protected CreateEnrollmentTokenResponse doParseInstance(XContentParser parser) throws IOException { + return PARSER.apply(parser, null); + } + + @Override + protected boolean supportsUnknownFields() { + return true; + } + + @SuppressWarnings("unchecked") + public static final ConstructingObjectParser + PARSER = + new ConstructingObjectParser<>("create_enrollment_token_response", true, a -> { + final String enrollmentToken = (String) a[0]; + return new CreateEnrollmentTokenResponse(enrollmentToken); + }); + + static { + PARSER.declareString(ConstructingObjectParser.constructorArg(), ENROLLMENT_TOKEN); + } +} diff --git a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java index 5773ccb74edf3..0e4210f4ab5f9 100644 --- a/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java +++ b/x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java @@ -172,6 +172,7 @@ public class Constants { "cluster:admin/xpack/security/api_key/invalidate", "cluster:admin/xpack/security/cache/clear", "cluster:admin/xpack/security/delegate_pki", + "cluster:admin/xpack/security/enrollment/create", "cluster:admin/xpack/security/enrollment/enroll/node", "cluster:admin/xpack/security/oidc/authenticate", "cluster:admin/xpack/security/oidc/logout", 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 5f39f5b645664..3af391b9b98a6 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 @@ -90,6 +90,7 @@ 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.enrollment.CreateEnrollmentTokenAction; import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectAuthenticateAction; import org.elasticsearch.xpack.core.security.action.oidc.OpenIdConnectLogoutAction; @@ -161,6 +162,7 @@ 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.enrollment.TransportCreateEnrollmentTokenAction; import org.elasticsearch.xpack.security.action.enrollment.TransportNodeEnrollmentAction; import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter; import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction; @@ -245,6 +247,7 @@ 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.enrollment.RestCreateEnrollmentTokenAction; import org.elasticsearch.xpack.security.rest.action.enrollment.RestNodeEnrollmentAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestInvalidateTokenAction; @@ -887,6 +890,7 @@ public void onIndexModule(IndexModule module) { new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class), new ActionHandler<>(GetServiceAccountCredentialsAction.INSTANCE, TransportGetServiceAccountCredentialsAction.class), new ActionHandler<>(GetServiceAccountAction.INSTANCE, TransportGetServiceAccountAction.class), + new ActionHandler<>(CreateEnrollmentTokenAction.INSTANCE, TransportCreateEnrollmentTokenAction.class), new ActionHandler<>(NodeEnrollmentAction.INSTANCE, TransportNodeEnrollmentAction.class), usageAction, infoAction); @@ -953,6 +957,7 @@ public List getRestHandlers(Settings settings, RestController restC new RestDeleteServiceAccountTokenAction(settings, getLicenseState()), new RestGetServiceAccountCredentialsAction(settings, getLicenseState()), new RestGetServiceAccountAction(settings, getLicenseState()), + new RestCreateEnrollmentTokenAction(settings, getLicenseState()), new RestNodeEnrollmentAction(settings, getLicenseState()) ); } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenAction.java new file mode 100644 index 0000000000000..dba3c9b92f0cb --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenAction.java @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.action.enrollment; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.HandledTransportAction; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.ssl.SslUtil; +import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.env.Environment; +import org.elasticsearch.http.HttpInfo; +import org.elasticsearch.node.NodeService; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenAction; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenRequest; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenResponse; +import org.elasticsearch.xpack.core.security.authz.RoleDescriptor; +import org.elasticsearch.xpack.core.ssl.KeyConfig; +import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.core.ssl.StoreKeyConfig; +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.nio.charset.StandardCharsets; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Transport action responsible for creating an enrollment token based on a request. + */ + +public class TransportCreateEnrollmentTokenAction + extends HandledTransportAction { + + protected static final long ENROLL_API_KEY_EXPIRATION_SEC = 30*60; + + private static final Logger logger = LogManager.getLogger(TransportCreateEnrollmentTokenAction.class); + private final ApiKeyGenerator generator; + private final SecurityContext securityContext; + private final Environment environment; + private final NodeService nodeService; + private final SSLService sslService; + + @Inject + public TransportCreateEnrollmentTokenAction(TransportService transportService, ActionFilters actionFilters, ApiKeyService apiKeyService, + SecurityContext context, CompositeRolesStore rolesStore, + NamedXContentRegistry xContentRegistry, Environment environment, NodeService nodeService, + SSLService sslService) { + this(transportService, actionFilters, context, + environment, nodeService, sslService, new ApiKeyGenerator(apiKeyService, rolesStore, xContentRegistry)); + } + + // Constructor for testing + TransportCreateEnrollmentTokenAction(TransportService transportService, ActionFilters actionFilters, SecurityContext context, + Environment environment, NodeService nodeService, SSLService sslService, + ApiKeyGenerator generator) { + super(CreateEnrollmentTokenAction.NAME, transportService, actionFilters, CreateEnrollmentTokenRequest::new); + this.generator = generator; + this.securityContext = context; + this.environment = environment; + this.nodeService = nodeService; + this.sslService = sslService; + } + + @Override + protected void doExecute(Task task, CreateEnrollmentTokenRequest request, + ActionListener listener) { + try { + final KeyConfig keyConfig = sslService.getHttpTransportSSLConfiguration().keyConfig(); + if (keyConfig instanceof StoreKeyConfig == false) { + listener.onFailure(new IllegalStateException( + "Unable to create an enrollment token. Elasticsearch node HTTP layer SSL configuration is not configured with a " + + "keystore")); + return; + } + final List> httpCaKeysAndCertificates = + ((StoreKeyConfig) keyConfig).getPrivateKeyEntries(environment).stream() + .filter(t -> t.v2().getBasicConstraints() != -1).collect(Collectors.toList()); + if (httpCaKeysAndCertificates.isEmpty()) { + listener.onFailure(new IllegalStateException( + "Unable to create an enrollment token. Elasticsearch node HTTP layer SSL configuration Keystore doesn't contain any " + + "PrivateKey entries where the associated certificate is a CA certificate")); + return; + } else if (httpCaKeysAndCertificates.size() > 1) { + listener.onFailure(new IllegalStateException( + "Unable to create an enrollment token. Elasticsearch node HTTP layer SSL configuration Keystore contain multiple " + + "PrivateKey entries where the associated certificate is a CA certificate")); + return; + } else { + final TimeValue expiration = TimeValue.timeValueSeconds(ENROLL_API_KEY_EXPIRATION_SEC); + final String[] clusterPrivileges = { "enroll" }; + final List roleDescriptors = List.of(new RoleDescriptor("create_enrollment_token", clusterPrivileges, + null, null)); + CreateApiKeyRequest apiRequest = new CreateApiKeyRequest("enrollment_token_API_key_" + UUIDs.base64UUID(), + roleDescriptors, expiration); + final String fingerprint = SslUtil.calculateFingerprint(httpCaKeysAndCertificates.get(0).v2()); + generator.generateApiKey(securityContext.getAuthentication(), apiRequest, + ActionListener.wrap( + CreateApiKeyResponse -> { + try { + final String address = nodeService.info(false, false, false, false, false, false, + true, false, false, false, false).getInfo(HttpInfo.class).getAddress().publishAddress().toString(); + final XContentBuilder builder = JsonXContent.contentBuilder(); + builder.startObject(); + builder.field("adr", address); + builder.field("fgr", fingerprint); + builder.field("key", CreateApiKeyResponse.getKey().toString()); + builder.endObject(); + final String jsonString = Strings.toString(builder); + final String token = Base64.getEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)); + final CreateEnrollmentTokenResponse response = new CreateEnrollmentTokenResponse(token); + listener.onResponse(response); + } catch (Exception e) { + logger.error(("Error generating enrollment token"), e); + listener.onFailure(e); + } + }, + listener::onFailure + ) + ); + } + } catch (Exception e) { + listener.onFailure(e); + } + } +} 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 f78e4549002aa..a43874eafac3b 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 @@ -279,7 +279,7 @@ private void createApiKeyAndIndexIt(Authentication authentication, CreateApiKeyR indexResponse -> { assert request.getId().equals(indexResponse.getId()); listener.onResponse( - new CreateApiKeyResponse(request.getName(), request.getId(), apiKey, expiration)); + new CreateApiKeyResponse(request.getName(), request.getId(), apiKey, expiration)); }, listener::onFailure)))); } catch (IOException e) { 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 index 71afd11c21696..953655fe35eed 100644 --- 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 @@ -55,5 +55,4 @@ public void generateApiKey(Authentication authentication, CreateApiKeyRequest re listener::onFailure)); } - } diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestCreateEnrollmentTokenAction.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestCreateEnrollmentTokenAction.java new file mode 100644 index 0000000000000..a6d6cd272dde3 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestCreateEnrollmentTokenAction.java @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.rest.action.enrollment; + +import org.elasticsearch.action.ActionType; +import org.elasticsearch.client.node.NodeClient; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.license.XPackLicenseState; +import org.elasticsearch.node.Node; +import org.elasticsearch.rest.BytesRestResponse; +import org.elasticsearch.rest.RestRequest; +import org.elasticsearch.rest.RestResponse; +import org.elasticsearch.rest.RestStatus; +import org.elasticsearch.rest.action.RestBuilderListener; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenAction; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenRequest; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenResponse; +import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler; + +import java.io.IOException; +import java.util.List; + +import static org.elasticsearch.rest.RestRequest.Method.POST; +import static org.elasticsearch.rest.RestRequest.Method.PUT; + +/** + * Rest endpoint to create an enrollment token + */ +public class RestCreateEnrollmentTokenAction extends SecurityBaseRestHandler { + private final Settings settings; + + /** + * @param settings the node's settings + * @param licenseState the license state that will be used to determine if + * security is licensed + */ + public RestCreateEnrollmentTokenAction(Settings settings, XPackLicenseState licenseState) { + super(settings, licenseState); + this.settings = settings; + } + + @Override + public List routes() { + return List.of( + new Route(POST, "/_security/enrollment_token"), + new Route(PUT, "/_security/enrollment_token")); + } + + @Override + public String getName() { + return "cluster_enrolment_token_action"; + } + + @Override + protected RestChannelConsumer innerPrepareRequest(final RestRequest request, final NodeClient client) throws IOException { + if (Node.ENROLLMENT_ENABLED.get(settings) != true) { + throw new IllegalStateException("Enrollment mode is not enabled."); + } + final CreateEnrollmentTokenRequest enrollmentTokenRequest = new CreateEnrollmentTokenRequest(); + final ActionType action = CreateEnrollmentTokenAction.INSTANCE; + return channel -> client.execute(action, enrollmentTokenRequest, + new RestBuilderListener<>(channel) { + @Override + public RestResponse buildResponse(CreateEnrollmentTokenResponse response, XContentBuilder builder) throws Exception { + builder.startObject(); + builder.field("enrollment_token", response.getEnrollmentToken()); + builder.endObject(); + return new BytesRestResponse(RestStatus.OK, builder); + } + } + ); + } +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenActionTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenActionTests.java new file mode 100644 index 0000000000000..59d9028b425c5 --- /dev/null +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportCreateEnrollmentTokenActionTests.java @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.security.action.enrollment; + +import org.elasticsearch.Build; +import org.elasticsearch.Version; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.action.admin.cluster.node.info.NodeInfo; +import org.elasticsearch.action.support.ActionFilters; +import org.elasticsearch.action.support.PlainActionFuture; +import org.elasticsearch.cluster.node.DiscoveryNode; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.transport.BoundTransportAddress; +import org.elasticsearch.common.transport.TransportAddress; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.env.Environment; +import org.elasticsearch.http.HttpInfo; +import org.elasticsearch.node.NodeService; +import org.elasticsearch.tasks.Task; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.VersionUtils; +import org.elasticsearch.threadpool.ThreadPool; +import org.elasticsearch.transport.Transport; +import org.elasticsearch.transport.TransportService; +import org.elasticsearch.xpack.core.security.SecurityContext; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyRequest; +import org.elasticsearch.xpack.core.security.action.CreateApiKeyResponse; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenRequest; +import org.elasticsearch.xpack.core.security.action.enrollment.CreateEnrollmentTokenResponse; +import org.elasticsearch.xpack.core.security.authc.Authentication; +import org.elasticsearch.xpack.core.security.user.User; +import org.elasticsearch.xpack.core.ssl.SSLConfiguration; +import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.security.authc.support.ApiKeyGenerator; +import org.junit.Before; +import org.mockito.Mockito; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static org.elasticsearch.xpack.security.action.enrollment.TransportCreateEnrollmentTokenAction.ENROLL_API_KEY_EXPIRATION_SEC; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportCreateEnrollmentTokenActionTests extends ESTestCase { + private TransportCreateEnrollmentTokenAction action; + private ApiKeyGenerator apiKeyGenerator; + private SecureString key; + private BoundTransportAddress dummyBoundTransportAddress; + private Instant now; + + @Before + public void setupMocks() throws Exception { + final Clock clock = Clock.systemUTC(); + now = clock.instant(); + final Environment env = mock(Environment.class); + final Path tempDir = createTempDir(); + final Path httpCaPath = tempDir.resolve("httpCa.p12"); + Files.copy(getDataPath("/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12"), httpCaPath); + when(env.configFile()).thenReturn(tempDir); + final Settings settings = Settings.builder() + .put("xpack.security.enabled", true) + .put( "xpack.security.authc.api_key.enabled", true) + .put("keystore.path", "httpCa.p12") + .put("keystore.password", "password") + .build(); + final SSLService sslService = mock(SSLService.class); + final SSLConfiguration sslConfiguration = new SSLConfiguration(settings); + when(sslService.getHttpTransportSSLConfiguration()).thenReturn(sslConfiguration); + + Authentication authentication = + new Authentication(new User("joe", "manage_enrollment"), + new Authentication.RealmRef("test", "test", "node"), null); + final SecurityContext securityContext = mock(SecurityContext.class); + when(securityContext.getAuthentication()).thenReturn(authentication); + + final NodeService nodeService = mock(NodeService.class); + dummyBoundTransportAddress = new BoundTransportAddress( + new TransportAddress[]{buildNewFakeTransportAddress()}, buildNewFakeTransportAddress()); + NodeInfo nodeInfo = new NodeInfo( + Version.CURRENT, + Build.CURRENT, + new DiscoveryNode("test_node", buildNewFakeTransportAddress(), emptyMap(), emptySet(), VersionUtils.randomVersion(random())), + null, + null, + null, + null, + null, + null, + new HttpInfo(dummyBoundTransportAddress, randomNonNegativeLong()), + null, + null, + null, + null); + doReturn(nodeInfo).when(nodeService).info(false, false, false, false, false, false, + true, false, false, false, false); + + final TransportService transportService = new TransportService(settings, + mock(Transport.class), + mock(ThreadPool.class), + TransportService.NOOP_TRANSPORT_INTERCEPTOR, + x -> null, + null, + Collections.emptySet()); + + apiKeyGenerator = mock(ApiKeyGenerator.class); + key = new SecureString(randomAlphaOfLength(18).toCharArray()); + final CreateApiKeyResponse createApiKeyResponse = new CreateApiKeyResponse(randomAlphaOfLengthBetween(6, 32), + randomAlphaOfLength(12), key, now.plusMillis(ENROLL_API_KEY_EXPIRATION_SEC*1000)); + + Mockito.doAnswer(inv -> { + final Object[] args = inv.getArguments(); + assertThat(args, arrayWithSize(3)); + + assertThat(args[0], equalTo(authentication)); + + ActionListener listener = (ActionListener) args[args.length - 1]; + listener.onResponse(createApiKeyResponse); + + return null; + }).when(apiKeyGenerator).generateApiKey(any(Authentication.class), any(CreateApiKeyRequest.class), any(ActionListener.class)); + + action = + new TransportCreateEnrollmentTokenAction(transportService, mock(ActionFilters.class), + securityContext, env, nodeService, sslService, apiKeyGenerator); + } + + public void testDoExecute() throws IOException { + assumeFalse("NodeEnrollment is not supported on FIPS because it requires a KeyStore", inFipsJvm()); + + final CreateEnrollmentTokenRequest request = new CreateEnrollmentTokenRequest(); + final PlainActionFuture future = new PlainActionFuture<>(); + action.doExecute(mock(Task.class), request, future); + final CreateEnrollmentTokenResponse response = future.actionGet(); + + Map info = getDecoded(response); + assertEquals(dummyBoundTransportAddress.publishAddress().toString(), info.get("adr")); + assertEquals("598a35cd831ee6bb90e79aa80d6b073cda88b41d", info.get("fgr")); + assertEquals(key.toString(), info.get("key")); + } + + private Map getDecoded(CreateEnrollmentTokenResponse response) throws IOException { + assertNotEquals(response.getEnrollmentToken(), null); + String jsonString = new String(Base64.getDecoder().decode(response.getEnrollmentToken()), StandardCharsets.UTF_8); + try (XContentParser parser = createParser(JsonXContent.jsonXContent, jsonString)) { + Map info = parser.map(); + assertNotEquals(info, null); + return info.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().toString())); + } + } +} diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml index 90842d3c04cd0..49ed55d2ddbee 100644 --- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/privileges/11_builtin.yml @@ -8,7 +8,26 @@ setup: wait_for_status: yellow --- -"Test get builtin privileges": +"Test get builtin privileges 8.x": + - skip: + version: " - 7.99.99" + reason: "new privileges in 8.0 only" + + - do: + security.get_builtin_privileges: {} + + # This is fragile - it needs to be updated every time we add a new cluster/index privilege + # I would much prefer we could just check that specific entries are in the array, but we don't have + # an assertion for that + - length: { "cluster" : 43 } + - length: { "index" : 19 } + +--- +"Test get builtin privileges 7.x": + - skip: + version: "7.99.99 - " + reason: "new privileges in 8.0 only" + - do: security.get_builtin_privileges: {}