Skip to content

Commit

Permalink
Provide locally mounted secure settings implementation. (#93392)
Browse files Browse the repository at this point in the history
  • Loading branch information
grcevski committed Feb 6, 2023
1 parent b199470 commit c1b0bf6
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 1 deletion.
5 changes: 5 additions & 0 deletions docs/changelog/93392.yaml
@@ -0,0 +1,5 @@
pr: 93392
summary: Provide locally mounted secure settings implementation
area: Infra/Core
type: enhancement
issues: []
@@ -0,0 +1,186 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

package org.elasticsearch.common.settings;

import org.apache.lucene.util.SetOnce;
import org.elasticsearch.Version;
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 org.elasticsearch.env.Environment;
import org.elasticsearch.reservedstate.service.ReservedStateVersion;
import org.elasticsearch.xcontent.ConstructingObjectParser;
import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.XContentParserConfiguration;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
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.GeneralSecurityException;
import java.util.Map;
import java.util.Set;

import static org.elasticsearch.xcontent.XContentType.JSON;

/**
* An implementation of {@link SecureSettings} which loads the secrets from
* externally mounted local directory. It looks for the folder called 'secrets'
* under the config directory. All secure settings should be supplied in a single
* file called 'secrets.json' which sits inside the 'secrets' directory.
* <p>
* If the 'secrets' directory or the 'secrets.json' file don't exist, the
* SecureSettings implementation is loaded with empty settings map.
* <p>
* Example secrets.json format:
* {
* "metadata": {
* "version": "1",
* "compatibility": "8.7.0"
* },
* "secrets": {
* "secure.setting.key.one": "aaa",
* "secure.setting.key.two": "bbb"
* }
* }
*/
public class LocallyMountedSecrets implements SecureSettings {

public static final String SECRETS_FILE_NAME = "secrets.json";
public static final String SECRETS_DIRECTORY = "secrets";

public static final ParseField SECRETS_FIELD = new ParseField("secrets");
public static final ParseField METADATA_FIELD = new ParseField("metadata");

@SuppressWarnings("unchecked")
private final ConstructingObjectParser<LocalFileSecrets, Void> secretsParser = new ConstructingObjectParser<>(
"locally_mounted_secrets",
a -> new LocalFileSecrets((Map<String, String>) a[0], (ReservedStateVersion) a[1])
);

private final String secretsDir;
private final String secretsFile;
private final SetOnce<LocalFileSecrets> secrets = new SetOnce<>();

/**
* Direct constructor to be used by the CLI
*/
public LocallyMountedSecrets(Environment environment) {
var secretsDirPath = environment.configFile().toAbsolutePath().resolve(SECRETS_DIRECTORY);
var secretsFilePath = secretsDirPath.resolve(SECRETS_FILE_NAME);
secretsParser.declareObject(ConstructingObjectParser.constructorArg(), (p, c) -> p.map(), SECRETS_FIELD);
secretsParser.declareObject(ConstructingObjectParser.constructorArg(), (p, c) -> ReservedStateVersion.parse(p), METADATA_FIELD);
if (Files.exists(secretsDirPath) && Files.exists(secretsFilePath)) {
try {
secrets.set(processSecretsFile(secretsFilePath));
} catch (IOException e) {
throw new IllegalStateException("Error processing secrets file", e);
}
} else {
secrets.set(new LocalFileSecrets(Map.of(), new ReservedStateVersion(-1L, Version.CURRENT)));
}
this.secretsDir = secretsDirPath.toString();
this.secretsFile = secretsFilePath.toString();
}

/**
* Used by {@link org.elasticsearch.bootstrap.ServerArgs} to deserialize the secrets
* when they are received by the Elasticsearch process. The ServerCli code serializes
* the secrets as part of ServerArgs.
*/
public LocallyMountedSecrets(StreamInput in) throws IOException {
this.secretsDir = in.readString();
this.secretsFile = in.readString();
if (in.readBoolean()) {
secrets.set(LocalFileSecrets.readFrom(in));
}
// TODO: Add support for watching for file changes here.
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(secretsDir);
out.writeString(secretsFile);
if (secrets.get() == null) {
out.writeBoolean(false);
} else {
out.writeBoolean(true);
secrets.get().writeTo(out);
}
}

@Override
public boolean isLoaded() {
return secrets.get() != null;
}

@Override
public Set<String> getSettingNames() {
assert isLoaded();
return secrets.get().map().keySet();
}

@Override
public SecureString getString(String setting) {
assert isLoaded();
var value = secrets.get().map().get(setting);
if (value == null) {
return null;
}
return new SecureString(value.toCharArray());
}

@Override
public InputStream getFile(String setting) throws GeneralSecurityException {
assert isLoaded();
return new ByteArrayInputStream(getString(setting).toString().getBytes(StandardCharsets.UTF_8));
}

@Override
public byte[] getSHA256Digest(String setting) throws GeneralSecurityException {
assert isLoaded();
return MessageDigests.sha256().digest(getString(setting).toString().getBytes(StandardCharsets.UTF_8));
}

@Override
public void close() throws IOException {
if (null != secrets.get() && secrets.get().map().isEmpty() == false) {
for (var entry : secrets.get().map().entrySet()) {
entry.setValue(null);
}
}
}

// package private for testing
LocalFileSecrets processSecretsFile(Path path) throws IOException {
try (
var fis = Files.newInputStream(path);
var bis = new BufferedInputStream(fis);
var parser = JSON.xContent().createParser(XContentParserConfiguration.EMPTY, bis)
) {
return secretsParser.apply(parser, null);
}
}

record LocalFileSecrets(Map<String, String> map, ReservedStateVersion metadata) implements Writeable {
public static LocalFileSecrets readFrom(StreamInput in) throws IOException {
return new LocalFileSecrets(in.readMap(StreamInput::readString, StreamInput::readString), ReservedStateVersion.readFrom(in));
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeMap((map == null) ? Map.of() : map, StreamOutput::writeString, StreamOutput::writeString);
metadata.writeTo(out);
}
}
}
Expand Up @@ -9,15 +9,20 @@
package org.elasticsearch.reservedstate.service;

import org.elasticsearch.Version;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.xcontent.ConstructingObjectParser;
import org.elasticsearch.xcontent.ParseField;
import org.elasticsearch.xcontent.XContentParser;

import java.io.IOException;

/**
* File settings metadata class that holds information about
* versioning and Elasticsearch version compatibility
*/
public record ReservedStateVersion(Long version, Version compatibleWith) {
public record ReservedStateVersion(Long version, Version compatibleWith) implements Writeable {

public static final ParseField VERSION = new ParseField("version");
public static final ParseField COMPATIBILITY = new ParseField("compatibility");
Expand All @@ -44,4 +49,14 @@ public static ReservedStateVersion parse(XContentParser parser) {
public Version minCompatibleVersion() {
return compatibleWith;
}

public static ReservedStateVersion readFrom(StreamInput input) throws IOException {
return new ReservedStateVersion(input.readLong(), Version.readVersion(input));
}

@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeLong(version());
Version.writeVersion(compatibleWith(), out);
}
}
@@ -0,0 +1,149 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/

package org.elasticsearch.common.settings;

import org.elasticsearch.common.io.stream.BytesStreamOutput;
import org.elasticsearch.env.Environment;
import org.elasticsearch.test.ESTestCase;
import org.junit.Before;

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.nio.file.StandardCopyOption;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;

import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

public class LocallyMountedSecretsTests extends ESTestCase {
Environment env;

private static String testJSON = """
{
"metadata": {
"version": "1",
"compatibility": "8.4.0"
},
"secrets": {
"aaa": "bbb",
"ccc": "ddd"
}
}""";

private static String noMetadataJSON = """
{
"secrets": {
"aaa": "bbb",
"ccc": "ddd"
}
}""";

@Before
public void setupEnv() {
env = newEnvironment();
}

public void testCreate() {
SecureSettings secrets = new LocallyMountedSecrets(env);
assertTrue(secrets.isLoaded());
}

public void testProcessSettingsFile() throws IOException {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), testJSON);
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);
assertTrue(secrets.isLoaded());
assertThat(secrets.getSettingNames(), containsInAnyOrder("aaa", "ccc"));
assertEquals("bbb", secrets.getString("aaa").toString());
assertEquals("ddd", secrets.getString("ccc").toString());
}

