[January 2020 version] See Azure Key Vault in Quarkus with MicroProfile Config

Azure Key Vault Microprofile Config support

The following article described Azure Key Vault's support for MicroProfile Config.

--Configure MicroProfile with Azure Key Vault

I wasn't happy to write the secret information such as the settings in Quarkus, the DB connection string, and the API key directly in the property file! I wanted to use Key Vault to manage these keys and so on.

The following is the API that connects Azure Key Vault as ConfigSource of MicroProfile Config API ...

However, there is a big problem, the following source ...

java:com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultOperation.java


//        // NOTE: azure keyvault secret name convention: ^[0-9a-zA-Z-]+$ "." is not allowed
//        final String localSecretName = secretName.replace(".", "-");

That's right. You can't use ". (Period)" in the name in KeyVault. .. .. So, "-" also appears a lot as the name of the config. There was also abandoned wreckage at the bottom of this code.

java:com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultOperation.java


//            for (final SecretItem secret : secrets) {
//                propertiesMap.putIfAbsent(secret.id().replaceFirst(vaultUri + "/secrets/", "")
//                        .replaceAll("-", "."), secret.id());
//                propertiesMap.putIfAbsent(secret.id().replaceFirst(vaultUri + "/secrets/", ""), secret.id());
//            }

Oh yeah, you can't just replace "." And "-". .. ..

So, looking at the Quarkus config ...

You can see " (double quotes) here and there, but basically the symbols seem to be only . and -.

This time ... ** If you change . to --, it's OK! !! I would like to go with the division of **.

Also, when I implemented it and debugged it, it turned out that multiple instances of "AzureKeyVaultConfigSource" were generated for some reason, so multiple Azure Clients were also generated. I'm not happy that multiple simultaneous connections to Key Vault occur, so I'd like to make it a singleton here as well. There was a sample or model of the implementation of ConfigSource around here on the home site Smallrye of the MicroProfile implementation.

Especially this time, I would like to implement ConfigSource by using the class that uses "Zookeeper" as a model because it is a reference to an external service.

Implemented source code

Paste the implementation code below.

AzureKeyVaultConfigSource

As a specification or restriction of ConfigSource this time, connection information is described in ʻapplication.properties in ʻazure.keyvault.xxxx, but if this is not available, use another ConfigSource and KeyVault There is a saying that ConfigSource itself will work even if you can't connect to. It also lowers the priority of the default ConfigSource that references ʻapplication.properties (was it 90?) (This time 150`) to give priority to locally defined values.

package com.microsoft.azure.microprofile.config.keyvault;

import com.microsoft.azure.keyvault.KeyVaultClient;
import com.microsoft.azure.keyvault.authentication.KeyVaultCredentials;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigProvider;
import org.eclipse.microprofile.config.spi.ConfigSource;
import io.smallrye.config.common.AbstractConfigSource;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import java.util.stream.StreamSupport;

public class AzureKeyVaultConfigSource extends AbstractConfigSource {

  private static final long serialVersionUID = -5546756831559903301L;

  static AtomicReference<Optional<AzureKeyVaultOperation>> keyVaultOperation =
      new AtomicReference<>(Optional.empty());

  private static final Logger logger = Logger.getLogger(AzureKeyVaultConfigSource.class.getName());

  private static final String IGNORED_PREFIX = "azure.keyvault";

  private static final String KEYVAULT_CLIENT_ID = "azure.keyvault.client.id";

  private static final String KEYVAULT_CLIENT_KEY = "azure.keyvault.client.key";

  private static final String KEYVAULT_URL = "azure.keyvault.url";

  private static final String AZURE_KEY_VAULT_CONFIG_SOURCE_NAME = "azure.configsource.keyvault";

  private Optional<Config> config = Optional.empty();

  public AzureKeyVaultConfigSource() {
    super(AZURE_KEY_VAULT_CONFIG_SOURCE_NAME, 150);
  }

  @Override
  public Set<String> getPropertyNames() {
    return getClient().map(AzureKeyVaultOperation::getKeys)
        .orElse(config
            .map(cnf -> StreamSupport.stream(cnf.getConfigSources().spliterator(), false)
                .filter(src -> src.getName() != this.getName()).map(ConfigSource::getPropertyNames)
                .filter(v -> v != null).findFirst().orElse(Collections.<String>emptySet()))
            .orElse(Collections.<String>emptySet()));
  }

