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
This commit is contained in:
parent
00a1ae1b04
commit
82e7fbda53
|
@ -273,5 +273,4 @@ public final class ClusterClient {
|
|||
return restHighLevelClient.performRequestAsync(componentTemplatesRequest,
|
||||
ClusterRequestConverters::componentTemplatesExist, options, RestHighLevelClient::convertExistsResponse, listener, emptySet());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -386,5 +386,4 @@ public class ClusterClientIT extends ESRestHighLevelClientTestCase {
|
|||
|
||||
assertFalse(exist);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@
|
|||
"url":{
|
||||
"paths":[
|
||||
{
|
||||
"path":"/_security/enroll_node",
|
||||
"path":"/_security/enroll/node",
|
||||
"methods":[
|
||||
"GET"
|
||||
]
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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]"));
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
-----------------------------------------------------------------------------------------------------------
|
||||
|
Binary file not shown.
Loading…
Reference in New Issue