public void testSettingsGetFile() throws IOException, GeneralSecurityException {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), testJSON);
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);
assertTrue(secrets.isLoaded());
assertThat(secrets.getSettingNames(), containsInAnyOrder("aaa", "ccc"));
try (InputStream stream = secrets.getFile("aaa")) {
for (int i = 0; i < 3; ++i) {
int got = stream.read();
if (got < 0) {
fail("Expected 3 bytes but read " + i);
}
assertEquals('b', got);
}
assertEquals(-1, stream.read()); // nothing left
}
}

public void testSettingsSHADigest() throws IOException, GeneralSecurityException {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), testJSON);
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);
assertTrue(secrets.isLoaded());
assertThat(secrets.getSettingNames(), containsInAnyOrder("aaa", "ccc"));

final byte[] stringSettingHash = MessageDigest.getInstance("SHA-256").digest("bbb".getBytes(StandardCharsets.UTF_8));
assertThat(secrets.getSHA256Digest("aaa"), equalTo(stringSettingHash));
}

public void testProcessBadSettingsFile() throws IOException {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), noMetadataJSON);
assertThat(
expectThrows(IllegalArgumentException.class, () -> new LocallyMountedSecrets(env)).getMessage(),
containsString("Required [metadata]")
);
}

public void testSerializationWithSecrets() throws Exception {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), testJSON);
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);

final BytesStreamOutput out = new BytesStreamOutput();
secrets.writeTo(out);
final LocallyMountedSecrets fromStream = new LocallyMountedSecrets(out.bytes().streamInput());

assertThat(fromStream.getSettingNames(), hasSize(2));
assertThat(fromStream.getSettingNames(), containsInAnyOrder("aaa", "ccc"));

assertEquals(secrets.getString("aaa"), fromStream.getString("aaa"));
assertTrue(fromStream.isLoaded());
}

public void testSerializationNewlyCreated() throws Exception {
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);

final BytesStreamOutput out = new BytesStreamOutput();
secrets.writeTo(out);
final LocallyMountedSecrets fromStream = new LocallyMountedSecrets(out.bytes().streamInput());

assertTrue(fromStream.isLoaded());
}

public void testClose() throws IOException {
writeTestFile(env.configFile().resolve("secrets").resolve("secrets.json"), testJSON);
LocallyMountedSecrets secrets = new LocallyMountedSecrets(env);
assertEquals("bbb", secrets.getString("aaa").toString());
assertEquals("ddd", secrets.getString("ccc").toString());
secrets.close();
assertNull(secrets.getString("aaa"));
assertNull(secrets.getString("ccc"));
}

private void writeTestFile(Path path, String contents) throws IOException {
Path tempFilePath = createTempFile();

Files.write(tempFilePath, contents.getBytes(StandardCharsets.UTF_8));
Files.createDirectories(path.getParent());
Files.move(tempFilePath, path, StandardCopyOption.ATOMIC_MOVE);
}
}

0 comments on commit c1b0bf6

Please sign in to comment.