diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java index 8029341f5c4..8eee595120d 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/Security.java @@ -128,8 +128,8 @@ import org.elasticsearch.xpack.security.authz.store.CompositeRolesStore; import org.elasticsearch.xpack.security.authz.store.FileRolesStore; import org.elasticsearch.xpack.security.authz.store.NativeRolesStore; import org.elasticsearch.xpack.security.authz.store.ReservedRolesStore; +import org.elasticsearch.xpack.security.bootstrap.BootstrapElasticPassword; import org.elasticsearch.xpack.security.bootstrap.ContainerPasswordBootstrapCheck; -import org.elasticsearch.xpack.security.crypto.CryptoService; import org.elasticsearch.xpack.security.rest.SecurityRestFilter; import org.elasticsearch.xpack.security.rest.action.RestAuthenticateAction; import org.elasticsearch.xpack.security.rest.action.oauth2.RestGetTokenAction; @@ -180,7 +180,6 @@ import java.util.stream.Collectors; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; -import static org.elasticsearch.common.settings.Setting.groupSetting; import static org.elasticsearch.xpack.XPackSettings.HTTP_SSL_ENABLED; public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { @@ -386,6 +385,11 @@ public class Security implements ActionPlugin, IngestPlugin, NetworkPlugin { DestructiveOperations destructiveOperations = new DestructiveOperations(settings, clusterService.getClusterSettings()); securityInterceptor.set(new SecurityServerTransportInterceptor(settings, threadPool, authcService.get(), authzService, licenseState, sslService, securityContext.get(), destructiveOperations)); + + BootstrapElasticPassword bootstrapElasticPassword = new BootstrapElasticPassword(settings, logger, clusterService, reservedRealm, + securityLifecycleService); + bootstrapElasticPassword.initiatePasswordBootstrap(); + return components; } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/action/user/ChangePasswordRequestBuilder.java b/plugin/src/main/java/org/elasticsearch/xpack/security/action/user/ChangePasswordRequestBuilder.java index 82b8ebcbb0c..95179655991 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/action/user/ChangePasswordRequestBuilder.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/action/user/ChangePasswordRequestBuilder.java @@ -39,18 +39,23 @@ public class ChangePasswordRequestBuilder return this; } + public static char[] validateAndHashPassword(SecureString password) { + Validation.Error error = Validation.Users.validatePassword(password.getChars()); + if (error != null) { + ValidationException validationException = new ValidationException(); + validationException.addValidationError(error.toString()); + throw validationException; + } + return Hasher.BCRYPT.hash(password); + } + /** * Sets the password. Note: the char[] passed to this method will be cleared. */ public ChangePasswordRequestBuilder password(char[] password) { try (SecureString secureString = new SecureString(password)) { - Validation.Error error = Validation.Users.validatePassword(password); - if (error != null) { - ValidationException validationException = new ValidationException(); - validationException.addValidationError(error.toString()); - throw validationException; - } - request.passwordHash(Hasher.BCRYPT.hash(secureString)); + char[] hash = validateAndHashPassword(secureString); + request.passwordHash(hash); } return this; } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealm.java b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealm.java index 3b55ce42dbe..0dc3db6d839 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealm.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealm.java @@ -9,14 +9,23 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.apache.logging.log4j.util.Supplier; import org.elasticsearch.Version; import org.elasticsearch.action.ActionListener; +import org.elasticsearch.cluster.ClusterChangedEvent; +import org.elasticsearch.cluster.ClusterStateListener; +import org.elasticsearch.common.settings.SecureSetting; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.util.concurrent.ThreadContext; import org.elasticsearch.env.Environment; +import org.elasticsearch.gateway.GatewayService; +import org.elasticsearch.threadpool.ThreadPool; import org.elasticsearch.xpack.XPackSettings; +import org.elasticsearch.xpack.security.InternalClient; import org.elasticsearch.xpack.security.Security; import org.elasticsearch.xpack.security.SecurityLifecycleService; +import org.elasticsearch.xpack.security.action.user.ChangePasswordAction; +import org.elasticsearch.xpack.security.action.user.ChangePasswordRequest; +import org.elasticsearch.xpack.security.action.user.ChangePasswordResponse; import org.elasticsearch.xpack.security.authc.IncomingRequest; import org.elasticsearch.xpack.security.authc.RealmConfig; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore.ReservedUserInfo; @@ -39,6 +48,8 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.locks.ReentrantLock; /** * A realm for predefined users. These users can only be modified in terms of changing their passwords; no other modifications are allowed. @@ -58,6 +69,7 @@ public class ReservedRealm extends CachingUsernamePasswordRealm { public static final Setting ACCEPT_DEFAULT_PASSWORD_SETTING = Setting.boolSetting( Security.setting("authc.accept_default_password"), true, Setting.Property.NodeScope, Setting.Property.Filtered, Setting.Property.Deprecated); + public static final Setting BOOTSTRAP_ELASTIC_PASSWORD = SecureSetting.secureString("es.bootstrap.passwd.elastic", null); private final NativeUsersStore nativeUsersStore; private final AnonymousUser anonymousUser; @@ -186,6 +198,31 @@ public class ReservedRealm extends CachingUsernamePasswordRealm { } } + public synchronized void bootstrapElasticUserCredentials(SecureString passwordHash, ActionListener listener) { + getUserInfo(ElasticUser.NAME, new ActionListener() { + @Override + public void onResponse(ReservedUserInfo reservedUserInfo) { + if (reservedUserInfo == null) { + listener.onFailure(new IllegalStateException("unexpected state: ReservedUserInfo was null")); + } else if (reservedUserInfo.hasEmptyPassword) { + ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); + changePasswordRequest.username(ElasticUser.NAME); + changePasswordRequest.passwordHash(passwordHash.getChars()); + nativeUsersStore.changePassword(changePasswordRequest, + ActionListener.wrap(v -> listener.onResponse(true), listener::onFailure)); + + } else { + listener.onResponse(false); + } + } + + @Override + public void onFailure(Exception e) { + listener.onFailure(e); + } + }); + } + private User getUser(String username, ReservedUserInfo userInfo) { assert username != null; switch (username) { @@ -277,5 +314,6 @@ public class ReservedRealm extends CachingUsernamePasswordRealm { public static void addSettings(List> settingsList) { settingsList.add(ACCEPT_DEFAULT_PASSWORD_SETTING); + settingsList.add(BOOTSTRAP_ELASTIC_PASSWORD); } } diff --git a/plugin/src/main/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPassword.java b/plugin/src/main/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPassword.java new file mode 100644 index 00000000000..db15acc2029 --- /dev/null +++ b/plugin/src/main/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPassword.java @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.bootstrap; + +import org.apache.logging.log4j.Logger; +import org.apache.lucene.util.IOUtils; +import org.apache.lucene.util.SetOnce; +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.cluster.ClusterChangedEvent; +import org.elasticsearch.cluster.ClusterStateListener; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.gateway.GatewayService; +import org.elasticsearch.xpack.XPackSettings; +import org.elasticsearch.xpack.security.SecurityLifecycleService; +import org.elasticsearch.xpack.security.action.user.ChangePasswordRequestBuilder; +import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; + +import java.util.concurrent.Semaphore; + +/** + * This process adds a ClusterStateListener to the ClusterService that will listen for cluster state updates. + * Once the cluster and the security index are ready, it will attempt to bootstrap the elastic user's + * password with a password from the keystore. If the password is not in the keystore or the elastic user + * already has a password, then the user's password will not be set. Once the process is complete, the + * listener will remove itself. + */ +public final class BootstrapElasticPassword { + + private final Settings settings; + private final Logger logger; + private final ClusterService clusterService; + private final ReservedRealm reservedRealm; + private final SecurityLifecycleService lifecycleService; + private final boolean reservedRealmDisabled; + + public BootstrapElasticPassword(Settings settings, Logger logger, ClusterService clusterService, ReservedRealm reservedRealm, + SecurityLifecycleService lifecycleService) { + this.reservedRealmDisabled = XPackSettings.RESERVED_REALM_ENABLED_SETTING.get(settings) == false; + this.settings = settings; + this.logger = logger; + this.clusterService = clusterService; + this.reservedRealm = reservedRealm; + this.lifecycleService = lifecycleService; + } + + public void initiatePasswordBootstrap() { + SecureString bootstrapPassword = ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.get(settings); + if (bootstrapPassword.length() == 0) { + return; + } else if (reservedRealmDisabled) { + logger.warn("elastic password will not be bootstrapped because the reserved realm is disabled"); + bootstrapPassword.close(); + return; + } + + SecureString passwordHash = new SecureString(ChangePasswordRequestBuilder.validateAndHashPassword(bootstrapPassword)); + bootstrapPassword.close(); + + clusterService.addListener(new BootstrapPasswordClusterStateListener(passwordHash)); + } + + private class BootstrapPasswordClusterStateListener implements ClusterStateListener { + + private final Semaphore semaphore = new Semaphore(1); + private final SecureString passwordHash; + private final SetOnce isDone = new SetOnce<>(); + + private BootstrapPasswordClusterStateListener(SecureString passwordHash) { + this.passwordHash = passwordHash; + } + + @Override + public void clusterChanged(ClusterChangedEvent event) { + if (event.state().blocks().hasGlobalBlock(GatewayService.STATE_NOT_RECOVERED_BLOCK) + || lifecycleService.isSecurityIndexOutOfDate() + || (lifecycleService.isSecurityIndexExisting() && lifecycleService.isSecurityIndexAvailable() == false) + || lifecycleService.isSecurityIndexWriteable() == false) { + // We hold off bootstrapping until the node recovery is complete, the security index is up to date, and + // security index is writeable. If the security index currently exists, it must also be available. + return; + } + + // Only allow one attempt to bootstrap the password at a time + if (semaphore.tryAcquire()) { + // Ensure that we do not attempt to bootstrap after the process is complete. This is important as we + // clear the password hash in the cleanup phase. + if (isDone.get() != null) { + semaphore.release(); + return; + } + + reservedRealm.bootstrapElasticUserCredentials(passwordHash, new ActionListener() { + @Override + public void onResponse(Boolean passwordSet) { + cleanup(); + if (passwordSet == false) { + logger.warn("elastic password was not bootstrapped because its password was already set"); + } + semaphore.release(); + } + + @Override + public void onFailure(Exception e) { + cleanup(); + logger.error("unexpected exception when attempting to bootstrap password", e); + semaphore.release(); + } + }); + } + } + + private void cleanup() { + isDone.set(true); + IOUtils.closeWhileHandlingException(() -> clusterService.removeListener(this), passwordHash); + } + } +} diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealmTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealmTests.java index 01e87e8a8c9..58702fa6f70 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealmTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/authc/esnative/ReservedRealmTests.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.security.authc.esnative; import org.elasticsearch.ElasticsearchSecurityException; import org.elasticsearch.Version; +import org.elasticsearch.action.ActionFuture; import org.elasticsearch.action.ActionListener; import org.elasticsearch.action.support.PlainActionFuture; import org.elasticsearch.common.SuppressForbidden; @@ -17,6 +18,7 @@ import org.elasticsearch.env.Environment; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.xpack.XPackSettings; import org.elasticsearch.xpack.security.SecurityLifecycleService; +import org.elasticsearch.xpack.security.action.user.ChangePasswordRequest; import org.elasticsearch.xpack.security.authc.IncomingRequest; import org.elasticsearch.xpack.security.authc.esnative.NativeUsersStore.ReservedUserInfo; import org.elasticsearch.xpack.security.authc.support.Hasher; @@ -373,6 +375,64 @@ public class ReservedRealmTests extends ESTestCase { assertThat(e.getMessage(), containsString("failed to authenticate")); } + @SuppressWarnings("unchecked") + public void testBootstrapElasticPassword() { + ReservedUserInfo user = new ReservedUserInfo(ReservedRealm.EMPTY_PASSWORD_HASH, true, true); + mockGetAllReservedUserInfo(usersStore, Collections.singletonMap(ElasticUser.NAME, user)); + Settings settings = Settings.builder().build(); + when(securityLifecycleService.isSecurityIndexExisting()).thenReturn(true); + + final ReservedRealm reservedRealm = new ReservedRealm(mock(Environment.class), settings, usersStore, + new AnonymousUser(Settings.EMPTY), securityLifecycleService, new ThreadContext(Settings.EMPTY)); + PlainActionFuture listenerFuture = new PlainActionFuture<>(); + SecureString passwordHash = new SecureString(randomAlphaOfLength(10).toCharArray()); + reservedRealm.bootstrapElasticUserCredentials(passwordHash, listenerFuture); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ChangePasswordRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + verify(usersStore).changePassword(requestCaptor.capture(), listenerCaptor.capture()); + assertEquals(passwordHash.getChars(), requestCaptor.getValue().passwordHash()); + + listenerCaptor.getValue().onResponse(null); + + assertTrue(listenerFuture.actionGet()); + } + + public void testBootstrapElasticPasswordNotSetIfPasswordExists() { + mockGetAllReservedUserInfo(usersStore, Collections.singletonMap(ElasticUser.NAME, new ReservedUserInfo(new char[7], true, false))); + when(securityLifecycleService.isSecurityIndexExisting()).thenReturn(true); + + Settings settings = Settings.builder().build(); + final ReservedRealm reservedRealm = new ReservedRealm(mock(Environment.class), settings, usersStore, + new AnonymousUser(Settings.EMPTY), securityLifecycleService, new ThreadContext(Settings.EMPTY)); + SecureString passwordHash = new SecureString(randomAlphaOfLength(10).toCharArray()); + reservedRealm.bootstrapElasticUserCredentials(passwordHash, new PlainActionFuture<>()); + + verify(usersStore, times(0)).changePassword(any(ChangePasswordRequest.class), any()); + } + + public void testBootstrapElasticPasswordSettingFails() { + ReservedUserInfo user = new ReservedUserInfo(ReservedRealm.EMPTY_PASSWORD_HASH, true, true); + mockGetAllReservedUserInfo(usersStore, Collections.singletonMap(ElasticUser.NAME, user)); + Settings settings = Settings.builder().build(); + when(securityLifecycleService.isSecurityIndexExisting()).thenReturn(true); + + final ReservedRealm reservedRealm = new ReservedRealm(mock(Environment.class), settings, usersStore, + new AnonymousUser(Settings.EMPTY), securityLifecycleService, new ThreadContext(Settings.EMPTY)); + PlainActionFuture listenerFuture = new PlainActionFuture<>(); + SecureString passwordHash = new SecureString(randomAlphaOfLength(10).toCharArray()); + reservedRealm.bootstrapElasticUserCredentials(passwordHash, listenerFuture); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ChangePasswordRequest.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(ActionListener.class); + verify(usersStore).changePassword(requestCaptor.capture(), listenerCaptor.capture()); + assertEquals(passwordHash.getChars(), requestCaptor.getValue().passwordHash()); + + listenerCaptor.getValue().onFailure(new RuntimeException()); + + expectThrows(RuntimeException.class, listenerFuture::actionGet); + } + /* * NativeUserStore#getAllReservedUserInfo is pkg private we can't mock it otherwise */ diff --git a/plugin/src/test/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPasswordTests.java b/plugin/src/test/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPasswordTests.java new file mode 100644 index 00000000000..4607b84ebfc --- /dev/null +++ b/plugin/src/test/java/org/elasticsearch/xpack/security/bootstrap/BootstrapElasticPasswordTests.java @@ -0,0 +1,337 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.security.bootstrap; + +import org.elasticsearch.action.ActionListener; +import org.elasticsearch.cluster.ClusterChangedEvent; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.ClusterStateListener; +import org.elasticsearch.cluster.block.ClusterBlocks; +import org.elasticsearch.cluster.service.ClusterService; +import org.elasticsearch.common.ValidationException; +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.SecureString; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.gateway.GatewayService; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.XPackSettings; +import org.elasticsearch.xpack.security.SecurityLifecycleService; +import org.elasticsearch.xpack.security.authc.esnative.ReservedRealm; +import org.elasticsearch.xpack.security.authc.support.Hasher; +import org.junit.Before; +import org.mockito.ArgumentCaptor; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class BootstrapElasticPasswordTests extends ESTestCase { + + private ClusterService clusterService; + private ReservedRealm realm; + private SecurityLifecycleService lifecycle; + private ArgumentCaptor listenerCaptor; + private ArgumentCaptor actionLister; + + @Before + public void setupBootstrap() { + clusterService = mock(ClusterService.class); + realm = mock(ReservedRealm.class); + lifecycle = mock(SecurityLifecycleService.class); + listenerCaptor = ArgumentCaptor.forClass(ClusterStateListener.class); + actionLister = ArgumentCaptor.forClass(ActionListener.class); + } + + public void testNoListenerAttachedWhenNoBootstrapPassword() { + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(Settings.EMPTY, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verifyZeroInteractions(clusterService); + } + + public void testNoListenerAttachedWhenReservedRealmDisabled() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .put(XPackSettings.RESERVED_REALM_ENABLED_SETTING.getKey(), false) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verifyZeroInteractions(clusterService); + } + + public void testPasswordHasToBeValid() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(5)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + expectThrows(ValidationException.class, bootstrap::initiatePasswordBootstrap); + + verifyZeroInteractions(clusterService); + } + + public void testDoesNotBootstrapUntilStateRecovered() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = mock(ClusterChangedEvent.class); + ClusterState state = mock(ClusterState.class); + ClusterBlocks blocks = mock(ClusterBlocks.class); + when(event.state()).thenReturn(state); + when(state.blocks()).thenReturn(blocks); + when(blocks.hasGlobalBlock(GatewayService.STATE_NOT_RECOVERED_BLOCK)).thenReturn(true); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + + listener.clusterChanged(event); + + verifyZeroInteractions(realm); + } + + public void testDoesNotBootstrapUntilSecurityIndexUpdated() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(true); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(true); + + listener.clusterChanged(event); + + verifyZeroInteractions(realm); + } + + public void testDoesNotBootstrapUntilSecurityIndexIfExistingIsAvailable() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(false); + + listener.clusterChanged(event); + + verifyZeroInteractions(realm); + } + + public void testDoesNotBootstrapUntilSecurityIndexWriteable() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(false); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(true); + + listener.clusterChanged(event); + + verifyZeroInteractions(realm); + } + + public void testDoesAllowBootstrapForUnavailableIndexIfNotExisting() { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), randomAlphaOfLength(10)); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(false); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(false); + + listener.clusterChanged(event); + + verify(realm).bootstrapElasticUserCredentials(any(SecureString.class), any(ActionListener.class)); + } + + public void testDoesNotBootstrapBeginsWhenRecoveryDoneAndIndexReady() { + String password = randomAlphaOfLength(10); + ensureBootstrapStarted(password); + + ArgumentCaptor hashedPasswordCaptor = ArgumentCaptor.forClass(SecureString.class); + verify(realm).bootstrapElasticUserCredentials(hashedPasswordCaptor.capture(), any(ActionListener.class)); + assertTrue(Hasher.BCRYPT.verify(new SecureString(password.toCharArray()), hashedPasswordCaptor.getValue().getChars())); + } + + public void testWillNotAllowTwoConcurrentBootstrapAttempts() { + String password = randomAlphaOfLength(10); + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), password); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(true); + + listener.clusterChanged(event); + listener.clusterChanged(event); + + verify(realm, times(1)).bootstrapElasticUserCredentials(any(SecureString.class), any(ActionListener.class)); + } + + public void testWillNotAllowSecondBootstrapAttempt() { + String password = randomAlphaOfLength(10); + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), password); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(true); + + listener.clusterChanged(event); + + verify(realm, times(1)).bootstrapElasticUserCredentials(any(SecureString.class), actionLister.capture()); + + actionLister.getValue().onResponse(true); + + listener.clusterChanged(event); + + verify(realm, times(1)).bootstrapElasticUserCredentials(any(SecureString.class), any()); + } + + public void testBootstrapCompleteRemovesListener() { + String password = randomAlphaOfLength(10); + ensureBootstrapStarted(password); + + verify(realm).bootstrapElasticUserCredentials(any(SecureString.class), actionLister.capture()); + + actionLister.getValue().onResponse(randomBoolean()); + + verify(clusterService).removeListener(listenerCaptor.getValue()); + } + + public void testBootstrapFailedRemovesListener() { + String password = randomAlphaOfLength(10); + ensureBootstrapStarted(password); + + verify(realm).bootstrapElasticUserCredentials(any(SecureString.class), actionLister.capture()); + + actionLister.getValue().onFailure(new RuntimeException("failed")); + + verify(clusterService).removeListener(listenerCaptor.getValue()); + } + + private void ensureBootstrapStarted(String password) { + MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString(ReservedRealm.BOOTSTRAP_ELASTIC_PASSWORD.getKey(), password); + Settings settings = Settings.builder() + .setSecureSettings(secureSettings) + .build(); + BootstrapElasticPassword bootstrap = new BootstrapElasticPassword(settings, logger, clusterService, realm, lifecycle); + + bootstrap.initiatePasswordBootstrap(); + + verify(clusterService).addListener(listenerCaptor.capture()); + + ClusterStateListener listener = listenerCaptor.getValue(); + + ClusterChangedEvent event = getStateRecoveredEvent(); + when(lifecycle.isSecurityIndexOutOfDate()).thenReturn(false); + when(lifecycle.isSecurityIndexWriteable()).thenReturn(true); + when(lifecycle.isSecurityIndexExisting()).thenReturn(true); + when(lifecycle.isSecurityIndexAvailable()).thenReturn(true); + + listener.clusterChanged(event); + } + + private ClusterChangedEvent getStateRecoveredEvent() { + ClusterChangedEvent event = mock(ClusterChangedEvent.class); + ClusterState state = mock(ClusterState.class); + ClusterBlocks blocks = mock(ClusterBlocks.class); + when(event.state()).thenReturn(state); + when(state.blocks()).thenReturn(blocks); + when(blocks.hasGlobalBlock(GatewayService.STATE_NOT_RECOVERED_BLOCK)).thenReturn(false); + return event; + } +}