KEYCLOAK-10934 PlainTextVaultProvider
This commit is contained in:
parent
49e9cd759b
commit
3afbdd3ea3
12 changed files with 1714 additions and 0 deletions
|
@ -60,5 +60,6 @@ public class DefaultVaultRawSecret implements VaultRawSecret {
|
|||
if (rawSecret.hasArray()) {
|
||||
ThreadLocalRandom.current().nextBytes(rawSecret.array());
|
||||
}
|
||||
rawSecret.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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("_", "__"));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
org.keycloak.vault.PlainTextVaultProviderFactory
|
File diff suppressed because it is too large
Load diff
|
@ -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"));
|
||||
}
|
||||
}
|
30
services/src/test/java/org/keycloak/vault/Scenario.java
Normal file
30
services/src/test/java/org/keycloak/vault/Scenario.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
5
services/src/test/resources/log4j.properties
Executable file
5
services/src/test/resources/log4j.properties
Executable 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
|
|
@ -0,0 +1 @@
|
|||
underscore_secret1
|
1
services/src/test/resources/org/keycloak/vault/test_key1
Normal file
1
services/src/test/resources/org/keycloak/vault/test_key1
Normal file
|
@ -0,0 +1 @@
|
|||
secret1
|
Loading…
Reference in a new issue