【2020年1月版】QuarkusでAzure Key Vault を MicroProfile Config で参照

# Azure Key Vault の Microprofile Config 対応

以下の記事で Azure Key Vault のMicroProfile Config 対応について書かれておりました。

- [Azure Key Vault を使用した MicroProfile の構成](https://docs.microsoft.com/ja-jp/azure/java/microprofile/configure-microprofile-with-keyvault)

Quarkus での設定値やDBの接続文字列、APIキーなどのシークレット情報をプロパティファイルに直書きするのは嬉しくなかったんですよね！
これらのキーなどを管理するのに Key Vault を利用したいと考えました。

以下が Azure Key Vault をMicroProfile Config APIのConfigSourceとして接続するAPIなのですが・・・

- [Azure Key Vault Config Source for MicroProfile Config API](https://github.com/Azure/azure-microprofile/tree/master/microprofile-config-keyvault)

ところが大きな問題がありまして、以下のソース…

- [com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultOperation ](https://github.com/Azure/azure-microprofile/blob/master/microprofile-config-keyvault/src/main/java/com/microsoft/azure/microprofile/config/keyvault/AzureKeyVaultOperation.java)

```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(".", "-");
```

そうなんです。KeyVault では ".(ピリオド)"が名前に使えないんですね。。。で、"-"もコンフィグの名前としてたくさん出てきます。
このコードの下の方にも断念した残骸がありました。

```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());
//            }
```

そうそう、単純に"."と"-"の置き換えるだけではダメなのですよね～。。。

で、Quarkusのコンフィグを眺めていると・・・

- [ALL CONFIG | QUARKUS](https://quarkus.io/guides/all-config)

ちらほら`"`(ダブルクオート)が見られますが、基本的に記号は`.`と`-`だけのようです。

今回は… **`.`を`--`にしてしまえばOK！！** という割り切りで行きたいと思います。

また実装してみてデバッグしていたところ、"AzureKeyVaultConfigSource" がなぜか複数インスタンス生成されるのでAzure Clientも複数生成されていることが判明しました。
Key Vaultへの同時接続が複数発生するのは嬉しくないのでここもシングルトン化をしてみたいと思います。
このあたりの `ConfigSource` の実装についてサンプルというかお手本がMicroProfile実装の本家サイト Smallrye にありました。

- [SmallRye implementation of Eclipse MicroProfile Config](https://github.com/smallrye/smallrye-config)

特に今回は外部サービスの参照ということで "Zookeeper" を使用するクラスをお手本に ConfigSource を実装してみたいと思います。

- [ZkMicroProfileConfig](https://github.com/smallrye/smallrye-config/tree/master/sources/zookeeper)

## 実装したソースコード

さっそく実装コードを以下に貼り付けます。

### AzureKeyVaultConfigSource

今回のConfigSourceの仕様というか制約事項として、`azure.keyvault.xxxx` で接続情報を`application.properties`に記述するのですが、これがなかった場合は他のConfigSourceを使用するようにして、KeyVaultに接続できなくてもConfigSource自体は機能するようにする、というのがあります。
また`application.properties`を参照するデフォルトのConfigSourceの優先順位(確か`90`だったか？)より下げて(今回は`150`)ローカルに定義した値を優先するようにしています。

```java
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;
  }
}
```

`getClient()`の戻り値を`Optional`にして`null`だったら…という処理を強制するようにしております。
また`static AtomicReference<Optional<AzureKeyVaultOperation>>` で`AzureKeyVaultOperation`の参照のOptionalをスレッドセーフなシングルトンにしております。

### AzureKeyVaultOperation

ここではKey Vault の設定値を調べるときに `.` を `--` に置き換えるようにいたしました。

```java
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();
    }
  }
}
```

ここは `.`を`--`に変更する以外は(確か)手を加えてないですね。

また、`AzureKeyVaultCredential.java` はオリジナルそのままでOKです。

ソースコードの改修は以上です。

## ConfigSourceを有効にする設定ファイル

今回の `AzureKeyVaultConfigSource` を有効にするためにいくつか設定ファイルが必要です。

### `application.properties` もしくは環境変数でKey Vault の接続情報を設定

`application.properties` もしくは環境変数でKey Vault の接続情報を設定しておきます。

```properties: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/
```

今回はプロパティファイルに記載しましたが環境変数では、

```shell
$ 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/
```

のようになるかと思います。環境変数は試してないのですいません。

### services ファイルの作成

MicroProfile Config のお決まり事ですが、独自のConfigSourceを使用するには以下のファイルを作成する必要があります。

```text:src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource
com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultConfigSource
```

ここで指定したConfigSourceクラスが実行時に使用されます。

以上です！

## まとめ

上記の手順で、Quarkusのプロジェクトで参照する設定値は KeyVault を参照するようになります。

一度 Key Vault にプロパティを登録すれば複数の Functions からCosmosDBの接続情報を共有する、などもできるので、非常に便利ですね！
Key Vault の接続情報を環境変数で設定すれば、jarから機密情報が一掃できます。いや～素晴らしい！