Browse Source

Add the Enroll Kibana API (#72207)

This change adds the Enroll Kibana API that enables a Kibana instance to
configure itself to communicate with a secured elasticsearch cluster
Ioannis Kakavas 4 years ago
parent
commit
82e7fbda53
31 changed files with 927 additions and 23 deletions
  1. 0 1
      client/rest-high-level/src/main/java/org/elasticsearch/client/ClusterClient.java
  2. 31 1
      client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java
  3. 25 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/KibanaEnrollmentRequest.java
  4. 63 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/KibanaEnrollmentResponse.java
  5. 1 1
      client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentRequest.java
  6. 0 1
      client/rest-high-level/src/test/java/org/elasticsearch/client/ClusterClientIT.java
  7. 12 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java
  8. 42 1
      client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java
  9. 66 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/security/KibanaErnollmentResponseTests.java
  10. 47 0
      docs/java-rest/high-level/security/enroll_kibana.asciidoc
  11. 24 0
      rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_kibana.json
  12. 1 1
      rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_node.json
  13. 2 0
      x-pack/docs/en/rest-api/security.asciidoc
  14. 47 0
      x-pack/docs/en/rest-api/security/enroll-kibana.asciidoc
  15. 2 7
      x-pack/docs/en/rest-api/security/enroll-node.asciidoc
  16. 20 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentAction.java
  17. 29 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentRequest.java
  18. 85 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentResponse.java
  19. 1 1
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentAction.java
  20. 20 0
      x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java
  21. 47 0
      x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentResponseTests.java
  22. 2 1
      x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java
  23. 5 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java
  24. 123 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportKibanaEnrollmentAction.java
  25. 4 5
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentToken.java
  26. 61 0
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestKibanaEnrollAction.java
  27. 1 1
      x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestNodeEnrollmentAction.java
  28. 120 0
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportKibanaEnrollmentActionTests.java
  29. 2 2
      x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java
  30. 44 0
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc
  31. BIN
      x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12

+ 0 - 1
client/rest-high-level/src/main/java/org/elasticsearch/client/ClusterClient.java

@@ -273,5 +273,4 @@ public final class ClusterClient {
         return restHighLevelClient.performRequestAsync(componentTemplatesRequest,
             ClusterRequestConverters::componentTemplatesExist, options, RestHighLevelClient::convertExistsResponse, listener, emptySet());
     }
-
 }

+ 31 - 1
client/rest-high-level/src/main/java/org/elasticsearch/client/SecurityClient.java

@@ -78,6 +78,8 @@ import org.elasticsearch.client.security.PutRoleRequest;
 import org.elasticsearch.client.security.PutRoleResponse;
 import org.elasticsearch.client.security.PutUserRequest;
 import org.elasticsearch.client.security.PutUserResponse;
+import org.elasticsearch.client.security.KibanaEnrollmentRequest;
+import org.elasticsearch.client.security.KibanaEnrollmentResponse;
 
 import java.io.IOException;
 
@@ -1299,7 +1301,6 @@ public final class SecurityClient {
                 DelegatePkiAuthenticationResponse::fromXContent, listener, emptySet());
     }
 
