KEYCLOAK-10934 PlainTextVaultProvider

This commit is contained in:
Sebastian Laskawiec 2019-08-06 11:45:56 +02:00 committed by Hynek Mlnařík
parent 49e9cd759b
commit 3afbdd3ea3
12 changed files with 1714 additions and 0 deletions

View file

@ -60,5 +60,6 @@ public class DefaultVaultRawSecret implements VaultRawSecret {
if (rawSecret.hasArray()) {
ThreadLocalRandom.current().nextBytes(rawSecret.array());
}
rawSecret.clear();
}
}

View file

@ -0,0 +1,80 @@
package org.keycloak.vault;
import org.jboss.logging.Logger;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.EnumSet;
import java.util.Optional;
/**
* A text-based vault provider, which stores each secret in a separate file. The file name needs to match a
* vault secret id (or a key for short). A typical vault directory layout looks like this:
* <pre>
* ${VAULT}/realma__key1 (contains secret for key 1)
* ${VAULT}/realma__key2 (contains secret for key 2)
* etc...
* </pre>
* Note, that each key needs is prefixed by realm name. This kind of layout is used by Kubernetes by default
* (when mounting a volume into the pod).
*
* See https://kubernetes.io/docs/concepts/configuration/secret/
* See https://github.com/keycloak/keycloak-community/blob/master/design/secure-credentials-store.md#plain-text-file-per-secret-kubernetes--openshift
*
* @author Sebastian Łaskawiec
*/
public class PlainTextVaultProvider implements VaultProvider {
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
private final Path vaultPath;
private final String realmName;
/**
* Creates a new {@link PlainTextVaultProvider}.
*
* @param path A path to a vault. Can not be null.
* @param realmName A realm name. Can not be null.
*/
public PlainTextVaultProvider(@Nonnull Path path, @Nonnull String realmName) {
this.vaultPath = path;
this.realmName = realmName;
logger.debugf("PlainTextVaultProvider will operate in %s directory", vaultPath.toAbsolutePath());
}
@Override
public VaultRawSecret obtainSecret(String vaultSecretId) {
Path secretPath = resolveSecretPath(vaultSecretId);
if (!Files.exists(secretPath)) {
return DefaultVaultRawSecret.forBuffer(Optional.empty());
}
try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(secretPath, EnumSet.of(StandardOpenOption.READ))) {
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
return DefaultVaultRawSecret.forBuffer(Optional.of(mappedByteBuffer));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void close() {
}
/**
* A method that resolves the exact secret location.
*
* @param vaultSecretId Secret ID.
* @return Path for the secret.
*/
protected Path resolveSecretPath(String vaultSecretId) {
return vaultPath.resolve(realmName.replaceAll("_", "__") + "_" + vaultSecretId.replaceAll("_", "__"));
}
}

View file

@ -0,0 +1,77 @@
package org.keycloak.vault;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.lang.invoke.MethodHandles;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Creates and configures {@link PlainTextVaultProvider}.
*
* @author Sebastian Łaskawiec
*/
public class PlainTextVaultProviderFactory implements VaultProviderFactory {
private static final Logger logger = Logger.getLogger(MethodHandles.lookup().lookupClass());
public static final String PROVIDER_ID = "plaintext";
private String vaultDirectory;
private boolean disabled;
private Path vaultPath;
@Override
public VaultProvider create(KeycloakSession session) {
if (disabled || vaultDirectory == null) {
//init method not called?
throw new IllegalStateException("Can not create a vault since it's disabled or not initialized correctly");
}
return new PlainTextVaultProvider(vaultPath, session.getContext().getRealm().getName());
}
@Override
public void init(Config.Scope config) {
vaultDirectory = config.get("dir");
Boolean disabledFromConfig = config.getBoolean("disabled");
if (disabledFromConfig == null) {
disabled = false;
} else {
disabled = disabledFromConfig.booleanValue();
}
if (disabled) {
logger.debug("PlainTextVaultProviderFactory disabled");
return;
}
if (vaultDirectory == null) {
logger.debug("PlainTextVaultProviderFactory not configured");
return;
}
vaultPath = Paths.get(vaultDirectory);
if (!Files.exists(vaultPath)) {
throw new VaultNotFoundException("The " + vaultPath.toAbsolutePath().toString() + " directory doesn't exist");
}
logger.debugf("Configured PlainTextVaultProviderFactory with directory %s", vaultPath.toString());
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,18 @@
package org.keycloak.vault;
/**
* Thrown when a vault directory doesn't exist.
*
* @author Sebastian Łaskawiec
*/
public class VaultNotFoundException extends RuntimeException {
/**
* Constructs new exception.
*
* @param message A full text message of the exception.
*/
public VaultNotFoundException(String message) {
super(message);
}
}

View file

@ -0,0 +1 @@
org.keycloak.vault.PlainTextVaultProviderFactory

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,134 @@
package org.keycloak.vault;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.keycloak.vault.SecretContains.secretContains;
/**
* Tests for {@link PlainTextVaultProvider}.
*
* @author Sebastian Łaskawiec
*/
public class PlainTextVaultProviderTest {
@Test
public void shouldObtainSecret() throws Exception {
//given
PlainTextVaultProvider provider = new PlainTextVaultProvider(Scenario.EXISTING.getPath(), "test");
//when
VaultRawSecret secret1 = provider.obtainSecret("key1");
//then
assertNotNull(secret1);
assertNotNull(secret1.getRawSecret().get());
assertThat(secret1, secretContains("secret1"));
}
@Test
public void shouldReplaceUnderscoreWithTwoUnderscores() throws Exception {
//given
PlainTextVaultProvider provider = new PlainTextVaultProvider(Scenario.EXISTING.getPath(), "test_realm");
//when
VaultRawSecret secret1 = provider.obtainSecret("underscore_key1");
//then
assertNotNull(secret1);
assertNotNull(secret1.getRawSecret().get());
assertThat(secret1, secretContains("underscore_secret1"));
}
@Test
public void shouldReturnEmptyOptionalOnMissingSecret() throws Exception {
//given
PlainTextVaultProvider provider = new PlainTextVaultProvider(Scenario.EXISTING.getPath(), "test");
//when
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
//then
assertNotNull(secret);
assertFalse(secret.getRawSecret().isPresent());
}
@Test
public void shouldOperateOnNonExistingVaultDirectory() throws Exception {
//given
PlainTextVaultProvider provider = new PlainTextVaultProvider(Scenario.NON_EXISTING.getPath(), "test");
//when
VaultRawSecret secret = provider.obtainSecret("non-existing-key");
//then
assertNotNull(secret);
assertFalse(secret.getRawSecret().isPresent());
}
@Test
public void shouldReflectChangesInASecretFile() throws Exception {
//given
Path temporarySecretFile = Files.createTempFile("vault", null);
Path vaultDirectory = temporarySecretFile.getParent();
String secretName = temporarySecretFile.getFileName().toString();
PlainTextVaultProvider provider = new PlainTextVaultProvider(vaultDirectory, "ignored") {
@Override
protected Path resolveSecretPath(String vaultSecretId) {
return vaultDirectory.resolve(vaultSecretId);
}
};
//when
String secret1AsString = null;
String secret2AsString = null;
Files.write(temporarySecretFile, "secret1".getBytes());
try (VaultRawSecret secret1 = provider.obtainSecret(secretName)) {
secret1AsString = StandardCharsets.UTF_8.decode(secret1.getRawSecret().get()).toString();
}
Files.write(temporarySecretFile, "secret2".getBytes());
try (VaultRawSecret secret2 = provider.obtainSecret(secretName)) {
secret2AsString = StandardCharsets.UTF_8.decode(secret2.getRawSecret().get()).toString();
}
//then
assertEquals("secret1", secret1AsString);
assertEquals("secret2", secret2AsString);
}
@Test
public void shouldNotOverrideFileWhenDestroyingASecret() throws Exception {
//given
Path temporarySecretFile = Files.createTempFile("vault", null);
Path vaultDirectory = temporarySecretFile.getParent();
String secretName = temporarySecretFile.getFileName().toString();
PlainTextVaultProvider provider = new PlainTextVaultProvider(vaultDirectory, "ignored") {
@Override
protected Path resolveSecretPath(String vaultSecretId) {
return vaultDirectory.resolve(vaultSecretId);
}
};
Files.write(temporarySecretFile, "secret".getBytes());
//when
VaultRawSecret secretAfterFirstRead = provider.obtainSecret(secretName);
secretAfterFirstRead.close();
VaultRawSecret secretAfterSecondRead = provider.obtainSecret(secretName);
//then
assertThat(secretAfterFirstRead, secretContains("secret"));
assertThat(secretAfterSecondRead, secretContains("secret"));
}
}

View file

@ -0,0 +1,30 @@
package org.keycloak.vault;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* A small helper class to navigate to proper vault directory.
*
* @author Sebastian Łaskawiec
*/
enum Scenario {
EXISTING("src/test/resources/org/keycloak/vault"),
NON_EXISTING("src/test/resources/org/keycloak/vault/non-existing"),
WRITABLE_IN_RUNTIME("target/test-classes");
Path path;
Scenario(String path) {
this.path = Paths.get(path);
}
public Path getPath() {
return path;
}
public String getAbsolutePathAsString() {
return path.toAbsolutePath().toString();
}
}

View file

@ -0,0 +1,34 @@
package org.keycloak.vault;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.nio.charset.StandardCharsets;
/**
* Checks if {@link VaultRawSecret} is equal to a String.
*/
public class SecretContains extends TypeSafeMatcher<VaultRawSecret> {
private String thisVaultAsString;
public SecretContains(String thisVaultAsString) {
this.thisVaultAsString = thisVaultAsString;
}
@Override
protected boolean matchesSafely(VaultRawSecret secret) {
String convertedSecret = StandardCharsets.UTF_8.decode(secret.getRawSecret().get()).toString();
return thisVaultAsString.equals(convertedSecret);
}
@Override
public void describeTo(Description description) {
description.appendText("is equal to " + thisVaultAsString);
}
public static Matcher<VaultRawSecret> secretContains(String thisVaultAsString) {
return new SecretContains(thisVaultAsString);
}
}

View file

@ -0,0 +1,5 @@
log4j.rootLogger=info, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d{HH:mm:ss,SSS} %-5p %t [%c] %m%n

View file

@ -0,0 +1 @@
underscore_secret1

View file

@ -0,0 +1 @@
secret1