GUACAMOLE-641: Retrieve tokens asynchronously and in parallel.

This commit is contained in:
Michael Jumper
2022-01-21 15:23:40 -08:00
parent 2f946d962b
commit 3dbb821baf
4 changed files with 122 additions and 41 deletions

View File

@@ -25,10 +25,11 @@ import com.google.inject.Singleton;
import com.microsoft.azure.keyvault.KeyVaultClient; import com.microsoft.azure.keyvault.KeyVaultClient;
import com.microsoft.azure.keyvault.authentication.KeyVaultCredentials; import com.microsoft.azure.keyvault.authentication.KeyVaultCredentials;
import com.microsoft.azure.keyvault.models.SecretBundle; import com.microsoft.azure.keyvault.models.SecretBundle;
import com.microsoft.rest.ServiceCallback;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleServerException;
import org.apache.guacamole.auth.vault.azure.conf.AzureKeyVaultAuthenticationException; import org.apache.guacamole.auth.vault.azure.conf.AzureKeyVaultAuthenticationException;
import org.apache.guacamole.auth.vault.azure.conf.AzureKeyVaultConfigurationService; import org.apache.guacamole.auth.vault.azure.conf.AzureKeyVaultConfigurationService;
import org.apache.guacamole.auth.vault.secret.CachedVaultSecretService; import org.apache.guacamole.auth.vault.secret.CachedVaultSecretService;
@@ -77,20 +78,43 @@ public class AzureKeyVaultSecretService extends CachedVaultSecretService {
int ttl = confService.getSecretTTL(); int ttl = confService.getSecretTTL();
String url = confService.getVaultURL(); String url = confService.getVaultURL();
try { CompletableFuture<String> retrievedValue = new CompletableFuture<>();
// Retrieve requested secret from Azure Key Vault // getSecretAsync() still blocks for around half a second, despite
KeyVaultClient client = new KeyVaultClient(credentialProvider.get()); // technically being asynchronous
SecretBundle secret = client.getSecret(url, name); (new Thread() {
// Cache retrieved value @Override
String value = (secret != null) ? secret.value() : null; public void run() {
return new CachedSecret(value, ttl); try {
} // Retrieve requested secret from Azure Key Vault
catch (AzureKeyVaultAuthenticationException e) { KeyVaultClient client = new KeyVaultClient(credentialProvider.get());
throw new GuacamoleServerException("Unable to authenticate with Azure.", e); client.getSecretAsync(url, name, new ServiceCallback<SecretBundle>() {
}
@Override
public void failure(Throwable t) {
retrievedValue.completeExceptionally(t);
}
@Override
public void success(SecretBundle secret) {
String value = (secret != null) ? secret.value() : null;
retrievedValue.complete(value);
}
});
}
catch (AzureKeyVaultAuthenticationException e) {
retrievedValue.completeExceptionally(e);
}
}
}).start();
// Cache retrieved value
return new CachedSecret(retrievedValue, ttl);
} }

View File

