Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enroll Kibana API uses Service Accounts #76370

Merged
merged 16 commits into from
Aug 17, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ public void testEnrollKibana() throws Exception {
assertThat(kibanaResponse, notNullValue());
assertThat(kibanaResponse.getHttpCa()
, endsWith("brcNC5xq6YE7C4/06nH7F6le4kE4Uo6c9fpkl4ehOxQxndNLn462tFF+8VBA8IftJ1PPWzqGxLsCTzM6p6w8sa+XhgNYglLfkRjirc="));
assertNotNull(kibanaResponse.getPassword());
assertThat(kibanaResponse.getPassword().toString().length(), equalTo(14));
assertNotNull(kibanaResponse.getToken());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@

public final class KibanaEnrollmentResponse {

private SecureString password;
private SecureString token;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azasypkin would Kibana want to know the token name in addition to the value ? ( https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-service-token.html )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A explicit token name might be convenient for kibana, but the token value itself also has the token name embedded. The token value takes the format of \0\1\0\1elastic/kibana-system/$tokenName:$tokenSecret and is base64 encoded.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@azasypkin would Kibana want to know the token name in addition to the value ? ( https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-service-token.html )

Yeah, I think it'd be better to have one (to log it least).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can gather we only need the service account token to authenticate so don't think we need the name at all.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we don't need it in the code, but I thought it'd be beneficial to log it anyway, to know which Kibana triggered generation of which token, for troubleshooting purposes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, for consistency, it might make sense to mirror the create service token API anyways:

{
  "token": {
    "name": "token1",
    "value": "AAEAAWVsYXN0aWM...vZmxlZXQtc2VydmVyL3Rva2VuMTo3TFdaSDZ" 
  }
}

Learn once, use anywhere, etc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coo, I'll make the change to return the name too, thanks for weighing in folks

private String httpCa;

public KibanaEnrollmentResponse(SecureString password, String httpCa) {
this.password = password;
public KibanaEnrollmentResponse(SecureString token, String httpCa) {
this.token = token;
this.httpCa = httpCa;
}

public SecureString getPassword() { return password; }
public SecureString getToken() { return token; }

public String getHttpCa() {
return httpCa;
}

private static final ParseField PASSWORD = new ParseField("password");
private static final ParseField TOKEN = new ParseField("token");
private static final ParseField HTTP_CA = new ParseField("http_ca");

@SuppressWarnings("unchecked")
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -42,7 +42,7 @@ public String getHttpCa() {
a -> new KibanaEnrollmentResponse(new SecureString(((String) a[0]).toCharArray()), (String) a[1]));

static {
PARSER.declareString(ConstructingObjectParser.constructorArg(), PASSWORD);
PARSER.declareString(ConstructingObjectParser.constructorArg(), TOKEN);
PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA);
}

Expand All @@ -54,10 +54,10 @@ public static KibanaEnrollmentResponse fromXContent(XContentParser parser) throw
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);
return token.equals(that.token) && httpCa.equals(that.httpCa);
}

@Override public int hashCode() {
return Objects.hash(password, httpCa);
return Objects.hash(token, httpCa);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2875,7 +2875,7 @@ public void onFailure(Exception e) {
}
}

@AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys")
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/75097")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really blocked by this issue? I think it shouldn't be unless we use truststore containing multiple CA certs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, we should have re-enabled this test as part of #73807. Will take care of it on Monday

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, testEnrollNode and testEnrollKibana here are already doing what we need, I believe

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, not really. EnrollIT means to test the HLRC behavior where the *DocumentationIT means to test that our documentation snippets are accurate and work

public void testNodeEnrollment() throws Exception {
RestHighLevelClient client = highLevelClient();

Expand Down Expand Up @@ -2918,7 +2918,7 @@ public void onFailure(Exception e) {
}
}

@AwaitsFix(bugUrl = "Determine behavior for keystores with multiple keys")
@AwaitsFix(bugUrl = "https://github.com/elastic/elasticsearch/issues/75097")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above ^^

