diff --git a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java index 1e982ca25e1a4..c5fd400070936 100644 --- a/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java +++ b/server/src/main/java/org/elasticsearch/cluster/ClusterModule.java @@ -65,7 +65,9 @@ import org.elasticsearch.common.io.stream.NamedWriteable; import org.elasticsearch.common.io.stream.NamedWriteableRegistry.Entry; import org.elasticsearch.common.io.stream.Writeable.Reader; +import org.elasticsearch.common.settings.ClusterSecrets; import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.ProjectSecrets; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; @@ -264,7 +266,9 @@ public static List getNamedWriteables() { RegisteredPolicySnapshots::new, RegisteredPolicySnapshots.RegisteredSnapshotsDiff::new ); - + // Secrets + registerClusterCustom(entries, ClusterSecrets.TYPE, ClusterSecrets::new, ClusterSecrets::readDiffFrom); + registerProjectCustom(entries, ProjectSecrets.TYPE, ProjectSecrets::new, ProjectSecrets::readDiffFrom); // Task Status (not Diffable) entries.add(new Entry(Task.Status.class, PersistentTasksNodeService.Status.NAME, PersistentTasksNodeService.Status::new)); diff --git a/server/src/main/java/org/elasticsearch/common/settings/ClusterSecrets.java b/server/src/main/java/org/elasticsearch/common/settings/ClusterSecrets.java new file mode 100644 index 0000000000000..d8039b59932aa --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/settings/ClusterSecrets.java @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.TransportVersions; +import org.elasticsearch.cluster.AbstractNamedDiffable; +import org.elasticsearch.cluster.ClusterState; +import org.elasticsearch.cluster.NamedDiff; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.xcontent.ToXContent; + +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.Objects; + +/** + * Secrets that are stored in cluster state + * + *

Cluster state secrets are initially loaded on each node, from a file on disk, + * in the format defined by {@link org.elasticsearch.common.settings.LocallyMountedSecrets}. + * Once the cluster is running, the master node watches the file for changes. This class + * propagates changes in the file-based secure settings from the master node out to other + * nodes. + * + *

Since the master node should always have settings on disk, we don't need to + * persist this class to saved cluster state, either on disk or in the cloud. Therefore, + * we have defined this {@link ClusterState.Custom} as a private custom object. Additionally, + * we don't want to ever write this class's secrets out in a client response, so + * {@link #toXContentChunked(ToXContent.Params)} returns an empty iterator. + */ +public class ClusterSecrets extends AbstractNamedDiffable implements ClusterState.Custom { + + /** + * The name for this data class + * + *

