diff --git a/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/conf/AzureKeyVaultConfigurationService.java b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/conf/AzureKeyVaultConfigurationService.java index 2be7bd1b0..e8e1ceb51 100644 --- a/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/conf/AzureKeyVaultConfigurationService.java +++ b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/conf/AzureKeyVaultConfigurationService.java @@ -25,6 +25,7 @@ import com.microsoft.aad.adal4j.ClientCredential; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.auth.vault.conf.VaultConfigurationService; import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; /** @@ -46,6 +47,19 @@ public class AzureKeyVaultConfigurationService extends VaultConfigurationService */ private static final String TOKEN_MAPPING_FILENAME = "azure-keyvault-token-mapping.json"; + /** + * The number of milliseconds that each retrieved secret should be cached + * for. + */ + private static final IntegerGuacamoleProperty SECRET_TTL = new IntegerGuacamoleProperty() { + + @Override + public String getName() { + return "azure-keyvault-secret-ttl"; + } + + }; + /** * The URL of the Azure Key Vault that should be used to populate token * values. @@ -95,6 +109,21 @@ public class AzureKeyVaultConfigurationService extends VaultConfigurationService super(TOKEN_MAPPING_FILENAME); } + /** + * Returns the number of milliseconds that each retrieved secret should be + * cached for. By default, secrets are cached for 10 seconds. + * + * @return + * The number of milliseconds to cache each retrieved secret. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed. + */ + public int getSecretTTL() throws GuacamoleException { + return environment.getProperty(SECRET_TTL, 10000); + } + /** * Returns the base URL of the Azure Key Vault containing the secrets that * should be retrieved to populate connection parameter tokens. The base diff --git a/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/secret/AzureKeyVaultSecretService.java b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/secret/AzureKeyVaultSecretService.java index ccbd6c9cc..aa46b7ac0 100644 --- a/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/secret/AzureKeyVaultSecretService.java +++ b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-azure/src/main/java/org/apache/guacamole/auth/vault/azure/secret/AzureKeyVaultSecretService.java @@ -31,13 +31,13 @@ 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.AzureKeyVaultConfigurationService; -import org.apache.guacamole.auth.vault.secret.VaultSecretService; +import org.apache.guacamole.auth.vault.secret.CachedVaultSecretService; /** * Service which retrieves secrets from Azure Key Vault. */ @Singleton -public class AzureKeyVaultSecretService implements VaultSecretService { +public class AzureKeyVaultSecretService extends CachedVaultSecretService { /** * Pattern which matches contiguous groups of characters which are not @@ -71,23 +71,20 @@ public class AzureKeyVaultSecretService implements VaultSecretService { } @Override - public String getValue(String name) throws GuacamoleException { + protected CachedSecret refreshCachedSecret(String name) + throws GuacamoleException { + + int ttl = confService.getSecretTTL(); + String url = confService.getVaultURL(); try { - // Retrieve configuration information necessary for connecting to - // Azure Key Vault - String url = confService.getVaultURL(); - KeyVaultCredentials credentials = credentialProvider.get(); - - // Authenticate against Azure Key Vault - KeyVaultClient client = new KeyVaultClient(credentials); - - // Retrieve requested secret + // Retrieve requested secret from Azure Key Vault + KeyVaultClient client = new KeyVaultClient(credentialProvider.get()); SecretBundle secret = client.getSecret(url, name); // FIXME: STUB - return null; + return new CachedSecret(null, ttl); } catch (AzureKeyVaultAuthenticationException e) { diff --git a/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-base/src/main/java/org/apache/guacamole/auth/vault/secret/CachedVaultSecretService.java b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-base/src/main/java/org/apache/guacamole/auth/vault/secret/CachedVaultSecretService.java new file mode 100644 index 000000000..838449857 --- /dev/null +++ b/extensions/guacamole-auth-vault/modules/guacamole-auth-vault-base/src/main/java/org/apache/guacamole/auth/vault/secret/CachedVaultSecretService.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.auth.vault.secret; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Caching implementation of VaultSecretService. Requests for the values of + * secrets will automatically be cached for a duration determined by the + * implementation. Subclasses must implement refreshCachedSecret() to provide + * a mechanism for CachedVaultSecretService to explicitly retrieve a value + * which is missing from the cache or has expired. + */ +public abstract class CachedVaultSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private final Logger logger = LoggerFactory.getLogger(CachedVaultSecretService.class); + + /** + * The cached value of a secret. + */ + protected class CachedSecret { + + /** + * The value of the secret at the time it was last retrieved. + */ + private final String value; + + /** + * The time the value should be considered out-of-date, in milliseconds + * since midnight of January 1, 1970 UTC. + */ + private final long expires; + + /** + * Creates a new CachedSecret which represents a cached snapshot of the + * value of a secret. Each CachedSecret has a limited lifespan after + * which it should be considered out-of-date. + * + * @param value + * The current value of the secret. + * + * @param ttl + * The maximum number of milliseconds that this value should be + * cached. + */ + public CachedSecret(String value, int ttl) { + this.value = value; + this.expires = System.currentTimeMillis() + ttl; + } + + /** + * Returns the value of the secret at the time it was last retrieved. + * The actual value of the secret may have changed. + * + * @return + * The value of the secret at the time it was last retrieved. + */ + public String getValue() { + return value; + } + + /** + * Returns whether this specific cached value has expired. Expired + * values will be automatically refreshed by CachedVaultSecretService. + * + * @return + * true if this cached value has expired, false otherwise. + */ + public boolean isExpired() { + return System.currentTimeMillis() >= expires; + } + + } + + /** + * Cache of past requests to retrieve secrets. Expired secrets are lazily + * removed. + */ + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + /** + * Explicitly retrieves the value of the secret having the given name, + * returning a result that can be cached. The length of time that this + * specific value will be cached is determined by the TTL value provided to + * the returned CachedSecret. This function will be automatically invoked + * in response to calls to getValue() when the requested secret is either + * not cached or has expired. Expired secrets are not removed from the + * cache until another request is made for that secret. + * + * @param name + * The name of the secret to retrieve. + * + * @return + * A CachedSecret which defines the current value of the secret and the + * point in time that value should be considered potentially + * out-of-date. + * + * @throws GuacamoleException + * If an error occurs while retrieving the secret from the vault. + */ + protected abstract CachedSecret refreshCachedSecret(String name) + throws GuacamoleException; + + @Override + public String getValue(String name) throws GuacamoleException { + + CompletableFuture refreshEntry; + + try { + + // Attempt to use cached result of previous call + Future cachedEntry = cache.get(name); + if (cachedEntry != null) { + + // Use cached result if not yet expired + CachedSecret secret = cachedEntry.get(); + if (!secret.isExpired()) { + logger.debug("Using cached secret for \"{}\".", name); + return secret.getValue(); + } + + // Evict if expired + else { + logger.debug("Cached secret for \"{}\" is expired.", name); + cache.remove(name, cachedEntry); + } + + } + + // If no cached result, or result is too old, race with other + // threads to be the thread which refreshes the entry + refreshEntry = new CompletableFuture<>(); + cachedEntry = cache.putIfAbsent(name, refreshEntry); + + // If a refresh operation is already in progress, wait for that + // operation to complete and use its value + if (cachedEntry != null) + return cachedEntry.get().getValue(); + + } + catch (InterruptedException | ExecutionException e) { + throw new GuacamoleServerException("Attempt to retrieve secret " + + "failed.", e); + } + + // If we reach this far, the cache entry is stale or missing, and it's + // this thread's responsibility to refresh the entry + try { + CachedSecret secret = refreshCachedSecret(name); + refreshEntry.complete(secret); + logger.debug("Cached secret for \"{}\" has been refreshed.", name); + return secret.getValue(); + } + + // Abort the refresh operation if an error occurs + catch (Error | RuntimeException | GuacamoleException e) { + refreshEntry.completeExceptionally(e); + cache.remove(name, refreshEntry); + logger.debug("Cached secret for \"{}\" could not be refreshed.", name); + throw e; + } + + } + +}