  @Override
  public Map<String, String> getProperties() {
    return getClient().map(AzureKeyVaultOperation::getProperties)
        .orElse(config
            .map(cnf -> StreamSupport.stream(cnf.getConfigSources().spliterator(), false)
                .filter(src -> src.getName() != this.getName()).map(ConfigSource::getProperties)
                .filter(v -> v != null).findFirst().orElse(Collections.<String, String>emptyMap()))
            .orElse(Collections.<String, String>emptyMap()));
  }

  @Override
  public String getValue(String key) {
    if (key.contains(IGNORED_PREFIX)) {
      logger.fine("ignored key->" + key);
      return null;
    }

    return getClient().map(keyVault -> keyVault.getValue(key))
        .orElse(config.map(cnf -> StreamSupport.stream(cnf.getConfigSources().spliterator(), false)
            .filter(src -> src.getName() != this.getName()).map(src -> src.getValue(key))
            .filter(v -> v != null).findFirst().orElse(null)).orElse(null));
  }

  private Optional<AzureKeyVaultOperation> getClient() {
    Optional<AzureKeyVaultOperation> client = keyVaultOperation.get();

    if (!client.isPresent()) {
      logger.fine("Start Init");
      try {
        this.config = Optional.of(ConfigProvider.getConfig());
        Optional<String> keyvaultClientID =
            config.get().getOptionalValue(KEYVAULT_CLIENT_ID, String.class);
        Optional<String> keyvaultClientKey =
            config.get().getOptionalValue(KEYVAULT_CLIENT_KEY, String.class);
        Optional<String> keyvaultURL = config.get().getOptionalValue(KEYVAULT_URL, String.class);

        if (keyvaultClientID.isPresent() && keyvaultClientKey.isPresent()
            && keyvaultURL.isPresent()) {
          logger.info("Create KeyVault Client");
          // create the keyvault client
          KeyVaultCredentials credentials =
              new AzureKeyVaultCredential(keyvaultClientID.get(), keyvaultClientKey.get());
          KeyVaultClient keyVaultClient = new KeyVaultClient(credentials);
          client = Optional.of(new AzureKeyVaultOperation(keyVaultClient, keyvaultURL.get()));
          if (!keyVaultOperation.compareAndSet(Optional.empty(), client)) {
            logger.warning("Client Instance Not Set");
          }
        }
      } catch (Exception e) {
        logger.warning("Not Use Azure KeyVault");
      }
    }

    return client;
  }
}

If the return value of getClient () is set to ʻOptional and it is null, then the process is forced. Also, in static AtomicReference <Optional >, Optional of the reference of ʻAzureKeyVaultOperation is set to thread-safe singleton.

AzureKeyVaultOperation

Here, when checking the Key Vault setting value, . is replaced with-.

package com.microsoft.azure.microprofile.config.keyvault;

import com.microsoft.azure.PagedList;
import com.microsoft.azure.keyvault.KeyVaultClient;
import com.microsoft.azure.keyvault.models.SecretItem;

import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Logger;

class AzureKeyVaultOperation {
  private static final long CACHE_REFRESH_INTERVAL_IN_MS = 1800000L; // 30 minutes
  private static final Logger logger = Logger.getLogger(AzureKeyVaultOperation.class.getName());

  private final KeyVaultClient keyVaultClient;
  private final String vaultUri;

  private final Set<String> knownSecretKeys;
  private final Map<String, String> propertiesMap;

  private final AtomicLong lastUpdateTime = new AtomicLong();
  private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

  AzureKeyVaultOperation(KeyVaultClient keyVaultClient, String vaultUri) {
    this.keyVaultClient = keyVaultClient;
    this.propertiesMap = new ConcurrentHashMap<>();
    this.knownSecretKeys = new TreeSet<>();

    vaultUri = vaultUri.trim();
    if (vaultUri.endsWith("/")) {
      vaultUri = vaultUri.substring(0, vaultUri.length() - 1);
    }
    this.vaultUri = vaultUri;

    createOrUpdateHashMap();
  }