public void testKibanaEnrollment() throws Exception {
RestHighLevelClient client = highLevelClient();

Expand All @@ -2928,10 +2928,10 @@ public void testKibanaEnrollment() throws Exception {
// end::kibana-enrollment-execute

// tag::kibana-enrollment-response
SecureString password = response.getPassword(); // <1>
SecureString token = response.getToken(); // <1>
String httoCa = response.getHttpCa(); // <2>
// end::kibana-enrollment-response
assertThat(password.length(), equalTo(14));
assertNotNull(token);
}

{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,42 +24,42 @@
public class KibanaErnollmentResponseTests extends ESTestCase {

public void testFromXContent() throws IOException {
final String password = randomAlphaOfLength(14);
final String token = randomAlphaOfLengthBetween(30, 40);
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
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();
builder.startObject().field("token", token).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.getToken(), equalTo(token));
assertThat(response.getHttpCa(), equalTo(httpCa));
}

public void testEqualsHashCode() {
final SecureString password = new SecureString(randomAlphaOfLength(14).toCharArray());
final SecureString password = new SecureString(randomAlphaOfLengthBetween(30, 40).toCharArray());
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
final String httpCa = randomAlphaOfLength(50);
KibanaEnrollmentResponse kibanaEnrollmentResponse = new KibanaEnrollmentResponse(password, httpCa);

EqualsHashCodeTestUtils.checkEqualsAndHashCode(kibanaEnrollmentResponse,
(original) -> new KibanaEnrollmentResponse(original.getPassword(), original.getHttpCa()));
(original) -> new KibanaEnrollmentResponse(original.getToken(), original.getHttpCa()));

EqualsHashCodeTestUtils.checkEqualsAndHashCode(kibanaEnrollmentResponse,
(original) -> new KibanaEnrollmentResponse(original.getPassword(), original.getHttpCa()),
(original) -> new KibanaEnrollmentResponse(original.getToken(), original.getHttpCa()),
KibanaErnollmentResponseTests::mutateTestItem);
}

private static KibanaEnrollmentResponse mutateTestItem(KibanaEnrollmentResponse original) {
switch (randomIntBetween(0, 1)) {
case 0:
return new KibanaEnrollmentResponse(new SecureString(randomAlphaOfLength(14).toCharArray()),
return new KibanaEnrollmentResponse(new SecureString(randomAlphaOfLengthBetween(30, 40).toCharArray()),
original.getHttpCa());
case 1:
return new KibanaEnrollmentResponse(original.getPassword(), randomAlphaOfLength(51));
return new KibanaEnrollmentResponse(original.getToken(), randomAlphaOfLength(51));
default:
return new KibanaEnrollmentResponse(original.getPassword(),
return new KibanaEnrollmentResponse(original.getToken(),
original.getHttpCa());
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
}
}
Expand Down
2 changes: 1 addition & 1 deletion docs/java-rest/high-level/security/enroll_kibana.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ executed operation as follows:
--------------------------------------------------
include-tagged::{doc-tests-file}[{api-kibana-response]
--------------------------------------------------
<1> The password for the `kibana_system` user
<1> A token that can be used as a bearer token for the `elastic/kibana` service account.
<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.

Expand Down
4 changes: 2 additions & 2 deletions x-pack/docs/en/rest-api/security/enroll-kibana.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ The API returns the following response:
[source,console_result]
----
{
"password" : "longsecurepassword", <1>
"token" : "AAEAAWVsYXN0aWM...vZmxlZXQtc2VydmVyL3Rva2VuMTo3TFdaSDZ", <1>
"http_ca" : "MIIJlAIBAzCCCVoGCSqGSIb3....vsDfsA3UZBAjEPfhubpQysAICCAA=", <2>
}
----
<1> The password for the `kibana_system` user.
<1> A token that can be used as a bearer token for the `elastic/kibana` service account.
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
<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.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

public final class KibanaEnrollmentResponse extends ActionResponse implements ToXContentObject {

private static final ParseField PASSWORD = new ParseField("password");
private static final ParseField TOKEN = new ParseField("token");
private static final ParseField HTTP_CA = new ParseField("http_ca");

@SuppressWarnings("unchecked")
Expand All @@ -32,39 +32,39 @@ public final class KibanaEnrollmentResponse extends ActionResponse implements To
a -> new KibanaEnrollmentResponse(new SecureString(((String) a[0]).toCharArray()), (String) a[1]));

static {
PARSER.declareString(ConstructingObjectParser.constructorArg(), PASSWORD);
PARSER.declareString(ConstructingObjectParser.constructorArg(), TOKEN);
PARSER.declareString(ConstructingObjectParser.constructorArg(), HTTP_CA);
}

private final SecureString password;
private final SecureString token;
private final String httpCa;

public KibanaEnrollmentResponse(StreamInput in) throws IOException {
super(in);
password = in.readSecureString();
token = in.readSecureString();
httpCa = in.readString();
}

public KibanaEnrollmentResponse(SecureString password, String httpCa) {
this.password = password;
public KibanaEnrollmentResponse(SecureString token, String httpCa) {
this.token = token;
this.httpCa = httpCa;
}

public SecureString getPassword() { return password; }
public SecureString getToken() { return token; }
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(TOKEN.getPreferredName(), token.toString());
builder.field(HTTP_CA.getPreferredName(), httpCa);
return builder.endObject();
}

@Override public void writeTo(StreamOutput out) throws IOException {
out.writeSecureString(password);
out.writeSecureString(token);
out.writeString(httpCa);
}

Expand All @@ -76,10 +76,10 @@ public static KibanaEnrollmentResponse fromXContent(XContentParser parser) {
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);
return token.equals(response.token) && httpCa.equals(response.httpCa);
}

@Override public int hashCode() {
return Objects.hash(password, httpCa);
return Objects.hash(token, httpCa);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class KibanaEnrollmentResponseTests extends AbstractXContentTestCase<Kiba

@Override protected KibanaEnrollmentResponse createTestInstance() {
return new KibanaEnrollmentResponse(
new SecureString(randomAlphaOfLength(14).toCharArray()),
new SecureString(randomAlphaOfLengthBetween(30, 40).toCharArray()),
randomAlphaOfLength(50));
}

Expand All @@ -40,7 +40,7 @@ public void testSerialization() throws IOException{
try (StreamInput in = out.bytes().streamInput()) {
KibanaEnrollmentResponse serialized = new KibanaEnrollmentResponse(in);
assertThat(response.getHttpCa(), is(serialized.getHttpCa()));
assertThat(response.getPassword(), is(serialized.getPassword()));
assertThat(response.getToken(), is(serialized.getToken()));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,25 @@
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.core.Tuple;
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.security.action.service.CreateServiceAccountTokenAction;
import org.elasticsearch.xpack.core.security.action.service.CreateServiceAccountTokenRequest;
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.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;
Expand All @@ -58,7 +55,6 @@ public class TransportKibanaEnrollmentAction extends HandledTransportAction<Kiba
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;
}
Expand Down Expand Up @@ -98,28 +94,19 @@ public class TransportKibanaEnrollmentAction extends HandledTransportAction<Kiba
cee));
return;
}
final char[] password = generateKibanaSystemPassword();
final ChangePasswordRequest changePasswordRequest =
new ChangePasswordRequestBuilder(client).username("kibana_system")
.password(password.clone(), 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))));
final CreateServiceAccountTokenRequest createServiceAccountTokenRequest =
new CreateServiceAccountTokenRequest("elastic", "kibana", getTokenName());
client.execute(CreateServiceAccountTokenAction.INSTANCE, createServiceAccountTokenRequest, ActionListener.wrap(response -> {
logger.debug("Successfully created credentials for the [elastic/kibana] service account during kibana enrollment");
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
listener.onResponse(new KibanaEnrollmentResponse(response.getValue(), httpCa));
}, e -> listener.onFailure(
new ElasticsearchException("Failed to create credentials for the [elastic/kibana] service account", 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;
protected static String getTokenName(){
final ZonedDateTime autoConfigDate = ZonedDateTime.now(ZoneOffset.UTC);
final String prefix = "enroll-process-token-";
return prefix + autoConfigDate.toInstant().getEpochSecond();
jkakavas marked this conversation as resolved.
Show resolved Hide resolved
}

}
Loading