-
     /**
      * Allows a node to join to a cluster with security features enabled using the Enroll Node API.
      * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
@@ -1321,4 +1322,33 @@ public final class SecurityClient {
         return restHighLevelClient.performRequestAsyncAndParseEntity(NodeEnrollmentRequest.INSTANCE, NodeEnrollmentRequest::getRequest,
             options, NodeEnrollmentResponse::fromXContent, listener, emptySet());
     }
+
+    /**
+     * Allows a kibana instance to configure itself to connect to a secured cluster using the Enroll Kibana API
+     * @param options the request options (e.g. headers), use {@link RequestOptions#DEFAULT} if nothing needs to be customized
+     * @return the response
+     * @throws IOException in case there is a problem sending the request or parsing back the response
+     */
+    public KibanaEnrollmentResponse enrollKibana(RequestOptions options) throws IOException {
+        return restHighLevelClient.performRequestAndParseEntity(
+            KibanaEnrollmentRequest.INSTANCE,
+            KibanaEnrollmentRequest::getRequest,
+            options,
+            KibanaEnrollmentResponse::fromXContent,
+            emptySet());
+    }
+
+    /**
+     * Asynchronously allows a kibana instance to configure itself to connect to a secured cluster using the Enroll Kibana 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 enrollKibanaAsync(
+        RequestOptions options,
+        ActionListener<KibanaEnrollmentResponse> listener) {
+        return restHighLevelClient.performRequestAsyncAndParseEntity(
+            KibanaEnrollmentRequest.INSTANCE,
+            KibanaEnrollmentRequest::getRequest, options, KibanaEnrollmentResponse::fromXContent, listener, emptySet());
+    }
+
 }

+ 25 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/KibanaEnrollmentRequest.java

@@ -0,0 +1,25 @@
+/*
+ * 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.HttpGet;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.Validatable;
+
+public final class KibanaEnrollmentRequest implements Validatable {
+
+    public static final KibanaEnrollmentRequest INSTANCE = new KibanaEnrollmentRequest();
+    private KibanaEnrollmentRequest() {
+    }
+
+    public Request getRequest() {
+        return new Request(HttpGet.METHOD_NAME, "/_security/enroll/kibana");
+    }
+
+}

+ 63 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/security/KibanaEnrollmentResponse.java

@@ -0,0 +1,63 @@
+/*
+ * 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.settings.SecureString;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ParseField;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Objects;
+
+public final class KibanaEnrollmentResponse {
+
+    private SecureString password;
+    private String httpCa;
+
+    public KibanaEnrollmentResponse(SecureString password, String httpCa) {
+        this.password = password;
+        this.httpCa = httpCa;
+    }
+
+    public SecureString getPassword() { return password; }
+
+    public String getHttpCa() {
+        return httpCa;
+    }
+
+    private static final ParseField PASSWORD = new ParseField("password");
+    private static final ParseField HTTP_CA = new ParseField("http_ca");
+
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<KibanaEnrollmentResponse, Void> PARSER =
+        new ConstructingObjectParser<>(
+            KibanaEnrollmentResponse.class.getName(), true,
+            a -> new KibanaEnrollmentResponse(new SecureString(((String) a[0]).toCharArray()), (String) a[1]));
+
+    static {
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), PASSWORD);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA);
+    }
+
+    public static KibanaEnrollmentResponse 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;
+        KibanaEnrollmentResponse that = (KibanaEnrollmentResponse) o;
+        return password.equals(that.password) && httpCa.equals(that.httpCa);
+    }
+
+    @Override public int hashCode() {
+        return Objects.hash(password, httpCa);
+    }
+}

+ 1 - 1
client/rest-high-level/src/main/java/org/elasticsearch/client/security/NodeEnrollmentRequest.java

@@ -24,6 +24,6 @@ public final class NodeEnrollmentRequest implements Validatable {
     }
 
     public Request getRequest() {
-        return new Request(HttpGet.METHOD_NAME, "/_security/enroll_node");
+        return new Request(HttpGet.METHOD_NAME, "/_security/enroll/node");
     }
 }

+ 0 - 1
client/rest-high-level/src/test/java/org/elasticsearch/client/ClusterClientIT.java

@@ -386,5 +386,4 @@ public class ClusterClientIT extends ESRestHighLevelClientTestCase {
 
         assertFalse(exist);
     }
-
 }

+ 12 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/SecurityIT.java

@@ -34,6 +34,7 @@ import org.elasticsearch.client.security.user.privileges.GlobalPrivilegesTests;
 import org.elasticsearch.client.security.user.privileges.IndicesPrivileges;
 import org.elasticsearch.client.security.user.privileges.IndicesPrivilegesTests;
 import org.elasticsearch.client.security.user.privileges.Role;
+import org.elasticsearch.client.security.KibanaEnrollmentResponse;
 import org.elasticsearch.core.CharArrays;
 
 import java.io.IOException;
@@ -208,6 +209,17 @@ public class SecurityIT extends ESRestHighLevelClientTestCase {
         assertThat(nodesAddresses.size(), equalTo(1));
     }
 
+    @AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys")
+    public void testEnrollKibana() throws Exception {
+        KibanaEnrollmentResponse kibanaResponse =
+            execute(highLevelClient().security()::enrollKibana, highLevelClient().security()::enrollKibanaAsync, RequestOptions.DEFAULT);
+        assertThat(kibanaResponse, notNullValue());
+        assertThat(kibanaResponse.getHttpCa()
+            , endsWith("OWFyeGNmcwovSDJReE1tSG1leXJRaWxYbXJPdk9PUDFTNGRrSTFXbFJLOFdaN3c9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"));
+        assertNotNull(kibanaResponse.getPassword());
+        assertThat(kibanaResponse.getPassword().toString().length(), equalTo(14));
+    }
+
     private void deleteUser(User user) throws IOException {
         final Request deleteUserRequest = new Request(HttpDelete.METHOD_NAME, "/_security/user/" + user.getUsername());
         highLevelClient().getLowLevelClient().performRequest(deleteUserRequest);

+ 42 - 1
client/rest-high-level/src/test/java/org/elasticsearch/client/documentation/SecurityDocumentationIT.java

@@ -100,6 +100,7 @@ import org.elasticsearch.client.security.user.privileges.Role;
 import org.elasticsearch.client.security.user.privileges.Role.ClusterPrivilegeName;
 import org.elasticsearch.client.security.user.privileges.Role.IndexPrivilegeName;
 import org.elasticsearch.client.security.user.privileges.UserIndicesPrivileges;
+import org.elasticsearch.client.security.KibanaEnrollmentResponse;
 import org.elasticsearch.core.CheckedConsumer;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.settings.SecureString;
@@ -2894,7 +2895,6 @@ public class SecurityDocumentationIT extends ESRestHighLevelClientTestCase {
                         // <1>
                     }
 
-
                     @Override
                     public void onFailure(Exception e) {
                         // <2>
@@ -2911,6 +2911,47 @@ public class SecurityDocumentationIT extends ESRestHighLevelClientTestCase {
         }
     }
 
+    @AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys")
+    public void testKibanaEnrollment() throws Exception {
+        RestHighLevelClient client = highLevelClient();
+
+        {
+            // tag::kibana-enrollment-execute
+            KibanaEnrollmentResponse response = client.security().enrollKibana(RequestOptions.DEFAULT);
+            // end::kibana-enrollment-execute
+
+            // tag::kibana-enrollment-response
+            SecureString password = response.getPassword(); // <1>
+            String httoCa = response.getHttpCa(); // <2>
+            // end::kibana-enrollment-response
+            assertThat(password.length(), equalTo(14));
+        }
+
+        {
+            // tag::kibana-enrollment-execute-listener
+            ActionListener<KibanaEnrollmentResponse> listener =
+                new ActionListener<KibanaEnrollmentResponse>() {
+                    @Override
+                    public void onResponse(KibanaEnrollmentResponse response) {
+                        // <1>
+                    }
+
+                    @Override
+                    public void onFailure(Exception e) {
+                        // <2>
+                    }};
+            // end::kibana-enrollment-execute-listener
+
+            final CountDownLatch latch = new CountDownLatch(1);
+            listener = new LatchedActionListener<>(listener, latch);
+
+            // tag::kibana-enrollment-execute-async
+            client.security().enrollKibanaAsync(RequestOptions.DEFAULT, listener);
+            // end::kibana-enrollment-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)) {

+ 66 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/security/KibanaErnollmentResponseTests.java

@@ -0,0 +1,66 @@
+/*
+ * 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.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 org.elasticsearch.test.EqualsHashCodeTestUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class KibanaErnollmentResponseTests extends ESTestCase {
+
+    public void testFromXContent() throws IOException {
+        final String password = randomAlphaOfLength(14);
+        final String httpCa = randomAlphaOfLength(50);
+        final List<String> nodesAddresses = randomList(2, 10, () -> buildNewFakeTransportAddress().toString());
+
+        final XContentType xContentType = randomFrom(XContentType.values());
+        final XContentBuilder builder = XContentFactory.contentBuilder(xContentType);
+        builder.startObject().field("password", password).field("http_ca", httpCa).field("nodes_addresses", nodesAddresses).endObject();
+        BytesReference xContent = BytesReference.bytes(builder);
+
+        final KibanaEnrollmentResponse response = KibanaEnrollmentResponse.fromXContent(createParser(xContentType.xContent(), xContent));
+        assertThat(response.getPassword(), equalTo(password));
+        assertThat(response.getHttpCa(), equalTo(httpCa));
+    }
+
+    public void testEqualsHashCode() {
+        final SecureString password = new SecureString(randomAlphaOfLength(14).toCharArray());
+        final String httpCa = randomAlphaOfLength(50);
+        KibanaEnrollmentResponse kibanaEnrollmentResponse = new KibanaEnrollmentResponse(password, httpCa);
+
+        EqualsHashCodeTestUtils.checkEqualsAndHashCode(kibanaEnrollmentResponse,
+            (original) -> new KibanaEnrollmentResponse(original.getPassword(), original.getHttpCa()));
+
+        EqualsHashCodeTestUtils.checkEqualsAndHashCode(kibanaEnrollmentResponse,
+            (original) -> new KibanaEnrollmentResponse(original.getPassword(), original.getHttpCa()),
+            KibanaErnollmentResponseTests::mutateTestItem);
+    }
+
+    private static KibanaEnrollmentResponse mutateTestItem(KibanaEnrollmentResponse original) {
+        switch (randomIntBetween(0, 1)) {
+            case 0:
+                return new KibanaEnrollmentResponse(new SecureString(randomAlphaOfLength(14).toCharArray()),
+                    original.getHttpCa());
+            case 1:
+                return new KibanaEnrollmentResponse(original.getPassword(), randomAlphaOfLength(51));
+            default:
+                return new KibanaEnrollmentResponse(original.getPassword(),
+                    original.getHttpCa());
+        }
+    }
+}

+ 47 - 0
docs/java-rest/high-level/security/enroll_kibana.asciidoc

@@ -0,0 +1,47 @@
+--
+:api: kibana-enrollment
+:request: KibanaEnrollmentRequest
+:response: KibanaEnrollmentResponse
+--
+
+[id="{upid}-{api}"]
+=== Enroll Kibana API
+
+Allows a kibana instance to configure itself to communicate with a secured {es} cluster.
+
+include::../execution.asciidoc[]
+
+[id="{upid}-{api}-response"]
+==== Enroll Kibana Response
+
+The returned +{response}+ allows to retrieve information about the
+executed operation as follows:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api-kibana-response]
+--------------------------------------------------
+<1> The password for the `kibana_system` user
+<2> The CA certificate that has signed the certificate that the cluster uses for TLS on the HTTP layer,
+as a Base64 encoded string of the ASN.1 DER encoding of the certificate.
+
+[id="{upid}-{api}-execute-async"]
+==== Asynchronous Execution
+
+This request can be executed asynchronously using the `security().enrollClientAsync()`
+method:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api}-execute-async]
+--------------------------------------------------
+
+A typical listener for a `KibanaEnrollmentResponse` looks like:
+
+["source","java",subs="attributes,callouts,macros"]
+--------------------------------------------------
+include-tagged::{doc-tests-file}[{api}-execute-listener]
+--------------------------------------------------
+<1> Called when the execution is successfully completed. The response is
+provided as an argument
+<2> Called in case of failure. The raised exception is provided as an argument

+ 24 - 0
rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_kibana.json

@@ -0,0 +1,24 @@
+{
+  "security.enroll_kibana":{
+    "documentation":{
+      "url":"https://www.elastic.co/guide/en/elasticsearch/reference/master/security-api-enroll-kibana.html",
+      "description":"Allows a kibana instance 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/enroll/kibana",
+          "methods":[
+            "GET"
+          ]
+        }
+      ]
+    }
+  }
+}

+ 1 - 1
rest-api-spec/src/main/resources/rest-api-spec/api/security.enroll_node.json

@@ -13,7 +13,7 @@
     "url":{
       "paths":[
         {
-          "path":"/_security/enroll_node",
+          "path":"/_security/enroll/node",
           "methods":[
             "GET"
           ]

+ 2 - 0
x-pack/docs/en/rest-api/security.asciidoc

@@ -129,6 +129,7 @@ security enabled or to allow a client to configure itself to communicate with
 a secured {es} cluster
 
 * <<security-api-node-enrollment, Enroll a new node>>
+* <<security-api-kibana-enrollment, Enroll a new kibana instance>>
 
 
 include::security/authenticate.asciidoc[]
@@ -139,6 +140,7 @@ include::security/clear-privileges-cache.asciidoc[]
 include::security/clear-api-key-cache.asciidoc[]
 include::security/clear-service-token-caches.asciidoc[]
 include::security/create-api-keys.asciidoc[]
+include::security/enroll-kibana.asciidoc[]
 include::security/put-app-privileges.asciidoc[]
 include::security/create-role-mappings.asciidoc[]
 include::security/create-roles.asciidoc[]

+ 47 - 0
x-pack/docs/en/rest-api/security/enroll-kibana.asciidoc

@@ -0,0 +1,47 @@
+[[security-api-kibana-enrollment]]
+=== Enroll {kib} API
+++++
+<titleabbrev>Enroll {kib}</titleabbrev>
+++++
+
+Enables a {kib} instance to configure itself for communication with a secured {es} cluster.
+
+[[security-api-kibana-enrollment-request]]
+==== {api-request-title}
+
+`GET /_security/enroll/kibana`
+
+[[security-api-kibana-enrollment-prereqs]]
+==== {api-prereq-title}
+
+
+[[security-api-kibana-enrollment-desc]]
+==== {api-description-title}
+
+The purpose of the enroll kibana API is to allow a kibana instance to configure itself to
+communicate with an {es} cluster that is already configured with security features
+enabled.
+
+[[security-api-client-enrollment-examples]]
+==== {api-examples-title}
+
+The following example shows how to enroll a {kib} instance.
+
+[source,console]
+----
+GET /_security/enroll/kibana
+----
+// TEST[skip:we need to enable HTTP TLS for the docs cluster]
+
+The API returns the following response:
+
+[source,console_result]
+----
+{
+  "password" : "longsecurepassword", <1>
+  "http_ca" : "MIIJlAIBAzCCCVoGCSqGSIb3....vsDfsA3UZBAjEPfhubpQysAICCAA=", <2>
+}
+----
+<1> The password for the `kibana_system` user.
+<2> The CA certificate used to sign the node certificates that {es} uses for TLS on the HTTP layer.
+The certificate is returned as a Base64 encoded string of the ASN.1 DER encoding of the certificate

+ 2 - 7
x-pack/docs/en/rest-api/security/enroll-node.asciidoc

@@ -9,12 +9,7 @@ Allows a new node to join an existing cluster with security features enabled.
 [[security-api-node-enrollment-api-request]]
 ==== {api-request-title}
 
-`GET /_security/enroll_node`
-
-[[security-api-node-enrollment-api-prereqs]]
-==== {api-prereq-title}
-
-* You must have the `enroll` <<privileges-list-cluster,cluster privilege>> to use this API.
+`GET /_security/enroll/node`
 
 [[security-api-node-enrollment-api-desc]]
 ==== {api-description-title}
@@ -32,7 +27,7 @@ caller to generate valid signed certificates for the HTTP layer of all nodes in
 
 [source,console]
 --------------------------------------------------
-GET /security/enroll_node
+GET /security/enroll/node
 --------------------------------------------------
 // TEST[skip:Determine behavior for keystore with multiple keys]
 The API returns a response such as

+ 20 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentAction.java

@@ -0,0 +1,20 @@
+/*
+ * 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;
+
+public final class KibanaEnrollmentAction extends ActionType<KibanaEnrollmentResponse> {
+
+    public static final String NAME = "cluster:admin/xpack/security/enroll/kibana";
+    public static final KibanaEnrollmentAction INSTANCE = new KibanaEnrollmentAction();
+
+    private KibanaEnrollmentAction() {
+        super(NAME, KibanaEnrollmentResponse::new);
+    }
+}

+ 29 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentRequest.java

@@ -0,0 +1,29 @@
+/*
+ * 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 java.io.IOException;
+
+public final class KibanaEnrollmentRequest extends ActionRequest {
+
+    public KibanaEnrollmentRequest() {
+
+    }
+
+    public KibanaEnrollmentRequest(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override public ActionRequestValidationException validate() {
+        return null;
+    }
+}

+ 85 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentResponse.java

@@ -0,0 +1,85 @@
+/*
+ * 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.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ParseField;
+import org.elasticsearch.common.xcontent.ToXContentObject;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Objects;
+
+public final class KibanaEnrollmentResponse extends ActionResponse implements ToXContentObject {
+
+    private static final ParseField PASSWORD = new ParseField("password");
+    private static final ParseField HTTP_CA = new ParseField("http_ca");
+
+    @SuppressWarnings("unchecked")
+    private static final ConstructingObjectParser<KibanaEnrollmentResponse, Void> PARSER =
+        new ConstructingObjectParser<>(
+            KibanaEnrollmentResponse.class.getName(), true,
+            a -> new KibanaEnrollmentResponse(new SecureString(((String) a[0]).toCharArray()), (String) a[1]));
+
+    static {
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), PASSWORD);
+        PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA);
+    }
+
+    private final SecureString password;
+    private final String httpCa;
+
+    public KibanaEnrollmentResponse(StreamInput in) throws IOException {
+        super(in);
+        password = in.readSecureString();
+        httpCa = in.readString();
+    }
+
+    public KibanaEnrollmentResponse(SecureString password, String httpCa) {
+        this.password = password;
+        this.httpCa = httpCa;
+    }
+
+    public SecureString getPassword() { return password; }
+    public String getHttpCa() {
+        return httpCa;
+    }
+
+    @Override public XContentBuilder toXContent(
+        XContentBuilder builder, Params params) throws IOException {
+        builder.startObject();
+        builder.field(PASSWORD.getPreferredName(), password.toString());
+        builder.field(HTTP_CA.getPreferredName(), httpCa);
+        return builder.endObject();
+    }
+
+    @Override public void writeTo(StreamOutput out) throws IOException {
+        out.writeSecureString(password);
+        out.writeString(httpCa);
+    }
+
+    public static KibanaEnrollmentResponse fromXContent(XContentParser parser) {
+        return PARSER.apply(parser, null);
+    }
+
+    @Override public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        KibanaEnrollmentResponse response = (KibanaEnrollmentResponse) o;
+        return password.equals(response.password) && httpCa.equals(response.httpCa);
+    }
+
+    @Override public int hashCode() {
+        return Objects.hash(password, httpCa);
+    }
+}

+ 1 - 1
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/action/enrollment/NodeEnrollmentAction.java

@@ -11,7 +11,7 @@ import org.elasticsearch.action.ActionType;
 
 public final class NodeEnrollmentAction extends ActionType<NodeEnrollmentResponse> {
 
-    public static final String NAME = "cluster:admin/xpack/security/enrollment/enroll/node";
+    public static final String NAME = "cluster:admin/xpack/security/enroll/node";
     public static final NodeEnrollmentAction INSTANCE = new NodeEnrollmentAction();
 
     private NodeEnrollmentAction() {

+ 20 - 0
x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/StoreKeyConfig.java

@@ -33,8 +33,10 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Enumeration;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * A key configuration that is backed by a {@link KeyStore}
@@ -127,6 +129,24 @@ public class StoreKeyConfig extends KeyConfig {
         return certificates;
     }
 
+    /**
+     * Returns all certificates that can be found in the keystore, either as part of a PrivateKeyEntry or a TrustedCertificateEntry.
+     * Duplicates are removed.
+     */
+    public Collection<X509Certificate> x509Certificates(Environment environment) throws GeneralSecurityException, IOException {
+        final KeyStore trustStore = getStore(CertParsingUtils.resolvePath(keyStorePath, environment), keyStoreType, keyStorePassword);
+        final Set<X509Certificate> certificates = new HashSet<>();
+        final Enumeration<String> 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<Path> filesToMonitor(@Nullable Environment environment) {
         if (keyStorePath == null) {

+ 47 - 0
x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/action/enrollment/KibanaEnrollmentResponseTests.java

@@ -0,0 +1,47 @@
+/*
+ * 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.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.test.AbstractXContentTestCase;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.is;
+
+public class KibanaEnrollmentResponseTests extends AbstractXContentTestCase<KibanaEnrollmentResponse> {
+
+    @Override protected KibanaEnrollmentResponse createTestInstance() {
+        return new KibanaEnrollmentResponse(
+            new SecureString(randomAlphaOfLength(14).toCharArray()),
+            randomAlphaOfLength(50));
+    }
+
+    @Override protected KibanaEnrollmentResponse doParseInstance(XContentParser parser) throws IOException {
+        return KibanaEnrollmentResponse.fromXContent(parser);
+    }
+
+    @Override protected boolean supportsUnknownFields() {
+        return false;
+    }
+
+    public void testSerialization() throws IOException{
+        KibanaEnrollmentResponse response = createTestInstance();
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            response.writeTo(out);
+            try (StreamInput in = out.bytes().streamInput()) {
+                KibanaEnrollmentResponse serialized = new KibanaEnrollmentResponse(in);
+                assertThat(response.getHttpCa(), is(serialized.getHttpCa()));
+                assertThat(response.getPassword(), is(serialized.getPassword()));
+            }
+        }
+    }
+}

+ 2 - 1
x-pack/plugin/security/qa/operator-privileges-tests/src/javaRestTest/java/org/elasticsearch/xpack/security/operator/Constants.java

@@ -175,7 +175,8 @@ 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/enroll/node",
+        "cluster:admin/xpack/security/enroll/node",
+        "cluster:admin/xpack/security/enroll/kibana",
         "cluster:admin/xpack/security/oidc/authenticate",
         "cluster:admin/xpack/security/oidc/logout",
         "cluster:admin/xpack/security/oidc/prepare",

+ 5 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/Security.java

@@ -92,6 +92,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.NodeEnrollmentAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
 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.OpenIdConnectPrepareAuthenticationAction;
@@ -163,6 +164,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.TransportNodeEnrollmentAction;
+import org.elasticsearch.xpack.security.action.enrollment.TransportKibanaEnrollmentAction;
 import org.elasticsearch.xpack.security.action.filter.SecurityActionFilter;
 import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectAuthenticateAction;
 import org.elasticsearch.xpack.security.action.oidc.TransportOpenIdConnectLogoutAction;
@@ -247,6 +249,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.RestNodeEnrollmentAction;
+import org.elasticsearch.xpack.security.rest.action.enrollment.RestKibanaEnrollAction;
 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.oidc.RestOpenIdConnectAuthenticateAction;
@@ -888,6 +891,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new ActionHandler<>(DeleteServiceAccountTokenAction.INSTANCE, TransportDeleteServiceAccountTokenAction.class),
                 new ActionHandler<>(GetServiceAccountCredentialsAction.INSTANCE, TransportGetServiceAccountCredentialsAction.class),
                 new ActionHandler<>(GetServiceAccountAction.INSTANCE, TransportGetServiceAccountAction.class),
+                new ActionHandler<>(KibanaEnrollmentAction.INSTANCE, TransportKibanaEnrollmentAction.class),
                 new ActionHandler<>(NodeEnrollmentAction.INSTANCE, TransportNodeEnrollmentAction.class),
                 usageAction,
                 infoAction);
@@ -954,6 +958,7 @@ public class Security extends Plugin implements SystemIndexPlugin, IngestPlugin,
                 new RestDeleteServiceAccountTokenAction(settings, getLicenseState()),
                 new RestGetServiceAccountCredentialsAction(settings, getLicenseState()),
                 new RestGetServiceAccountAction(settings, getLicenseState()),
+                new RestKibanaEnrollAction(settings, getLicenseState()),
                 new RestNodeEnrollmentAction(settings, getLicenseState())
         );
     }

+ 123 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/action/enrollment/TransportKibanaEnrollmentAction.java

@@ -0,0 +1,123 @@
+/*
+ * 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.ElasticsearchException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.HandledTransportAction;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.client.OriginSettingClient;
+import org.elasticsearch.common.inject.Inject;
+import org.elasticsearch.common.settings.SecureString;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentResponse;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordAction;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequest;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequestBuilder;
+import org.elasticsearch.xpack.core.security.authc.support.Hasher;
+import org.elasticsearch.xpack.core.ssl.KeyConfig;
+import org.elasticsearch.xpack.core.ssl.SSLService;
+import org.elasticsearch.xpack.core.ssl.StoreKeyConfig;
+
+import static org.elasticsearch.xpack.core.ClientHelper.SECURITY_ORIGIN;
+
+import java.security.SecureRandom;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class TransportKibanaEnrollmentAction extends HandledTransportAction<KibanaEnrollmentRequest, KibanaEnrollmentResponse> {
+
+    private static final Logger logger = LogManager.getLogger(TransportKibanaEnrollmentAction.class);
+
+    private final Environment environment;
+    private final Client client;
+    private final SSLService sslService;
+
+    @Inject public TransportKibanaEnrollmentAction(
+        TransportService transportService,
+        Client client,
+        SSLService sslService,
+        Environment environment,
+        ActionFilters actionFilters) {
+        super(KibanaEnrollmentAction.NAME, transportService, actionFilters, KibanaEnrollmentRequest::new);
+        this.environment = environment;
+        // Should we use a specific origin for this ? Are we satisfied with the auditability of the change password request as-is ?
+        this.client = new OriginSettingClient(client, SECURITY_ORIGIN);
+        this.sslService = sslService;
+    }
+
+    @Override protected void doExecute(Task task, KibanaEnrollmentRequest request, ActionListener<KibanaEnrollmentResponse> listener) {
+
+        final KeyConfig keyConfig = sslService.getHttpTransportSSLConfiguration().keyConfig();
+        if (keyConfig instanceof StoreKeyConfig == false) {
+            listener.onFailure(new ElasticsearchException(
+                "Unable to enroll kibana instance. Elasticsearch node HTTP layer SSL configuration is not configured with a keystore"));
+            return;
+        }
+        List<X509Certificate> caCertificates;
+        try {
+            caCertificates = ((StoreKeyConfig) keyConfig).x509Certificates(environment)
+                .stream()
+                .filter(x509Certificate -> x509Certificate.getBasicConstraints() != -1)
+                .collect(Collectors.toList());
+        } catch (Exception e) {
+            listener.onFailure(new ElasticsearchException("Unable to enroll kibana instance. Cannot retrieve CA certificate " +
+                "for the HTTP layer of the Elasticsearch node.", e));
+            return;
+        }
+        if (caCertificates.size() != 1) {
+            listener.onFailure(new ElasticsearchException(
+                "Unable to enroll kibana instance. Elasticsearch node HTTP layer SSL configuration Keystore " +
+                "[xpack.security.http.ssl.keystore] doesn't contain a single PrivateKey entry where the associated " +
+                "certificate is a CA certificate"));
+        } else {
+            String httpCa;
+            try {
+                httpCa = Base64.getUrlEncoder().encodeToString(caCertificates.get(0).getEncoded());
+            } catch (CertificateEncodingException cee) {
+                listener.onFailure(new ElasticsearchException(
+                    "Unable to enroll kibana instance. Elasticsearch node HTTP layer SSL configuration uses a malformed CA certificate",
+                    cee));
+                return;
+            }
+            final char[] password = generateKibanaSystemPassword();
+            final ChangePasswordRequest changePasswordRequest =
+                new ChangePasswordRequestBuilder(client).username("kibana_system")
+                    .password(password, Hasher.resolve(XPackSettings.PASSWORD_HASHING_ALGORITHM.get(environment.settings())))
+                    .request();
+            client.execute(ChangePasswordAction.INSTANCE, changePasswordRequest, ActionListener.wrap(response -> {
+                logger.debug("Successfully set the password for user [kibana_system] during kibana enrollment");
+                listener.onResponse(new KibanaEnrollmentResponse(new SecureString(password), httpCa));
+            }, e -> listener.onFailure(new ElasticsearchException("Failed to set the password for user [kibana_system]", e))));
+        }
+
+    }
+
+    private char[] generateKibanaSystemPassword() {
+        final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray();
+        final SecureRandom secureRandom = new SecureRandom();
+        int passwordLength = 14;
+        char[] characters = new char[passwordLength];
+        for (int i = 0; i < passwordLength; ++i) {
+            characters[i] = passwordChars[secureRandom.nextInt(passwordChars.length)];
+        }
+        return characters;
+    }
+
+}

+ 4 - 5
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentToken.java

@@ -20,6 +20,7 @@ import org.elasticsearch.common.xcontent.json.JsonXContent;
 import org.elasticsearch.core.Tuple;
 import org.elasticsearch.env.Environment;
 import org.elasticsearch.xpack.core.XPackSettings;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentAction;
 import org.elasticsearch.xpack.core.security.action.enrollment.NodeEnrollmentAction;
 import org.elasticsearch.xpack.core.ssl.KeyConfig;
 import org.elasticsearch.xpack.core.ssl.SSLService;
@@ -70,11 +71,9 @@ public class CreateEnrollmentToken {
         return this.create(user, password, NodeEnrollmentAction.NAME);
     }
 
-    // TBD: Awaiting Enroll Kibana API to be merged
-    //
-    /*public String createKibanaEnrollmentToken(String user, SecureString password) throws Exception {
-    //    return this.create(user, password, KibanaEnrollmentAction.NAME);
-    }*/
+    public String createKibanaEnrollmentToken(String user, SecureString password) throws Exception {
+        return this.create(user, password, KibanaEnrollmentAction.NAME);
+    }
 
     protected String create(String user, SecureString password, String action) throws Exception {
         if (XPackSettings.ENROLLMENT_ENABLED.get(environment.settings()) != true) {

+ 61 - 0
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestKibanaEnrollAction.java

@@ -0,0 +1,61 @@
+/*
+ * 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.client.node.NodeClient;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.rest.BytesRestResponse;
+import org.elasticsearch.rest.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.KibanaEnrollmentAction;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentResponse;
+import org.elasticsearch.xpack.security.rest.action.SecurityBaseRestHandler;
+
+import java.io.IOException;
+import java.util.List;
+
+public class RestKibanaEnrollAction extends SecurityBaseRestHandler {
+
+    /**
+     * @param settings the node's settings
+     * @param licenseState the license state that will be used to determine if security is licensed
+     */
+    public RestKibanaEnrollAction(Settings settings, XPackLicenseState licenseState) {
+        super(settings, licenseState);
+    }
+
+    @Override public String getName() {
+        return "kibana_enroll_action";
+    }
+
+    @Override public List<Route> routes() {
+        return List.of(new Route(RestRequest.Method.GET, "/_security/enroll/kibana"));
+    }
+
+    @Override protected RestChannelConsumer innerPrepareRequest(
+        RestRequest request, NodeClient client) throws IOException {
+        try (XContentParser parser = request.contentParser()) {
+            return restChannel -> client.execute(
+                KibanaEnrollmentAction.INSTANCE, new KibanaEnrollmentRequest(),
+                new RestBuilderListener<>(restChannel) {
+                    @Override public RestResponse buildResponse(
+                        KibanaEnrollmentResponse kibanaEnrollmentResponse, XContentBuilder builder) throws Exception {
+                        kibanaEnrollmentResponse.toXContent(builder, channel.request());
+                        return new BytesRestResponse(RestStatus.OK, builder);
+                    }
+                });
+        }
+    }
+
+}

+ 1 - 1
x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/rest/action/enrollment/RestNodeEnrollmentAction.java

@@ -40,7 +40,7 @@ public final class RestNodeEnrollmentAction extends SecurityBaseRestHandler {
 
     @Override public List<Route> routes() {
         return List.of(
-            new Route(RestRequest.Method.GET, "_security/enroll_node")
+            new Route(RestRequest.Method.GET, "_security/enroll/node")
         );
     }
 

+ 120 - 0
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/action/enrollment/TransportKibanaEnrollmentActionTests.java

@@ -0,0 +1,120 @@
+/*
+ * 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.ElasticsearchException;
+import org.elasticsearch.action.ActionListener;
+import org.elasticsearch.action.ActionResponse;
+import org.elasticsearch.action.support.ActionFilters;
+import org.elasticsearch.action.support.PlainActionFuture;
+import org.elasticsearch.client.Client;
+import org.elasticsearch.client.ValidationException;
+import org.elasticsearch.common.settings.MockSecureSettings;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.common.util.concurrent.ThreadContext;
+import org.elasticsearch.env.Environment;
+import org.elasticsearch.tasks.Task;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.threadpool.ThreadPool;
+import org.elasticsearch.transport.Transport;
+import org.elasticsearch.transport.TransportService;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentRequest;
+import org.elasticsearch.xpack.core.security.action.enrollment.KibanaEnrollmentResponse;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordAction;
+import org.elasticsearch.xpack.core.security.action.user.ChangePasswordRequest;
+import org.elasticsearch.xpack.core.ssl.SSLConfiguration;
+import org.elasticsearch.xpack.core.ssl.SSLService;
+import org.junit.Before;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.startsWith;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TransportKibanaEnrollmentActionTests extends ESTestCase {
+    private List<ChangePasswordRequest> changePasswordRequests;
+    private TransportKibanaEnrollmentAction action;
+    private Client client;
+    private Path httpCaPath;
+
+    @Before @SuppressWarnings("unchecked") public void setup() throws Exception {
+        changePasswordRequests = new ArrayList<>();
+        final Environment env = mock(Environment.class);
+        final Path tempDir = createTempDir();
+        httpCaPath = tempDir.resolve("httpCa.p12");
+        Files.copy(getDataPath("/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12"), httpCaPath);
+        when(env.configFile()).thenReturn(tempDir);
+        final MockSecureSettings secureSettings = new MockSecureSettings();
+        secureSettings.setString("keystore.secure_password", "password");
+        final Settings settings = Settings.builder()
+            .put("keystore.path", "httpCa.p12")
+            .setSecureSettings(secureSettings)
+            .build();
+        when(env.settings()).thenReturn(settings);
+        final SSLService sslService = mock(SSLService.class);
+        final SSLConfiguration sslConfiguration = new SSLConfiguration(settings);
+        when(sslService.getHttpTransportSSLConfiguration()).thenReturn(sslConfiguration);
+        final ThreadContext threadContext = new ThreadContext(settings);
+        final ThreadPool threadPool = mock(ThreadPool.class);
+        when(threadPool.getThreadContext()).thenReturn(threadContext);
+        client = mock(Client.class);
+        when(client.threadPool()).thenReturn(threadPool);
+        doAnswer(invocation -> {
+            ChangePasswordRequest changePasswordRequest = (ChangePasswordRequest) invocation.getArguments()[1];
+            changePasswordRequests.add(changePasswordRequest);
+            ActionListener<ActionResponse.Empty> listener = (ActionListener) invocation.getArguments()[2];
+            listener.onResponse(ActionResponse.Empty.INSTANCE);
+            return null;
+        }).when(client).execute(eq(ChangePasswordAction.INSTANCE), any(), any());
+
+        final TransportService transportService = new TransportService(Settings.EMPTY,
+            mock(Transport.class),
+            threadPool,
+            TransportService.NOOP_TRANSPORT_INTERCEPTOR,
+            x -> null,
+            null,
+            Collections.emptySet());
+        action = new TransportKibanaEnrollmentAction(transportService, client, sslService, env, mock(ActionFilters.class));
+    }
+
+    public void testKibanaEnrollment() {
+        final KibanaEnrollmentRequest request = new KibanaEnrollmentRequest();
+        final PlainActionFuture<KibanaEnrollmentResponse> future = new PlainActionFuture<>();
+        action.doExecute(mock(Task.class), request, future);
+        final KibanaEnrollmentResponse response = future.actionGet();
+        assertThat(response.getHttpCa(), startsWith("MIIDSjCCAjKgAwIBAgIVALCgZXvbceUrjJaQMheDCX0kXnRJMA0GCSqGSIb3DQEBCwUAMDQxMjAw" +
+            "BgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2VuZXJhdGVkIENBMB4XDTIxMDQyODEyNTY0MVoXDTI0MDQyNzEyNTY0MVowNDEyMDAGA1UEA" +
+            "xMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5lcmF0ZWQgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCCJbOU4JvxDD_F"));
+        assertNotNull(response.getPassword());
+        assertThat(changePasswordRequests.size(), equalTo(1));
+    }
+
+    public void testKibanaEnrollmentFailedPasswordChange() {
+        // Override change password mock
+        doAnswer(invocation -> {
+            ActionListener<ActionResponse.Empty> listener = (ActionListener) invocation.getArguments()[2];
+            listener.onFailure(new ValidationException());
+            return null;
+        }).when(client).execute(eq(ChangePasswordAction.INSTANCE), any(), any());
+        final KibanaEnrollmentRequest request = new KibanaEnrollmentRequest();
+        final PlainActionFuture<KibanaEnrollmentResponse> future = new PlainActionFuture<>();
+        action.doExecute(mock(Task.class), request, future);
+        ElasticsearchException e = expectThrows(ElasticsearchException.class, future::actionGet);
+        assertThat(e.getDetailedMessage(), containsString("Failed to set the password for user [kibana_system]"));
+    }
+}

+ 2 - 2
x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java

@@ -128,7 +128,7 @@ public class CreateEnrollmentTokenTests extends ESTestCase {
         Map<String, String> infoNode = getDecoded(tokenNode);
         assertEquals("8.0.0", infoNode.get("ver"));
         assertEquals("[192.168.0.1:9201, 172.16.254.1:9202, [2001:db8:0:1234:0:567:8:1]:9203]", infoNode.get("adr"));
-        assertEquals("598a35cd831ee6bb90e79aa80d6b073cda88b41d", infoNode.get("fgr"));
+        assertEquals("ecdc64cebdfa501b771bcf43eb38b43dc3a90d78", infoNode.get("fgr"));
         assertEquals("DR6CzXkBDf8amV_48yYX:x3YqU_rqQwm-ESrkExcnOg", infoNode.get("key"));
 
         final String tokenKibana = createEnrollmentToken.createNodeEnrollmentToken("elastic", new SecureString("elastic"));
@@ -136,7 +136,7 @@ public class CreateEnrollmentTokenTests extends ESTestCase {
         Map<String, String> infoKibana = getDecoded(tokenKibana);
         assertEquals("8.0.0", infoKibana.get("ver"));
         assertEquals("[192.168.0.1:9201, 172.16.254.1:9202, [2001:db8:0:1234:0:567:8:1]:9203]", infoKibana.get("adr"));
-        assertEquals("598a35cd831ee6bb90e79aa80d6b073cda88b41d", infoKibana.get("fgr"));
+        assertEquals("ecdc64cebdfa501b771bcf43eb38b43dc3a90d78", infoKibana.get("fgr"));
         assertEquals("DR6CzXkBDf8amV_48yYX:x3YqU_rqQwm-ESrkExcnOg", infoKibana.get("key"));
     }
 

+ 44 - 0
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc

@@ -0,0 +1,44 @@
+== Instructions for generating needed keystores
+The keystores in this directory are supposed to mimic the PKCS12 keystores that the elasticsearch
+startup script will auto-generate for a node. The transport.p12 contain a single PrivateKeyEntry for the
+nodes key and certificate for the transport layer.
+The httpCa.p12 keystore contains:
+
+- A PrivateKeyEntry for the node's key and certificate for the HTTP layer
+- A PrivateKeyEntry for the CA's key and certificate
+- A TrustedCertificateEntry for the CA's certificate
+
+=== Generate CA keystore
+[source,shell]
+-----------------------------------------------------------------------------------------------------------
+$ES_HOME/bin/elasticsearch-certutil ca --out ca.p12 --pass "password"
+-----------------------------------------------------------------------------------------------------------
+
+=== Generate the transport layer keystore
+[source,shell]
+-----------------------------------------------------------------------------------------------------------
+$ES_HOME/bin/elasticsearch-certutil cert --out transport.p12 --ca ca.p12 --ca-pass "password"
+-----------------------------------------------------------------------------------------------------------
+
+=== Generate the HTTP layer keystore
+[source,shell]
+-----------------------------------------------------------------------------------------------------------
+$ES_HOME/bin/elasticsearch-certutil cert --out httpCa.p12 --ca ca.p12 --ca-pass password \
+  --dns=localhost --dns=localhost.localdomain --dns=localhost4 --dns=localhost4.localdomain4 \
+  --dns=localhost6 --dns=localhost6.localdomain6 \
+  --ip=127.0.0.1 --ip=0:0:0:0:0:0:0:1
+-----------------------------------------------------------------------------------------------------------
+
+Change the alias of the TrustedCertificateEntry so that it won't clash with the CA PrivateKeyEntry
+[source,shell]
+-----------------------------------------------------------------------------------------------------------
+keytool -changealias -alias ca -destalias cacert -keystore httpCa.p12
+-----------------------------------------------------------------------------------------------------------
+
+
+Import the CA PrivateKeyEntry
+[source,shell]
+-----------------------------------------------------------------------------------------------------------
+keytool -importkeystore -srckeystore ca.p12 -destkeystore httpCa.p12
+-----------------------------------------------------------------------------------------------------------
+

BIN
x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/httpCa.p12