This name will be used to identify this {@link org.elasticsearch.common.io.stream.NamedWriteable} in cluster + * state. See {@link #getWriteableName()}. + */ + public static final String TYPE = "cluster_state_secrets"; + + private final SecureClusterStateSettings settings; + private final long version; + + public ClusterSecrets(long version, SecureClusterStateSettings settings) { + this.version = version; + this.settings = settings; + } + + public ClusterSecrets(StreamInput in) throws IOException { + this.version = in.readLong(); + this.settings = new SecureClusterStateSettings(in); + } + + public SecureSettings getSettings() { + return new SecureClusterStateSettings(settings); + } + + public long getVersion() { + return version; + } + + @Override + public boolean isPrivate() { + return true; + } + + @Override + public Iterator toXContentChunked(ToXContent.Params params) { + // never render this to the user + return Collections.emptyIterator(); + } + + @Override + public String getWriteableName() { + return TYPE; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TransportVersions.V_8_9_X; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeLong(version); + settings.writeTo(out); + } + + public static NamedDiff readDiffFrom(StreamInput in) throws IOException { + return readDiffFrom(ClusterState.Custom.class, TYPE, in); + } + + @Override + public String toString() { + return "ClusterStateSecrets{[all secret]}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ClusterSecrets that = (ClusterSecrets) o; + return version == that.version && Objects.equals(settings, that.settings); + } + + @Override + public int hashCode() { + return Objects.hash(settings, version); + } +} diff --git a/server/src/main/java/org/elasticsearch/common/settings/ProjectSecrets.java b/server/src/main/java/org/elasticsearch/common/settings/ProjectSecrets.java new file mode 100644 index 0000000000000..a821e276fb64e --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/settings/ProjectSecrets.java @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.TransportVersion; +import org.elasticsearch.TransportVersions; +import org.elasticsearch.cluster.AbstractNamedDiffable; +import org.elasticsearch.cluster.NamedDiff; +import org.elasticsearch.cluster.metadata.Metadata; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.xcontent.ToXContent; + +import java.io.IOException; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.Objects; + +/** + * Secrets that are stored in project state as a {@link Metadata.ProjectCustom} + * + *

Project state secrets are initially loaded on the master node, from a file on disk. + * Once the cluster is running, the master node watches the file for changes. This class + * propagates changes in the file-based secure settings for each project from the master + * node out to other nodes using the transport protocol. + * + *

Since the master node should always have settings on disk, we don't need to + * persist this class to saved cluster state, either on disk or in the cloud. Therefore, + * we have defined this {@link Metadata.ProjectCustom} as a "private custom" object by not + * serializing its content in {@link #toXContentChunked(ToXContent.Params)}. + */ +public class ProjectSecrets extends AbstractNamedDiffable implements Metadata.ProjectCustom { + + public static final String TYPE = "project_state_secrets"; + + private final SecureClusterStateSettings settings; + + public ProjectSecrets(SecureClusterStateSettings settings) { + this.settings = settings; + } + + public ProjectSecrets(StreamInput in) throws IOException { + this.settings = new SecureClusterStateSettings(in); + } + + public SecureSettings getSettings() { + return new SecureClusterStateSettings(settings); + } + + @Override + public Iterator toXContentChunked(ToXContent.Params params) { + // No need to persist in index or return to user, so do not serialize the secrets + return Collections.emptyIterator(); + } + + @Override + public String getWriteableName() { + return TYPE; + } + + @Override + public TransportVersion getMinimalSupportedVersion() { + return TransportVersions.MULTI_PROJECT; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + settings.writeTo(out); + } + + public static NamedDiff readDiffFrom(StreamInput in) throws IOException { + return readDiffFrom(Metadata.ProjectCustom.class, TYPE, in); + } + + @Override + public String toString() { + return "ProjectSecrets{[all secret]}"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ProjectSecrets that = (ProjectSecrets) o; + return Objects.equals(settings, that.settings); + } + + @Override + public int hashCode() { + return Objects.hash(settings); + } + + @Override + public EnumSet context() { + return EnumSet.noneOf(Metadata.XContentContext.class); + } +} diff --git a/server/src/main/java/org/elasticsearch/common/settings/SecureClusterStateSettings.java b/server/src/main/java/org/elasticsearch/common/settings/SecureClusterStateSettings.java new file mode 100644 index 0000000000000..a45d79e4acbe1 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/settings/SecureClusterStateSettings.java @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.common.hash.MessageDigests; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Implementation of {@link SecureSettings} that represents secrets in cluster state. Secrets are stored as byte arrays along with + * their SHA-256 digests. Provides functionality to read and serialize secure settings to broadcast them as part of cluster state. + * Does not provide any encryption. + */ +public class SecureClusterStateSettings implements SecureSettings { + + private final Map secrets; + + public SecureClusterStateSettings(SecureSettings secureSettings) { + secrets = new HashMap<>(); + for (String key : secureSettings.getSettingNames()) { + try { + secrets.put( + key, + new Entry(getValueAsByteArray(secureSettings, key), SecureClusterStateSettings.getSHA256Digest(secureSettings, key)) + ); + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + } + + public SecureClusterStateSettings(Map settings) { + secrets = settings.entrySet() + .stream() + .collect( + Collectors.toMap(Map.Entry::getKey, entry -> new Entry(entry.getValue(), MessageDigests.sha256().digest(entry.getValue()))) + ); + } + + SecureClusterStateSettings(StreamInput in) throws IOException { + secrets = in.readMap(StreamInput::readString, v -> new Entry(in.readByteArray(), in.readByteArray())); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeMap(secrets, StreamOutput::writeString, (o, v) -> v.writeTo(o)); + } + + @Override + public boolean isLoaded() { + return true; + } + + @Override + public Set getSettingNames() { + return secrets.keySet(); + } + + @Override + public SecureString getString(String setting) { + Entry value = secrets.get(setting); + if (value == null) { + return null; + } + ByteBuffer byteBuffer = ByteBuffer.wrap(value.secret()); + CharBuffer charBuffer = StandardCharsets.UTF_8.decode(byteBuffer); + return new SecureString(Arrays.copyOfRange(charBuffer.array(), charBuffer.position(), charBuffer.limit())); + } + + @Override + public InputStream getFile(String setting) { + var value = secrets.get(setting); + if (value == null) { + return null; + } + return new ByteArrayInputStream(value.secret()); + } + + @Override + public byte[] getSHA256Digest(String setting) { + return secrets.get(setting).sha256Digest(); + } + + @Override + public void close() { + if (null != secrets && secrets.isEmpty() == false) { + for (var entry : secrets.entrySet()) { + entry.setValue(null); + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SecureClusterStateSettings secrets1 = (SecureClusterStateSettings) o; + return Objects.equals(secrets, secrets1.secrets); + } + + @Override + public int hashCode() { + return Objects.hash(secrets); + } + + private static byte[] getValueAsByteArray(SecureSettings secureSettings, String key) throws GeneralSecurityException, IOException { + return secureSettings.getFile(key).readAllBytes(); + } + + private static byte[] getSHA256Digest(SecureSettings secureSettings, String key) { + try { + return secureSettings.getSHA256Digest(key); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + record Entry(byte[] secret, byte[] sha256Digest) implements Writeable { + + Entry(StreamInput in) throws IOException { + this(in.readByteArray(), in.readByteArray()); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Entry entry = (Entry) o; + return Arrays.equals(secret, entry.secret) && Arrays.equals(sha256Digest, entry.sha256Digest); + } + + @Override + public int hashCode() { + int result = Arrays.hashCode(secret); + result = 31 * result + Arrays.hashCode(sha256Digest); + return result; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeByteArray(secret); + out.writeByteArray(sha256Digest); + } + } +} diff --git a/server/src/test/java/org/elasticsearch/common/settings/ClusterStateSecretsTests.java b/server/src/test/java/org/elasticsearch/common/settings/ClusterStateSecretsTests.java new file mode 100644 index 0000000000000..edef763cc1418 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/settings/ClusterStateSecretsTests.java @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.env.Environment; +import org.elasticsearch.test.AbstractNamedWriteableTestCase; +import org.junit.After; +import org.junit.Before; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; + +import static org.elasticsearch.xcontent.ToXContent.EMPTY_PARAMS; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; + +public class ClusterStateSecretsTests extends AbstractNamedWriteableTestCase { + + private static final String MOUNTED_SETTINGS = """ + { + "metadata": { + "version": "1", + "compatibility": "8.7.0" + }, + "secrets": { + "foo": "bar", + "goo": "baz" + } + } + """; + + private LocallyMountedSecrets locallyMountedSecrets; + + @Before + public void setUp() throws Exception { + super.setUp(); + + Environment environment = newEnvironment(); + writeTestFile(environment.configDir().resolve("secrets").resolve("secrets.json"), MOUNTED_SETTINGS); + locallyMountedSecrets = new LocallyMountedSecrets(environment); + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + locallyMountedSecrets.close(); + } + + public void testGetSettings() throws Exception { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + assertThat(clusterStateSecrets.getSettings().getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(clusterStateSecrets.getSettings().getString("foo").toString(), equalTo("bar")); + assertThat(clusterStateSecrets.getSettings().getString("goo").toString(), equalTo("baz")); + + locallyMountedSecrets.close(); + + assertThat(clusterStateSecrets.getSettings().getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(clusterStateSecrets.getSettings().getString("foo").toString(), equalTo("bar")); + assertThat(clusterStateSecrets.getSettings().getString("goo").toString(), equalTo("baz")); + } + + public void testFileSettings() throws Exception { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + assertThat(clusterStateSecrets.getSettings().getFile("foo").readAllBytes(), equalTo("bar".getBytes(StandardCharsets.UTF_8))); + } + + public void testSerialize() throws Exception { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + final BytesStreamOutput out = new BytesStreamOutput(); + clusterStateSecrets.writeTo(out); + final ClusterSecrets fromStream = new ClusterSecrets(out.bytes().streamInput()); + + assertThat(fromStream.getVersion(), equalTo(clusterStateSecrets.getVersion())); + + assertThat(fromStream.getSettings().getSettingNames(), hasSize(2)); + assertThat(fromStream.getSettings().getSettingNames(), containsInAnyOrder("foo", "goo")); + + assertEquals(clusterStateSecrets.getSettings().getString("foo"), fromStream.getSettings().getString("foo")); + assertTrue(fromStream.getSettings().isLoaded()); + } + + public void testToXContentChunked() { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + // we never serialize anything to x-content + assertFalse(clusterStateSecrets.toXContentChunked(EMPTY_PARAMS).hasNext()); + } + + public void testToString() { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + assertThat(clusterStateSecrets.toString(), equalTo("ClusterStateSecrets{[all secret]}")); + } + + public void testClose() throws Exception { + ClusterSecrets clusterStateSecrets = new ClusterSecrets(1, new SecureClusterStateSettings(locallyMountedSecrets)); + + SecureSettings settings = clusterStateSecrets.getSettings(); + assertThat(settings.getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(settings.getString("foo").toString(), equalTo("bar")); + assertThat(settings.getString("goo").toString(), equalTo("baz")); + + settings.close(); + + // we can close the copy + assertThat(settings.getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(settings.getString("foo"), nullValue()); + assertThat(settings.getString("goo"), nullValue()); + + // fetching again returns a fresh object + SecureSettings settings2 = clusterStateSecrets.getSettings(); + assertThat(settings2.getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(settings2.getString("foo").toString(), equalTo("bar")); + assertThat(settings2.getString("goo").toString(), equalTo("baz")); + } + + @Override + protected ClusterSecrets createTestInstance() { + return new ClusterSecrets( + randomLong(), + new SecureClusterStateSettings( + randomMap(0, 3, () -> Tuple.tuple(randomAlphaOfLength(10), randomAlphaOfLength(15).getBytes(Charset.defaultCharset()))) + ) + ); + } + + @Override + protected ClusterSecrets mutateInstance(ClusterSecrets instance) throws IOException { + return new ClusterSecrets(instance.getVersion() + 1L, new SecureClusterStateSettings(instance.getSettings())); + } + + @Override + protected NamedWriteableRegistry getNamedWriteableRegistry() { + return new NamedWriteableRegistry( + List.of(new NamedWriteableRegistry.Entry(ClusterSecrets.class, ClusterSecrets.TYPE, ClusterSecrets::new)) + ); + } + + @Override + protected Class categoryClass() { + return ClusterSecrets.class; + } + + private void writeTestFile(Path path, String contents) throws IOException { + Path tempFilePath = createTempFile(); + + Files.writeString(tempFilePath, contents); + Files.createDirectories(path.getParent()); + Files.move(tempFilePath, path, StandardCopyOption.ATOMIC_MOVE); + } +} diff --git a/server/src/test/java/org/elasticsearch/common/settings/ProjectSecretsTests.java b/server/src/test/java/org/elasticsearch/common/settings/ProjectSecretsTests.java new file mode 100644 index 0000000000000..0bfdb5a603475 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/settings/ProjectSecretsTests.java @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.common.io.stream.NamedWriteableRegistry; +import org.elasticsearch.core.Tuple; +import org.elasticsearch.test.AbstractNamedWriteableTestCase; +import org.junit.Before; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.xcontent.ToXContent.EMPTY_PARAMS; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +public class ProjectSecretsTests extends AbstractNamedWriteableTestCase { + + private SecureClusterStateSettings secureClusterStateSettings; + + @Before + public void setUp() throws Exception { + super.setUp(); + MockSecureSettings mockSecureSettings = new MockSecureSettings(); + // SecureSettings in cluster state are handled as file settings (get the byte array) both can be fetched as + // string or file + mockSecureSettings.setFile("foo", "bar".getBytes(StandardCharsets.UTF_8)); + mockSecureSettings.setFile("goo", "baz".getBytes(StandardCharsets.UTF_8)); + secureClusterStateSettings = new SecureClusterStateSettings(mockSecureSettings); + } + + public void testGetSettings() throws Exception { + ProjectSecrets projectSecrets = new ProjectSecrets(secureClusterStateSettings); + assertThat(projectSecrets.getSettings().getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(projectSecrets.getSettings().getString("foo").toString(), equalTo("bar")); + assertThat(new String(projectSecrets.getSettings().getFile("goo").readAllBytes(), StandardCharsets.UTF_8), equalTo("baz")); + } + + public void testSerialize() throws Exception { + ProjectSecrets projectSecrets = new ProjectSecrets(secureClusterStateSettings); + + final BytesStreamOutput out = new BytesStreamOutput(); + projectSecrets.writeTo(out); + final ProjectSecrets fromStream = new ProjectSecrets(out.bytes().streamInput()); + + assertThat(fromStream.getSettings().getSettingNames(), hasSize(2)); + assertThat(fromStream.getSettings().getSettingNames(), containsInAnyOrder("foo", "goo")); + + assertEquals(projectSecrets.getSettings().getString("foo"), fromStream.getSettings().getString("foo")); + assertThat(new String(projectSecrets.getSettings().getFile("goo").readAllBytes(), StandardCharsets.UTF_8), equalTo("baz")); + assertTrue(fromStream.getSettings().isLoaded()); + } + + public void testToXContentChunked() { + ProjectSecrets projectSecrets = new ProjectSecrets(secureClusterStateSettings); + // we never serialize anything to x-content + assertFalse(projectSecrets.toXContentChunked(EMPTY_PARAMS).hasNext()); + } + + public void testToString() { + ProjectSecrets projectSecrets = new ProjectSecrets(secureClusterStateSettings); + assertThat(projectSecrets.toString(), equalTo("ProjectSecrets{[all secret]}")); + } + + @Override + protected ProjectSecrets createTestInstance() { + return new ProjectSecrets( + new SecureClusterStateSettings( + randomMap(0, 3, () -> Tuple.tuple(randomAlphaOfLength(10), randomAlphaOfLength(15).getBytes(Charset.defaultCharset()))) + ) + ); + } + + @Override + protected ProjectSecrets mutateInstance(ProjectSecrets instance) { + Map updatedSettings = new HashMap<>(); + + for (var settingName : instance.getSettings().getSettingNames()) { + updatedSettings.put(settingName, instance.getSettings().getString(settingName).toString().getBytes(StandardCharsets.UTF_8)); + } + updatedSettings.put(randomAlphaOfLength(9), randomAlphaOfLength(14).getBytes(StandardCharsets.UTF_8)); + return new ProjectSecrets(new SecureClusterStateSettings(updatedSettings)); + } + + @Override + protected NamedWriteableRegistry getNamedWriteableRegistry() { + return new NamedWriteableRegistry( + List.of(new NamedWriteableRegistry.Entry(ProjectSecrets.class, ProjectSecrets.TYPE, ProjectSecrets::new)) + ); + } + + @Override + protected Class categoryClass() { + return ProjectSecrets.class; + } +} diff --git a/server/src/test/java/org/elasticsearch/common/settings/SecureClusterStateSettingsTests.java b/server/src/test/java/org/elasticsearch/common/settings/SecureClusterStateSettingsTests.java new file mode 100644 index 0000000000000..71414caa3ac98 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/settings/SecureClusterStateSettingsTests.java @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +package org.elasticsearch.common.settings; + +import org.elasticsearch.common.io.stream.BytesStreamOutput; +import org.elasticsearch.test.ESTestCase; +import org.junit.Before; + +import java.nio.charset.StandardCharsets; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +public class SecureClusterStateSettingsTests extends ESTestCase { + MockSecureSettings mockSecureSettings = new MockSecureSettings(); + + @Before + public void setUp() throws Exception { + super.setUp(); + // SecureSettings in cluster state are handled as file settings (get the byte array) both can be fetched as + // string or file + mockSecureSettings.setFile("foo", "bar".getBytes(StandardCharsets.UTF_8)); + mockSecureSettings.setFile("goo", "baz".getBytes(StandardCharsets.UTF_8)); + } + + public void testGetSettings() throws Exception { + SecureClusterStateSettings secureClusterStateSettings = new SecureClusterStateSettings(mockSecureSettings); + assertThat(secureClusterStateSettings.getSettingNames(), containsInAnyOrder("foo", "goo")); + assertThat(secureClusterStateSettings.getString("foo").toString(), equalTo("bar")); + assertThat(new String(secureClusterStateSettings.getFile("goo").readAllBytes(), StandardCharsets.UTF_8), equalTo("baz")); + } + + public void testSerialize() throws Exception { + SecureClusterStateSettings secureClusterStateSettings = new SecureClusterStateSettings(mockSecureSettings); + + final BytesStreamOutput out = new BytesStreamOutput(); + secureClusterStateSettings.writeTo(out); + final SecureClusterStateSettings fromStream = new SecureClusterStateSettings(out.bytes().streamInput()); + + assertThat(fromStream.getSettingNames(), hasSize(2)); + assertThat(fromStream.getSettingNames(), containsInAnyOrder("foo", "goo")); + + assertEquals(secureClusterStateSettings.getString("foo"), fromStream.getString("foo")); + assertThat(new String(fromStream.getFile("goo").readAllBytes(), StandardCharsets.UTF_8), equalTo("baz")); + assertTrue(fromStream.isLoaded()); + } +}