@@ -48,9 +48,10 @@ public abstract class CachedVaultSecretService implements VaultSecretService {
protected class CachedSecret { protected class CachedSecret {
/** /**
* The value of the secret at the time it was last retrieved. * A Future which contains or will contain the value of the secret at
* the time it was last retrieved.
*/ */
private final String value; private final Future<String> value;
/** /**
* The time the value should be considered out-of-date, in milliseconds * The time the value should be considered out-of-date, in milliseconds
@@ -64,13 +65,15 @@ public abstract class CachedVaultSecretService implements VaultSecretService {
* which it should be considered out-of-date. * which it should be considered out-of-date.
* *
* @param value * @param value
* The current value of the secret. * A Future which contains or will contain the current value of the
* secret. If no such secret exists, the given Future should
* complete with null.
* *
* @param ttl * @param ttl
* The maximum number of milliseconds that this value should be * The maximum number of milliseconds that this value should be
* cached. * cached.
*/ */
public CachedSecret(String value, int ttl) { public CachedSecret(Future<String> value, int ttl) {
this.value = value; this.value = value;
this.expires = System.currentTimeMillis() + ttl; this.expires = System.currentTimeMillis() + ttl;
} }
@@ -80,9 +83,14 @@ public abstract class CachedVaultSecretService implements VaultSecretService {
* The actual value of the secret may have changed. * The actual value of the secret may have changed.
* *
* @return * @return
* The value of the secret at the time it was last retrieved. * A Future which will eventually complete with the value of the
* secret at the time it was last retrieved. If no such secret
* exists, the Future will be completed with null. If an error
* occurs which prevents retrieval of the secret, that error will
* be exposed through an ExecutionException when an attempt is made
* to retrieve the value from the Future.
*/ */
public String getValue() { public Future<String> getValue() {
return value; return value;
} }
@@ -129,7 +137,7 @@ public abstract class CachedVaultSecretService implements VaultSecretService {
throws GuacamoleException; throws GuacamoleException;
@Override @Override
public String getValue(String name) throws GuacamoleException { public Future<String> getValue(String name) throws GuacamoleException {
CompletableFuture<CachedSecret> refreshEntry; CompletableFuture<CachedSecret> refreshEntry;
@@ -175,7 +183,7 @@ public abstract class CachedVaultSecretService implements VaultSecretService {
try { try {
CachedSecret secret = refreshCachedSecret(name); CachedSecret secret = refreshCachedSecret(name);
refreshEntry.complete(secret); refreshEntry.complete(secret);
logger.debug("Cached secret for \"{}\" has been refreshed.", name); logger.debug("Cached secret for \"{}\" will be refreshed.", name);
return secret.getValue(); return secret.getValue();
} }

View File

@@ -19,6 +19,7 @@
package org.apache.guacamole.auth.vault.secret; package org.apache.guacamole.auth.vault.secret;
import java.util.concurrent.Future;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
/** /**
@@ -49,19 +50,23 @@ public interface VaultSecretService {
String canonicalize(String name); String canonicalize(String name);
/** /**
* Returns the value of the secret having the given name. If no such * Returns a Future which eventually completes with the value of the secret
* secret exists, null is returned. * having the given name. If no such secret exists, the Future will be
* completed with null.
* *
* @param name * @param name
* The name of the secret to retrieve. * The name of the secret to retrieve.
* *
* @return * @return
* The value of the secret having the given name, or null if no such * A Future which completes with value of the secret having the given
* secret exists. * name. If no such secret exists, the Future will be completed with
* null. If an error occurs asynchronously which prevents retrieval of
* the secret, that error will be exposed through an ExecutionException
* when an attempt is made to retrieve the value from the Future.
* *
* @throws GuacamoleException * @throws GuacamoleException
* If the secret cannot be retrieved due to an error. * If the secret cannot be retrieved due to an error.
*/ */
String getValue(String name) throws GuacamoleException; Future<String> getValue(String name) throws GuacamoleException;
} }

View File

@@ -24,7 +24,10 @@ import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject; import com.google.inject.assistedinject.AssistedInject;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleServerException;
import org.apache.guacamole.auth.vault.conf.VaultConfigurationService; import org.apache.guacamole.auth.vault.conf.VaultConfigurationService;
import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.ConnectionGroup;
@@ -149,9 +152,9 @@ public class VaultUserContext extends TokenInjectingUserContext {
} }
/** /**
* Retrieve all applicable tokens and corresponding values from the vault, * Initiates asynchronous retrieval of all applicable tokens and
* using the given TokenFilter to filter tokens within the secret names * corresponding values from the vault, using the given TokenFilter to
* prior to retrieving those secrets. * filter tokens within the secret names prior to retrieving those secrets.
* *
* @param tokenMapping * @param tokenMapping
* The mapping dictating the name of the secret which maps to each * The mapping dictating the name of the secret which maps to each
@@ -165,20 +168,20 @@ public class VaultUserContext extends TokenInjectingUserContext {
* secrets to be retrieved from the vault. * secrets to be retrieved from the vault.
* *
* @return * @return
* The tokens which should be added to the in-progress call to * A Map of token name to Future, where each Future represents the
* connect(). * pending retrieval operation which will ultimately be completed with
* the value of all secrets mapped to that token.
* *
* @throws GuacamoleException * @throws GuacamoleException
* If the value for any applicable secret cannot be retrieved from the * If the value for any applicable secret cannot be retrieved from the
* vault due to an error. * vault due to an error.
*/ */
private Map<String, String> getTokens(Map<String, String> tokenMapping, private Map<String, Future<String>> getTokens(Map<String, String> tokenMapping,
TokenFilter filter) throws GuacamoleException { TokenFilter filter) throws GuacamoleException {
Map<String, String> tokens = new HashMap<>(); // Populate map with pending secret retrieval operations corresponding
// to each mapped token
// Populate map with tokens containing the values of all secrets Map<String, Future<String>> pendingTokens = new HashMap<>(tokenMapping.size());
// indicated in the token mapping
for (Map.Entry<String, String> entry : tokenMapping.entrySet()) { for (Map.Entry<String, String> entry : tokenMapping.entrySet()) {
// Translate secret pattern into secret name, ignoring any // Translate secret pattern into secret name, ignoring any
@@ -195,19 +198,60 @@ public class VaultUserContext extends TokenInjectingUserContext {
continue; continue;
} }
// Initiate asynchronous retrieval of the token value
String tokenName = entry.getKey();
Future<String> secret = secretService.getValue(secretName);
pendingTokens.put(tokenName, secret);
}
return pendingTokens;
}
/**
* Waits for all pending secret retrieval operations to complete,
* transforming each Future within the given Map into its contained String
* value.
*
* @param pendingTokens
* A Map of token name to Future, where each Future represents the
* pending retrieval operation which will ultimately be completed with
* the value of all secrets mapped to that token.
*
* @throws GuacamoleException
* If the value for any applicable secret cannot be retrieved from the
* vault due to an error.
*/
private Map<String, String> resolve(Map<String,
Future<String>> pendingTokens) throws GuacamoleException {
// Populate map with tokens containing the values of their
// corresponding secrets
Map<String, String> tokens = new HashMap<>(pendingTokens.size());
for (Map.Entry<String, Future<String>> entry : pendingTokens.entrySet()) {
// Complete secret retrieval operation, blocking if necessary
String secretValue;
try {
secretValue = entry.getValue().get();
}
catch (InterruptedException | ExecutionException e) {
throw new GuacamoleServerException("Retrieval of secret value "
+ "failed.", e);
}
// If a value is defined for the secret in question, store that // If a value is defined for the secret in question, store that
// value under the mapped token // value under the mapped token
String tokenName = entry.getKey(); String tokenName = entry.getKey();
String secretValue = secretService.getValue(secretName);
if (secretValue != null) { if (secretValue != null) {
tokens.put(tokenName, secretValue); tokens.put(tokenName, secretValue);
logger.debug("Token \"{}\" populated with value from " logger.debug("Token \"{}\" populated with value from "
+ "secret \"{}\".", tokenName, secretName); + "secret.", tokenName);
} }
else else
logger.debug("Token \"{}\" not populated. Mapped " logger.debug("Token \"{}\" not populated. Mapped "
+ "secret \"{}\" has no value.", + "secret has no value.", tokenName);
tokenName, secretName);
} }
@@ -231,7 +275,7 @@ public class VaultUserContext extends TokenInjectingUserContext {
// Substitute tokens producing secret names, retrieving and storing // Substitute tokens producing secret names, retrieving and storing
// those secrets as parameter tokens // those secrets as parameter tokens
return getTokens(confService.getTokenMapping(), filter); return resolve(getTokens(confService.getTokenMapping(), filter));
} }
@@ -274,7 +318,7 @@ public class VaultUserContext extends TokenInjectingUserContext {
// Substitute tokens producing secret names, retrieving and storing // Substitute tokens producing secret names, retrieving and storing
// those secrets as parameter tokens // those secrets as parameter tokens
return getTokens(confService.getTokenMapping(), filter); return resolve(getTokens(confService.getTokenMapping(), filter));
} }