From e4c65cba19fadc87ff61500d2065766472398ad2 Mon Sep 17 00:00:00 2001 From: James Muehlner Date: Tue, 19 Jul 2022 18:31:40 +0000 Subject: [PATCH] GUACAMOLE-1656: Add per-user KSM vault functionality. --- .../vault/conf/VaultAttributeService.java | 30 +++ .../vault/user/VaultUserContext.java | 59 ++++-- .../ksm/KsmAuthenticationProviderModule.java | 1 + .../vault/ksm/conf/KsmAttributeService.java | 66 ++++++- .../ksm/conf/KsmConfigurationService.java | 26 +++ .../vault/ksm/secret/KsmSecretService.java | 177 +++++++++++------- .../vault/ksm/user/KsmConnection.java | 82 ++++++++ .../vault/ksm/user/KsmDirectoryService.java | 87 +++++++++ .../guacamole/vault/ksm/user/KsmUser.java | 82 ++++++++ .../src/main/resources/translations/en.json | 10 + .../net/auth/DelegatingUserContext.java | 5 + .../guacamole/net/auth/UserContext.java | 17 ++ .../controllers/manageUserController.js | 2 + .../src/app/rest/services/schemaService.js | 28 +++ .../directives/guacSettingsPreferences.js | 124 +++++++++++- .../templates/settingsPreferences.html | 10 + .../main/frontend/src/translations/en.json | 3 + guacamole/src/main/frontend/webpack.config.js | 12 -- .../guacamole/rest/schema/SchemaResource.java | 20 ++ .../guacamole/rest/user/UserResource.java | 53 +++++- 20 files changed, 785 insertions(+), 109 deletions(-) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java index 2bd08cde6..bccf08e03 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/conf/VaultAttributeService.java @@ -30,6 +30,16 @@ import org.apache.guacamole.form.Form; */ public interface VaultAttributeService { + /** + * Return all custom connection attributes to be exposed through the + * admin UI for the current vault implementation. + * + * @return + * All custom connection attributes to be exposed through the + * admin UI for the current vault implementation. + */ + public Collection
getConnectionAttributes(); + /** * Return all custom connection group attributes to be exposed through the * admin UI for the current vault implementation. @@ -39,4 +49,24 @@ public interface VaultAttributeService { * admin UI for the current vault implementation. */ public Collection getConnectionGroupAttributes(); + + /** + * Return all custom user attributes to be exposed through the admin UI for + * the current vault implementation. + * + * @return + * All custom user attributes to be exposed through the admin UI for + * the current vault implementation. + */ + public Collection getUserAttributes(); + + /** + * Return all user preference attributes to be exposed through the user + * preferences UI for the current vault implementation. + * + * @return + * All user preference attributes to be exposed through the user + * preferences UI for the current vault implementation. + */ + public Collection getUserPreferenceAttributes(); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java index 8e8f66853..748237dd2 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-base/src/main/java/org/apache/guacamole/vault/user/VaultUserContext.java @@ -241,7 +241,7 @@ public class VaultUserContext extends TokenInjectingUserContext { * * @throws GuacamoleException * If the value for any applicable secret cannot be retrieved from the - * vault due to an error. + * vault due to an error.1 */ private Map> getTokens( Connectable connectable, Map tokenMapping, @@ -407,7 +407,6 @@ public class VaultUserContext extends TokenInjectingUserContext { TokenFilter filter = createFilter(); filter.setToken(CONNECTION_NAME_TOKEN, connection.getName()); filter.setToken(CONNECTION_IDENTIFIER_TOKEN, identifier); - // Add hostname and username tokens if available (implementations are // not required to expose connection configuration details) @@ -439,17 +438,6 @@ public class VaultUserContext extends TokenInjectingUserContext { } - @Override - public Collection getConnectionGroupAttributes() { - - // Add any custom attributes to any previously defined attributes - return Collections.unmodifiableCollection(Stream.concat( - super.getConnectionGroupAttributes().stream(), - attributeService.getConnectionGroupAttributes().stream() - ).collect(Collectors.toList())); - - } - @Override public Directory getUserDirectory() throws GuacamoleException { @@ -490,6 +478,51 @@ public class VaultUserContext extends TokenInjectingUserContext { // Defer to the vault-specific directory service return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory()); + + } + + @Override + public Collection getUserAttributes() { + + // Add any custom attributes to any previously defined attributes + return Collections.unmodifiableCollection(Stream.concat( + super.getUserAttributes().stream(), + attributeService.getUserAttributes().stream() + ).collect(Collectors.toList())); + + } + + @Override + public Collection getUserPreferenceAttributes() { + + // Add any custom preference attributes to any previously defined attributes + return Collections.unmodifiableCollection(Stream.concat( + super.getUserPreferenceAttributes().stream(), + attributeService.getUserPreferenceAttributes().stream() + ).collect(Collectors.toList())); + + } + + @Override + public Collection getConnectionAttributes() { + + // Add any custom attributes to any previously defined attributes + return Collections.unmodifiableCollection(Stream.concat( + super.getConnectionAttributes().stream(), + attributeService.getConnectionAttributes().stream() + ).collect(Collectors.toList())); + + } + + @Override + public Collection getConnectionGroupAttributes() { + + // Add any custom attributes to any previously defined attributes + return Collections.unmodifiableCollection(Stream.concat( + super.getConnectionGroupAttributes().stream(), + attributeService.getConnectionGroupAttributes().stream() + ).collect(Collectors.toList())); + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java index 3c28553e0..faf22aa4e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/KsmAuthenticationProviderModule.java @@ -57,6 +57,7 @@ public class KsmAuthenticationProviderModule // Bind services specific to Keeper Secrets Manager bind(KsmRecordService.class); + bind(KsmAttributeService.class); bind(VaultAttributeService.class).to(KsmAttributeService.class); bind(VaultConfigurationService.class).to(KsmConfigurationService.class); bind(VaultSecretService.class).to(KsmSecretService.class); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java index 83ab9c4a3..bdceb4c21 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmAttributeService.java @@ -23,10 +23,13 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +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.vault.conf.VaultAttributeService; +import com.google.inject.Inject; import com.google.inject.Singleton; /** @@ -36,28 +39,81 @@ import com.google.inject.Singleton; @Singleton public class KsmAttributeService implements VaultAttributeService { + + @Inject + private KsmConfigurationService configurationService; + /** * The name of the attribute which can contain a KSM configuration blob - * associated with a connection group. + * associated with either a connection group or user. */ public static final String KSM_CONFIGURATION_ATTRIBUTE = "ksm-config"; /** * All attributes related to configuring the KSM vault on a - * per-connection-group basis. + * per-connection-group or per-user basis. */ public static final Form KSM_CONFIGURATION_FORM = new Form("ksm-config", Arrays.asList(new TextField(KSM_CONFIGURATION_ATTRIBUTE))); /** - * All KSM-specific connection group attributes, organized by form. + * All KSM-specific attributes for users or connection groups, organized by form. */ - public static final Collection KSM_CONNECTION_GROUP_ATTRIBUTES = + public static final Collection KSM_ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(KSM_CONFIGURATION_FORM)); + /** + * The name of the attribute which can controls whether a KSM user configuration + * is enabled on a connection-by-connection basis. + */ + public static final String KSM_USER_CONFIG_ENABLED_ATTRIBUTE = "ksm-user-config-enabled"; + + /** + * The string value used by KSM attributes to represent the boolean value "true". + */ + public static final String TRUTH_VALUE = "true"; + + /** + * All attributes related to configuring the KSM vault on a per-connection basis. + */ + public static final Form KSM_CONNECTION_FORM = new Form("ksm-config", + Arrays.asList(new BooleanField(KSM_USER_CONFIG_ENABLED_ATTRIBUTE, TRUTH_VALUE))); + + /** + * All KSM-specific attributes for connections, organized by form. + */ + public static final Collection KSM_CONNECTION_ATTRIBUTES = + Collections.unmodifiableCollection(Arrays.asList(KSM_CONNECTION_FORM)); + + @Override + public Collection getConnectionAttributes() { + return KSM_CONNECTION_ATTRIBUTES; + } + @Override public Collection getConnectionGroupAttributes() { - return KSM_CONNECTION_GROUP_ATTRIBUTES; + return KSM_ATTRIBUTES; } + @Override + public Collection getUserAttributes() { + return KSM_ATTRIBUTES; + } + + @Override + public Collection getUserPreferenceAttributes() { + + try { + + // Expose the user attributes IFF user-level KSM configuration is enabled + return configurationService.getAllowUserConfig() ? KSM_ATTRIBUTES : Collections.emptyList(); + + } catch (GuacamoleException e) { + + // If the configuration can't be parsed, default to not exposing the attribute + return Collections.emptyList(); + } + } + + } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java index c9a9536ea..9d2e4565f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/conf/KsmConfigurationService.java @@ -84,6 +84,17 @@ public class KsmConfigurationService extends VaultConfigurationService { } }; + /** + * Whether users should be able to supply their own KSM configurations. + */ + private static final BooleanGuacamoleProperty ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() { + + @Override + public String getName() { + return "ksm-allow-user-config"; + } + }; + /** * Whether windows domains should be stripped off from usernames that are * read from the KSM vault. @@ -138,6 +149,21 @@ public class KsmConfigurationService extends VaultConfigurationService { return environment.getProperty(ALLOW_UNVERIFIED_CERT, false); } + /** + * Return whether users should be able to provide their own KSM configs. + * + * @return + * true if users should be able to provide their own KSM configs, + * 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); + } + @Override public boolean getSplitWindowsUsernames() throws GuacamoleException { return environment.getProperty(STRIP_WINDOWS_DOMAINS, false); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java index cb5868cfa..a3b772256 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/secret/KsmSecretService.java @@ -26,8 +26,11 @@ import com.keepersecurity.secretsManager.core.SecretsManagerOptions; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -304,6 +307,34 @@ public class KsmSecretService implements VaultSecretService { } + /** + * Returns true if user-level KSM configuration is enabled for the given + * Connectable, false otherwise. + * + * @param connectable + * The connectable to check for whether user-level KSM configs are + * enabled. + * + * @return + * True if user-level KSM configuration is enabled for the given + * Connectable, false otherwise. + */ + private boolean isKsmUserConfigEnabled(Connectable connectable) { + + // If it's a connection, user-level config is enabled IFF the appropriate + // attribute is set to true + if (connectable instanceof Connection) + return KsmAttributeService.TRUTH_VALUE.equals(((Connection) connectable).getAttributes().get( + KsmAttributeService.KSM_USER_CONFIG_ENABLED_ATTRIBUTE)); + + // KSM token replacement is not enabled for balancing groups, so for + // now, user-level KSM configs will be explicitly disabled. + // TODO: If token replacement is implemented for balancing groups, + // implement this functionality for them as well. + return false; + + } + @Override public Map> getTokens(UserContext userContext, Connectable connectable, GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException { @@ -314,78 +345,92 @@ public class KsmSecretService implements VaultSecretService { // Attempt to find a KSM config for this connection or group String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable); - // Get a client instance for this KSM config - KsmClient ksm = getClient(ksmConfig); + // Create a list containing just the global / connection group config + List ksmClients = new ArrayList<>(2); + ksmClients.add(getClient(ksmConfig)); - // Retrieve and define server-specific tokens, if any - String hostname = parameters.get("hostname"); - if (hostname != null && !hostname.isEmpty()) - addRecordTokens(tokens, "KEEPER_SERVER_", - ksm.getRecordByHost(filter.filter(hostname))); + // Only use the user-specific KSM config if explicitly enabled in the global + // configuration, AND for the specific connectable being connected to + if (confService.getAllowUserConfig() && isKsmUserConfigEnabled(connectable)) { - // Tokens specific to RDP - if ("rdp".equals(config.getProtocol())) { - - // Retrieve and define gateway server-specific tokens, if any - String gatewayHostname = parameters.get("gateway-hostname"); - if (gatewayHostname != null && !gatewayHostname.isEmpty()) - addRecordTokens(tokens, "KEEPER_GATEWAY_", - ksm.getRecordByHost(filter.filter(gatewayHostname))); - - // Retrieve and define domain tokens, if any - String domain = parameters.get("domain"); - String filteredDomain = null; - if (domain != null && !domain.isEmpty()) { - filteredDomain = filter.filter(domain); - addRecordTokens(tokens, "KEEPER_DOMAIN_", - ksm.getRecordByDomain(filteredDomain)); - } - - // Retrieve and define gateway domain tokens, if any - String gatewayDomain = parameters.get("gateway-domain"); - String filteredGatewayDomain = null; - if (gatewayDomain != null && !gatewayDomain.isEmpty()) { - filteredGatewayDomain = filter.filter(gatewayDomain); - addRecordTokens(tokens, "KEEPER_GATEWAY_DOMAIN_", - ksm.getRecordByDomain(filteredGatewayDomain)); - } - - // If domain matching is disabled for user records, - // explicitly set the domains to null when storing - // user records to enable username-only matching - if (!confService.getMatchUserRecordsByDomain()) { - filteredDomain = null; - filteredGatewayDomain = null; - } - - // Retrieve and define user-specific tokens, if any - String username = parameters.get("username"); - if (username != null && !username.isEmpty()) - addRecordTokens(tokens, "KEEPER_USER_", - ksm.getRecordByLogin(filter.filter(username), - filteredDomain)); - - // Retrieve and define gateway user-specific tokens, if any - String gatewayUsername = parameters.get("gateway-username"); - if (gatewayUsername != null && !gatewayUsername.isEmpty()) - addRecordTokens(tokens, "KEEPER_GATEWAY_USER_", - ksm.getRecordByLogin( - filter.filter(gatewayUsername), - filteredGatewayDomain)); + // Find a user-specific KSM config, if one exists + String userKsmConfig = userContext.self().getAttributes().get( + KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE); + // If a user-specific config exsts, process it first + if (userKsmConfig != null && !userKsmConfig.trim().isEmpty()) + ksmClients.add(0, getClient(userKsmConfig)); } - else { + // Iterate through the KSM clients, processing using the user-specific + // config first (if it exists), to ensure that any admin-defined values + // will override the user-speicifc values + Iterator ksmIterator = ksmClients.iterator(); + while (ksmIterator.hasNext()) { - // Retrieve and define user-specific tokens, if any - // NOTE that non-RDP connections do not have a domain - // field in the connection parameters, so the domain - // will always be null - String username = parameters.get("username"); - if (username != null && !username.isEmpty()) - addRecordTokens(tokens, "KEEPER_USER_", - ksm.getRecordByLogin(filter.filter(username), null)); - } + KsmClient ksm = ksmIterator.next(); + + // Retrieve and define server-specific tokens, if any + String hostname = parameters.get("hostname"); + if (hostname != null && !hostname.isEmpty()) + addRecordTokens(tokens, "KEEPER_SERVER_", + ksm.getRecordByHost(filter.filter(hostname))); + + // Tokens specific to RDP + if ("rdp".equals(config.getProtocol())) { + // Retrieve and define domain tokens, if any + String domain = parameters.get("domain"); + String filteredDomain = null; + if (domain != null && !domain.isEmpty()) { + filteredDomain = filter.filter(domain); + addRecordTokens(tokens, "KEEPER_DOMAIN_", + ksm.getRecordByDomain(filteredDomain)); + } + + // Retrieve and define gateway domain tokens, if any + String gatewayDomain = parameters.get("gateway-domain"); + String filteredGatewayDomain = null; + if (gatewayDomain != null && !gatewayDomain.isEmpty()) { + filteredGatewayDomain = filter.filter(gatewayDomain); + addRecordTokens(tokens, "KEEPER_GATEWAY_DOMAIN_", + ksm.getRecordByDomain(filteredGatewayDomain)); + } + + // If domain matching is disabled for user records, + // explicitly set the domains to null when storing + // user records to enable username-only matching + if (!confService.getMatchUserRecordsByDomain()) { + filteredDomain = null; + filteredGatewayDomain = null; + } + + // Retrieve and define user-specific tokens, if any + String username = parameters.get("username"); + if (username != null && !username.isEmpty()) + addRecordTokens(tokens, "KEEPER_USER_", + ksm.getRecordByLogin(filter.filter(username), + filteredDomain)); + + // Retrieve and define gateway user-specific tokens, if any + String gatewayUsername = parameters.get("gateway-username"); + if (gatewayUsername != null && !gatewayUsername.isEmpty()) + addRecordTokens(tokens, "KEEPER_GATEWAY_USER_", + ksm.getRecordByLogin( + filter.filter(gatewayUsername), + filteredGatewayDomain)); + } + + else { + + // Retrieve and define user-specific tokens, if any + // NOTE that non-RDP connections do not have a domain + // field in the connection parameters, so the domain + // will always be null + String username = parameters.get("username"); + if (username != null && !username.isEmpty()) + addRecordTokens(tokens, "KEEPER_USER_", + ksm.getRecordByLogin(filter.filter(username), null)); + } return tokens; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java new file mode 100644 index 000000000..da2866202 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmConnection.java @@ -0,0 +1,82 @@ +/* + * 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.ksm.user; + +import java.util.List; +import java.util.Map; + +import org.apache.guacamole.net.auth.DelegatingConnection; +import org.apache.guacamole.net.auth.Connection; + +import com.google.common.collect.Maps; + +/** + * A Connection that explicitly adds a blank entry for any defined + * KSM connection attributes. + */ +public class KsmConnection extends DelegatingConnection { + + /** + * The names of all connection attributes defined for the vault. + */ + private List connectionAttributeNames; + + /** + * 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. + * + * @param connectionAttributeNames + * The names of all connection attributes to automatically expose. + */ + KsmConnection(Connection connection, List connectionAttributeNames) { + + super(connection); + this.connectionAttributeNames = connectionAttributeNames; + + } + + /** + * 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 attributeMap = Maps.newHashMap(super.getAttributes()); + + // Add every defined attribute + connectionAttributeNames.forEach( + attributeName -> attributeMap.putIfAbsent(attributeName, null)); + + return attributeMap; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java index b924bc524..3eff928fe 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmDirectoryService.java @@ -26,13 +26,16 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.language.TranslatableGuacamoleClientException; import org.apache.guacamole.net.auth.Attributes; +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.ksm.conf.KsmAttributeService; import org.apache.guacamole.vault.ksm.conf.KsmConfig; import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; @@ -57,6 +60,12 @@ public class KsmDirectoryService extends VaultDirectoryService { @Inject private KsmConfigurationService configurationService; + /** + * Service for retrieving KSM-specific attributes. + */ + @Inject + private KsmAttributeService ksmAttributeService; + /** * A singleton ObjectMapper for converting a Map to a JSON string when * generating a base64-encoded JSON KSM config blob. @@ -222,6 +231,36 @@ public class KsmDirectoryService extends VaultDirectoryService { } + @Override + public Directory getConnectionDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // A Connection directory that will intercept add and update calls to + // validate KSM configurations, and translate one-time-tokens, if possible + return new DecoratingDirectory(underlyingDirectory) { + + @Override + protected Connection decorate(Connection connection) throws GuacamoleException { + + // Wrap in a KsmConnection class to ensure that all defined KSM fields will be + // present + return new KsmConnection( + connection, + ksmAttributeService.getConnectionAttributes().stream().flatMap( + form -> form.getFields().stream().map(field -> field.getName()) + ).collect(Collectors.toList())); + } + + @Override + protected Connection undecorate(Connection connection) throws GuacamoleException { + + // Unwrap the KsmUser + return ((KsmConnection) connection).getUnderlyingConnection(); + } + + }; + } + @Override public Directory getConnectionGroupDirectory( Directory underlyingDirectory) throws GuacamoleException { @@ -269,4 +308,52 @@ public class KsmDirectoryService extends VaultDirectoryService { }; } + + @Override + public Directory getUserDirectory( + Directory underlyingDirectory) throws GuacamoleException { + + // A User directory that will intercept add and update calls to + // validate KSM configurations, and translate one-time-tokens, if possible + return new DecoratingDirectory(underlyingDirectory) { + + @Override + public void add(User user) throws GuacamoleException { + + // Check for the KSM config attribute and translate the one-time token + // if possible before adding + processAttributes(user); + super.add(user); + } + + @Override + public void update(User user) throws GuacamoleException { + + // Check for the KSM config attribute and translate the one-time token + // if possible before updating + processAttributes(user); + super.update(user); + } + + @Override + protected User decorate(User user) throws GuacamoleException { + + // Wrap in a KsmUser class to ensure that all defined KSM fields will be + // present + return new KsmUser( + user, + ksmAttributeService.getUserAttributes().stream().flatMap( + form -> form.getFields().stream().map(field -> field.getName()) + ).collect(Collectors.toList())); + } + + @Override + protected User undecorate(User user) throws GuacamoleException { + + // Unwrap the KsmUser + return ((KsmUser) user).getUnderlyingUser(); + } + + }; + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java new file mode 100644 index 000000000..a846eb0d8 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/java/org/apache/guacamole/vault/ksm/user/KsmUser.java @@ -0,0 +1,82 @@ +/* + * 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.ksm.user; + +import java.util.List; +import java.util.Map; + +import org.apache.guacamole.net.auth.DelegatingUser; +import org.apache.guacamole.net.auth.User; + +import com.google.common.collect.Maps; + +/** + * A User that explicitly adds a blank entry for any defined + * KSM user attributes. + */ +public class KsmUser extends DelegatingUser { + + /** + * The names of all user attributes defined for the vault. + */ + private List userAttributeNames; + + /** + * Create a new Vault user wrapping the provided User record. Any + * attributes defined in the provided user attribute forms will have empty + * values automatically populated when getAttributes() is called. + * + * @param user + * The user record to wrap. + * + * @param userAttributeNames + * The names of all user attributes to automatically expose. + */ + KsmUser(User user, List userAttributeNames) { + + super(user); + this.userAttributeNames = userAttributeNames; + + } + + /** + * 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 attributeMap = Maps.newHashMap(super.getAttributes()); + + // Add every defined attribute + userAttributeNames.forEach( + attributeName -> attributeMap.putIfAbsent(attributeName, null)); + + return attributeMap; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json index 4601a13f1..034f5f24a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json +++ b/extensions/guacamole-vault/modules/guacamole-vault-ksm/src/main/resources/translations/en.json @@ -4,12 +4,22 @@ "NAME" : "Keeper Secrets Manager" }, + "CONNECTION_ATTRIBUTES" : { + "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager", + "FIELD_HEADER_KSM_USER_CONFIG_ENABLED" : "Allow user-provided KSM configuration" + }, + "CONNECTION_GROUP_ATTRIBUTES" : { "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager", "FIELD_HEADER_KSM_CONFIG" : "KSM Service Configuration ", "ERROR_INVALID_KSM_CONFIG_BLOB" : "The provided base64-encoded KSM configuration blob is not valid. Please ensure that you have copied the entire blob.", "ERROR_INVALID_KSM_ONE_TIME_TOKEN" : "The provided configuration is not a valid KSM one-time token or base64-encoded configuration blob. Please ensure that you have copied the entire token value." + }, + + "USER_ATTRIBUTES" : { + "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager", + "FIELD_HEADER_KSM_CONFIG" : "KSM Service Configuration " } } diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java index 85e025909..4b0343181 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/DelegatingUserContext.java @@ -127,6 +127,11 @@ public class DelegatingUserContext implements UserContext { return userContext.getUserAttributes(); } + @Override + public Collection getUserPreferenceAttributes() { + return userContext.getUserPreferenceAttributes(); + } + @Override public Collection getUserGroupAttributes() { return userContext.getUserGroupAttributes(); diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java index ccdcaae09..3f75b899c 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/UserContext.java @@ -20,6 +20,8 @@ package org.apache.guacamole.net.auth; import java.util.Collection; +import java.util.Collections; + import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.form.Form; @@ -211,6 +213,21 @@ public interface UserContext { */ Collection getUserAttributes(); + /** + * Retrieves a collection of user attributes, specific to the user preferences + * page in the UI. Unlike standard user attributes, these should be self-editable. + * + * @return + * A collection of form of user attributes, specific to the user preferences + * page in the UI. + */ + default Collection getUserPreferenceAttributes() { + + // By default, a user context does not expose any preference user attributes + return Collections.emptyList(); + + } + /** * Retrieves a collection of all attributes applicable to user groups. This * collection will contain only those attributes which the current user has diff --git a/guacamole/src/main/frontend/src/app/manage/controllers/manageUserController.js b/guacamole/src/main/frontend/src/app/manage/controllers/manageUserController.js index f7ead136a..94e3d8944 100644 --- a/guacamole/src/main/frontend/src/app/manage/controllers/manageUserController.js +++ b/guacamole/src/main/frontend/src/app/manage/controllers/manageUserController.js @@ -501,4 +501,6 @@ angular.module('manage').controller('manageUserController', ['$scope', '$injecto return userService.deleteUser($scope.dataSource, $scope.user); }; + console.log($scope); + }]); diff --git a/guacamole/src/main/frontend/src/app/rest/services/schemaService.js b/guacamole/src/main/frontend/src/app/rest/services/schemaService.js index dee10e1bd..d6afa2b75 100644 --- a/guacamole/src/main/frontend/src/app/rest/services/schemaService.js +++ b/guacamole/src/main/frontend/src/app/rest/services/schemaService.js @@ -58,6 +58,34 @@ angular.module('rest').factory('schemaService', ['$injector', }; + /** + * Makes a request to the REST API to get the list of available user preference + * attributes, returning a promise that provides an array of @link{Form} objects + * if successful. Each element of the array describes a logical grouping of + * possible user preference attributes. + * + * @param {String} dataSource + * The unique identifier of the data source containing the users whose + * available user preference attributes are to be retrieved. This + * identifier corresponds to an AuthenticationProvider within the + * Guacamole web application. + * + * @returns {Promise.} + * A promise which will resolve with an array of @link{Form} + * objects, where each @link{Form} describes a logical grouping of + * possible attributes. + */ + service.getUserPreferenceAttributes = function getUserPreferenceAttributes(dataSource) { + + // Retrieve available user attributes + return authenticationService.request({ + cache : cacheService.schema, + method : 'GET', + url : 'api/session/data/' + encodeURIComponent(dataSource) + '/schema/userPreferenceAttributes' + }); + + }; + /** * Makes a request to the REST API to get the list of available attributes * for user group objects, returning a promise that provides an array of diff --git a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js index aad0a2e0e..5824eb194 100644 --- a/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js +++ b/guacamole/src/main/frontend/src/app/settings/directives/guacSettingsPreferences.js @@ -21,7 +21,7 @@ * A directive for managing preferences local to the current user. */ angular.module('settings').directive('guacSettingsPreferences', [function guacSettingsPreferences() { - + return { // Element only restrict: 'E', @@ -33,16 +33,18 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe controller: ['$scope', '$injector', function settingsPreferencesController($scope, $injector) { // Get required types - var PermissionSet = $injector.get('PermissionSet'); + const Form = $injector.get('Form'); + const PermissionSet = $injector.get('PermissionSet'); // Required services - var $translate = $injector.get('$translate'); - var authenticationService = $injector.get('authenticationService'); - var guacNotification = $injector.get('guacNotification'); - var permissionService = $injector.get('permissionService'); - var preferenceService = $injector.get('preferenceService'); - var requestService = $injector.get('requestService'); - var userService = $injector.get('userService'); + const $translate = $injector.get('$translate'); + const authenticationService = $injector.get('authenticationService'); + const guacNotification = $injector.get('guacNotification'); + const permissionService = $injector.get('permissionService'); + const preferenceService = $injector.get('preferenceService'); + const requestService = $injector.get('requestService'); + const schemaService = $injector.get('schemaService'); + const userService = $injector.get('userService'); /** * An action to be provided along with the object sent to @@ -56,6 +58,13 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe } }; + /** + * The user being modified. + * + * @type User + */ + $scope.user = null; + /** * The username of the current user. * @@ -78,6 +87,26 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe */ $scope.preferences = preferenceService.preferences; + /** + * All available user attributes, as a mapping of form name to form + * object. The form object contains a name, as well as a Map of fields. + * + * The Map type is used here to maintain form/name uniqueness, as well as + * insertion order, to ensure a consistent UI experience. + * + * @type Map + */ + $scope.attributeMap = new Map(); + + /** + * All available user attributes. This is only the set of attribute + * definitions, organized as logical groupings of attributes, not attribute + * values. + * + * @type Form[] + */ + $scope.attributes = null; + /** * The fields which should be displayed for choosing locale * preferences. Each field name must be a property on @@ -197,7 +226,82 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe }; + + /** + * Saves the current user, displaying an acknowledgement message if + * saving was successful, or an error if the save failed. + */ + $scope.saveUser = function saveUser() { + return userService.saveUser(dataSource, $scope.user) + .then(() => guacNotification.showStatus({ + text : { + key : 'SETTINGS_PREFERENCES.INFO_PREFERENCE_ATTRIBUTES_CHANGED' + }, + actions : [ ACKNOWLEDGE_ACTION ] + }), + guacNotification.SHOW_REQUEST_ERROR); + }; + + // Fetch the user record + userService.getUser(dataSource, username).then(function saveUserData(user) { + $scope.user = user; + }) + + // Get all datasources that are available for this user + authenticationService.getAvailableDataSources().forEach(function loadAttributesForDataSource(dataSource) { + + // Fetch all user attribute forms defined for the datasource + schemaService.getUserPreferenceAttributes(dataSource).then(function saveAttributes(attributes) { + + // Iterate through all attribute forms + attributes.forEach(function addAttribute(attributeForm) { + + // If the form with the retrieved name already exists + if ($scope.attributeMap.has(attributeForm.name)) { + const existingFields = $scope.attributeMap.get(attributeForm.name).fields; + + // Add each field to the existing list for this form + attributeForm.fields.forEach(function addAllFieldsToExistingMap(field) { + existingFields.set(field.name, field); + }) + } + + else { + + // Create a new entry for the form + $scope.attributeMap.set(attributeForm.name, { + name: attributeForm.name, + + // With the field array from the API converted into a Map + fields: attributeForm.fields.reduce( + function addFieldToMap(currentFieldMap, field) { + currentFieldMap.set(field.name, field); + return currentFieldMap; + }, new Map() + ) + + }) + } + + }); + + // Re-generate the attributes array every time + $scope.attributes = Array.of(...$scope.attributeMap.values()).map(function convertFieldsToArray(formObject) { + + // Convert each temporary form object to a Form type + return new Form({ + name: formObject.name, + + // Convert the field map to a simple array of fields + fields: Array.of(...formObject.fields.values()) + }) + }); + + }); + + }); + }] }; - + }]); diff --git a/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html b/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html index cabd2ae9f..ac5cc4f59 100644 --- a/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html +++ b/guacamole/src/main/frontend/src/app/settings/templates/settingsPreferences.html @@ -89,4 +89,14 @@ + +

{{'SETTINGS_PREFERENCES.SECTION_HEADER_UPDATE_ATTRIBUTES' | translate}}

+
+ + + + +
+ diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 05a9fa279..7f52fea11 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -916,6 +916,7 @@ "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", + "ACTION_SAVE" : "@:APP.ACTION_SAVE", "ACTION_UPDATE_PASSWORD" : "@:APP.ACTION_UPDATE_PASSWORD", "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR", @@ -942,6 +943,7 @@ "HELP_UPDATE_PASSWORD" : "If you wish to change your password, enter your current password and the desired new password below, and click \"Update Password\". The change will take effect immediately.", "INFO_PASSWORD_CHANGED" : "Password changed.", + "INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "User attributes saved.", "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE", "NAME_INPUT_METHOD_OSK" : "@:CLIENT.NAME_INPUT_METHOD_OSK", @@ -949,6 +951,7 @@ "SECTION_HEADER_DEFAULT_INPUT_METHOD" : "Default Input Method", "SECTION_HEADER_DEFAULT_MOUSE_MODE" : "Default Mouse Emulation Mode", + "SECTION_HEADER_UPDATE_ATTRIBUTES" : "User Attributes", "SECTION_HEADER_UPDATE_PASSWORD" : "Change Password" }, diff --git a/guacamole/src/main/frontend/webpack.config.js b/guacamole/src/main/frontend/webpack.config.js index 29bb8ddd5..3f1574fa1 100644 --- a/guacamole/src/main/frontend/webpack.config.js +++ b/guacamole/src/main/frontend/webpack.config.js @@ -77,18 +77,6 @@ module.exports = { ] }, optimization: { - minimizer: [ - - // Minify using Google Closure Compiler - new ClosureWebpackPlugin({ mode: 'STANDARD' }, { - languageIn: 'ECMASCRIPT_2020', - languageOut: 'ECMASCRIPT5', - compilationLevel: 'SIMPLE' - }), - - new CssMinimizerPlugin() - - ], splitChunks: { cacheGroups: { diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java index 9086ac93e..edc125127 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/schema/SchemaResource.java @@ -77,6 +77,26 @@ public class SchemaResource { } + /** + * Retrieves the possible user preference attributes of a user object. + * + * @return + * A collection of forms which describe the possible preference attributes of a + * user object. + * + * @throws GuacamoleException + * If an error occurs while retrieving the possible attributes. + */ + @GET + @Path("userPreferenceAttributes") + public Collection getUserAttrigetUserPreferenceAttributesbutes() + throws GuacamoleException { + + // Retrieve all possible user preference attributes + return userContext.getUserPreferenceAttributes(); + + } + /** * Retrieves the possible attributes of a user group object. * diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java index f31ce5dc8..5d4be4ffc 100644 --- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java +++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserResource.java @@ -21,6 +21,11 @@ package org.apache.guacamole.rest.user; import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; + +import java.util.Iterator; +import java.util.Set; +import java.util.stream.Collectors; + import javax.servlet.http.HttpServletRequest; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -145,9 +150,51 @@ public class UserResource @Override public void updateObject(APIUser modifiedObject) throws GuacamoleException { - // A user may not use this endpoint to modify himself - if (userContext.self().getIdentifier().equals(modifiedObject.getUsername())) - throw new GuacamoleSecurityException("Permission denied."); + User currentUser = userContext.self(); + + // A user may not use this endpoint to modify themself, except in the case + // that they are modifying one of the user attributes explicitly exposed + // in the user preferences form + if (currentUser.getIdentifier().equals(modifiedObject.getUsername())) { + + // A user may not use this endpoint to update their password + if (currentUser.getPassword() != null) + throw new GuacamoleSecurityException( + "Permission denied. The password update endpoint must" + + " be used to change the current user's password."); + + // All attributes exposed in the preferences forms + Set preferenceAttributes = ( + userContext.getUserPreferenceAttributes().stream() + .flatMap(form -> form.getFields().stream().map( + field -> field.getName()))) + .collect(Collectors.toSet()); + + // Go through every attribute value and check if it's changed + Iterator keyIterator = modifiedObject.getAttributes().keySet().iterator(); + while(keyIterator.hasNext()) { + + String key = keyIterator.next(); + String newValue = modifiedObject.getAttributes().get(key); + + // If it's not a preference attribute, editing is not allowed + if (!preferenceAttributes.contains(key)) { + + String currentValue = currentUser.getAttributes().get(key); + + // If the value of the attribute has been modified + if ( + !(currentValue == null && newValue == null) && ( + (currentValue == null && newValue != null) || + !currentValue.equals(newValue) + ) + ) + throw new GuacamoleSecurityException( + "Permission denied. Only user preference attributes" + + " can be modified for the current user."); + } + } + } super.updateObject(modifiedObject);