Azure Key Vault の Microprofile Config 対応
以下の記事で Azure Key Vault のMicroProfile Config 対応について書かれておりました。
Quarkus での設定値やDBの接続文字列、APIキーなどのシークレット情報をプロパティファイルに直書きするのは嬉しくなかったんですよね!
これらのキーなどを管理するのに Key Vault を利用したいと考えました。
以下が Azure Key Vault をMicroProfile Config APIのConfigSourceとして接続するAPIなのですが・・・
ところが大きな問題がありまして、以下のソース…
// // NOTE: azure keyvault secret name convention: ^[0-9a-zA-Z-]+$ "." is not allowed
// final String localSecretName = secretName.replace(".", "-");
そうなんです。KeyVault では ".(ピリオド)"が名前に使えないんですね。。。で、"-"もコンフィグの名前としてたくさん出てきます。
このコードの下の方にも断念した残骸がありました。
// 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 の接続情報を設定しておきます。
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を使用するには以下のファイルを作成する必要があります。
com.microsoft.azure.microprofile.config.keyvault.AzureKeyVaultConfigSource
ここで指定したConfigSourceクラスが実行時に使用されます。
以上です!
まとめ
上記の手順で、Quarkusのプロジェクトで参照する設定値は KeyVault を参照するようになります。
一度 Key Vault にプロパティを登録すれば複数の Functions からCosmosDBの接続情報を共有する、などもできるので、非常に便利ですね!
Key Vault の接続情報を環境変数で設定すれば、jarから機密情報が一掃できます。いや~素晴らしい!