LoginSignup
8
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-30

Azure Key Vault の Microprofile Config 対応

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

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

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

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

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

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のコンフィグを眺めていると・・・

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

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

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

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

実装したソースコード

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

AzureKeyVaultConfigSource

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

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 の設定値を調べるときに .-- に置き換えるようにいたしました。

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 の接続情報を設定しておきます。

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/

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

$ 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を使用するには以下のファイルを作成する必要があります。

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から機密情報が一掃できます。いや~素晴らしい!

8
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0