diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml index 4c4a5184b9..07cfa7ecf1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml @@ -49,6 +49,13 @@ 1.6.0 + + + org.apache.guacamole + guacamole-vault-hv + 1.6.0 + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml index 6f41e52782..c974390d1a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml @@ -41,6 +41,14 @@ + + + hv + + org.apache.guacamole:guacamole-vault-hv + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-hv/.ratignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml new file mode 100644 index 0000000000..c2905d025d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml @@ -0,0 +1,92 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-hv + jar + 1.6.0 + guacamole-vault-hv + http://guacamole.apache.org/ + + + org.apache.guacamole + guacamole-vault + 1.6.0 + ../../ + + + + 1.9.25 + + + + + + + org.apache.guacamole + guacamole-ext + provided + + + + + org.apache.guacamole + guacamole-vault-base + 1.6.0 + + + + com.fasterxml.jackson.core + jackson-databind + 2.19.0 + + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + + org.bouncycastle + bc-fips + 2.1.0 + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java new file mode 100644 index 0000000000..cb4f1a4f62 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java @@ -0,0 +1,47 @@ +/* + * 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.vault.hv; + +import org.apache.guacamole.GuacamoleException; + +/** + * A class that is basically equivalent to the standard Supplier class in + * Java, except that the get() function can throw GuacamoleException, which + * is impossible with any of the standard Java lambda type classes, since + * none of them can handle checked exceptions + * + * @param + * The type of object which will be returned as a result of calling + * get(). + */ +public interface GuacamoleExceptionSupplier { + + /** + * Returns a value of the declared type. + * + * @return + * A value of the declared type. + * + * @throws GuacamoleException + * If an error occurs while attemping to calculate the return value. + */ + public T get() throws GuacamoleException; + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java new file mode 100644 index 0000000000..08bbb2da95 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -0,0 +1,47 @@ +/* + * 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.vault.hv; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProvider; + +/** + * VaultAuthenticationProvider implementation which reads secrets from + * Hashicorp Vault + */ +public class HvAuthenticationProvider extends VaultAuthenticationProvider { + + /** + * Creates a new HvKeyVaultAuthenticationProvider which reads secrets + * from a configured Hashicorp Vault. + * + * @throws GuacamoleException + * If configuration details cannot be read from guacamole.properties. + */ + public HvAuthenticationProvider() throws GuacamoleException { + super(new HvAuthenticationProviderModule()); + } + + @Override + public String getIdentifier() { + return "hashicorp-vault"; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java new file mode 100644 index 0000000000..289fcad697 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java @@ -0,0 +1,81 @@ +/* + * 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.vault.hv; + +import com.google.inject.assistedinject.FactoryModuleBuilder; +import java.security.Security; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProviderModule; +import org.apache.guacamole.vault.conf.VaultAttributeService; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.secret.HvClient; +import org.apache.guacamole.vault.hv.secret.HvClientFactory; +import org.apache.guacamole.vault.hv.secret.HvSecretService; +import org.apache.guacamole.vault.hv.user.HvConnectionGroup; +import org.apache.guacamole.vault.hv.user.HvDirectoryService; +import org.apache.guacamole.vault.hv.user.HvUser; +import org.apache.guacamole.vault.hv.user.HvUserFactory; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.user.VaultDirectoryService; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; + +/** + * Guice module which configures injections specific to Hashicorp Vault + * support. + */ +public class HvAuthenticationProviderModule + extends VaultAuthenticationProviderModule { + + /** + * Creates a new HvAuthenticationProviderModule which + * configures dependency injection for the Hashicorp Vault + * authentication provider and related services. + * + * @throws GuacamoleException + * If configuration details in guacamole.properties cannot be parsed. + */ + public HvAuthenticationProviderModule() throws GuacamoleException { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Override + protected void configureVault() { + + // Bind services specific to Hashicorp Vault + bind(HvAttributeService.class); + bind(VaultAttributeService.class).to(HvAttributeService.class); + bind(VaultConfigurationService.class).to(HvConfigurationService.class); + bind(VaultSecretService.class).to(HvSecretService.class); + bind(VaultDirectoryService.class).to(HvDirectoryService.class); + + // Bind factory for creating HV Clients + install(new FactoryModuleBuilder() + .implement(HvClient.class, HvClient.class) + .build(HvClientFactory.class)); + + // Bind factory for creating HvUsers + install(new FactoryModuleBuilder() + .implement(HvUser.class, HvUser.class) + .build(HvUserFactory.class)); + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java new file mode 100644 index 0000000000..671653e3ec --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java @@ -0,0 +1,155 @@ +/* + * 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.vault.hv.conf; + +import com.google.inject.Singleton; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.form.BooleanField; +import org.apache.guacamole.form.Form; +import org.apache.guacamole.form.TextField; +import org.apache.guacamole.language.TranslatableGuacamoleClientException; +import org.apache.guacamole.vault.conf.VaultAttributeService; + +@Singleton +public class HvAttributeService implements VaultAttributeService { + + /** + * The name of the attribute which can contain a HV configuration blob + * associated with either a connection group or user. + */ + public static final String HV_CONFIGURATION_ATTRIBUTE = "hv-config"; + + /** + * The HV configuration attribute contains sensitive information, so it + * should not be exposed through the directory. Instead, if a value is + * set on the attributes of an object, the following value will be exposed + * in its place, and correspondingly the underlying value will not be + * changed if this value is provided to an update call. + */ + public static final String HV_ATTRIBUTE_PLACEHOLDER_VALUE = "**********"; + + /** + * All attributes related to configuring the HV vault on a + * per-connection-group or per-user basis. + */ + public static final Form HV_CONFIGURATION_FORM = new Form( + "hv-config", + Arrays.asList(new TextField(HV_CONFIGURATION_ATTRIBUTE)) + ); + + /** + * All HV-specific attributes for users, connections, or connection groups, + * organized by form. + */ + public static final Collection
HV_ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(HV_CONFIGURATION_FORM)); + + /** + * The name of the attribute which can controls whether a HV user configuration + * is enabled on a connection-by-connection basis. + */ + public static final String HV_USER_CONFIG_ENABLED_ATTRIBUTE = "hv-user-config-enabled"; + + /** + * The string value used by HV attributes to represent the boolean value "true". + */ + public static final String TRUTH_VALUE = "true"; + + /** + * All attributes related to configuring the HV vault on a per-connection basis. + */ + public static final Form HV_CONNECTION_FORM = new Form( + "hv-config", + Arrays.asList(new BooleanField(HV_USER_CONFIG_ENABLED_ATTRIBUTE, TRUTH_VALUE)) + ); + + /** + * All HV-specific attributes for connections, organized by form. + */ + public static final Collection HV_CONNECTION_ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(HV_CONNECTION_FORM)); + + @Override + public Collection getConnectionAttributes() { + return HV_CONNECTION_ATTRIBUTES; + } + + @Override + public Collection getUserAttributes() { + return HV_ATTRIBUTES; + } + + @Override + public Collection getUserPreferenceAttributes() { + return getUserAttributes(); + } + + @Override + public Collection getConnectionGroupAttributes() { + return HV_ATTRIBUTES; + } + + /** + * Sanitize the value of the provided HV config attribute. If the provided + * config value is non-empty, it will be replaced with the placeholder + * value to avoid leaking sensitive information. If the value is empty, it + * will be replaced by `null`. + * + * @param hvAttributeValue + * The HV configuration attribute value to sanitize. + * + * @return + * The sanitized HV configuration attribute value, stripped of any + * sensitive information. + */ + public static String sanitizeHvAttributeValue(String hvAttributeValue) { + + // Any non-empty values may contain sensitive information, and should + // be replaced by the safe placeholder value + if (hvAttributeValue != null && !hvAttributeValue.trim().isEmpty()) + return HV_ATTRIBUTE_PLACEHOLDER_VALUE; + + // If the configuration value is empty, expose a null value + else + return null; + + } + + public static Map processAttributes( + Map attributes) throws GuacamoleException { + + // Get the value of the HV config attribute in the provided map + String hvConfigValue = attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + + // If the placeholder value was provided, do not update the attribute + if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvConfigValue)) { + // Remove the attribute from the map so it won't be updated + attributes = new HashMap<>(attributes); + attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + } + + return attributes; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java new file mode 100644 index 0000000000..718323e19c --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -0,0 +1,204 @@ +/* + * 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.vault.hv.conf; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.properties.StringGuacamoleProperty; +import org.apache.guacamole.vault.conf.VaultConfigurationService; + +@Singleton +public class HvConfigurationService extends VaultConfigurationService { + + /** + * Property name of the URL of the vault specified in the base64 configuration blob. + */ + public static final String PARAM_NAME_VAULT_URL = "vault_url"; + + /** + * Property name of the authentication token for the vault specified in the + * base64 configuration blob. + */ + public static final String PARAM_NAME_VAULT_TOKEN = "vault_token"; + + /** + * Property name of the maximum time that cached data is considered valid. + */ + public static final String PARAM_NAME_CACHE_LIFETIME = "cache_lifetime"; + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * The name of the file which contains the YAML mapping of connection + * parameter token to secrets within Hashicorp Vault. + */ + private static final String TOKEN_MAPPING_FILENAME = "hv-token-mapping.yml"; + + /** + * The name of the properties file containing Guacamole configuration + * properties whose values are the names of corresponding secrets within + * Hashicorp Vault. + */ + private static final String PROPERTIES_FILENAME = "guacamole.properties.hv"; + + /** + * The base64-encoded configuration information. + */ + private static final StringGuacamoleProperty HV_CONFIG = new StringGuacamoleProperty() { + @Override + public String getName() { + return "hv-config"; + } + }; + + /** + * Whether unverified server certificates should be accepted. + */ + private static final BooleanGuacamoleProperty ALLOW_UNVERIFIED_CERT = new BooleanGuacamoleProperty() { + @Override + public String getName() { + return "hv-allow-unverified-cert"; + } + }; + + /** + * Whether users should be able to supply their own HV configurations. + */ + private static final BooleanGuacamoleProperty ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() { + @Override + public String getName() { + return "hv-allow-user-config"; + } + }; + + /** + * Creates a new HvConfigurationService which reads the configuration + * from "hv-token-mapping.yml" and properties from + * "guacamole.properties.hv". The token mapping is a YAML file which lists + * each connection parameter token and the name of the secret from which + * the value for that token should be read, while the properties file is an + * alternative to guacamole.properties where each property value is the + * name of a secret containing the actual value. + */ + public HvConfigurationService() { + super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); + } + + /** + * Return whether user-level HV configs should be enabled. If this + * flag is set to true, users can edit their own HV configs, as can + * admins. If set to false, no existing user-specific HV configuration + * will be exposed through the UI or used when looking up secrets. + * + * @return + * true if user-specific HV configuration is enabled, false otherwise. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed. + */ + public boolean getAllowUserConfig() throws GuacamoleException { + return environment.getProperty(ALLOW_USER_CONFIG, false); + } + + // Not used + @Override + public boolean getSplitWindowsUsernames() throws GuacamoleException { + return false; + } + + // Not used + @Override + public boolean getMatchUserRecordsByDomain() throws GuacamoleException { + return false; + } + + /** + * Return the globally-defined base-64-encoded JSON HV configuration blob + * as a string. + * + * @return + * The globally-defined base-64-encoded JSON HV configuration blob + * as a string. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed or does not exist. + */ + @Nonnull + @SuppressWarnings("null") + public String getHvConfig() throws GuacamoleException { + + // This will always return a non-null value; an exception would be + // thrown if the required value is not set + return environment.getRequiredProperty(HV_CONFIG); + } + + /** + * Given a base64-encoded JSON HV configuration, parse and return a + * KeyValueStorage object. + * + * @param value + * The base64-encoded JSON HV configuration to parse. + * + * @return + * The KeyValueStorage that is a result of the parsing operation + * + * @throws GuacamoleException + * If the provided value is not valid base-64 encoded JSON HV configuration. + */ + public static Map parseHvConfig(String value) throws GuacamoleException { + try { + Map config = new HashMap<>(); + String valueDecoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(valueDecoded); + jsonNode.properties().forEach(entry -> { + config.put(entry.getKey(), entry.getValue().asText()); + }); + + return config; + } + catch (IOException e) { + throw new GuacamoleServerException("Invalid JSON configuration for Hashicorp Vault.", e); + } + catch (IllegalArgumentException e) { + throw new GuacamoleServerException("Invalid base64 configuration for Hashicorp Vault.", e); + } + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java new file mode 100644 index 0000000000..b887e1b4e5 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -0,0 +1,270 @@ +/* + * 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.vault.hv.secret; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Future; +import javax.annotation.Nullable; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.secret.HvTimedSecretData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Client which retrieves records from Hashicorp Vault. + */ +public class HvClient { + + /** + * Name of the HTTP header for the token, as specified in documentation: + * https://developer.hashicorp.com/vault/docs/auth/token + */ + static final String HASHICORP_VAULT_HTTP_HEADER_TOKEN = "X-Vault-Token"; + + /** + * API version. + */ + static final String HASHICORP_VAULT_HTTP_VERSION = "/v1/"; + + /** + * Name of the Guacamole token to resolve on a Hashicorp Vault (secret path + * is set in the token modifier). + */ + static final String HASHICORP_VAULT_TOKEN_PREFIX = "HASHIVAULT:"; + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvClient.class); + + /** + * HttpClient for this class. + */ + private final HttpClient httpClient; + + /** + * ObjectMapper for this class. + */ + private final ObjectMapper objectMapper; + + /** + * The HV configuration associated with this client instance. + */ + private final Map hvConfig; + + /** + * The maximum amount of time that an entry will be stored in the cache + * before being refreshed, in milliseconds. + */ + private long cacheLifetime; + + /** + * All records retrieved from Hashicorp Vault, where each key is the + * UID of the corresponding record. The contents of this Map are + * automatically updated. + */ + private final ConcurrentMap cachedSecrets = new ConcurrentHashMap<>(); + + /** + * All in-flight HTTP requests to Vault, it prevents multiple queries + * on the same path. + */ + private final ConcurrentMap> inFlightRequests = new ConcurrentHashMap<>(); + + /** + * Create a new HV client based around the provided HV configuration and + * API timeout setting. + * + * @param hvConfig + * The HV configuration to use when retrieving properties from HV. + */ + @AssistedInject + public HvClient(@Assisted Map hvConfig) { + this.hvConfig = hvConfig; + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + + this.cacheLifetime = 60000; + if (hvConfig.containsKey(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME)) { + String strCacheLifetime = hvConfig.get(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME); + try { + this.cacheLifetime = Long.parseLong(strCacheLifetime); + } + catch (NumberFormatException e) { + logger.warn("Invalid {} in HV config: {}", HvConfigurationService.PARAM_NAME_CACHE_LIFETIME, strCacheLifetime); + } + } + } + + /** + * Returns the value of the secret stored within Hashicorp Vault. + * + * @param notation + * The HV notation of the secret to retrieve. + * + * @return + * A Future which completes with the value of the secret represented by + * the given HV notation, or null if there is no such secret. + * + * @throws GuacamoleException + * If the requested secret cannot be retrieved or the HV notation + * is invalid. + */ + public Future getSecret(String notation) throws GuacamoleException { + return getSecret(notation, null); + } + + /** + * Returns the value of the secret stored within Hashicorp Vault. + * + * @param notation + * The HV notation of the secret to retrieve. + * + * @param fallbackFunction + * A function to invoke in order to produce a Future for return, + * if the requested secret is not found. If the provided Function + * is null, it will not be run. + * + * @return + * A Future which completes with the value of the secret represented by + * the given HV notation, or empty string if there is no such secret to + * remove the token. + * + * @throws GuacamoleException + * If the requested secret cannot be retrieved or the HV notation + * is invalid. + */ + public Future getSecret(String notation, + @Nullable GuacamoleExceptionSupplier> fallbackFunction) + throws GuacamoleException { + + // If it's not an HV token, fail + if (!notation.startsWith(HASHICORP_VAULT_TOKEN_PREFIX)) + throw new GuacamoleException("Invalid token HV notation: " + notation); + + /* + * HASHIVAULT:path/to/secret <-- the Guacamole token name and its modifier + * ^^^^^^^ <-- this is the path + * ^^^^^^ <-- this is the secret (or key in HV terms) + */ + int lastSlashIndex = notation.lastIndexOf('/'); + if (lastSlashIndex == -1) + lastSlashIndex = HASHICORP_VAULT_TOKEN_PREFIX.length(); + + String path = notation.substring(HASHICORP_VAULT_TOKEN_PREFIX.length(), lastSlashIndex); + String secret = notation.substring(lastSlashIndex + 1); + + // Try to get the whole secret from cache and return key + HvTimedSecretData cachedSecret = cachedSecrets.get(path); + if (cachedSecret != null && System.currentTimeMillis() < cachedSecret.dateCreated + cacheLifetime) { + try { + JsonNode secretNode = cachedSecret.jsonNode.get("data").get("data").get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return CompletableFuture.completedFuture(""); + } + return CompletableFuture.completedFuture(secretNode.asText()); + } + catch (Exception e) { + throw new GuacamoleException("Failed to extract secret from cached JSON for " + notation, e); + } + } + + // Cache miss, either get an existing in-flight request or create a new one + CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(path, k -> { + return CompletableFuture.supplyAsync(() -> { + try { + // Perform the Vault query + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_URL) + HASHICORP_VAULT_HTTP_VERSION + path)) + .header(HASHICORP_VAULT_HTTP_HEADER_TOKEN, hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_TOKEN)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode jsonNode = objectMapper.readTree(response.body()); + + return jsonNode; + + } + catch (Exception e) { + logger.warn("Vault query failed for {} with {}", path, e); + throw new CompletionException("Vault query failed for " + path, e); + } + }); + }); + + return futureResponse.whenComplete((jsonNode, ex) -> { + // Put newly received JSON data into the cache + if (ex == null && jsonNode != null) { + HvTimedSecretData secretToCache = new HvTimedSecretData(); + secretToCache.dateCreated = System.currentTimeMillis(); + secretToCache.jsonNode = jsonNode; + cachedSecrets.put(path, secretToCache); + } + + // Now that the cache is filled, this in-flight request is obsolete and must be removed + inFlightRequests.remove(path); + + }).thenApply(jsonNode -> { + /* + * Extract and return the secret + * If there's no data.data, it's weird so fail with an exception + */ + try { + JsonNode secretNode = jsonNode.get("data").get("data").get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return ""; + } + return secretNode.asText(); + } + catch (Exception e) { + throw new CompletionException("Failed to parse JSON response for path " + path, e); + } + + }).exceptionally(e -> { + // Make sure that the exception is a GuacamoleException + Throwable cause = e.getCause(); + String errorMessage = (cause != null) ? cause.getMessage() : "Unknown error"; + + if (cause instanceof GuacamoleException) + throw new CompletionException(cause); + + throw new CompletionException( + new GuacamoleException("Vault query failed for " + path + ": " + errorMessage, cause)); + }); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java new file mode 100644 index 0000000000..4de0ae0d7d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java @@ -0,0 +1,44 @@ +/* + * 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.vault.hv.secret; + +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * Factory for creating HvClient instances. + */ +public interface HvClientFactory { + + /** + * Returns a new instance of a HvClient instance associated with + * the provided HV configuration options and API interval. + * + * @param hvConfigOptions + * The HV config options to use when constructing the HvClient + * object. + * + * @return + * A new HvClient instance associated with the provided HV config + * options. + */ + HvClient create(@Nonnull Map hvConfigOptions); + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java new file mode 100644 index 0000000000..cfd91ea033 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -0,0 +1,335 @@ +/* + * 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.vault.hv.secret; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import com.google.common.base.Objects; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Attributes; +import org.apache.guacamole.net.auth.Connectable; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.user.HvDirectory; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.secret.WindowsUsername; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service which retrieves secrets from Hashicorp Vault. + * The configuration used to connect to HV can be set at a global + * level using guacamole.properties, or using a connection group + * attribute. + */ +@Singleton +public class HvSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(VaultSecretService.class); + + /** + * Service for retrieving configuration information. + */ + @Inject + private HvConfigurationService confService; + + /** + * Factory for creating HV client instances. + */ + @Inject + private HvClientFactory hvClientFactory; + + /** + * A map of base-64 encoded JSON HV config blobs to associated HV client instances. + * A distinct HV client will exist for every HV config. + */ + private final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); + + /** + * Create and return a HV client for the provided HV config if not already + * present in the client map, otherwise return the existing client entry. + * + * @param hvConfig + * The base-64 encoded JSON HV config blob associated with the client entry. + * If an associated entry does not already exist, it will be created using + * this configuration. + * + * @return + * A HV client for the provided HV config if not already present in the + * client map, otherwise the existing client entry. + * + * @throws GuacamoleException + * If an error occurs while creating the HV client. + */ + private HvClient getClient(@Nonnull String hvConfigBase64) + throws GuacamoleException { + + // If a client already exists for the provided config, use it + HvClient hvClient = hvClientMap.get(hvConfigBase64); + if (hvClient != null) + return hvClient; + + // Create and store a new HV client instance for the provided HV config blob + Map hvConfig = confService.parseHvConfig(hvConfigBase64); + hvClient = hvClientFactory.create(hvConfig); + HvClient prevClient = hvClientMap.putIfAbsent(hvConfigBase64, hvClient); + + // If the client was already set before this thread got there, use the existing one + return prevClient != null ? prevClient : hvClient; + } + + @Override + public String canonicalize(String nameComponent) { + try { + + // As HV notation is essentially a URL, encode all components + // using standard URL escaping + return URLEncoder.encode(nameComponent, "UTF-8"); + + } + catch (UnsupportedEncodingException e) { + throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); + } + } + + @Override + public Future getValue( UserContext userContext, + Connectable connectable, String name) throws GuacamoleException { + + // Attempt to find a HV config for this connection or group + String hvConfig = getConnectionGroupHvConfig(userContext, connectable); + + return getClient(hvConfig).getSecret(name, new GuacamoleExceptionSupplier>() { + + @Override + public Future get() throws GuacamoleException { + + // Get the user-supplied HV config, if allowed by config and + // set by the user + String userHvConfig = getUserHVConfig(userContext, connectable); + + // If the user config happens to be the same as admin-defined one, + // don't bother trying again + if (userHvConfig != null && !Objects.equal(userHvConfig, hvConfig)) + return getClient(userHvConfig).getSecret(name); + + return CompletableFuture.completedFuture(null); + } + + }); + } + + @Override + public Future getValue(String name) throws GuacamoleException { + // Use the default HV configuration from guacamole.properties + return getClient(confService.getHvConfig()).getSecret(name); + } + + /** + * Search for a HV configuration attribute, recursing up the connection group tree + * until a connection group with the appropriate attribute is found. If the HV config + * is found, it will be returned. If not, the default value from the config file will + * be returned. + * + * @param userContext + * The userContext associated with the connection or connection group. + * + * @param connectable + * A connection or connection group for which the tokens are being replaced. + * + * @return + * The value of the HV configuration attribute if found in the tree, the default + * HV config blob defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to retrieve the HV config attribute, or if + * no HV config is found in the connection group tree, and the value is also not + * defined in the config file. + */ + @Nonnull + private String getConnectionGroupHvConfig(UserContext userContext, + Connectable connectable) throws GuacamoleException { + + // Check to make sure it's a usable type before proceeding + if (!(connectable instanceof Connection) && !(connectable instanceof ConnectionGroup)) { + logger.warn( + "Unsupported Connectable type: {}; skipping HV config lookup.", + connectable.getClass() + ); + + // Use the default value if searching is impossible + return confService.getHvConfig(); + } + + // For connections, start searching the parent group for the HV config + // For connection groups, start searching the group directly + String parentIdentifier = (connectable instanceof Connection) + ? ((Connection) connectable).getParentIdentifier() + : ((ConnectionGroup) connectable).getIdentifier(); + + // Keep track of all group identifiers seen while recursing up the tree + // in case there's a cycle - if the same identifier is ever seen twice, + // the search is over. + Set observedIdentifiers = new HashSet<>(); + observedIdentifiers.add(parentIdentifier); + + // Use the unwrapped connection group directory to avoid HV config + // value sanitization + Directory connectionGroupDirectory = ( + (HvDirectory) userContext.getConnectionGroupDirectory() + ).getUnderlyingDirectory(); + + while (true) { + // Fetch the parent group, if one exists + ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier); + if (group == null) + break; + + // If the current connection group has the HV configuration attribute + // set to a non-empty value, return immediately + String hvConfig = group.getAttributes().get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + if (hvConfig != null && !hvConfig.trim().isEmpty()) + return hvConfig; + + // Otherwise, keep searching up the tree until an appropriate configuration is found + parentIdentifier = group.getParentIdentifier(); + + // If the parent is a group that's already been seen, this is a cycle, so there's no + // need to search any further + if (!observedIdentifiers.add(parentIdentifier)) + break; + } + + + // If no HV configuration was ever found, use the default value + return confService.getHvConfig(); + } + + /** + * Returns true if user-level HV configuration is enabled for the given + * Connectable, false otherwise. + * + * @param connectable + * The connectable to check for whether user-level HV configs are + * enabled. + * + * @return + * True if user-level HV configuration is enabled for the given + * Connectable, false otherwise. + */ + private boolean isHvUserConfigEnabled(Connectable connectable) { + + // User-level config is enabled IFF the appropriate attribute is set to true + if (connectable instanceof Attributes) + return HvAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get( + HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE)); + + // If there's no attributes to check, the user config cannot be enabled + return false; + + } + + /** + * Return the HV config blob for the current user IFF user HV configs + * are enabled globally, and are enabled for the given connectable. If no + * HV config exists for the given user or HV configs are not enabled, + * null will be returned. + * + * @param userContext + * The user context from which the current user should be fetched. + * + * @param connectable + * The connectable to which the connection is being established. This + * is the connection which will be checked to see if user HV configs + * are enabled. + * + * @return + * The base64 encoded HV config blob for the current user if one + * exists, and if user HV configs are enabled globally and for the + * provided connectable. + * + * @throws GuacamoleException + * If an error occurs while attempting to fetch the HV config. + */ + private String getUserHVConfig(UserContext userContext, + Connectable connectable) throws GuacamoleException { + + return null; + } + + /* + * Scan the configuration parameters and fill the token map with all + * supported HV matches. + */ + @Override + public Map> getTokens(UserContext userContext, + Connectable connectable, GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException { + + Map> tokens = new HashMap<>(); + Map parameters = config.getParameters(); + + String hvConfigBase64 = getConnectionGroupHvConfig(userContext, connectable); + HvClient client = getClient(hvConfigBase64); + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.HASHICORP_VAULT_TOKEN_PREFIX + "[^}]+)\\}"); + + for (Map.Entry entry : parameters.entrySet()) { + Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); + while (tokenMatcher.find()) { + String notation = tokenMatcher.group(1); + tokens.put(notation, client.getSecret(notation)); + } + } + + return tokens; + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java new file mode 100644 index 0000000000..b509b07837 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java @@ -0,0 +1,33 @@ + +/* + * 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.vault.hv.secret; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Record to link JSON data to the time it was obtained from server. + * Might be simplified in the future to: + * record HvTimedSecretData(long dateCreated, JsonNode jsonNode) {}; + */ +public final class HvTimedSecretData { + public long dateCreated; + public JsonNode jsonNode; +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java new file mode 100644 index 0000000000..2a1eb1f52d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java @@ -0,0 +1,68 @@ +/* + * 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.vault.hv.user; + +import com.google.common.collect.Maps; +import java.util.Map; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.DelegatingConnection; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; + +/** + * A Connection that explicitly adds a blank entry for any defined HV + * connection attributes. This ensures that any such field will always + * be displayed to the user when editing a connection through the UI. + */ +public class HvConnection extends DelegatingConnection { + + /** + * Create a new Vault connection wrapping the provided Connection record. Any + * attributes defined in the provided connection attribute forms will have empty + * values automatically populated when getAttributes() is called. + * + * @param connection + * The connection record to wrap. + */ + HvConnection(Connection connection) { + super(connection); + } + + /** + * Return the underlying wrapped connection record. + * + * @return + * The wrapped connection record. + */ + Connection getUnderlyingConnection() { + return getDelegateConnection(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Add the user-config-enabled configuration attribute + attributes.putIfAbsent(HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE, null); + return attributes; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java new file mode 100644 index 0000000000..f406677025 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -0,0 +1,107 @@ +/* + * 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.vault.hv.user; + +import com.google.common.collect.Maps; +import java.util.HashMap; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DelegatingConnectionGroup; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific connection group implementation that always exposes + * the HV_CONFIGURATION_ATTRIBUTE attribute, even when no value is set. + * The value of the attribute will be sanitized if non-empty. This ensures + * that the attribute will always show up in the UI, even for connection + * groups that don't already have it set, and that any sensitive information + * in the attribute value will not be exposed. + */ +public class HvConnectionGroup extends DelegatingConnectionGroup { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvConnectionGroup.class); + + /** + * Create a new HvConnectionGroup wrapping the provided ConnectionGroup record. + * + * @param connectionGroup + * The ConnectionGroup record to wrap. + */ + HvConnectionGroup(ConnectionGroup connectionGroup) { + super(connectionGroup); + } + + /** + * Return the underlying wrapped connection group record. + * + * @return + * The wrapped connection group record. + */ + ConnectionGroup getUnderlyingConnectionGroup() { + return getDelegateConnectionGroup(); + } + + /** + * Return the underlying ConnectionGroup that's wrapped by this HvConnectionGroup. + * + * @return + * The underlying ConnectionGroup that's wrapped by this HvConnectionGroup. + */ + ConnectionGroup getUnderlyConnectionGroup() { + return getDelegateConnectionGroup(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Sanitize the HV configuration attribute, and ensure the attribute + // is always present + attributes.put( + HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, + HvAttributeService.sanitizeHvAttributeValue( + attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE) + ) + ); + + return attributes; + } + + @Override + public void setAttributes(Map attributes) { + try { + super.setAttributes( + HvAttributeService.processAttributes(attributes) + ); + } + catch (GuacamoleException e) { + logger.warn("HvConnectionGroup setAttributes failed"); + } + } + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java new file mode 100644 index 0000000000..eeb244eb4a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java @@ -0,0 +1,90 @@ +/* + * 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.vault.hv.user; + +import java.util.Collection; +import java.util.stream.Collectors; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.DelegatingDirectory; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.Identifiable; + +/** + * A HV-specific version of DecoratingDirectory that exposes the underlying + * directory for when it's needed. + */ +public abstract class HvDirectory + extends DelegatingDirectory { + + /** + * Create a new HvDirectory, delegating to the provided directory. + * + * @param directory + * The directory to delegate to. + */ + public HvDirectory(Directory directory) { + super(directory); + } + + /** + * Returns the underlying directory that this DecoratingDirectory is + * delegating to. + * + * @return + * The underlying directory. + */ + public Directory getUnderlyingDirectory() { + return getDelegateDirectory(); + } + + /** + * Process and return a potentially-modified version of the object + * with the same identifier in the wrapped directory. + * + * @param object + * The object from the underlying directory. + * + * @return + * A potentially-modified version of the object with the same + * identifier in the wrapped directory. + */ + protected abstract ObjectType wrap(ObjectType object); + + @Override + public ObjectType get(String identifier) throws GuacamoleException { + + // Process and return the object from the wrapped directory + return wrap(super.get(identifier)); + + } + + @Override + public Collection getAll(Collection identifiers) + throws GuacamoleException { + + // Process and return each object from the wrapped directory + return super.getAll(identifiers).stream() + .map(superObject -> wrap(superObject)) + .filter(wrappedObject -> wrappedObject != null) + .collect(Collectors.toList()); + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java new file mode 100644 index 0000000000..c84f43effd --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -0,0 +1,172 @@ +/* + * 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.vault.hv.user; + +import com.google.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DecoratingDirectory; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.user.HvConnectionGroup; +import org.apache.guacamole.vault.user.VaultDirectoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific vault directory service that wraps the connection group directory + * to sanitize sensitive data from exposed settings. + */ +public class HvDirectoryService extends VaultDirectoryService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvDirectoryService.class); + + /** + * A factory for constructing new HvUser instances. + */ + @Inject + private HvUserFactory hvUserFactory; + + /** + * Service for retrieving any custom attributes defined for the + * current vault implementation and processing of said attributes. + */ + @Inject + private HvAttributeService attributeService; + + @Override + public Directory getConnectionDirectory(Directory underlyingDirectory) throws GuacamoleException { + + return new DecoratingDirectory(underlyingDirectory) { + + @Override + protected Connection decorate(Connection connection) throws GuacamoleException { + return new HvConnection(connection); + } + + @Override + protected Connection undecorate(Connection connection) throws GuacamoleException { + return ((HvConnection) connection).getUnderlyingConnection(); + } + + }; + } + + @Override + public Directory getConnectionGroupDirectory(Directory underlyingDirectory) throws GuacamoleException { + // A ConnectionGroup directory that will intercept add and update calls to + // validate HV configurations, and translate one-time-tokens, if possible, + // as well as ensuring that all ConnectionGroups returned include the + // HV_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI. + // The value of the HV_CONFIGURATION_ATTRIBUTE will be sanitized if set. + return new HvDirectory(underlyingDirectory) { + + @Override + public void add(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Process attribute values before saving + connectionGroup.setAttributes(connectionGroup.getAttributes()); + + super.add(connectionGroup); + } + + @Override + public void update(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Unwrap the existing ConnectionGroup + if (connectionGroup instanceof HvConnectionGroup) + connectionGroup = ((HvConnectionGroup) connectionGroup).getUnderlyingConnectionGroup(); + + // Process attribute values before saving + connectionGroup.setAttributes(connectionGroup.getAttributes()); + + super.update(connectionGroup); + + } + + @Override + protected ConnectionGroup wrap(ConnectionGroup object) { + + // Do not process the ConnectionGroup further if it does not exist + if (object == null) + return null; + + // Sanitize values when a ConnectionGroup is fetched from the directory + return new HvConnectionGroup(object); + + } + + }; + } + + @Override + public Directory getUserDirectory(Directory underlyingDirectory) throws GuacamoleException { + // A User directory that will intercept add and update calls to + // validate HV configurations, and translate one-time-tokens, if possible + // Additionally, this directory will will decorate all users with a + // HvUser wrapper to ensure that all defined HV fields will be exposed + // in the user attributes. The value of the HV_CONFIGURATION_ATTRIBUTE + // will be sanitized if set. + return new HvDirectory(underlyingDirectory) { + + @Override + public void add(User user) throws GuacamoleException { + + // Process attribute values before saving + user.setAttributes(user.getAttributes()); + + super.add(user); + } + + @Override + public void update(User user) throws GuacamoleException { + + // Unwrap the existing user + if (user instanceof HvUser) + user = ((HvUser) user).getUnderlyingUser(); + + // Process attribute values before saving + user.setAttributes(user.getAttributes()); + + super.update(user); + + } + + @Override + protected User wrap(User object) { + + // Do not process the user further if it does not exist + if (object == null) + return null; + + // Sanitize values when a user is fetched from the directory + return hvUserFactory.create(object); + + } + + }; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java new file mode 100644 index 0000000000..f5dc7f242d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -0,0 +1,126 @@ +/* + * 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.vault.hv.user; + +import com.google.common.collect.Maps; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.DelegatingUser; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific user implementation that exposes the + * HV_CONFIGURATION_ATTRIBUTE attribute even if no value is set. but only + * if user-specific HV configuration is enabled. The value of the attribute + * will be sanitized if non-empty. This ensures that the attribute will always + * show up in the UI when the feature is enabled, even for users that don't + * already have it set, and that any sensitive information in the attribute + * value will not be exposed. + */ +public class HvUser extends DelegatingUser { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvUser.class); + + /** + * Service for retrieving HV configuration details. + */ + @Inject + private HvConfigurationService configurationService; + + /** + * Create a new Hvuser wrapping the provided User record. + * + * @param user + * The User record to wrap. + */ + @AssistedInject + HvUser(@Assisted User user) { + super(user); + } + + /** + * Return the underlying wrapped user record. + * + * @return + * The wrapped user record. + */ + User getUnderlyingUser() { + return getDelegateUser(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Figure out if user-level HV config is enabled + boolean userHvConfigEnabled = false; + try { + userHvConfigEnabled = configurationService.getAllowUserConfig(); + } + catch (GuacamoleException e) { + + logger.warn( + "Disabling user HV config due to exception: {}" + , e.getMessage()); + logger.debug("Error looking up if user HV config is enabled.", e); + + } + + // If user-specific HV configuration is not enabled, do not expose the + // attribute at all + if (!userHvConfigEnabled) + attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + + else + // Sanitize the HV configuration attribute, and ensure the attribute + // is always present + attributes.put( + HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, + HvAttributeService.sanitizeHvAttributeValue( + attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE))); + + return attributes; + } + + @Override + public void setAttributes(Map attributes) { + try { + super.setAttributes( + HvAttributeService.processAttributes(attributes) + ); + } + catch (GuacamoleException e) { + logger.warn("HvUser setAttributes failed"); + } + } + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java new file mode 100644 index 0000000000..e27c84ad19 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java @@ -0,0 +1,40 @@ +/* + * 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.vault.hv.user; + +import org.apache.guacamole.net.auth.User; + +/** + * Factory for creating HV-specific users, which wrap an underlying User. + */ +public interface HvUserFactory { + + /** + * Returns a new instance of a HvUser, wrapping the provided underlying User. + * + * @param user + * The underlying User that should be wrapped. + * + * @return + * A new instance of a HvUser, wrapping the provided underlying User. + */ + HvUser create(User user); + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json new file mode 100644 index 0000000000..11a0e3dfda --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json @@ -0,0 +1,16 @@ +{ + + "guacamoleVersion" : "1.6.0", + + "name" : "Hashicorp Vault", + "namespace" : "hashicorp-vault", + + "authProviders" : [ + "org.apache.guacamole.vault.hv.HvAuthenticationProvider" + ], + + "translations" : [ + "translations/en.json" + ] + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json new file mode 100644 index 0000000000..d66bd07faa --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json @@ -0,0 +1,23 @@ +{ + + "CONNECTION_ATTRIBUTES" : { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED" : "Allow user-provided HV configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + }, + + "CONNECTION_GROUP_ATTRIBUTES" : { + "ERROR_INVALID_HV_CONFIG_BLOB" : "The provided base64-encoded HV configuration blob is not valid.", + "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + }, + + "DATA_SOURCE_HASHICORP_VAULT" : { + "NAME" : "Hashicorp Vault" + }, + + "USER_ATTRIBUTES" : { + "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + } + +} diff --git a/extensions/guacamole-vault/pom.xml b/extensions/guacamole-vault/pom.xml index a8ea92c68b..12feae1a4a 100644 --- a/extensions/guacamole-vault/pom.xml +++ b/extensions/guacamole-vault/pom.xml @@ -47,6 +47,7 @@ modules/guacamole-vault-ksm + modules/guacamole-vault-hv diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java index 673e134d90..99eab585e0 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java @@ -129,8 +129,11 @@ public void setToken(String name, String value) { * The value of the token with the given name, or null if no such * token exists. */ - public String getToken(String name) { - return tokenValues.get(name); + public String getToken(String name, String modifier) { + if (modifier != null && tokenValues.containsKey(name + ":" + modifier)) + return tokenValues.get(name + ":" + modifier); + else + return tokenValues.get(name); } /** @@ -226,7 +229,7 @@ private String filter(String input, boolean strict) // Pull token value String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP); - String tokenValue = getToken(tokenName); + String tokenValue = getToken(tokenName, modifier); // If token is unknown, interpretation depends on whether // strict mode is enabled