  Set<String> getKeys() {
    checkRefreshTimeOut();

    try {
      rwLock.readLock().lock();
      return propertiesMap.keySet();
    } finally {
      rwLock.readLock().unlock();
    }
  }

  Map<String, String> getProperties() {
    checkRefreshTimeOut();

    try {
      rwLock.readLock().lock();
      return Collections.unmodifiableMap(propertiesMap);
    } finally {
      rwLock.readLock().unlock();
    }
  }

  String getValue(String secretName) {
    checkRefreshTimeOut();

    // // NOTE: azure keyvault secret name convention: ^[0-9a-zA-Z-]+$ "." is not allowed
    final String localSecretName = secretName.replace(".", "--");

    if (knownSecretKeys.contains(localSecretName)) {
      logger.fine(localSecretName + " is Used");
      return propertiesMap.computeIfAbsent(localSecretName,
          key -> keyVaultClient.getSecret(vaultUri, key).value());
    }

    return null;
  }

  private void checkRefreshTimeOut() {
    // refresh periodically
    if (System.currentTimeMillis() - lastUpdateTime.get() > CACHE_REFRESH_INTERVAL_IN_MS) {
      lastUpdateTime.set(System.currentTimeMillis());
      createOrUpdateHashMap();
    }
  }

  private void createOrUpdateHashMap() {
    try {
      rwLock.writeLock().lock();
      propertiesMap.clear();
      knownSecretKeys.clear();

      PagedList<SecretItem> knownSecrets = keyVaultClient.listSecrets(vaultUri);
      knownSecrets.loadAll();
      // for (final SecretItem secret : secrets) {
      // propertiesMap.putIfAbsent(
      // secret.id().replaceFirst(vaultUri + "/secrets/", "").replaceAll("--", "."),
      // secret.id());
      // propertiesMap.putIfAbsent(secret.id().replaceFirst(vaultUri + "/secrets/", ""),
      // secret.id());
      // }
      knownSecrets.stream().map(SecretItem::id)
          .map(s -> s.replaceFirst("(?i)" + vaultUri + "/secrets/", ""))
          .forEach(knownSecretKeys::add);

      lastUpdateTime.set(System.currentTimeMillis());
    } finally {
      rwLock.writeLock().unlock();
    }
  }
}

I haven't (certainly) modified it here except to change . to --.

Also, ʻAzureKeyVaultCredential.java` can be left as it is.

This concludes the modification of the source code.

Configuration file to enable ConfigSource

We need some config files to enable ʻAzureKeyVaultConfigSource` this time.

Set Key Vault connection information in ʻapplication.properties` or environment variables

Set the Key Vault connection information in ʻapplication.properties` or an environment variable.

application.properties


azure.keyvault.client.id= xxxxxxx-xxxxxxx-xxxxx-xxxxx-xxxxx
azure.keyvault.client.key= xxxxxxx-xxxxxxx-xxxxx-xxxxx-xxxxx
azure.keyvault.url= https://xxxxxxxxxxxxx.vault.azure.net/

This time I described it in the property file, but in the environment variable,

$ export AZURE_KEYVALUT_CLIENT_ID=xxxxxxx-xxxxxxx-xxxxx-xxxxx-xxxxx
$ export AZURE_KEYVALUT_CLIENT_KEY=xxxxxxx-xxxxxxx-xxxxx-xxxxx-xxxxx
$ export AZURE_KEYVALUT_URL=https://xxxxxxxxxxxxx.vault.azure.net/

I think it will be like this. I'm sorry I haven't tried environment variables.

Creating a services file

As is a convention of MicroProfile Config, you need to create the following files to use your own ConfigSource.

text:src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource


com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultConfigSource

The ConfigSource class specified here will be used at run time.

that's all!

Summary

With the above steps, the settings referenced in the Quarkus project will now reference KeyVault.

Once you register a property in Key Vault, you can share Cosmos DB connection information from multiple Functions, which is very convenient! You can clear sensitive information from the jar by setting the Key Vault connection information in an environment variable. No, it ’s wonderful!

Recommended Posts

[January 2020 version] See Azure Key Vault in Quarkus with MicroProfile Config
MicroProfile Config operation verification in Azure Web App for Containers environment