From 07676e01e7b1a047dcf339a90ecc39a0dbefb78b Mon Sep 17 00:00:00 2001 From: Ioannis Kakavas Date: Thu, 15 Jul 2021 16:55:35 +0300 Subject: [PATCH] Add a tool for creating enrollment tokens (#74890) This change introduces a CLI tool that can be used to create enrollment tokens. It doesn't require credentials, but simply write access to the local filesystem of a node. It uses an auto-generated user in the file-realm with superuser role. For this purpose, this change also introduces a base class for a CLI tool that can be used by any CLI tool needs to perform actions against an ES node as a superuser without requiring credentials from the user. It is worth noting that this doesn't change our existing thread model, because already an actor with write access to the fs of an ES node, can become superuser (again, by adding a superuser to the file realm, albeit manually). Co-authored-by: Adam Locke --- .../commands/create-enrollment-token.asciidoc | 59 ++++ docs/reference/commands/index.asciidoc | 2 + .../bin/elasticsearch-create-enrollment-token | 11 + .../elasticsearch-create-enrollment-token.bat | 21 ++ .../tool/CreateEnrollmentTokenTool.java | 89 ++++++ .../tool/BaseRunAsSuperuserCommand.java | 296 ++++++++++++++++++ .../CreateEnrollmentTokenTests.java | 12 +- .../action/enrollment/README.asciidoc | 4 +- .../tool/BaseRunAsSuperuserCommandTests.java | 295 +++++++++++++++++ .../tool/CreateEnrollmentTokenToolTests.java | 212 +++++++++++++ 10 files changed, 993 insertions(+), 8 deletions(-) create mode 100644 docs/reference/commands/create-enrollment-token.asciidoc create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token create mode 100644 x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java create mode 100644 x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java create mode 100644 x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java create mode 100644 x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java diff --git a/docs/reference/commands/create-enrollment-token.asciidoc b/docs/reference/commands/create-enrollment-token.asciidoc new file mode 100644 index 0000000000000..4fd95f1b7bef4 --- /dev/null +++ b/docs/reference/commands/create-enrollment-token.asciidoc @@ -0,0 +1,59 @@ +[roles="xpack"] +[[create-enrollment-token]] + +== elasticsearch-create-enrollment-token + +The `elasticsearch-create-enrollment-token` command creates enrollment tokens for +{es} nodes and {kib} instances. + +[discrete] +=== Synopsis + +[source,shell] +---- +bin/elasticsearch-create-enrollment-token +[-f, --force] [-h, --help] [-E ] [-s, --scope] +---- + +[discrete] +=== Description + +Use this command to create enrollment tokens, which you can use to enroll new +{es} nodes to an existing cluster or configure {kib} instances to communicate +with an existing {es} cluster that has security features enabled. +The command generates (and subsequently removes) a temporary user in the +<> to run the request that creates enrollment tokens. +IMPORTANT: You cannot use this tool if the file realm is disabled in your +`elasticsearch.yml` file. + +This command uses an HTTP connection to connect to the cluster and run the user +management requests. The command automatically attempts to establish the connection +over HTTPS by using the `xpack.security.http.ssl` settings in +the `elasticsearch.yml` file. If you do not use the default configuration directory, +ensure that the `ES_PATH_CONF` environment variable returns the +correct path before you run the `elasticsearch-create-enrollment-token` command. You can +override settings in your `elasticsearch.yml` file by using the `-E` command +option. For more information about debugging connection failures, see +<>. + +[discrete] +[[create-enrollment-token-parameters]] +=== Parameters + +`-E `:: Configures a standard {es} or {xpack} setting. + +`-f, --force`:: Forces the command to run against an unhealthy cluster. + +`-h, --help`:: Returns all of the command parameters. + +`-s, --scope`:: Specifies the scope of the generated token. Supported values are `node` and `kibana`. + +[discrete] +=== Examples + +The following command creates an enrollment token for enrolling an {es} node into a cluster: + +[source,shell] +---- +bin/elasticsearch-create-enrollment-token -s node +---- diff --git a/docs/reference/commands/index.asciidoc b/docs/reference/commands/index.asciidoc index 270667add876c..1bf71038e1e35 100644 --- a/docs/reference/commands/index.asciidoc +++ b/docs/reference/commands/index.asciidoc @@ -9,6 +9,7 @@ tasks from the command line: * <> * <> +* <> * <> * <> * <> @@ -22,6 +23,7 @@ tasks from the command line: include::certgen.asciidoc[] include::certutil.asciidoc[] +include::create-enrollment-token.asciidoc[] include::croneval.asciidoc[] include::keystore.asciidoc[] include::node-tool.asciidoc[] diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token new file mode 100644 index 0000000000000..ce17ab6022c6c --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token @@ -0,0 +1,11 @@ +#!/bin/bash + +# 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. + +ES_MAIN_CLASS=org.elasticsearch.xpack.security.enrollment.tool.CreateEnrollmentTokenTool \ + ES_ADDITIONAL_SOURCES="x-pack-env;x-pack-security-env" \ + "`dirname "$0"`"/elasticsearch-cli \ + "$@" diff --git a/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat new file mode 100644 index 0000000000000..39027ef697727 --- /dev/null +++ b/x-pack/plugin/security/src/main/bin/elasticsearch-create-enrollment-token.bat @@ -0,0 +1,21 @@ +@echo off + +rem Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +rem or more contributor license agreements. Licensed under the Elastic License +rem 2.0; you may not use this file except in compliance with the Elastic License +rem 2.0. + +setlocal enabledelayedexpansion +setlocal enableextensions + +set ES_MAIN_CLASS=org.elasticsearch.xpack.security.enrollment.tool.CreateEnrollmentTokenTool +set ES_ADDITIONAL_SOURCES=x-pack-env;x-pack-security-env +set ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/security-cli +call "%~dp0elasticsearch-cli.bat" ^ + %%* ^ + || goto exit + +endlocal +endlocal +:exit +exit /b %ERRORLEVEL% diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java new file mode 100644 index 0000000000000..b01b550367f92 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenTool.java @@ -0,0 +1,89 @@ +/* + * 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.enrollment.tool; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.security.enrollment.CreateEnrollmentToken; +import org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; + +import java.util.List; +import java.util.function.Function; + +public class CreateEnrollmentTokenTool extends BaseRunAsSuperuserCommand { + + private final OptionSpec scope; + private final CheckedFunction createEnrollmentTokenFunction; + static final List ALLOWED_SCOPES = List.of("node", "kibana"); + + CreateEnrollmentTokenTool() { + this( + environment -> new CommandLineHttpClient(environment), + environment -> KeyStoreWrapper.load(environment.configFile()), + environment -> new CreateEnrollmentToken(environment) + ); + } + + CreateEnrollmentTokenTool( + Function clientFunction, + CheckedFunction keyStoreFunction, + CheckedFunction createEnrollmentTokenFunction + ) { + super(clientFunction, keyStoreFunction, "Creates enrollment tokens for elasticsearch nodes and kibana instances"); + this.createEnrollmentTokenFunction = createEnrollmentTokenFunction; + scope = parser.acceptsAll(List.of("scope", "s"), "The scope of this enrollment token, can be either \"node\" or \"kibana\"") + .withRequiredArg() + .required(); + } + + public static void main(String[] args) throws Exception { + exit(new CreateEnrollmentTokenTool().main(args, Terminal.DEFAULT)); + } + + @Override + protected void validate(Terminal terminal, OptionSet options, Environment env) throws Exception { + if (XPackSettings.ENROLLMENT_ENABLED.get(env.settings()) == false) { + throw new UserException( + ExitCodes.CONFIG, + "[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token" + ); + } + final String tokenScope = scope.value(options); + if (ALLOWED_SCOPES.contains(tokenScope) == false) { + terminal.errorPrintln("The scope of this enrollment token, can only be one of " + ALLOWED_SCOPES); + throw new UserException(ExitCodes.USAGE, "Invalid scope"); + } + } + + @Override + protected void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception { + final String tokenScope = scope.value(options); + try { + CreateEnrollmentToken createEnrollmentTokenService = createEnrollmentTokenFunction.apply(env); + if (tokenScope.equals("node")) { + terminal.println(createEnrollmentTokenService.createNodeEnrollmentToken(username, password)); + } else { + terminal.println(createEnrollmentTokenService.createKibanaEnrollmentToken(username, password)); + } + } catch (Exception e) { + terminal.errorPrintln("Unable to create enrollment token for scope [" + tokenScope + "]"); + throw new UserException(ExitCodes.CANT_CREATE, e.getMessage(), e.getCause()); + } + } +} diff --git a/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java new file mode 100644 index 0000000000000..29708d6cb4e13 --- /dev/null +++ b/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/tool/BaseRunAsSuperuserCommand.java @@ -0,0 +1,296 @@ +/* + * 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.tool; + +import joptsimple.OptionSet; +import joptsimple.OptionSpecBuilder; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.KeyStoreAwareCommand; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.io.Streams; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.authc.RealmConfig; +import org.elasticsearch.xpack.core.security.authc.RealmSettings; +import org.elasticsearch.xpack.core.security.authc.file.FileRealmSettings; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.authc.file.FileUserPasswdStore; +import org.elasticsearch.xpack.security.authc.file.FileUserRolesStore; +import org.elasticsearch.xpack.security.support.FileAttributesChecker; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * A {@link KeyStoreAwareCommand} that can be extended fpr any CLI tool that needs to allow a local user with + * filesystem write access to perform actions on the node as a superuser. It leverages temporary file realm users + * with a `superuser` role. + */ +public abstract class BaseRunAsSuperuserCommand extends KeyStoreAwareCommand { + + private static final String[] ROLES = new String[] { "superuser" }; + private static final int PASSWORD_LENGTH = 14; + + private final OptionSpecBuilder force; + private final Function clientFunction; + private final CheckedFunction keyStoreFunction; + final SecureRandom secureRandom = new SecureRandom(); + + public BaseRunAsSuperuserCommand( + Function clientFunction, + CheckedFunction keyStoreFunction, + String description + ) { + super(description); + this.clientFunction = clientFunction; + this.keyStoreFunction = keyStoreFunction; + force = parser.acceptsAll(List.of("f", "force"), + "Use this option to force execution of the command against a cluster that is currently unhealthy."); + } + + @Override + protected final void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + validate(terminal, options, env); + ensureFileRealmEnabled(env.settings()); + KeyStoreWrapper keyStoreWrapper = keyStoreFunction.apply(env); + final Environment newEnv; + final Settings settings; + if (keyStoreWrapper != null) { + decryptKeyStore(keyStoreWrapper, terminal); + Settings.Builder settingsBuilder = Settings.builder(); + settingsBuilder.put(env.settings(), true); + if (settingsBuilder.getSecureSettings() == null) { + settingsBuilder.setSecureSettings(keyStoreWrapper); + } + settings = settingsBuilder.build(); + newEnv = new Environment(settings, env.configFile()); + } else { + newEnv = env; + settings = env.settings(); + } + + final String username = generateUsername(); + try (SecureString password = new SecureString(generatePassword(PASSWORD_LENGTH))){ + final Hasher hasher = Hasher.resolve(XPackSettings.PASSWORD_HASHING_ALGORITHM.get(settings)); + final Path passwordFile = FileUserPasswdStore.resolveFile(newEnv); + final Path rolesFile = FileUserRolesStore.resolveFile(newEnv); + FileAttributesChecker attributesChecker = new FileAttributesChecker(passwordFile, rolesFile); + // Store the roles file first so that when we get to store the user, it will definitely be a superuser + Map userRoles = FileUserRolesStore.parseFile(rolesFile, null); + if (userRoles == null) { + throw new IllegalStateException("File realm configuration file [" + rolesFile + "] is missing"); + } + userRoles = new HashMap<>(userRoles); + userRoles.put(username, ROLES); + FileUserRolesStore.writeFile(userRoles, rolesFile); + + Map users = FileUserPasswdStore.parseFile(passwordFile, null, settings); + if (users == null) { + throw new IllegalStateException("File realm configuration file [" + passwordFile + "] is missing"); + } + users = new HashMap<>(users); + users.put(username, hasher.hash(password)); + FileUserPasswdStore.writeFile(users, passwordFile); + + attributesChecker.check(terminal); + final boolean forceExecution = options.has(force); + checkClusterHealthWithRetries(newEnv, terminal, username, password, 5, forceExecution); + executeCommand(terminal, options, newEnv, username, password); + } catch (Exception e) { + int exitCode; + if (e instanceof UserException) { + exitCode = ((UserException) e).exitCode; + } else { + exitCode = ExitCodes.DATA_ERROR; + } + throw new UserException(exitCode, e.getMessage()); + } finally { + cleanup(terminal, newEnv, username); + } + } + + /** + * Removes temporary file realm user from users and roles file + */ + private void cleanup(Terminal terminal, Environment env, String username) throws Exception { + final Path passwordFile = FileUserPasswdStore.resolveFile(env); + final Path rolesFile = FileUserRolesStore.resolveFile(env); + final List errorMessages = new ArrayList<>(); + FileAttributesChecker attributesChecker = new FileAttributesChecker(passwordFile, rolesFile); + + Map users = FileUserPasswdStore.parseFile(passwordFile, null, env.settings()); + if (users == null) { + errorMessages.add("File realm configuration file [" + passwordFile + "] is missing"); + } else { + users = new HashMap<>(users); + char[] passwd = users.remove(username); + if (passwd != null) { + // No need to overwrite, if the user was already removed + FileUserPasswdStore.writeFile(users, passwordFile); + Arrays.fill(passwd, '\0'); + } + } + Map userRoles = FileUserRolesStore.parseFile(rolesFile, null); + if (userRoles == null) { + errorMessages.add("File realm configuration file [" + rolesFile + "] is missing"); + } else { + userRoles = new HashMap<>(userRoles); + String[] roles = userRoles.remove(username); + if (roles != null) { + // No need to overwrite, if the user was already removed + FileUserRolesStore.writeFile(userRoles, rolesFile); + } + } + if ( errorMessages.isEmpty() == false ) { + throw new UserException(ExitCodes.CONFIG, String.join(" , ", errorMessages)); + } + attributesChecker.check(terminal); + } + + private void ensureFileRealmEnabled(Settings settings) throws Exception { + final Map realms = RealmSettings.getRealmSettings(settings); + Map fileRealmSettings = realms.entrySet().stream() + .filter(e -> e.getKey().getType().equals(FileRealmSettings.TYPE)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + if (fileRealmSettings.size() == 1) { + final String fileRealmName = fileRealmSettings.entrySet().iterator().next().getKey().getName(); + if (RealmSettings.ENABLED_SETTING.apply(FileRealmSettings.TYPE) + .getConcreteSettingForNamespace(fileRealmName) + .get(settings) == false) throw new UserException(ExitCodes.CONFIG, "File realm must be enabled"); + } + // Else it's either explicitly enabled, or not defined in the settings so it is implicitly enabled. + } + + /** + * Checks that we can connect to the cluster and that the cluster health is not RED. It optionally handles + * retries as the file realm might not have reloaded the users file yet in order to authenticate our + * newly created file realm user. + */ + private void checkClusterHealthWithRetries(Environment env, Terminal terminal, String username, SecureString password, int retries, + boolean force) throws Exception { + CommandLineHttpClient client = clientFunction.apply(env); + final URL clusterHealthUrl = createURL(new URL(client.getDefaultURL()), "_cluster/health", "?pretty"); + final HttpResponse response; + try { + response = client.execute("GET", clusterHealthUrl, username, password, () -> null, this::responseBuilder); + } catch (Exception e) { + throw new UserException(ExitCodes.UNAVAILABLE, "Failed to determine the health of the cluster. ", e); + } + final int responseStatus = response.getHttpStatus(); + if (responseStatus != HttpURLConnection.HTTP_OK) { + // We try to write the roles file first and then the users one, but theoretically we could have loaded the users + // before we have actually loaded the roles so we also retry on 403 ( temp user is found but has no roles ) + if ((responseStatus == HttpURLConnection.HTTP_UNAUTHORIZED || responseStatus == HttpURLConnection.HTTP_FORBIDDEN) + && retries > 0 ) { + terminal.println( + Terminal.Verbosity.VERBOSE, + "Unexpected http status [" + responseStatus + "] while attempting to determine cluster health. Will retry at most " + + retries + + " more times." + ); + Thread.sleep(1000); + retries -= 1; + checkClusterHealthWithRetries(env, terminal, username, password, retries, force); + } else { + throw new UserException( + ExitCodes.DATA_ERROR, + "Failed to determine the health of the cluster. Unexpected http status [" + responseStatus + "]" + ); + } + } else { + final String clusterStatus = Objects.toString(response.getResponseBody().get("status"), ""); + if (clusterStatus.isEmpty()) { + throw new UserException( + ExitCodes.DATA_ERROR, + "Failed to determine the health of the cluster. Cluster health API did not return a status value." + ); + } else if ("red".equalsIgnoreCase(clusterStatus) && force == false) { + terminal.errorPrintln("Failed to determine the health of the cluster. Cluster health is currently RED."); + terminal.errorPrintln("This means that some cluster data is unavailable and your cluster is not fully functional."); + terminal.errorPrintln("The cluster logs (https://www.elastic.co/guide/en/elasticsearch/reference/" + + Version.CURRENT.major + "." + Version.CURRENT.minor + "/logging.html)" + + " might contain information/indications for the underlying cause"); + terminal.errorPrintln( + "It is recommended that you resolve the issues with your cluster before continuing"); + terminal.errorPrintln("It is very likely that the command will fail when run against an unhealthy cluster."); + terminal.errorPrintln(""); + terminal.errorPrintln("If you still want to attempt to execute this command against an unhealthy cluster," + + " you can pass the `-f` parameter."); + throw new UserException(ExitCodes.UNAVAILABLE, + "Failed to determine the health of the cluster. Cluster health is currently RED."); + } + // else it is yellow or green so we can continue + } + } + + HttpResponse.HttpResponseBuilder responseBuilder(InputStream is) throws IOException { + final HttpResponse.HttpResponseBuilder httpResponseBuilder = new HttpResponse.HttpResponseBuilder(); + final String responseBody = Streams.readFully(is).utf8ToString(); + httpResponseBuilder.withResponseBody(responseBody); + return httpResponseBuilder; + } + + char[] generatePassword(int passwordLength) { + final char[] passwordChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*-_=+?").toCharArray(); + char[] characters = new char[passwordLength]; + for (int i = 0; i < passwordLength; ++i) { + characters[i] = passwordChars[secureRandom.nextInt(passwordChars.length)]; + } + return characters; + } + + private String generateUsername() { + final char[] usernameChars = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789").toCharArray(); + int usernameLength = 8; + char[] characters = new char[usernameLength]; + for (int i = 0; i < usernameLength; ++i) { + characters[i] = usernameChars[secureRandom.nextInt(usernameChars.length)]; + } + return "enrollment_autogenerated_" + new String(characters); + } + + URL createURL(URL url, String path, String query) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + path).replaceAll("/+", "/") + query); + } + + /** + * This is called after we have created a temporary superuser in the file realm and verified that its + * credentials work. The username and password of the generated user are passed as parameters. Overriding methods should + * not try to close the password. + */ + protected abstract void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception; + + /** + * This method is called before we attempt to crete a temporary superuser in the file realm. Commands that + * implement {@link BaseRunAsSuperuserCommand} can do preflight checks such as parsing and validating options without + * the need to go through the process of attempting to create and remove the temporary user unnecessarily. + */ + protected abstract void validate(Terminal terminal, OptionSet options, Environment env) throws Exception ; +} diff --git a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java index 82fba30bd425c..0294e0261f6f5 100644 --- a/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java +++ b/x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/enrollment/CreateEnrollmentTokenTests.java @@ -69,9 +69,9 @@ public void setupMocks() throws Exception { .put("xpack.security.enabled", true) .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) - .put("xpack.http.ssl.truststore.path", "httpCa.p12") + .put("xpack.http.ssl.truststore.path", httpCaPath) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa.p12") + .put("xpack.security.http.ssl.keystore.path", httpCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -208,7 +208,7 @@ public void testFailedNoCaInKeystore() throws Exception { .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "transport.p12") + .put("xpack.security.http.ssl.keystore.path", httpNoCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -261,7 +261,7 @@ public void testFailedManyCaInKeystore() throws Exception { .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa2.p12") + .put("xpack.security.http.ssl.keystore.path", httpNoCaPath) .put("xpack.security.enrollment.enabled", "true") .setSecureSettings(secureSettings) .put("path.home", tempDir) @@ -330,9 +330,9 @@ public void testEnrollmentNotEnabled() throws Exception { .put("xpack.security.enabled", true) .put("xpack.http.ssl.enabled", true) .put("xpack.security.authc.api_key.enabled", true) - .put("xpack.http.ssl.truststore.path", "httpCa.p12") + .put("xpack.http.ssl.truststore.path", httpCaPath) .put("xpack.security.http.ssl.enabled", true) - .put("xpack.security.http.ssl.keystore.path", "httpCa.p12") + .put("xpack.security.http.ssl.keystore.path", httpCaPath) .setSecureSettings(secureSettings) .put("path.home", tempDir) .build(); diff --git a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc index eb4400e78a4bc..7d89bad929034 100644 --- a/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc +++ b/x-pack/plugin/security/src/test/resources/org/elasticsearch/xpack/security/action/enrollment/README.asciidoc @@ -17,13 +17,13 @@ $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" +$ES_HOME/bin/elasticsearch-certutil cert --out transport.p12 --ca ca.p12 --ca-pass "password" --pass "password" ----------------------------------------------------------------------------------------------------------- === Generate the HTTP layer keystore [source,shell] ----------------------------------------------------------------------------------------------------------- -$ES_HOME/bin/elasticsearch-certutil cert --out httpCa.p12 --ca ca.p12 --ca-pass password \ +$ES_HOME/bin/elasticsearch-certutil cert --out httpCa.p12 --ca ca.p12 --ca-pass password --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 diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java new file mode 100644 index 0000000000000..3f65572952942 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/BaseRunAsSuperuserCommandTests.java @@ -0,0 +1,295 @@ +/* + * 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.enrollment.tool; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import joptsimple.OptionSet; + +import org.elasticsearch.Version; +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedSupplier; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.PathUtilsForTesting; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.core.security.authc.support.Hasher; +import org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.elasticsearch.test.SecurityIntegTestCase.getFastStoredHashAlgoForTests; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.stringContainsInOrder; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class BaseRunAsSuperuserCommandTests extends CommandTestCase { + + private CommandLineHttpClient client; + private KeyStoreWrapper keyStoreWrapper; + private static Hasher hasher; + static FileSystem jimfs; + private Path confDir; + private Settings settings; + + @Override + protected Command newCommand() { + return new DummyRunAsSuperuserCommand(environment -> client, environment -> keyStoreWrapper) { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(BaseRunAsSuperuserCommandTests.this.settings, confDir); + } + }; + } + + @BeforeClass + public static void setupJimfs() { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setup() throws Exception { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); + Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); + hasher = getFastStoredHashAlgoForTests(); + settings = Settings.builder() + .put("path.home", homeDir) + .put(XPackSettings.PASSWORD_HASHING_ALGORITHM.getKey(), hasher.name()) + .build(); + + this.client = mock(CommandLineHttpClient.class); + when(client.getDefaultURL()).thenReturn("https://localhost:9200"); + + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = new HttpResponse( + HttpURLConnection.HTTP_OK, + Map.of("status", randomFrom("yellow", "green")) + ); + when( + client.execute( + anyString(), + eq(clusterHealthUrl(url)), + anyString(), + any(SecureString.class), + any(CheckedSupplier.class), + any(CheckedFunction.class) + ) + ).thenReturn(healthResponse); + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + public void testSuccessfulCommand() throws Exception { + execute(); + assertThat(terminal.getOutput(), is(emptyString())); + assertThat(terminal.getErrorOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testFailureWhenFileRealmIsDisabled() throws Exception { + settings = Settings.builder() + .put(settings) + .put("xpack.security.authc.realms.file." + randomAlphaOfLength(8) + ".enabled", false) + .build(); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), equalTo("File realm must be enabled")); + assertThat(terminal.getOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testUsersFileIsMissing() throws Exception { + final Path usersPath = confDir.resolve("users"); + Files.delete(usersPath); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), equalTo("File realm configuration file [/work/" + usersPath + "] is missing")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + public void testUsersRolesFileIsMissing() throws Exception { + final Path rolesPath = confDir.resolve("users_roles"); + Files.delete(rolesPath); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.getMessage(), + equalTo("File realm configuration file [/work/" + rolesPath + "] is missing")); + assertThat(terminal.getOutput(), is(emptyString())); + } + + public void testUnhealthyCluster() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.exitCode, equalTo(ExitCodes.UNAVAILABLE)); + assertThat(e.getMessage(), containsString("RED")); + assertThat(terminal.getOutput(), is(emptyString())); + String error = terminal.getErrorOutput(); + assertThat(error, stringContainsInOrder("Failed to determine the health of the cluster. Cluster health is currently RED.", + "This means that some cluster data is unavailable and your cluster is not fully functional.", + "The cluster logs (https://www.elastic.co/guide/en/elasticsearch/reference/" + + Version.CURRENT.major + "." + Version.CURRENT.minor + "/logging.html)" + + " might contain information/indications for the underlying cause")); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testUnhealthyClusterWithForce() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + execute("-f"); + assertThat(terminal.getOutput(), is(emptyString())); + assertThat(terminal.getErrorOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testWillRetryOnUnauthorized() throws Exception { + URL url = new URL(client.getDefaultURL()); + HttpResponse unauthorizedResponse = + new HttpResponse(HttpURLConnection.HTTP_UNAUTHORIZED, Map.of()); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(unauthorizedResponse); + UserException e = expectThrows(UserException.class, () -> execute("--verbose")); + String verboseOutput = terminal.getOutput(); + assertThat(verboseOutput.split("\\n").length, equalTo(5)); + assertThat(verboseOutput, + containsString("Unexpected http status [401] while attempting to determine cluster health. Will retry at most")); + assertThat(e.exitCode, equalTo(ExitCodes.DATA_ERROR)); + assertNoUsers(); + assertNoUsersRoles(); + } + + public void testWithPasswordProtectedKeystore() throws Exception { + this.keyStoreWrapper = mock(KeyStoreWrapper.class); + when(keyStoreWrapper.isLoaded()).thenReturn(true); + when(keyStoreWrapper.hasPassword()).thenReturn(true); + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) throws Throwable { + Object[] args = invocation.getArguments(); + char[] password = (char[]) args[0]; + if (Arrays.equals(password, "keystore-password".toCharArray()) == false) { + throw new GeneralSecurityException("Wrong password"); + } + return null; + } + }).when(keyStoreWrapper).decrypt(any()); + terminal.addSecretInput("some-other-password"); + UserException e = expectThrows(UserException.class, this::execute); + assertThat(e.exitCode, equalTo(ExitCodes.DATA_ERROR)); + assertNoUsers(); + assertNoUsersRoles(); + assertThat(terminal.getOutput(), is(emptyString())); + terminal.addSecretInput("keystore-password"); + execute(); + assertThat(terminal.getOutput(), is(emptyString())); + assertNoUsers(); + assertNoUsersRoles(); + } + + private void assertNoUsers() throws Exception { + List lines = Files.readAllLines(confDir.resolve("users"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(0)); + } + + private void assertNoUsersRoles() throws Exception { + List lines = Files.readAllLines(confDir.resolve("users_roles"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(0)); + } + + private URL clusterHealthUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_cluster/health").replaceAll("/+", "/") + "?pretty"); + } + + /** + * {@link DummyRunAsSuperuserCommand#executeCommand(Terminal, OptionSet, Environment, String, SecureString)} is executed while the file + * realm user is persisted in file and still valid. We check that the username and the password that would be passed to extending + * Classes as parameters are what is actually created and stored in the file realm. + */ + static class DummyRunAsSuperuserCommand extends BaseRunAsSuperuserCommand { + DummyRunAsSuperuserCommand( + Function clientFunction, + CheckedFunction keyStoreFunction + ) { + super(clientFunction, keyStoreFunction, "dummy command"); + } + + @Override + protected void executeCommand(Terminal terminal, OptionSet options, Environment env, String username, SecureString password) + throws Exception { + final Path confDir = jimfs.getPath(env.settings().get("path.home")).resolve("config"); + List lines = Files.readAllLines(confDir.resolve("users"), StandardCharsets.UTF_8); + assertThat(lines.size(), equalTo(1)); + assertThat(lines.get(0), containsString(username)); + final char[] passwordHashFromFile = lines.get(0).split(":")[1].toCharArray(); + hasher.verify(password, passwordHashFromFile); + } + + @Override + protected void validate(Terminal terminal, OptionSet options, Environment env) throws Exception { + + } + } +} diff --git a/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java new file mode 100644 index 0000000000000..d7b662684c828 --- /dev/null +++ b/x-pack/qa/security-tools-tests/src/test/java/org/elasticsearch/xpack/security/enrollment/tool/CreateEnrollmentTokenToolTests.java @@ -0,0 +1,212 @@ +/* + * 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.enrollment.tool; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; + +import org.elasticsearch.cli.Command; +import org.elasticsearch.cli.CommandTestCase; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedSupplier; +import org.elasticsearch.common.settings.KeyStoreWrapper; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.CheckedFunction; +import org.elasticsearch.core.PathUtilsForTesting; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.XPackSettings; +import org.elasticsearch.xpack.security.enrollment.CreateEnrollmentToken; +import org.elasticsearch.xpack.security.tool.CommandLineHttpClient; +import org.elasticsearch.xpack.security.tool.HttpResponse; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class CreateEnrollmentTokenToolTests extends CommandTestCase { + + static FileSystem jimfs; + String pathHomeParameter; + Path confDir; + Settings settings; + + private CommandLineHttpClient client; + private KeyStoreWrapper keyStoreWrapper; + private CreateEnrollmentToken createEnrollmentTokenService; + + @Override + protected Command newCommand() { + return new CreateEnrollmentTokenTool(environment -> client, environment -> keyStoreWrapper, + environment -> createEnrollmentTokenService) { + @Override + protected Environment createEnv(Map settings) throws UserException { + return new Environment(CreateEnrollmentTokenToolTests.this.settings, confDir); + } + }; + } + + @BeforeClass + public static void muteInFips(){ + assumeFalse("Enrollment mode is not supported in FIPS mode.", inFipsJvm()); + } + + @BeforeClass + public static void setupJimfs() { + String view = randomFrom("basic", "posix"); + Configuration conf = Configuration.unix().toBuilder().setAttributeViews(view).build(); + jimfs = Jimfs.newFileSystem(conf); + PathUtilsForTesting.installMock(jimfs); + } + + @Before + public void setup() throws Exception { + Path homeDir = jimfs.getPath("eshome"); + IOUtils.rm(homeDir); + confDir = homeDir.resolve("config"); + Files.createDirectories(confDir); + Files.write(confDir.resolve("users"), List.of(), StandardCharsets.UTF_8); + Files.write(confDir.resolve("users_roles"), List.of(), StandardCharsets.UTF_8); + settings = Settings.builder() + .put("path.home", homeDir) + .put("xpack.security.enrollment.enabled", true) + .build(); + pathHomeParameter = "-Epath.home=" + homeDir; + + this.keyStoreWrapper = mock(KeyStoreWrapper.class); + when(keyStoreWrapper.isLoaded()).thenReturn(true); + + this.client = mock(CommandLineHttpClient.class); + when(client.getDefaultURL()).thenReturn("https://localhost:9200"); + + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("yellow", "green"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + + this.createEnrollmentTokenService = mock(CreateEnrollmentToken.class); + when(createEnrollmentTokenService.createKibanaEnrollmentToken(anyString(), any(SecureString.class))) + .thenReturn("eyJ2ZXIiOiI4LjAuMCIsImFkciI6WyJbOjoxXTo5MjAwIiwiMTI3LjAuMC4xOjkyMDAiXSwiZmdyIjoiOWQ4MTRmYzdiNDQ0MWE0MWJlMDA5ZmQ0" + + "MzlkOWU5MzRiMDZiMjZjZjk4N2I1YzNjOGU0OWI1NmQ2MGYzMmMxMiIsImtleSI6Im5NMmVYbm9CbnBvT3ZncGFiaWU5OlRHaHF5UU9UVENhUEJpOVZQak1i" + + "OWcifQ=="); + when(createEnrollmentTokenService.createNodeEnrollmentToken(anyString(), any(SecureString.class))) + .thenReturn("eyJ2ZXIiOiI4LjAuMCIsImFkciI6WyJbOjoxXTo5MjAwIiwiMTI3LjAuMC4xOjkyMDAiXSwiZmdyIjoiOWQ4MTRmYzdiNDQ0MWE0MWJlMDA5ZmQ0" + + "MzlkOWU5MzRiMDZiMjZjZjk4N2I1YzNjOGU0OWI1NmQ2MGYzMmMxMiIsImtleSI6IndLTmZYSG9CQTFPMHI4UXBOV25FOnRkdUgzTmNTVHNTOGN0c3AwaWNU" + + "eEEifQ=="); + } + + @AfterClass + public static void closeJimfs() throws IOException { + if (jimfs != null) { + jimfs.close(); + jimfs = null; + } + } + + public void testCreateToken() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + String output = execute("--scope", scope); + if (scope.equals("kibana")) { + assertThat(output, containsString("WU5OlRHaHF5UU9UVENhUEJpOVZQak1iOWcifQ==")); + } else { + assertThat(output, containsString("25FOnRkdUgzTmNTVHNTOGN0c3AwaWNUeEEifQ==")); + } + } + + public void testInvalidScope() throws Exception { + String scope = randomAlphaOfLength(14); + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.USAGE)); + assertThat(e.getMessage(), equalTo("Invalid scope")); + assertThat(terminal.getErrorOutput(), + containsString("The scope of this enrollment token, can only be one of "+ CreateEnrollmentTokenTool.ALLOWED_SCOPES)); + } + + public void testUnhealthyCluster() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + URL url = new URL(client.getDefaultURL()); + HttpResponse healthResponse = + new HttpResponse(HttpURLConnection.HTTP_OK, Map.of("status", randomFrom("red"))); + when(client.execute(anyString(), eq(clusterHealthUrl(url)), anyString(), any(SecureString.class), any(CheckedSupplier.class), + any(CheckedFunction.class))).thenReturn(healthResponse); + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.UNAVAILABLE)); + assertThat(e.getMessage(), containsString("RED")); + } + + public void testUnhealthyClusterWithForce() throws Exception { + String scope = randomBoolean() ? "node" : "kibana"; + String output = execute("--scope", scope); + if (scope.equals("kibana")) { + assertThat(output, containsString("WU5OlRHaHF5UU9UVENhUEJpOVZQak1iOWcifQ==")); + } else { + assertThat(output, containsString("25FOnRkdUgzTmNTVHNTOGN0c3AwaWNUeEEifQ==")); + } + } + + public void testEnrollmentDisabled() throws Exception { + settings = Settings.builder() + .put(settings) + .put(XPackSettings.ENROLLMENT_ENABLED.getKey(), false) + .build(); + + String scope = randomBoolean() ? "node" : "kibana"; + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.CONFIG)); + assertThat(e.getMessage(), + equalTo("[xpack.security.enrollment.enabled] must be set to `true` to create an enrollment token")); + } + + public void testUnableToCreateToken() throws Exception { + this.createEnrollmentTokenService = mock(CreateEnrollmentToken.class); + when(createEnrollmentTokenService.createKibanaEnrollmentToken(anyString(), any(SecureString.class))) + .thenThrow(new IllegalStateException("example exception message")); + when(createEnrollmentTokenService.createNodeEnrollmentToken(anyString(), any(SecureString.class))) + .thenThrow(new IllegalStateException("example exception message")); + String scope = randomBoolean() ? "node" : "kibana"; + UserException e = expectThrows(UserException.class, () -> { + execute(randomFrom("-s", "--s"), scope); + }); + assertThat(e.exitCode, equalTo(ExitCodes.CANT_CREATE)); + assertThat(e.getMessage(), + equalTo("example exception message")); + } + + private URL clusterHealthUrl(URL url) throws MalformedURLException, URISyntaxException { + return new URL(url, (url.toURI().getPath() + "/_cluster/health").replaceAll("/+", "/") + "?pretty"); + } +}