diff --git a/build.gradle b/build.gradle index aa99828d5c..607649d082 100644 --- a/build.gradle +++ b/build.gradle @@ -613,6 +613,7 @@ dependencies { //OpenSAML implementation 'net.shibboleth.utilities:java-support:8.4.1' + runtimeOnly "io.dropwizard.metrics:metrics-core:4.2.15" implementation "com.onelogin:java-saml:${one_login_java_saml}" implementation "com.onelogin:java-saml-core:${one_login_java_saml}" implementation "org.opensaml:opensaml-core:${open_saml_version}" @@ -638,7 +639,6 @@ dependencies { runtimeOnly 'com.google.j2objc:j2objc-annotations:2.8' compileOnly 'com.google.code.findbugs:jsr305:3.0.2' runtimeOnly 'org.lz4:lz4-java:1.8.0' - runtimeOnly 'io.dropwizard.metrics:metrics-core:4.2.25' runtimeOnly 'org.slf4j:slf4j-api:1.7.36' runtimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:${versions.log4j}" runtimeOnly 'org.xerial.snappy:snappy-java:1.1.10.5' @@ -699,12 +699,12 @@ dependencies { exclude(group:'org.springframework', module: 'spring-jcl' ) } testRuntimeOnly 'org.scala-lang:scala-library:2.13.13' - testRuntimeOnly 'com.yammer.metrics:metrics-core:2.2.0' testRuntimeOnly 'com.typesafe.scala-logging:scala-logging_3:3.9.5' testRuntimeOnly('org.apache.zookeeper:zookeeper:3.9.2') { exclude(group:'ch.qos.logback', module: 'logback-classic' ) exclude(group:'ch.qos.logback', module: 'logback-core' ) } + testRuntimeOnly 'com.yammer.metrics:metrics-core:2.2.0' testRuntimeOnly "org.apache.kafka:kafka-metadata:${kafka_version}" testRuntimeOnly "org.apache.kafka:kafka-storage:${kafka_version}" diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java b/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java similarity index 69% rename from src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java rename to src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java index eb028c74e4..5387b3e516 100644 --- a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationTests.java +++ b/src/integrationTest/java/org/opensearch/security/AbstractDefaultConfigurationTests.java @@ -1,12 +1,12 @@ /* -* Copyright OpenSearch Contributors -* SPDX-License-Identifier: Apache-2.0 -* -* The OpenSearch Contributors require contributions made to -* this file be licensed under the Apache-2.0 license or a -* compatible open source license. -* -*/ + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ package org.opensearch.security; import java.io.IOException; @@ -19,17 +19,16 @@ import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.io.FileUtils; +import org.apache.http.HttpStatus; import org.awaitility.Awaitility; import org.junit.AfterClass; -import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; -import org.opensearch.test.framework.TestSecurityConfig.User; -import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.security.state.SecurityMetadata; +import org.opensearch.test.framework.TestSecurityConfig; import org.opensearch.test.framework.cluster.LocalCluster; import org.opensearch.test.framework.cluster.TestRestClient; -import org.opensearch.test.framework.cluster.TestRestClient.HttpResponse; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.aMapWithSize; @@ -37,29 +36,22 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; @RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) @ThreadLeakScope(ThreadLeakScope.Scope.NONE) -public class DefaultConfigurationTests { - - private final static Path configurationFolder = ConfigurationFiles.createConfigurationDirectory(); - private static final User ADMIN_USER = new User("admin"); - private static final User NEW_USER = new User("new-user"); - private static final User LIMITED_USER = new User("limited-user"); - - @ClassRule - public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) - .nodeSettings( - Map.of( - "plugins.security.allow_default_init_securityindex", - true, - "plugins.security.restapi.roles_enabled", - List.of("user_admin__all_access") - ) - ) - .defaultConfigurationInitDirectory(configurationFolder.toString()) - .loadConfigurationIntoIndex(false) - .build(); +public abstract class AbstractDefaultConfigurationTests { + public final static Path configurationFolder = ConfigurationFiles.createConfigurationDirectory(); + private static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin"); + private static final TestSecurityConfig.User NEW_USER = new TestSecurityConfig.User("new-user"); + private static final TestSecurityConfig.User LIMITED_USER = new TestSecurityConfig.User("limited-user"); + + private final LocalCluster cluster; + + protected AbstractDefaultConfigurationTests(LocalCluster cluster) { + this.cluster = cluster; + } @AfterClass public static void cleanConfigurationDirectory() throws IOException { @@ -73,18 +65,43 @@ public void shouldLoadDefaultConfiguration() { } try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) { client.confirmCorrectCredentials(ADMIN_USER.getName()); - HttpResponse response = client.get("_plugins/_security/api/internalusers"); - response.assertStatusCode(200); + TestRestClient.HttpResponse response = client.get("_plugins/_security/api/internalusers"); + response.assertStatusCode(HttpStatus.SC_OK); Map users = response.getBodyAs(Map.class); assertThat( + response.getBody(), users, allOf(aMapWithSize(3), hasKey(ADMIN_USER.getName()), hasKey(NEW_USER.getName()), hasKey(LIMITED_USER.getName())) ); } } + void assertClusterState(final TestRestClient client) { + if (cluster.node().settings().getAsBoolean("plugins.security.allow_default_init_securityindex.use_cluster_state", false)) { + final TestRestClient.HttpResponse response = client.get("_cluster/state"); + response.assertStatusCode(HttpStatus.SC_OK); + final var clusterState = response.getBodyAs(Map.class); + assertTrue(response.getBody(), clusterState.containsKey(SecurityMetadata.TYPE)); + @SuppressWarnings("unchecked") + final var securityClusterState = (Map) clusterState.get(SecurityMetadata.TYPE); + @SuppressWarnings("unchecked") + final var securityConfiguration = (Map) ((Map) clusterState.get(SecurityMetadata.TYPE)).get( + "configuration" + ); + assertTrue(response.getBody(), securityClusterState.containsKey("created")); + assertNotNull(response.getBody(), securityClusterState.get("created")); + + for (final var k : securityConfiguration.keySet()) { + @SuppressWarnings("unchecked") + final var sc = (Map) securityConfiguration.get(k); + assertTrue(response.getBody(), sc.containsKey("hash")); + assertTrue(response.getBody(), sc.containsKey("last_modified")); + } + } + } + @Test - public void securityRolesUgrade() throws Exception { + public void securityRolesUpgrade() throws Exception { try (var client = cluster.getRestClient(ADMIN_USER)) { // Setup: Make sure the config is ready before starting modifications Awaitility.await().alias("Load default configuration").until(() -> client.getAuthInfo().getStatusCode(), equalTo(200)); @@ -159,4 +176,5 @@ private Set extractFieldNames(final JsonNode json) { json.fieldNames().forEachRemaining(set::add); return set; } + } diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterTests.java b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterTests.java new file mode 100644 index 0000000000..704e2c7255 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterTests.java @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security; + +import java.util.List; +import java.util.Map; + +import org.junit.ClassRule; + +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +public class DefaultConfigurationMultiNodeClusterTests extends AbstractDefaultConfigurationTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.THREE_CLUSTER_MANAGERS) + .nodeSettings( + Map.of( + "plugins.security.allow_default_init_securityindex", + true, + "plugins.security.restapi.roles_enabled", + List.of("user_admin__all_access") + ) + ) + .defaultConfigurationInitDirectory(configurationFolder.toString()) + .loadConfigurationIntoIndex(false) + .build(); + + public DefaultConfigurationMultiNodeClusterTests() { + super(cluster); + } +} diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterUseClusterStateTests.java b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterUseClusterStateTests.java new file mode 100644 index 0000000000..8abffac9cf --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationMultiNodeClusterUseClusterStateTests.java @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security; + +import java.util.List; +import java.util.Map; + +import org.junit.ClassRule; + +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +public class DefaultConfigurationMultiNodeClusterUseClusterStateTests extends AbstractDefaultConfigurationTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.THREE_CLUSTER_MANAGERS) + .nodeSettings( + Map.of( + "plugins.security.allow_default_init_securityindex", + true, + "plugins.security.allow_default_init_securityindex.use_cluster_state", + true, + "plugins.security.restapi.roles_enabled", + List.of("user_admin__all_access") + ) + ) + .defaultConfigurationInitDirectory(configurationFolder.toString()) + .loadConfigurationIntoIndex(false) + .build(); + + public DefaultConfigurationMultiNodeClusterUseClusterStateTests() { + super(cluster); + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterTests.java b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterTests.java new file mode 100644 index 0000000000..362245db5e --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterTests.java @@ -0,0 +1,44 @@ +/* +* Copyright OpenSearch Contributors +* SPDX-License-Identifier: Apache-2.0 +* +* The OpenSearch Contributors require contributions made to +* this file be licensed under the Apache-2.0 license or a +* compatible open source license. +* +*/ +package org.opensearch.security; + +import java.util.List; +import java.util.Map; + +import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope; +import org.junit.ClassRule; +import org.junit.runner.RunWith; + +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class) +@ThreadLeakScope(ThreadLeakScope.Scope.NONE) +public class DefaultConfigurationSingleNodeClusterTests extends AbstractDefaultConfigurationTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .nodeSettings( + Map.of( + "plugins.security.allow_default_init_securityindex", + true, + "plugins.security.restapi.roles_enabled", + List.of("user_admin__all_access") + ) + ) + .defaultConfigurationInitDirectory(configurationFolder.toString()) + .loadConfigurationIntoIndex(false) + .build(); + + public DefaultConfigurationSingleNodeClusterTests() { + super(cluster); + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterUseClusterStateTests.java b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterUseClusterStateTests.java new file mode 100644 index 0000000000..e05005e912 --- /dev/null +++ b/src/integrationTest/java/org/opensearch/security/DefaultConfigurationSingleNodeClusterUseClusterStateTests.java @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security; + +import java.util.List; +import java.util.Map; + +import org.junit.ClassRule; + +import org.opensearch.test.framework.cluster.ClusterManager; +import org.opensearch.test.framework.cluster.LocalCluster; + +public class DefaultConfigurationSingleNodeClusterUseClusterStateTests extends AbstractDefaultConfigurationTests { + + @ClassRule + public static LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE) + .nodeSettings( + Map.of( + "plugins.security.allow_default_init_securityindex", + true, + "plugins.security.allow_default_init_securityindex.use_cluster_state", + true, + "plugins.security.restapi.roles_enabled", + List.of("user_admin__all_access") + ) + ) + .defaultConfigurationInitDirectory(configurationFolder.toString()) + .loadConfigurationIntoIndex(false) + .build(); + + public DefaultConfigurationSingleNodeClusterUseClusterStateTests() { + super(cluster); + } + +} diff --git a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java index 5b83e0d6d0..e6af5d58bb 100644 --- a/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java +++ b/src/integrationTest/java/org/opensearch/security/SecurityConfigurationBootstrapTests.java @@ -124,6 +124,7 @@ public void shouldStillLoadSecurityConfigDuringBootstrapAndActiveConfigUpdateReq .put("action_groups.yml", CType.ACTIONGROUPS) .put("config.yml", CType.CONFIG) .put("roles.yml", CType.ROLES) + .put("roles_mapping.yml", CType.ROLESMAPPING) .put("tenants.yml", CType.TENANTS) .build(); @@ -146,7 +147,7 @@ public void shouldStillLoadSecurityConfigDuringBootstrapAndActiveConfigUpdateReq // After the configuration has been loaded, the rest clients should be able to connect successfully cluster.triggerConfigurationReloadForCTypes( internalNodeClient, - List.of(CType.ACTIONGROUPS, CType.CONFIG, CType.ROLES, CType.TENANTS), + List.of(CType.ACTIONGROUPS, CType.CONFIG, CType.ROLES, CType.ROLESMAPPING, CType.TENANTS), true ); try (final TestRestClient freshClient = cluster.getRestClient(USER_ADMIN)) { diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index ac32da1d1b..a59d1f531d 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -73,6 +73,8 @@ import org.opensearch.action.search.SearchScrollAction; import org.opensearch.action.support.ActionFilter; import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.NamedDiff; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.node.DiscoveryNode; import org.opensearch.cluster.node.DiscoveryNodes; @@ -176,6 +178,7 @@ import org.opensearch.security.ssl.http.netty.ValidatingDispatcher; import org.opensearch.security.ssl.transport.DefaultPrincipalExtractor; import org.opensearch.security.ssl.util.SSLConfigConstants; +import org.opensearch.security.state.SecurityMetadata; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.GuardedSearchOperationWrapper; import org.opensearch.security.support.HeaderHelper; @@ -208,6 +211,8 @@ import static org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.ENDPOINTS_WITH_PERMISSIONS; import static org.opensearch.security.dlic.rest.api.RestApiAdminPrivilegesEvaluator.SECURITY_CONFIG_UPDATE; import static org.opensearch.security.setting.DeprecatedSettings.checkForDeprecatedSetting; +import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX; +import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE; import static org.opensearch.security.support.ConfigConstants.SECURITY_UNSUPPORTED_RESTAPI_ALLOW_SECURITYCONFIG_MODIFICATION; // CS-ENFORCE-SINGLE @@ -288,6 +293,10 @@ private static boolean isDisabled(final Settings settings) { return settings.getAsBoolean(ConfigConstants.SECURITY_DISABLED, false); } + private static boolean useClusterStateToInitSecurityConfig(final Settings settings) { + return settings.getAsBoolean(SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE, false); + } + /** * SSL Cert Reload will be enabled only if security is not disabled and not in we are not using sslOnly mode. * @param settings Elastic configuration settings @@ -1172,11 +1181,23 @@ public Collection createComponents( components.add(si); components.add(dcf); components.add(userService); - + final var allowDefaultInit = settings.getAsBoolean(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, false); + final var useClusterState = useClusterStateToInitSecurityConfig(settings); + if (!SSLConfig.isSslOnlyMode() && !isDisabled(settings) && allowDefaultInit && useClusterState) { + clusterService.addListener(cr); + } return components; } + @Override + public List getNamedWriteables() { + return List.of( + new NamedWriteableRegistry.Entry(ClusterState.Custom.class, SecurityMetadata.TYPE, SecurityMetadata::new), + new NamedWriteableRegistry.Entry(NamedDiff.class, SecurityMetadata.TYPE, SecurityMetadata::readDiffFrom) + ); + } + @Override public Settings additionalSettings() { @@ -1317,9 +1338,8 @@ public List> getSettings() { settings.add( Setting.boolSetting(ConfigConstants.SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES, false, Property.NodeScope, Property.Filtered) ); - settings.add( - Setting.boolSetting(ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, false, Property.NodeScope, Property.Filtered) - ); + settings.add(Setting.boolSetting(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, false, Property.NodeScope, Property.Filtered)); + settings.add(Setting.boolSetting(SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE, false, Property.NodeScope, Property.Filtered)); settings.add( Setting.boolSetting( ConfigConstants.SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST, @@ -1915,11 +1935,10 @@ public List getSettingsFilter() { @Override public void onNodeStarted(DiscoveryNode localNode) { - log.info("Node started"); - if (!SSLConfig.isSslOnlyMode() && !client && !disabled) { + this.localNode.set(localNode); + if (!SSLConfig.isSslOnlyMode() && !client && !disabled && !useClusterStateToInitSecurityConfig(settings)) { cr.initOnNodeStart(); } - this.localNode.set(localNode); final Set securityModules = ReflectionHelper.getModulesLoaded(); log.info("{} OpenSearch Security modules loaded so far: {}", securityModules.size(), securityModules); } diff --git a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java index 353286fc4a..44ba77428f 100644 --- a/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java +++ b/src/main/java/org/opensearch/security/configuration/ConfigurationRepository.java @@ -31,6 +31,7 @@ import java.security.AccessController; import java.security.PrivilegedAction; import java.text.SimpleDateFormat; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -38,12 +39,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; +import java.util.stream.Collectors; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; @@ -58,13 +63,19 @@ import org.opensearch.action.admin.cluster.health.ClusterHealthResponse; import org.opensearch.action.admin.indices.create.CreateIndexRequest; import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterChangedEvent; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.ClusterStateListener; +import org.opensearch.cluster.ClusterStateUpdateTask; import org.opensearch.cluster.health.ClusterHealthStatus; import org.opensearch.cluster.metadata.IndexMetadata; import org.opensearch.cluster.metadata.MappingMetadata; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.Priority; import org.opensearch.common.settings.Settings; import org.opensearch.common.util.concurrent.ThreadContext; import org.opensearch.common.util.concurrent.ThreadContext.StoredContext; +import org.opensearch.core.action.ActionListener; import org.opensearch.core.common.Strings; import org.opensearch.core.rest.RestStatus; import org.opensearch.core.xcontent.MediaTypeRegistry; @@ -75,12 +86,16 @@ import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; import org.opensearch.security.ssl.util.ExceptionUtils; +import org.opensearch.security.state.SecurityMetadata; import org.opensearch.security.support.ConfigConstants; import org.opensearch.security.support.ConfigHelper; +import org.opensearch.security.support.SecurityIndexHandler; import org.opensearch.security.support.SecurityUtils; import org.opensearch.threadpool.ThreadPool; -public class ConfigurationRepository { +import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE; + +public class ConfigurationRepository implements ClusterStateListener { private static final Logger LOGGER = LogManager.getLogger(ConfigurationRepository.class); private final String securityIndex; @@ -96,20 +111,27 @@ public class ConfigurationRepository { private DynamicConfigFactory dynamicConfigFactory; public static final int DEFAULT_CONFIG_VERSION = 2; private final CompletableFuture initalizeConfigTask = new CompletableFuture<>(); + private final boolean acceptInvalid; - private ConfigurationRepository( - Settings settings, + private final AtomicBoolean auditHotReloadingEnabled = new AtomicBoolean(false); + + private final AtomicBoolean initializationInProcess = new AtomicBoolean(false); + + private final SecurityIndexHandler securityIndexHandler; + + // visible for testing + protected ConfigurationRepository( + final String securityIndex, + final Settings settings, final Path configPath, - ThreadPool threadPool, - Client client, - ClusterService clusterService, - AuditLog auditLog + final ThreadPool threadPool, + final Client client, + final ClusterService clusterService, + final AuditLog auditLog, + final SecurityIndexHandler securityIndexHandler ) { - this.securityIndex = settings.get( - ConfigConstants.SECURITY_CONFIG_INDEX_NAME, - ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX - ); + this.securityIndex = securityIndex; this.settings = settings; this.configPath = configPath; this.client = client; @@ -119,8 +141,38 @@ private ConfigurationRepository( this.configurationChangedListener = new ArrayList<>(); this.acceptInvalid = settings.getAsBoolean(ConfigConstants.SECURITY_UNSUPPORTED_ACCEPT_INVALID_CONFIG, false); cl = new ConfigurationLoaderSecurity7(client, threadPool, settings, clusterService); - configCache = CacheBuilder.newBuilder().build(); + this.securityIndexHandler = securityIndexHandler; + } + + private Path resolveConfigDir() { + return Optional.ofNullable(System.getProperty("security.default_init.dir")) + .map(Path::of) + .orElseGet(() -> new Environment(settings, configPath).configDir().resolve("opensearch-security/")); + } + + @Override + public void clusterChanged(final ClusterChangedEvent event) { + final SecurityMetadata securityMetadata = event.state().custom(SecurityMetadata.TYPE); + // init and upload sec index on the manager node only as soon as + // creation of index and upload config are done a new cluster state will be created. + // in case of failures it repeats attempt after restart + if (nodeSelectedAsManager(event)) { + if (securityMetadata == null) { + initSecurityIndex(event); + } + } + // executes reload of cache on each node on the cluster, + // since sec initialization has been finished + if (securityMetadata != null) { + executeConfigurationInitialization(securityMetadata); + } + } + + private boolean nodeSelectedAsManager(final ClusterChangedEvent event) { + boolean wasClusterManager = event.previousState().nodes().isLocalNodeElectedClusterManager(); + boolean isClusterManager = event.localNodeClusterManager(); + return !wasClusterManager && isClusterManager; } public String getConfigDirectory() { @@ -236,7 +288,7 @@ private void initalizeClusterConfiguration(final boolean installDefaultConfig) { } catch (Exception e) { LOGGER.debug("Unable to load configuration due to {}", String.valueOf(ExceptionUtils.getRootCause(e))); try { - Thread.sleep(3000); + TimeUnit.MILLISECONDS.sleep(3000); } catch (InterruptedException e1) { Thread.currentThread().interrupt(); LOGGER.debug("Thread was interrupted so we cancel initialization"); @@ -244,27 +296,7 @@ private void initalizeClusterConfiguration(final boolean installDefaultConfig) { } } } - - final Set deprecatedAuditKeysInSettings = AuditConfig.getDeprecatedKeys(settings); - if (!deprecatedAuditKeysInSettings.isEmpty()) { - LOGGER.warn( - "Following keys {} are deprecated in opensearch settings. They will be removed in plugin v2.0.0.0", - deprecatedAuditKeysInSettings - ); - } - final boolean isAuditConfigDocPresentInIndex = cl.isAuditConfigDocPresentInIndex(); - if (isAuditConfigDocPresentInIndex) { - if (!deprecatedAuditKeysInSettings.isEmpty()) { - LOGGER.warn("Audit configuration settings found in both index and opensearch settings (deprecated)"); - } - LOGGER.info("Hot-reloading of audit configuration is enabled"); - } else { - LOGGER.info( - "Hot-reloading of audit configuration is disabled. Using configuration with defaults from opensearch settings. Populate the configuration in index using audit.yml or securityadmin to enable it." - ); - auditLog.setConfig(AuditConfig.from(settings)); - } - + setupAuditConfigurationIfAny(cl.isAuditConfigDocPresentInIndex()); LOGGER.info("Node '{}' initialized", clusterService.localNode().getName()); } catch (Exception e) { @@ -272,6 +304,27 @@ private void initalizeClusterConfiguration(final boolean installDefaultConfig) { } } + private void setupAuditConfigurationIfAny(final boolean auditConfigDocPresent) { + final Set deprecatedAuditKeysInSettings = AuditConfig.getDeprecatedKeys(settings); + if (!deprecatedAuditKeysInSettings.isEmpty()) { + LOGGER.warn( + "Following keys {} are deprecated in opensearch settings. They will be removed in plugin v2.0.0.0", + deprecatedAuditKeysInSettings + ); + } + if (auditConfigDocPresent) { + if (!deprecatedAuditKeysInSettings.isEmpty()) { + LOGGER.warn("Audit configuration settings found in both index and opensearch settings (deprecated)"); + } + LOGGER.info("Hot-reloading of audit configuration is enabled"); + } else { + LOGGER.info( + "Hot-reloading of audit configuration is disabled. Using configuration with defaults from opensearch settings. Populate the configuration in index using audit.yml or securityadmin to enable it." + ); + auditLog.setConfig(AuditConfig.from(settings)); + } + } + private boolean createSecurityIndexIfAbsent() { try { final Map indexSettings = ImmutableMap.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); @@ -304,7 +357,7 @@ private void waitForSecurityIndexToBeAtLeastYellow() { response == null ? "no response" : (response.isTimedOut() ? "timeout" : "other, maybe red cluster") ); try { - Thread.sleep(500); + TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException e) { // ignore Thread.currentThread().interrupt(); @@ -317,6 +370,69 @@ private void waitForSecurityIndexToBeAtLeastYellow() { } } + void initSecurityIndex(final ClusterChangedEvent event) { + if (!event.state().metadata().hasIndex(securityIndex)) { + securityIndexHandler.createIndex( + ActionListener.wrap(r -> uploadDefaultConfiguration0(), e -> LOGGER.error("Couldn't create index {}", securityIndex, e)) + ); + } else { + // in case index was created and cluster state has not been changed (e.g. restart of the node or something) + // just upload default configuration + uploadDefaultConfiguration0(); + } + } + + private void uploadDefaultConfiguration0() { + securityIndexHandler.uploadDefaultConfiguration( + resolveConfigDir(), + ActionListener.wrap( + configuration -> clusterService.submitStateUpdateTask( + "init-security-configuration", + new ClusterStateUpdateTask(Priority.IMMEDIATE) { + @Override + public ClusterState execute(ClusterState clusterState) throws Exception { + return ClusterState.builder(clusterState) + .putCustom(SecurityMetadata.TYPE, new SecurityMetadata(Instant.now(), configuration)) + .build(); + } + + @Override + public void onFailure(String s, Exception e) { + LOGGER.error(s, e); + } + } + ), + e -> LOGGER.error("Couldn't upload default configuration", e) + ) + ); + } + + Future executeConfigurationInitialization(final SecurityMetadata securityMetadata) { + if (!initalizeConfigTask.isDone()) { + if (initializationInProcess.compareAndSet(false, true)) { + return threadPool.generic().submit(() -> { + securityIndexHandler.loadConfiguration(securityMetadata.configuration(), ActionListener.wrap(cTypeConfigs -> { + notifyConfigurationListeners(cTypeConfigs); + final var auditConfigDocPresent = cTypeConfigs.containsKey(CType.AUDIT) && cTypeConfigs.get(CType.AUDIT).notEmpty(); + setupAuditConfigurationIfAny(auditConfigDocPresent); + auditHotReloadingEnabled.getAndSet(auditConfigDocPresent); + initalizeConfigTask.complete(null); + LOGGER.info( + "Security configuration initialized. Applied hashes: {}", + securityMetadata.configuration() + .stream() + .map(c -> String.format("%s:%s", c.type().toLCString(), c.hash())) + .collect(Collectors.toList()) + ); + }, e -> LOGGER.error("Couldn't reload security configuration", e))); + return null; + }); + } + } + return CompletableFuture.completedFuture(null); + } + + @Deprecated public CompletableFuture initOnNodeStart() { final boolean installDefaultConfig = settings.getAsBoolean(ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, false); @@ -333,13 +449,15 @@ public CompletableFuture initOnNodeStart() { return startInitialization.get(); } else if (settings.getAsBoolean(ConfigConstants.SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST, true)) { LOGGER.info( - "Will not attempt to create index {} and default configs if they are absent. Use securityadmin to initialize cluster", + "Will not attempt to create index {} and default configs if they are absent." + + " Use securityadmin to initialize cluster", securityIndex ); return startInitialization.get(); } else { LOGGER.info( - "Will not attempt to create index {} and default configs if they are absent. Will not perform background initialization", + "Will not attempt to create index {} and default configs if they are absent. " + + "Will not perform background initialization", securityIndex ); initalizeConfigTask.complete(null); @@ -352,7 +470,11 @@ public CompletableFuture initOnNodeStart() { } public boolean isAuditHotReloadingEnabled() { - return cl.isAuditConfigDocPresentInIndex(); + if (settings.getAsBoolean(SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE, false)) { + return auditHotReloadingEnabled.get(); + } else { + return cl.isAuditConfigDocPresentInIndex(); + } } public static ConfigurationRepository create( @@ -363,15 +485,20 @@ public static ConfigurationRepository create( ClusterService clusterService, AuditLog auditLog ) { - final ConfigurationRepository repository = new ConfigurationRepository( + final var securityIndex = settings.get( + ConfigConstants.SECURITY_CONFIG_INDEX_NAME, + ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX + ); + return new ConfigurationRepository( + securityIndex, settings, configPath, threadPool, client, clusterService, - auditLog + auditLog, + new SecurityIndexHandler(securityIndex, settings, client) ); - return repository; } public void setDynamicConfigFactory(DynamicConfigFactory dynamicConfigFactory) { @@ -403,6 +530,10 @@ private boolean reloadConfiguration(final Collection configTypes, final b LOGGER.warn("Unable to reload configuration, initalization thread has not yet completed."); return false; } + return loadConfigurationWithLock(configTypes); + } + + private boolean loadConfigurationWithLock(Collection configTypes) { try { if (LOCK.tryLock(60, TimeUnit.SECONDS)) { try { @@ -422,8 +553,12 @@ private boolean reloadConfiguration(final Collection configTypes, final b private void reloadConfiguration0(Collection configTypes, boolean acceptInvalid) { final Map> loaded = getConfigurationsFromIndex(configTypes, false, acceptInvalid); - configCache.putAll(loaded); - notifyAboutChanges(loaded); + notifyConfigurationListeners(loaded); + } + + private void notifyConfigurationListeners(final Map> configuration) { + configCache.putAll(configuration); + notifyAboutChanges(configuration); } public synchronized void subscribeOnChange(ConfigurationChangeListener listener) { diff --git a/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java b/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java index 90508840e7..83553f2de7 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/SecurityDynamicConfiguration.java @@ -68,6 +68,11 @@ public static SecurityDynamicConfiguration empty() { return new SecurityDynamicConfiguration(); } + @JsonIgnore + public boolean notEmpty() { + return !centries.isEmpty(); + } + public static SecurityDynamicConfiguration fromJson(String json, CType ctype, int version, long seqNo, long primaryTerm) throws IOException { return fromJson(json, ctype, version, seqNo, primaryTerm, false); diff --git a/src/main/java/org/opensearch/security/state/SecurityConfig.java b/src/main/java/org/opensearch/security/state/SecurityConfig.java new file mode 100644 index 0000000000..f8de098365 --- /dev/null +++ b/src/main/java/org/opensearch/security/state/SecurityConfig.java @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.state; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.security.securityconf.impl.CType; + +import static java.time.format.DateTimeFormatter.ISO_INSTANT; + +public class SecurityConfig implements Writeable, ToXContent { + + private final CType type; + + private final Instant lastModified; + + private final String hash; + + public SecurityConfig(final CType type, final String hash, final Instant lastModified) { + this.type = type; + this.hash = hash; + this.lastModified = lastModified; + } + + public SecurityConfig(final StreamInput in) throws IOException { + this.type = in.readEnum(CType.class); + this.hash = in.readString(); + this.lastModified = in.readOptionalInstant(); + } + + public Optional lastModified() { + return Optional.ofNullable(lastModified); + } + + public CType type() { + return type; + } + + public String hash() { + return hash; + } + + @Override + public void writeTo(final StreamOutput out) throws IOException { + out.writeEnum(type); + out.writeString(hash); + out.writeOptionalInstant(lastModified); + } + + @Override + public XContentBuilder toXContent(final XContentBuilder xContentBuilder, final Params params) throws IOException { + xContentBuilder.startObject(type.toLCString()).field("hash", hash); + if (lastModified != null) { + xContentBuilder.field("last_modified", ISO_INSTANT.format(lastModified)); + } else { + xContentBuilder.nullField("last_modified"); + } + return xContentBuilder.endObject(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SecurityConfig that = (SecurityConfig) o; + return type == that.type && Objects.equals(lastModified, that.lastModified) && Objects.equals(hash, that.hash); + } + + @Override + public int hashCode() { + return Objects.hash(type, lastModified, hash); + } + + public final static class Builder { + + private final CType type; + + private Instant lastModified; + + private String hash; + + Builder(final SecurityConfig securityConfig) { + this.type = securityConfig.type; + this.lastModified = securityConfig.lastModified; + this.hash = securityConfig.hash; + } + + public Builder withHash(final String hash) { + this.hash = hash; + return this; + } + + public Builder withLastModified(final Instant lastModified) { + this.lastModified = lastModified; + return this; + } + + public SecurityConfig build() { + return new SecurityConfig(type, hash, lastModified); + } + + } + + public static SecurityConfig.Builder from(final SecurityConfig securityConfig) { + return new SecurityConfig.Builder(securityConfig); + } + +} diff --git a/src/main/java/org/opensearch/security/state/SecurityMetadata.java b/src/main/java/org/opensearch/security/state/SecurityMetadata.java new file mode 100644 index 0000000000..f8e2e043fd --- /dev/null +++ b/src/main/java/org/opensearch/security/state/SecurityMetadata.java @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.state; + +import java.io.IOException; +import java.time.Instant; +import java.util.Comparator; +import java.util.Objects; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; + +import org.opensearch.Version; +import org.opensearch.cluster.AbstractNamedDiffable; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.NamedDiff; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.StreamOutput; +import org.opensearch.core.xcontent.XContentBuilder; + +import static java.time.format.DateTimeFormatter.ISO_INSTANT; + +public final class SecurityMetadata extends AbstractNamedDiffable implements ClusterState.Custom { + + public final static String TYPE = "security"; + + private final Instant created; + + private final Set configuration; + + public SecurityMetadata(final Instant created, final Set configuration) { + this.created = created; + this.configuration = configuration; + } + + public SecurityMetadata(StreamInput in) throws IOException { + this.created = in.readInstant(); + this.configuration = in.readSet(SecurityConfig::new); + } + + public Instant created() { + return created; + } + + public Set configuration() { + return configuration; + } + + @Override + public Version getMinimalSupportedVersion() { + return Version.CURRENT.minimumCompatibilityVersion(); + } + + @Override + public String getWriteableName() { + return TYPE; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeInstant(created); + out.writeCollection(configuration); + } + + @Override + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { + xContentBuilder.field("created", ISO_INSTANT.format(created)); + xContentBuilder.startObject("configuration"); + for (final var securityConfig : configuration) { + securityConfig.toXContent(xContentBuilder, EMPTY_PARAMS); + } + return xContentBuilder.endObject(); + } + + public static NamedDiff readDiffFrom(StreamInput in) throws IOException { + return readDiffFrom(ClusterState.Custom.class, TYPE, in); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SecurityMetadata that = (SecurityMetadata) o; + return Objects.equals(created, that.created) && Objects.equals(configuration, that.configuration); + } + + @Override + public int hashCode() { + return Objects.hash(created, configuration); + } + + public final static class Builder { + + private final Instant created; + + private final ImmutableSet.Builder configuration = new ImmutableSortedSet.Builder<>( + Comparator.comparing(SecurityConfig::type) + ); + + Builder(SecurityMetadata oldMetadata) { + this.created = oldMetadata.created; + this.configuration.addAll(oldMetadata.configuration); + } + + public Builder withSecurityConfig(final SecurityConfig securityConfig) { + this.configuration.add(securityConfig); + return this; + } + + public SecurityMetadata build() { + return new SecurityMetadata(created, configuration.build()); + } + + } + + public static SecurityMetadata.Builder from(final SecurityMetadata securityMetadata) { + return new SecurityMetadata.Builder(securityMetadata); + } + +} diff --git a/src/main/java/org/opensearch/security/support/ConfigConstants.java b/src/main/java/org/opensearch/security/support/ConfigConstants.java index 3060e1b2dc..5169d02d20 100644 --- a/src/main/java/org/opensearch/security/support/ConfigConstants.java +++ b/src/main/java/org/opensearch/security/support/ConfigConstants.java @@ -220,9 +220,14 @@ public class ConfigConstants { public static final String SECURITY_NODES_DN = "plugins.security.nodes_dn"; public static final String SECURITY_NODES_DN_DYNAMIC_CONFIG_ENABLED = "plugins.security.nodes_dn_dynamic_config_enabled"; public static final String SECURITY_DISABLED = "plugins.security.disabled"; + public static final String SECURITY_CACHE_TTL_MINUTES = "plugins.security.cache.ttl_minutes"; public static final String SECURITY_ALLOW_UNSAFE_DEMOCERTIFICATES = "plugins.security.allow_unsafe_democertificates"; public static final String SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX = "plugins.security.allow_default_init_securityindex"; + + public static final String SECURITY_ALLOW_DEFAULT_INIT_USE_CLUSTER_STATE = + "plugins.security.allow_default_init_securityindex.use_cluster_state"; + public static final String SECURITY_BACKGROUND_INIT_IF_SECURITYINDEX_NOT_EXIST = "plugins.security.background_init_if_securityindex_not_exist"; diff --git a/src/main/java/org/opensearch/security/support/ConfigHelper.java b/src/main/java/org/opensearch/security/support/ConfigHelper.java index 4f310f6af7..e8526478f2 100644 --- a/src/main/java/org/opensearch/security/support/ConfigHelper.java +++ b/src/main/java/org/opensearch/security/support/ConfigHelper.java @@ -57,6 +57,7 @@ import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; +@Deprecated public class ConfigHelper { private static final Logger LOGGER = LogManager.getLogger(ConfigHelper.class); diff --git a/src/main/java/org/opensearch/security/support/SecurityIndexHandler.java b/src/main/java/org/opensearch/security/support/SecurityIndexHandler.java new file mode 100644 index 0000000000..1ed8a99614 --- /dev/null +++ b/src/main/java/org/opensearch/security/support/SecurityIndexHandler.java @@ -0,0 +1,233 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.support; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSortedSet; +import com.google.common.hash.Hashing; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.action.DocWriteRequest; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.bulk.BulkRequest; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.get.MultiGetResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.WriteRequest; +import org.opensearch.client.Client; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.state.SecurityConfig; + +import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; +import static org.opensearch.security.configuration.ConfigurationRepository.DEFAULT_CONFIG_VERSION; +import static org.opensearch.security.support.YamlConfigReader.emptyJsonConfigFor; +import static org.opensearch.security.support.YamlConfigReader.yamlContentFor; + +public class SecurityIndexHandler { + + private final static int MINIMUM_HASH_BITS = 128; + + private static final Logger LOGGER = LogManager.getLogger(SecurityIndexHandler.class); + + private final Settings settings; + + private final Client client; + + private final String indexName; + + public SecurityIndexHandler(final String indexName, final Settings settings, final Client client) { + this.indexName = indexName; + this.settings = settings; + this.client = client; + } + + public final static Map INDEX_SETTINGS = Map.of("index.number_of_shards", 1, "index.auto_expand_replicas", "0-all"); + + public void createIndex(ActionListener listener) { + try (final ThreadContext.StoredContext threadContext = client.threadPool().getThreadContext().stashContext()) { + client.admin() + .indices() + .create( + new CreateIndexRequest(indexName).settings(INDEX_SETTINGS).waitForActiveShards(1), + ActionListener.runBefore(ActionListener.wrap(r -> { + if (r.isAcknowledged()) { + listener.onResponse(true); + } else listener.onFailure(new SecurityException("Couldn't create security index " + indexName)); + }, listener::onFailure), threadContext::restore) + ); + } + } + + public void uploadDefaultConfiguration(final Path configDir, final ActionListener> listener) { + try (final ThreadContext.StoredContext threadContext = client.threadPool().getThreadContext().stashContext()) { + AccessController.doPrivileged((PrivilegedAction) () -> { + try { + LOGGER.info("Uploading default security configuration from {}", configDir.toAbsolutePath()); + final var bulkRequest = new BulkRequest().setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE); + final var configuration = new ImmutableSortedSet.Builder<>(Comparator.comparing(SecurityConfig::type)); + for (final var cType : CType.values()) { + final var fileExists = Files.exists(cType.configFile(configDir)); + // Audit config is not packaged by default and while list is deprecated + if ((cType == CType.AUDIT || cType == CType.WHITELIST) && !fileExists) continue; + if (cType == CType.WHITELIST) { + LOGGER.warn( + "WHITELIST configuration type is deprecated and will be replaced with ALLOWLIST in the next major version" + ); + } + final var yamlContent = yamlContentFor(cType, configDir); + final var hash = Hashing.goodFastHash(MINIMUM_HASH_BITS).hashBytes(yamlContent.toBytesRef().bytes); + configuration.add(new SecurityConfig(cType, hash.toString(), null)); + bulkRequest.add( + new IndexRequest(indexName).id(cType.toLCString()) + .opType(DocWriteRequest.OpType.INDEX) + .source(cType.toLCString(), yamlContent) + ); + } + client.bulk(bulkRequest, ActionListener.runBefore(ActionListener.wrap(r -> { + if (r.hasFailures()) { + listener.onFailure(new SecurityException(r.buildFailureMessage())); + return; + } + listener.onResponse(configuration.build()); + }, listener::onFailure), threadContext::restore)); + } catch (final IOException ioe) { + listener.onFailure(new SecurityException(ioe)); + } + return null; + }); + } + } + + public void loadConfiguration( + final Set configuration, + final ActionListener>> listener + ) { + try (final ThreadContext.StoredContext threadContext = client.threadPool().getThreadContext().stashContext()) { + client.threadPool().getThreadContext().putHeader(ConfigConstants.OPENDISTRO_SECURITY_CONF_REQUEST_HEADER, "true"); + final var configurationTypes = configuration.stream().map(SecurityConfig::type).collect(Collectors.toUnmodifiableList()); + client.multiGet(newMultiGetRequest(configurationTypes), ActionListener.runBefore(ActionListener.wrap(r -> { + final var cTypeConfigsBuilder = ImmutableMap.>builderWithExpectedSize( + configuration.size() + ); + var hasFailures = false; + for (final var item : r.getResponses()) { + if (item.isFailed()) { + listener.onFailure(new SecurityException(multiGetFailureMessage(item.getId(), item.getFailure()))); + hasFailures = true; + break; + } + final var cType = CType.fromString(item.getId()); + final var cTypeResponse = item.getResponse(); + if (cTypeResponse.isExists() && !cTypeResponse.isSourceEmpty()) { + final var config = buildDynamicConfiguration( + cType, + cTypeResponse.getSourceAsBytesRef(), + cTypeResponse.getSeqNo(), + cTypeResponse.getPrimaryTerm() + ); + if (config.getVersion() != DEFAULT_CONFIG_VERSION) { + listener.onFailure( + new SecurityException("Version " + config.getVersion() + " is not supported for " + cType.name()) + ); + hasFailures = true; + break; + } + cTypeConfigsBuilder.put(cType, config); + } else { + if (!cType.emptyIfMissing()) { + listener.onFailure(new SecurityException("Missing required configuration for type: " + cType)); + hasFailures = true; + break; + } + cTypeConfigsBuilder.put( + cType, + SecurityDynamicConfiguration.fromJson( + emptyJsonConfigFor(cType), + cType, + DEFAULT_CONFIG_VERSION, + cTypeResponse.getSeqNo(), + cTypeResponse.getPrimaryTerm() + ) + ); + } + } + if (!hasFailures) { + listener.onResponse(cTypeConfigsBuilder.build()); + } + }, listener::onFailure), threadContext::restore)); + } + } + + private MultiGetRequest newMultiGetRequest(final List configurationTypes) { + final var request = new MultiGetRequest().realtime(true).refresh(true); + for (final var cType : configurationTypes) { + request.add(indexName, cType.toLCString()); + } + return request; + } + + private SecurityDynamicConfiguration buildDynamicConfiguration( + final CType cType, + final BytesReference bytesRef, + final long seqNo, + final long primaryTerm + ) { + try { + final var source = SecurityUtils.replaceEnvVars(configTypeSource(bytesRef.streamInput()), settings); + final var jsonNode = DefaultObjectMapper.readTree(source); + var version = 1; + if (jsonNode.has("_meta")) { + if (jsonNode.get("_meta").has("config_version")) { + version = jsonNode.get("_meta").get("config_version").asInt(); + } + } + return SecurityDynamicConfiguration.fromJson(source, cType, version, seqNo, primaryTerm); + } catch (IOException e) { + throw new SecurityException("Couldn't parse content for " + cType, e); + } + } + + private String configTypeSource(final InputStream inputStream) throws IOException { + final var jsonContent = XContentType.JSON.xContent(); + try (final var parser = jsonContent.createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, inputStream)) { + parser.nextToken(); + parser.nextToken(); + parser.nextToken(); + return new String(parser.binaryValue(), StandardCharsets.UTF_8); + } + } + + private String multiGetFailureMessage(final String cTypeId, final MultiGetResponse.Failure failure) { + return String.format("Failure %s retrieving configuration for %s (index=%s)", failure, cTypeId, indexName); + } + +} diff --git a/src/main/java/org/opensearch/security/support/YamlConfigReader.java b/src/main/java/org/opensearch/security/support/YamlConfigReader.java new file mode 100644 index 0000000000..237e5b5bfb --- /dev/null +++ b/src/main/java/org/opensearch/security/support/YamlConfigReader.java @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.support; + +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.common.bytes.BytesReference; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.Meta; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; + +import static org.opensearch.core.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; +import static org.opensearch.security.configuration.ConfigurationRepository.DEFAULT_CONFIG_VERSION; + +/** + * Read YAML security config files + */ +public final class YamlConfigReader { + + private static final Logger LOGGER = LogManager.getLogger(YamlConfigReader.class); + + public static BytesReference yamlContentFor(final CType cType, final Path configDir) throws IOException { + final var yamlXContent = XContentType.YAML.xContent(); + try ( + final var r = newReader(cType, configDir); + final var parser = yamlXContent.createParser(NamedXContentRegistry.EMPTY, THROW_UNSUPPORTED_OPERATION, r) + ) { + parser.nextToken(); + try (final var xContentBuilder = XContentFactory.jsonBuilder()) { + xContentBuilder.copyCurrentStructure(parser); + final var bytesRef = BytesReference.bytes(xContentBuilder); + validateYamlContent(cType, bytesRef.streamInput()); + return bytesRef; + } + } + } + + public static Reader newReader(final CType cType, final Path configDir) throws IOException { + final var cTypeFile = cType.configFile(configDir); + final var fileExists = Files.exists(cTypeFile); + if (!fileExists && !cType.emptyIfMissing()) { + throw new IOException("Couldn't find configuration file " + cTypeFile.getFileName()); + } + if (fileExists) { + LOGGER.info("Reading {} configuration from {}", cType, cTypeFile.getFileName()); + return new FileReader(cTypeFile.toFile(), StandardCharsets.UTF_8); + } else { + LOGGER.info("Reading empty {} configuration", cType); + return new StringReader(emptyYamlConfigFor(cType)); + } + } + + private static SecurityDynamicConfiguration emptyConfigFor(final CType cType) { + final var emptyConfiguration = SecurityDynamicConfiguration.empty(); + emptyConfiguration.setCType(cType); + emptyConfiguration.set_meta(new Meta()); + emptyConfiguration.get_meta().setConfig_version(DEFAULT_CONFIG_VERSION); + emptyConfiguration.get_meta().setType(cType.toLCString()); + return emptyConfiguration; + } + + public static String emptyJsonConfigFor(final CType cType) throws IOException { + return DefaultObjectMapper.writeValueAsString(emptyConfigFor(cType), false); + } + + public static String emptyYamlConfigFor(final CType cType) throws IOException { + return DefaultObjectMapper.YAML_MAPPER.writeValueAsString(emptyConfigFor(cType)); + } + + private static void validateYamlContent(final CType cType, final InputStream in) throws IOException { + SecurityDynamicConfiguration.fromNode(DefaultObjectMapper.YAML_MAPPER.readTree(in), cType, DEFAULT_CONFIG_VERSION, -1, -1); + } + +} diff --git a/src/test/java/org/opensearch/security/configuration/ConfigurationRepositoryTest.java b/src/test/java/org/opensearch/security/configuration/ConfigurationRepositoryTest.java index 5ce1873405..30cbbe6a01 100644 --- a/src/test/java/org/opensearch/security/configuration/ConfigurationRepositoryTest.java +++ b/src/test/java/org/opensearch/security/configuration/ConfigurationRepositoryTest.java @@ -13,22 +13,37 @@ import java.io.IOException; import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; +import java.util.Set; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.opensearch.client.Client; +import org.opensearch.cluster.ClusterChangedEvent; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.ClusterStateUpdateTask; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.node.DiscoveryNodes; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.Priority; import org.opensearch.common.settings.Settings; +import org.opensearch.core.action.ActionListener; import org.opensearch.security.auditlog.AuditLog; import org.opensearch.security.securityconf.impl.CType; import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.state.SecurityConfig; +import org.opensearch.security.state.SecurityMetadata; import org.opensearch.security.support.ConfigConstants; +import org.opensearch.security.support.SecurityIndexHandler; import org.opensearch.security.transport.SecurityInterceptorTests; import org.opensearch.threadpool.ThreadPool; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -36,7 +51,22 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; +import static org.opensearch.security.support.ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX; +import static org.opensearch.security.support.ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +@RunWith(MockitoJUnitRunner.class) public class ConfigurationRepositoryTest { @Mock @@ -50,21 +80,44 @@ public class ConfigurationRepositoryTest { private ThreadPool threadPool; + @Mock + private SecurityIndexHandler securityIndexHandler; + + @Mock + private ClusterChangedEvent event; + @Before public void setUp() { - MockitoAnnotations.openMocks(this); - Settings settings = Settings.builder() .put("node.name", SecurityInterceptorTests.class.getSimpleName()) .put("request.headers.default", "1") .build(); threadPool = new ThreadPool(settings); + + final var previousState = mock(ClusterState.class); + final var previousDiscoveryNodes = mock(DiscoveryNodes.class); + when(previousState.nodes()).thenReturn(previousDiscoveryNodes); + when(event.previousState()).thenReturn(previousState); + + final var newState = mock(ClusterState.class); + when(event.state()).thenReturn(newState); + when(event.state().metadata()).thenReturn(mock(Metadata.class)); + + when(event.state().custom(SecurityMetadata.TYPE)).thenReturn(null); } private ConfigurationRepository createConfigurationRepository(Settings settings) { - - return ConfigurationRepository.create(settings, path, threadPool, localClient, clusterService, auditLog); + return new ConfigurationRepository( + settings.get(ConfigConstants.SECURITY_CONFIG_INDEX_NAME, ConfigConstants.OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX), + settings, + path, + threadPool, + localClient, + clusterService, + auditLog, + securityIndexHandler + ); } @Test @@ -77,7 +130,7 @@ public void create_shouldReturnConfigurationRepository() { @Test public void initOnNodeStart_withSecurityIndexCreationEnabledShouldSetInstallDefaultConfigTrue() { - Settings settings = Settings.builder().put(ConfigConstants.SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true).build(); + Settings settings = Settings.builder().put(SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX, true).build(); ConfigurationRepository configRepository = createConfigurationRepository(settings); @@ -111,4 +164,129 @@ public void getConfiguration_withInvalidConfigurationShouldReturnNewEmptyConfigu assertThat(config.getSeqNo(), is(equalTo(emptyConfig.getSeqNo()))); assertThat(config, is(not(equalTo(emptyConfig)))); } + + @Test + public void testClusterChanged_shouldInitSecurityIndexIfNoSecurityData() { + when(event.previousState().nodes().isLocalNodeElectedClusterManager()).thenReturn(false); + when(event.localNodeClusterManager()).thenReturn(true); + + final var configurationRepository = mock(ConfigurationRepository.class); + doCallRealMethod().when(configurationRepository).clusterChanged(any()); + configurationRepository.clusterChanged(event); + + verify(configurationRepository).initSecurityIndex(any()); + } + + @Test + public void testClusterChanged_shouldExecuteInitialization() { + when(event.state().custom(SecurityMetadata.TYPE)).thenReturn(new SecurityMetadata(Instant.now(), Set.of())); + + final var configurationRepository = mock(ConfigurationRepository.class); + doCallRealMethod().when(configurationRepository).clusterChanged(any()); + configurationRepository.clusterChanged(event); + + verify(configurationRepository).executeConfigurationInitialization(any()); + } + + @Test + public void testClusterChanged_shouldNotExecuteInitialization() { + final var configurationRepository = mock(ConfigurationRepository.class); + doCallRealMethod().when(configurationRepository).clusterChanged(any()); + configurationRepository.clusterChanged(event); + + verify(configurationRepository, never()).executeConfigurationInitialization(any()); + } + + @Test + public void testInitSecurityIndex_shouldCreateIndexAndUploadConfiguration() throws Exception { + System.setProperty("security.default_init.dir", Path.of(".").toString()); + ConfigurationRepository configRepository = createConfigurationRepository(Settings.EMPTY); + + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + final var listener = (ActionListener) invocation.getArgument(0); + listener.onResponse(true); + return null; + }).when(securityIndexHandler).createIndex(any()); + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + final var listener = (ActionListener>) invocation.getArgument(1); + listener.onResponse(Set.of(new SecurityConfig(CType.CONFIG, "aaa", null))); + return null; + }).when(securityIndexHandler).uploadDefaultConfiguration(any(), any()); + when(event.state().metadata().hasIndex(OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX)).thenReturn(false); + configRepository.initSecurityIndex(event); + + final var clusterStateUpdateTaskCaptor = ArgumentCaptor.forClass(ClusterStateUpdateTask.class); + verify(securityIndexHandler).createIndex(any()); + verify(securityIndexHandler).uploadDefaultConfiguration(any(), any()); + verify(clusterService).submitStateUpdateTask(anyString(), clusterStateUpdateTaskCaptor.capture()); + verifyNoMoreInteractions(clusterService, securityIndexHandler); + + assertClusterState(clusterStateUpdateTaskCaptor); + } + + @Test + public void testInitSecurityIndex_shouldUploadConfigIfIndexCreated() throws Exception { + System.setProperty("security.default_init.dir", Path.of(".").toString()); + + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + final var listener = (ActionListener>) invocation.getArgument(1); + listener.onResponse(Set.of(new SecurityConfig(CType.CONFIG, "aaa", null))); + return null; + }).when(securityIndexHandler).uploadDefaultConfiguration(any(), any()); + + when(event.state().metadata().hasIndex(OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX)).thenReturn(true); + + ConfigurationRepository configRepository = createConfigurationRepository(Settings.EMPTY); + configRepository.initSecurityIndex(event); + + final var clusterStateUpdateTaskCaptor = ArgumentCaptor.forClass(ClusterStateUpdateTask.class); + + verify(event.state().metadata()).hasIndex(OPENDISTRO_SECURITY_DEFAULT_CONFIG_INDEX); + verify(clusterService).submitStateUpdateTask(anyString(), clusterStateUpdateTaskCaptor.capture()); + verify(securityIndexHandler, never()).createIndex(any()); + verify(securityIndexHandler).uploadDefaultConfiguration(any(), any()); + verifyNoMoreInteractions(securityIndexHandler, clusterService); + + assertClusterState(clusterStateUpdateTaskCaptor); + } + + @Test + public void testExecuteConfigurationInitialization_executeInitializationOnlyOnce() throws Exception { + doAnswer(invocation -> { + @SuppressWarnings("unchecked") + final var listener = (ActionListener>>) invocation.getArgument(1); + listener.onResponse(Map.of()); + return null; + }).when(securityIndexHandler).loadConfiguration(any(), any()); + + ConfigurationRepository configRepository = createConfigurationRepository(Settings.EMPTY); + configRepository.executeConfigurationInitialization( + new SecurityMetadata(Instant.now(), Set.of(new SecurityConfig(CType.CONFIG, "aaa", null))) + ).get(); + + verify(securityIndexHandler).loadConfiguration(any(), any()); + verifyNoMoreInteractions(securityIndexHandler); + + reset(securityIndexHandler); + + configRepository.executeConfigurationInitialization( + new SecurityMetadata(Instant.now(), Set.of(new SecurityConfig(CType.CONFIG, "aaa", null))) + ).get(); + + verify(securityIndexHandler, never()).loadConfiguration(any(), any()); + verifyNoMoreInteractions(securityIndexHandler); + } + + void assertClusterState(final ArgumentCaptor clusterStateUpdateTaskCaptor) throws Exception { + final var initializedStateUpdate = clusterStateUpdateTaskCaptor.getValue(); + assertEquals(Priority.IMMEDIATE, initializedStateUpdate.priority()); + var clusterState = initializedStateUpdate.execute(ClusterState.EMPTY_STATE); + SecurityMetadata securityMetadata = clusterState.custom(SecurityMetadata.TYPE); + assertNotNull(securityMetadata.created()); + assertNotNull(securityMetadata.configuration()); + } + } diff --git a/src/test/java/org/opensearch/security/state/SecurityMetadataSerializationTestCase.java b/src/test/java/org/opensearch/security/state/SecurityMetadataSerializationTestCase.java new file mode 100644 index 0000000000..c52f37cf54 --- /dev/null +++ b/src/test/java/org/opensearch/security/state/SecurityMetadataSerializationTestCase.java @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.security.state; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import com.carrotsearch.randomizedtesting.RandomizedContext; +import com.carrotsearch.randomizedtesting.RandomizedRunner; +import com.carrotsearch.randomizedtesting.RandomizedTest; +import com.google.common.collect.ImmutableSortedSet; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.opensearch.Version; +import org.opensearch.cluster.ClusterState; +import org.opensearch.common.io.stream.BytesStreamOutput; +import org.opensearch.core.common.io.stream.NamedWriteableAwareStreamInput; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.core.common.io.stream.StreamInput; +import org.opensearch.core.common.io.stream.Writeable; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.test.DiffableTestUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +@RunWith(RandomizedRunner.class) +public class SecurityMetadataSerializationTestCase extends RandomizedTest { + + protected ClusterState.Custom createTestInstance() { + final var configuration = new ImmutableSortedSet.Builder<>(Comparator.comparing(SecurityConfig::type)); + for (final var c : CType.values()) { + configuration.add(new SecurityConfig(c, randomAsciiAlphanumOfLength(128), null)); + } + return new SecurityMetadata(randomInstant(), configuration.build()); + } + + protected ClusterState.Custom makeTestChanges(ClusterState.Custom custom) { + final var securityMetadata = (SecurityMetadata) custom; + + if (randomBoolean()) { + final var configuration = securityMetadata.configuration(); + int leaveElements = randomIntBetween(0, configuration.size() - 1); + final var randomConfigs = randomSubsetOf(leaveElements, configuration); + final var securityMetadataBuilder = SecurityMetadata.from(securityMetadata); + for (final var config : randomConfigs) { + securityMetadataBuilder.withSecurityConfig( + SecurityConfig.from(config).withLastModified(randomInstant()).withHash(randomAsciiAlphanumOfLength(128)).build() + ); + } + return securityMetadataBuilder.build(); + } + + return securityMetadata; + } + + public static List randomSubsetOf(int size, Collection collection) { + if (size > collection.size()) { + throw new IllegalArgumentException( + "Can't pick " + size + " random objects from a collection of " + collection.size() + " objects" + ); + } + List tempList = new ArrayList<>(collection); + Collections.shuffle(tempList, RandomizedContext.current().getRandom()); + return tempList.subList(0, size); + } + + protected Instant randomInstant() { + return Instant.ofEpochSecond(randomLongBetween(0L, 3000000000L), randomLongBetween(0L, 999999999L)); + } + + @Test + public void testSerialization() throws IOException { + for (int runs = 0; runs < 20; runs++) { + ClusterState.Custom testInstance = createTestInstance(); + assertSerialization(testInstance); + } + } + + void assertSerialization(ClusterState.Custom testInstance) throws IOException { + assertSerialization(testInstance, Version.CURRENT); + } + + void assertSerialization(ClusterState.Custom testInstance, Version version) throws IOException { + ClusterState.Custom deserializedInstance = copyInstance(testInstance, version); + assertEqualInstances(testInstance, deserializedInstance); + } + + void assertEqualInstances(ClusterState.Custom expectedInstance, ClusterState.Custom newInstance) { + assertNotSame(newInstance, expectedInstance); + assertEquals(expectedInstance, newInstance); + assertEquals(expectedInstance.hashCode(), newInstance.hashCode()); + } + + @Test + public void testDiffableSerialization() throws IOException { + DiffableTestUtils.testDiffableSerialization( + this::createTestInstance, + this::makeTestChanges, + getNamedWriteableRegistry(), + SecurityMetadata::new, + SecurityMetadata::readDiffFrom + ); + } + + protected NamedWriteableRegistry getNamedWriteableRegistry() { + return new NamedWriteableRegistry(Collections.emptyList()); + } + + protected final ClusterState.Custom copyInstance(ClusterState.Custom instance, Version version) throws IOException { + return copyWriteable(instance, getNamedWriteableRegistry(), SecurityMetadata::new, version); + } + + public static T copyWriteable( + T original, + NamedWriteableRegistry namedWriteableRegistry, + Writeable.Reader reader, + Version version + ) throws IOException { + return copyInstance(original, namedWriteableRegistry, (out, value) -> value.writeTo(out), reader, version); + } + + protected static T copyInstance( + T original, + NamedWriteableRegistry namedWriteableRegistry, + Writeable.Writer writer, + Writeable.Reader reader, + Version version + ) throws IOException { + try (BytesStreamOutput output = new BytesStreamOutput()) { + output.setVersion(version); + writer.write(output, original); + try (StreamInput in = new NamedWriteableAwareStreamInput(output.bytes().streamInput(), namedWriteableRegistry)) { + in.setVersion(version); + return reader.read(in); + } + } + } + +} diff --git a/src/test/java/org/opensearch/security/support/ConfigReaderTest.java b/src/test/java/org/opensearch/security/support/ConfigReaderTest.java new file mode 100644 index 0000000000..189b92ff68 --- /dev/null +++ b/src/test/java/org/opensearch/security/support/ConfigReaderTest.java @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ +package org.opensearch.security.support; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; + +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.CType; + +import static org.opensearch.security.configuration.ConfigurationRepository.DEFAULT_CONFIG_VERSION; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ConfigReaderTest { + + @ClassRule + public static TemporaryFolder folder = new TemporaryFolder(); + + private static File configDir; + + @BeforeClass + public static void createConfigFile() throws IOException { + configDir = folder.newFolder("config"); + } + + @Test + public void testThrowsIOExceptionForMandatoryCTypes() { + for (final var cType : CType.REQUIRED_CONFIG_FILES) { + assertThrows(IOException.class, () -> YamlConfigReader.newReader(cType, configDir.toPath())); + } + } + + @Test + public void testCreateReaderForNonMandatoryCTypes() throws IOException { + final var yamlMapper = DefaultObjectMapper.YAML_MAPPER; + for (final var cType : CType.NOT_REQUIRED_CONFIG_FILES) { + try (final var reader = new BufferedReader(YamlConfigReader.newReader(cType, configDir.toPath()))) { + final var emptyYaml = yamlMapper.readTree(reader); + assertTrue(emptyYaml.has("_meta")); + + final var meta = emptyYaml.get("_meta"); + assertEquals(cType.toLCString(), meta.get("type").asText()); + assertEquals(DEFAULT_CONFIG_VERSION, meta.get("config_version").asInt()); + } + } + } + +} diff --git a/src/test/java/org/opensearch/security/support/SecurityIndexHandlerTest.java b/src/test/java/org/opensearch/security/support/SecurityIndexHandlerTest.java new file mode 100644 index 0000000000..170f0a9853 --- /dev/null +++ b/src/test/java/org/opensearch/security/support/SecurityIndexHandlerTest.java @@ -0,0 +1,510 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + */ + +package org.opensearch.security.support; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +import org.opensearch.action.DocWriteRequest; +import org.opensearch.action.admin.indices.create.CreateIndexRequest; +import org.opensearch.action.admin.indices.create.CreateIndexResponse; +import org.opensearch.action.bulk.BulkItemResponse; +import org.opensearch.action.bulk.BulkRequest; +import org.opensearch.action.bulk.BulkResponse; +import org.opensearch.action.get.GetResponse; +import org.opensearch.action.get.MultiGetItemResponse; +import org.opensearch.action.get.MultiGetRequest; +import org.opensearch.action.get.MultiGetResponse; +import org.opensearch.action.index.IndexRequest; +import org.opensearch.action.support.ActiveShardCount; +import org.opensearch.client.AdminClient; +import org.opensearch.client.Client; +import org.opensearch.client.IndicesAdminClient; +import org.opensearch.common.CheckedSupplier; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.common.bytes.BytesArray; +import org.opensearch.index.get.GetResult; +import org.opensearch.security.DefaultObjectMapper; +import org.opensearch.security.securityconf.impl.CType; +import org.opensearch.security.securityconf.impl.SecurityDynamicConfiguration; +import org.opensearch.security.state.SecurityConfig; +import org.opensearch.threadpool.ThreadPool; + +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.opensearch.security.configuration.ConfigurationRepository.DEFAULT_CONFIG_VERSION; +import static org.opensearch.security.support.YamlConfigReader.emptyYamlConfigFor; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class SecurityIndexHandlerTest { + + final static String INDEX_NAME = "some_index"; + + final static String CONFIG_YAML = "_meta: \n" + + " type: \"config\"\n" + + " config_version: 2\n" + + "config:\n" + + " dynamic:\n" + + " http:\n" + + " anonymous_auth_enabled: false\n"; + + final static String USERS_YAML = "_meta:\n" + + " type: \"internalusers\"\n" + + " config_version: 2\n" + + "admin:\n" + + " hash: \"$2y$12$erlkZeSv7eRMa1vs3UgDl.xoqu1P9GY94Toj1BwdvJiq7eKTOjQjS\"\n" + + " reserved: true\n" + + " backend_roles:\n" + + " - \"admin\"\n" + + " description: \"Some admin user\"\n"; + + final static String ROLES_YAML = "_meta:\n" + " type: \"roles\"\n" + " config_version: 2\n" + "some_role:\n" + " reserved: true\n"; + + final static String ROLES_MAPPING_YAML = "_meta:\n" + + " type: \"rolesmapping\"\n" + + " config_version: 2\n" + + "all_access: \n" + + " reserved: false\n"; + + static final Map> YAML = Map.of( + CType.ACTIONGROUPS, + () -> emptyYamlConfigFor(CType.ACTIONGROUPS), + CType.ALLOWLIST, + () -> emptyYamlConfigFor(CType.ALLOWLIST), + CType.AUDIT, + () -> emptyYamlConfigFor(CType.AUDIT), + CType.CONFIG, + () -> CONFIG_YAML, + CType.INTERNALUSERS, + () -> USERS_YAML, + CType.NODESDN, + () -> emptyYamlConfigFor(CType.NODESDN), + CType.ROLES, + () -> ROLES_YAML, + CType.ROLESMAPPING, + () -> ROLES_MAPPING_YAML, + CType.TENANTS, + () -> emptyYamlConfigFor(CType.TENANTS), + CType.WHITELIST, + () -> emptyYamlConfigFor(CType.WHITELIST) + ); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + private Client client; + + @Mock + private ThreadPool threadPool; + + @Mock + private IndicesAdminClient indicesAdminClient; + + private Path configFolder; + + private ThreadContext threadContext; + + private SecurityIndexHandler securityIndexHandler; + + @Before + public void setupClient() throws IOException { + when(client.admin()).thenReturn(mock(AdminClient.class)); + when(client.admin().indices()).thenReturn(indicesAdminClient); + when(client.threadPool()).thenReturn(threadPool); + threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool()).thenReturn(threadPool); + when(threadPool.getThreadContext()).thenReturn(threadContext); + configFolder = temporaryFolder.newFolder("config").toPath(); + securityIndexHandler = new SecurityIndexHandler(INDEX_NAME, Settings.EMPTY, client); + } + + @Test + public void testCreateIndex_shouldCreateIndex() { + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + actionListener.onResponse(new CreateIndexResponse(true, true, "some_index")); + return null; + }).when(indicesAdminClient).create(any(), any()); + + securityIndexHandler.createIndex(ActionListener.wrap(Assert::assertTrue, Assert::assertNull)); + + final var requestCaptor = ArgumentCaptor.forClass(CreateIndexRequest.class); + + verify(indicesAdminClient).create(requestCaptor.capture(), any()); + + final var createRequest = requestCaptor.getValue(); + assertEquals(INDEX_NAME, createRequest.index()); + for (final var setting : SecurityIndexHandler.INDEX_SETTINGS.entrySet()) + assertEquals(setting.getValue().toString(), createRequest.settings().get(setting.getKey())); + + assertEquals(ActiveShardCount.ONE, createRequest.waitForActiveShards()); + } + + @Test + public void testCreateIndex_shouldReturnSecurityExceptionIfItCanNotCreateIndex() { + + final var listener = spy(ActionListener.wrap(r -> fail("Unexpected behave"), e -> { + assertEquals(SecurityException.class, e.getClass()); + assertEquals("Couldn't create security index " + INDEX_NAME, e.getMessage()); + })); + + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + actionListener.onResponse(new CreateIndexResponse(false, false, "some_index")); + return null; + }).when(indicesAdminClient).create(any(), any()); + + securityIndexHandler.createIndex(listener); + + verify(indicesAdminClient).create(isA(CreateIndexRequest.class), any()); + verify(listener).onFailure(any()); + } + + @Test + public void testUploadDefaultConfiguration_shouldFailIfRequiredConfigFilesAreMissing() { + final var listener = spy(ActionListener.>wrap(r -> fail("Unexpected behave"), e -> { + assertEquals(SecurityException.class, e.getClass()); + assertThat(e.getMessage(), containsString("Couldn't find configuration file")); + })); + securityIndexHandler.uploadDefaultConfiguration(configFolder, listener); + + verify(listener).onFailure(any()); + } + + @Test + public void testUploadDefaultConfiguration_shouldFailIfBulkHasFailures() throws IOException { + final var failedBulkResponse = new BulkResponse( + new BulkItemResponse[] { + new BulkItemResponse(1, DocWriteRequest.OpType.CREATE, new BulkItemResponse.Failure("a", "b", new Exception())) }, + 100L + ); + final var listener = spy(ActionListener.>wrap(r -> fail("Unexpected behave"), e -> { + assertEquals(SecurityException.class, e.getClass()); + assertEquals(e.getMessage(), failedBulkResponse.buildFailureMessage()); + })); + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + actionListener.onResponse(failedBulkResponse); + return null; + }).when(client).bulk(any(BulkRequest.class), any()); + for (final var c : CType.REQUIRED_CONFIG_FILES) { + try (final var io = Files.newBufferedWriter(c.configFile(configFolder))) { + io.write(YAML.get(c).get()); + io.flush(); + } + } + securityIndexHandler.uploadDefaultConfiguration(configFolder, listener); + verify(listener).onFailure(any()); + } + + @Test + public void testUploadDefaultConfiguration_shouldCreateSetOfSecurityConfigs() throws IOException { + + final var listener = spy(ActionListener.>wrap(configuration -> { + for (final var sc : configuration) { + assertTrue(sc.lastModified().isEmpty()); + assertNotNull(sc.hash()); + } + }, e -> fail("Unexpected behave"))); + + for (final var c : CType.REQUIRED_CONFIG_FILES) { + try (final var io = Files.newBufferedWriter(c.configFile(configFolder))) { + final var source = YAML.get(c).get(); + io.write(source); + io.flush(); + } + } + + final var bulkRequestCaptor = ArgumentCaptor.forClass(BulkRequest.class); + + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + final var r = mock(BulkResponse.class); + when(r.hasFailures()).thenReturn(false); + actionListener.onResponse(r); + return null; + }).when(client).bulk(bulkRequestCaptor.capture(), any()); + securityIndexHandler.uploadDefaultConfiguration(configFolder, listener); + + final var bulkRequest = bulkRequestCaptor.getValue(); + for (final var r : bulkRequest.requests()) { + final var indexRequest = (IndexRequest) r; + assertEquals(INDEX_NAME, r.index()); + assertEquals(DocWriteRequest.OpType.INDEX, indexRequest.opType()); + } + verify(listener).onResponse(any()); + } + + @Test + public void testUploadDefaultConfiguration_shouldSkipAudit() throws IOException { + final var listener = spy( + ActionListener.>wrap( + configuration -> assertFalse(configuration.stream().anyMatch(sc -> sc.type() == CType.AUDIT)), + e -> fail("Unexpected behave") + ) + ); + + for (final var c : CType.REQUIRED_CONFIG_FILES) { + if (c == CType.AUDIT) continue; + try (final var io = Files.newBufferedWriter(c.configFile(configFolder))) { + final var source = YAML.get(c).get(); + io.write(source); + io.flush(); + } + } + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + final var r = mock(BulkResponse.class); + when(r.hasFailures()).thenReturn(false); + actionListener.onResponse(r); + return null; + }).when(client).bulk(any(BulkRequest.class), any()); + + securityIndexHandler.uploadDefaultConfiguration(configFolder, listener); + verify(listener).onResponse(any()); + } + + @Test + public void testUploadDefaultConfiguration_shouldSkipWhitelist() throws IOException { + final var listener = spy( + ActionListener.>wrap( + configuration -> assertFalse(configuration.stream().anyMatch(sc -> sc.type() == CType.WHITELIST)), + e -> fail("Unexpected behave") + ) + ); + + for (final var c : CType.REQUIRED_CONFIG_FILES) { + if (c == CType.WHITELIST) continue; + try (final var io = Files.newBufferedWriter(c.configFile(configFolder))) { + final var source = YAML.get(c).get(); + io.write(source); + io.flush(); + } + } + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + final var r = mock(BulkResponse.class); + when(r.hasFailures()).thenReturn(false); + actionListener.onResponse(r); + return null; + }).when(client).bulk(any(BulkRequest.class), any()); + + securityIndexHandler.uploadDefaultConfiguration(configFolder, listener); + verify(listener).onResponse(any()); + } + + @Test + public void testLoadConfiguration_shouldFailIfResponseHasFailures() { + final var listener = spy( + ActionListener.>>wrap( + r -> fail("Unexpected behave"), + e -> assertEquals(SecurityException.class, e.getClass()) + ) + ); + + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + final var r = mock(MultiGetResponse.class); + final var mr = mock(MultiGetItemResponse.class); + when(mr.isFailed()).thenReturn(true); + when(mr.getFailure()).thenReturn(new MultiGetResponse.Failure("a", "id", new Exception())); + when(r.getResponses()).thenReturn(new MultiGetItemResponse[] { mr }); + actionListener.onResponse(r); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + + securityIndexHandler.loadConfiguration(configuration(), listener); + verify(listener).onFailure(any()); + } + + @Test + public void testLoadConfiguration_shouldFailIfNoRequiredConfigInResponse() { + final var listener = spy( + ActionListener.>>wrap( + r -> fail("Unexpected behave"), + e -> assertEquals("Missing required configuration for type: CONFIG", e.getMessage()) + ) + ); + doAnswer(invocation -> { + ActionListener actionListener = invocation.getArgument(1); + final var getResult = mock(GetResult.class); + final var r = new MultiGetResponse(new MultiGetItemResponse[] { new MultiGetItemResponse(new GetResponse(getResult), null) }); + when(getResult.getId()).thenReturn(CType.CONFIG.toLCString()); + when(getResult.isExists()).thenReturn(false); + actionListener.onResponse(r); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + + securityIndexHandler.loadConfiguration(configuration(), listener); + + verify(listener).onFailure(any()); + } + + @Test + public void testLoadConfiguration_shouldFailForUnsupportedVersion() { + final var listener = spy( + ActionListener.>>wrap( + r -> fail("Unexpected behave"), + e -> assertEquals("Version 1 is not supported for CONFIG", e.getMessage()) + ) + ); + doAnswer(invocation -> { + + final var objectMapper = DefaultObjectMapper.objectMapper; + + ActionListener actionListener = invocation.getArgument(1); + final var getResult = mock(GetResult.class); + final var r = new MultiGetResponse(new MultiGetItemResponse[] { new MultiGetItemResponse(new GetResponse(getResult), null) }); + when(getResult.getId()).thenReturn(CType.CONFIG.toLCString()); + when(getResult.isExists()).thenReturn(true); + + final var oldVersionJson = objectMapper.createObjectNode() + .set("opendistro_security", objectMapper.createObjectNode().set("dynamic", objectMapper.createObjectNode())) + .toString() + .getBytes(StandardCharsets.UTF_8); + final var configResponse = objectMapper.createObjectNode().put(CType.CONFIG.toLCString(), oldVersionJson); + final var source = objectMapper.writeValueAsBytes(configResponse); + when(getResult.sourceRef()).thenReturn(new BytesArray(source, 0, source.length)); + actionListener.onResponse(r); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + securityIndexHandler.loadConfiguration(configuration(), listener); + + verify(listener).onFailure(any()); + } + + @Test + public void testLoadConfiguration_shouldFailForUnparseableConfig() { + final var listener = spy( + ActionListener.>>wrap( + r -> fail("Unexpected behave"), + e -> assertEquals("Couldn't parse content for CONFIG", e.getMessage()) + ) + ); + doAnswer(invocation -> { + + final var objectMapper = DefaultObjectMapper.objectMapper; + + ActionListener actionListener = invocation.getArgument(1); + final var getResult = mock(GetResult.class); + final var r = new MultiGetResponse(new MultiGetItemResponse[] { new MultiGetItemResponse(new GetResponse(getResult), null) }); + when(getResult.getId()).thenReturn(CType.CONFIG.toLCString()); + when(getResult.isExists()).thenReturn(true); + + final var configResponse = objectMapper.createObjectNode() + .put( + CType.CONFIG.toLCString(), + objectMapper.createObjectNode() + .set("_meta", objectMapper.createObjectNode().put("type", CType.CONFIG.toLCString())) + .toString() + .getBytes(StandardCharsets.UTF_8) + ); + final var source = objectMapper.writeValueAsBytes(configResponse); + when(getResult.sourceRef()).thenReturn(new BytesArray(source, 0, source.length)); + actionListener.onResponse(r); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + securityIndexHandler.loadConfiguration(configuration(), listener); + + verify(listener).onFailure(any()); + } + + @Test + public void testLoadConfiguration_shouldBuildSecurityConfig() { + final var listener = spy(ActionListener.>>wrap(config -> { + assertEquals(CType.values().length, config.keySet().size()); + for (final var c : CType.values()) { + assertTrue(c.toLCString(), config.containsKey(c)); + } + }, e -> fail("Unexpected behave"))); + doAnswer(invocation -> { + final var objectMapper = DefaultObjectMapper.objectMapper; + ActionListener actionListener = invocation.getArgument(1); + + final var responses = new MultiGetItemResponse[CType.values().length]; + var counter = 0; + for (final var c : CType.values()) { + final var getResult = mock(GetResult.class); + if (!c.emptyIfMissing()) { + when(getResult.getId()).thenReturn(c.toLCString()); + when(getResult.isExists()).thenReturn(true); + + final var minimumRequiredConfig = minimumRequiredConfig(c); + if (c == CType.CONFIG) minimumRequiredConfig.set( + "config", + objectMapper.createObjectNode().set("dynamic", objectMapper.createObjectNode()) + ); + + final var source = objectMapper.writeValueAsBytes( + objectMapper.createObjectNode() + .put(c.toLCString(), minimumRequiredConfig.toString().getBytes(StandardCharsets.UTF_8)) + ); + + when(getResult.sourceRef()).thenReturn(new BytesArray(source, 0, source.length)); + + responses[counter] = new MultiGetItemResponse(new GetResponse(getResult), null); + } else { + when(getResult.getId()).thenReturn(c.toLCString()); + when(getResult.isExists()).thenReturn(false); + responses[counter] = new MultiGetItemResponse(new GetResponse(getResult), null); + } + counter++; + } + actionListener.onResponse(new MultiGetResponse(responses)); + return null; + }).when(client).multiGet(any(MultiGetRequest.class), any()); + securityIndexHandler.loadConfiguration(configuration(), listener); + + verify(listener).onResponse(any()); + } + + private ObjectNode minimumRequiredConfig(final CType cType) { + final var objectMapper = DefaultObjectMapper.objectMapper; + return objectMapper.createObjectNode() + .set("_meta", objectMapper.createObjectNode().put("type", cType.toLCString()).put("config_version", DEFAULT_CONFIG_VERSION)); + } + + private Set configuration() { + return Set.of(new SecurityConfig(CType.CONFIG, "aaa", null), new SecurityConfig(CType.AUDIT, "bbb", null)); + } + +}