All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.elasticsearch.xpack.security.tool.BaseRunAsSuperuserCommand Maven / Gradle / Ivy

There is a newer version: 8.16.1
Show newest version
/*
 * 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.OptionSpec;
import joptsimple.OptionSpecBuilder;

import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.ProcessInfo;
import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.ReferenceDocs;
import org.elasticsearch.common.cli.KeyStoreAwareCommand;
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.CommandLineHttpClient;
import org.elasticsearch.xpack.core.security.HttpResponse;
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.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Path;
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;

import static org.elasticsearch.xpack.security.tool.CommandUtils.generatePassword;
import static org.elasticsearch.xpack.security.tool.CommandUtils.generateUsername;

/**
 * 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;
    protected final OptionSpec urlOption;
    private final Function clientFunction;
    private final CheckedFunction keyStoreFunction;

    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."
        );
        urlOption = parser.accepts("url", "the URL where the elasticsearch node listens for connections.").withRequiredArg();
    }

    @Override
    public final void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) 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("autogenerated_", null, 8);
        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, options, 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 static 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 static 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,
        OptionSet options,
        Terminal terminal,
        String username,
        SecureString password,
        int retries,
        boolean force
    ) throws Exception {
        CommandLineHttpClient client = clientFunction.apply(env);
        final URL baseUrl = options.has(urlOption) ? new URL(options.valueOf(urlOption)) : new URL(client.getDefaultURL());
        final URL clusterHealthUrl = CommandLineHttpClient.createURL(baseUrl, "_cluster/health", "?pretty");
        final HttpResponse response;
        try {
            response = client.execute("GET", clusterHealthUrl, username, password, () -> null, CommandLineHttpClient::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, options, 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 (" + ReferenceDocs.LOGGING + ")" + " 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
        }
    }

    /**
     * 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;
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy