GUACAMOLE-1656: Merge support for per-user KSM vaults.

This commit is contained in:
Mike Jumper
2022-09-28 15:06:40 -07:00
committed by GitHub
26 changed files with 1279 additions and 266 deletions

View File

@@ -164,8 +164,8 @@ public class JDBCAuthenticationProviderService implements AuthenticationProvider
UserContext context, AuthenticatedUser authenticatedUser, UserContext context, AuthenticatedUser authenticatedUser,
Credentials credentials) throws GuacamoleException { Credentials credentials) throws GuacamoleException {
// No need to update the context // Refresh the user context
return context; return getUserContext(authenticationProvider, authenticatedUser);
} }

View File

@@ -415,10 +415,6 @@ public class UserService extends ModeledDirectoryObjectService<ModeledUser, User
public ModeledUser retrieveUser(AuthenticationProvider authenticationProvider, public ModeledUser retrieveUser(AuthenticationProvider authenticationProvider,
AuthenticatedUser authenticatedUser) throws GuacamoleException { AuthenticatedUser authenticatedUser) throws GuacamoleException {
// If we already queried this user, return that rather than querying again
if (authenticatedUser instanceof ModeledAuthenticatedUser)
return ((ModeledAuthenticatedUser) authenticatedUser).getUser();
// Retrieve corresponding user model, if such a user exists // Retrieve corresponding user model, if such a user exists
UserModel userModel = userMapper.selectOne(authenticatedUser.getIdentifier()); UserModel userModel = userMapper.selectOne(authenticatedUser.getIdentifier());
if (userModel == null) if (userModel == null)

View File

@@ -30,6 +30,16 @@ import org.apache.guacamole.form.Form;
*/ */
public interface VaultAttributeService { 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<Form> getConnectionAttributes();
/** /**
* Return all custom connection group attributes to be exposed through the * Return all custom connection group attributes to be exposed through the
* admin UI for the current vault implementation. * admin UI for the current vault implementation.
@@ -39,4 +49,24 @@ public interface VaultAttributeService {
* admin UI for the current vault implementation. * admin UI for the current vault implementation.
*/ */
public Collection<Form> getConnectionGroupAttributes(); public Collection<Form> 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<Form> 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<Form> getUserPreferenceAttributes();
} }

View File

@@ -407,7 +407,6 @@ public class VaultUserContext extends TokenInjectingUserContext {
TokenFilter filter = createFilter(); TokenFilter filter = createFilter();
filter.setToken(CONNECTION_NAME_TOKEN, connection.getName()); filter.setToken(CONNECTION_NAME_TOKEN, connection.getName());
filter.setToken(CONNECTION_IDENTIFIER_TOKEN, identifier); filter.setToken(CONNECTION_IDENTIFIER_TOKEN, identifier);
// Add hostname and username tokens if available (implementations are // Add hostname and username tokens if available (implementations are
// not required to expose connection configuration details) // not required to expose connection configuration details)
@@ -439,17 +438,6 @@ public class VaultUserContext extends TokenInjectingUserContext {
} }
@Override
public Collection<Form> 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 @Override
public Directory<User> getUserDirectory() throws GuacamoleException { public Directory<User> getUserDirectory() throws GuacamoleException {
@@ -490,6 +478,51 @@ public class VaultUserContext extends TokenInjectingUserContext {
// Defer to the vault-specific directory service // Defer to the vault-specific directory service
return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory()); return directoryService.getSharingProfileDirectory(super.getSharingProfileDirectory());
}
@Override
public Collection<Form> 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<Form> 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<Form> 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<Form> getConnectionGroupAttributes() {
// Add any custom attributes to any previously defined attributes
return Collections.unmodifiableCollection(Stream.concat(
super.getConnectionGroupAttributes().stream(),
attributeService.getConnectionGroupAttributes().stream()
).collect(Collectors.toList()));
} }
} }

View File

@@ -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.ksm;
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 <T>
* The type of object which will be returned as a result of calling
* get().
*/
public interface GuacamoleExceptionSupplier<T> {
/**
* 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;
}

View File

@@ -24,7 +24,10 @@ import org.apache.guacamole.vault.VaultAuthenticationProviderModule;
import org.apache.guacamole.vault.ksm.conf.KsmAttributeService; import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
import org.apache.guacamole.vault.ksm.secret.KsmSecretService; import org.apache.guacamole.vault.ksm.secret.KsmSecretService;
import org.apache.guacamole.vault.ksm.user.KsmConnectionGroup;
import org.apache.guacamole.vault.ksm.user.KsmDirectoryService; import org.apache.guacamole.vault.ksm.user.KsmDirectoryService;
import org.apache.guacamole.vault.ksm.user.KsmUserFactory;
import org.apache.guacamole.vault.ksm.user.KsmUser;
import org.apache.guacamole.vault.conf.VaultAttributeService; import org.apache.guacamole.vault.conf.VaultAttributeService;
import org.apache.guacamole.vault.conf.VaultConfigurationService; import org.apache.guacamole.vault.conf.VaultConfigurationService;
import org.apache.guacamole.vault.ksm.secret.KsmClient; import org.apache.guacamole.vault.ksm.secret.KsmClient;
@@ -57,6 +60,7 @@ public class KsmAuthenticationProviderModule
// Bind services specific to Keeper Secrets Manager // Bind services specific to Keeper Secrets Manager
bind(KsmRecordService.class); bind(KsmRecordService.class);
bind(KsmAttributeService.class);
bind(VaultAttributeService.class).to(KsmAttributeService.class); bind(VaultAttributeService.class).to(KsmAttributeService.class);
bind(VaultConfigurationService.class).to(KsmConfigurationService.class); bind(VaultConfigurationService.class).to(KsmConfigurationService.class);
bind(VaultSecretService.class).to(KsmSecretService.class); bind(VaultSecretService.class).to(KsmSecretService.class);
@@ -66,6 +70,11 @@ public class KsmAuthenticationProviderModule
install(new FactoryModuleBuilder() install(new FactoryModuleBuilder()
.implement(KsmClient.class, KsmClient.class) .implement(KsmClient.class, KsmClient.class)
.build(KsmClientFactory.class)); .build(KsmClientFactory.class));
// Bind factory for creating KsmUsers
install(new FactoryModuleBuilder()
.implement(KsmUser.class, KsmUser.class)
.build(KsmUserFactory.class));
} }
} }

View File

@@ -19,15 +19,30 @@
package org.apache.guacamole.vault.ksm.conf; package org.apache.guacamole.vault.ksm.conf;
import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; 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.Form;
import org.apache.guacamole.form.TextField; import org.apache.guacamole.form.TextField;
import org.apache.guacamole.language.TranslatableGuacamoleClientException;
import org.apache.guacamole.vault.conf.VaultAttributeService; import org.apache.guacamole.vault.conf.VaultAttributeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.keepersecurity.secretsManager.core.InMemoryStorage;
import com.keepersecurity.secretsManager.core.SecretsManager;
import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
/** /**
* A service that exposes KSM-specific attributes, allowing setting KSM * A service that exposes KSM-specific attributes, allowing setting KSM
@@ -36,28 +51,302 @@ import com.google.inject.Singleton;
@Singleton @Singleton
public class KsmAttributeService implements VaultAttributeService { public class KsmAttributeService implements VaultAttributeService {
/**
* Logger for this class.
*/
private static final Logger logger = LoggerFactory.getLogger(KsmAttributeService.class);
/**
* Service for retrieving KSM configuration details.
*/
@Inject
private KsmConfigurationService configurationService;
/**
* A singleton ObjectMapper for converting a Map to a JSON string when
* generating a base64-encoded JSON KSM config blob.
*/
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* All expected fields in the KSM configuration JSON blob.
*/
private static final List<String> EXPECTED_KSM_FIELDS = (
Collections.unmodifiableList(Arrays.asList(
SecretsManager.KEY_HOSTNAME,
SecretsManager.KEY_CLIENT_ID,
SecretsManager.KEY_PRIVATE_KEY,
SecretsManager.KEY_CLIENT_KEY,
SecretsManager.KEY_APP_KEY,
SecretsManager.KEY_OWNER_PUBLIC_KEY,
SecretsManager.KEY_SERVER_PUBIC_KEY_ID
)));
/** /**
* The name of the attribute which can contain a KSM configuration blob * 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"; public static final String KSM_CONFIGURATION_ATTRIBUTE = "ksm-config";
/**
* The KSM 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 KSM_ATTRIBUTE_PLACEHOLDER_VALUE = "**********";
/** /**
* All attributes related to configuring the KSM vault on a * 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", public static final Form KSM_CONFIGURATION_FORM = new Form("ksm-config",
Arrays.asList(new TextField(KSM_CONFIGURATION_ATTRIBUTE))); Arrays.asList(new TextField(KSM_CONFIGURATION_ATTRIBUTE)));
/** /**
* All KSM-specific connection group attributes, organized by form. * All KSM-specific attributes for users, connections, or connection groups, organized by form.
*/ */
public static final Collection<Form> KSM_CONNECTION_GROUP_ATTRIBUTES = public static final Collection<Form> KSM_ATTRIBUTES =
Collections.unmodifiableCollection(Arrays.asList(KSM_CONFIGURATION_FORM)); 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<Form> KSM_CONNECTION_ATTRIBUTES =
Collections.unmodifiableCollection(Arrays.asList(KSM_CONNECTION_FORM));
@Override
public Collection<Form> getConnectionAttributes() {
return KSM_CONNECTION_ATTRIBUTES;
}
@Override @Override
public Collection<Form> getConnectionGroupAttributes() { public Collection<Form> getConnectionGroupAttributes() {
return KSM_CONNECTION_GROUP_ATTRIBUTES; return KSM_ATTRIBUTES;
} }
@Override
public Collection<Form> getUserAttributes() {
return KSM_ATTRIBUTES;
}
@Override
public Collection<Form> getUserPreferenceAttributes() {
try {
// Expose the user attributes IFF user-level KSM configuration is enabled
return configurationService.getAllowUserConfig() ? KSM_ATTRIBUTES : Collections.emptyList();
}
catch (GuacamoleException e) {
logger.warn(
"Unable to determine if user preference attributes "
+ "should be exposed due to config parsing error: {}.", e.getMessage());
logger.debug(
"Config parsing error prevented checking user preference configuration",
e);
// If the configuration can't be parsed, default to not exposing the attribute
return Collections.emptyList();
}
}
/**
* Sanitize the value of the provided KSM 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 ksmAttributeValue
* The KSM configuration attribute value to sanitize.
*
* @return
* The sanitized KSM configuration attribute value, stripped of any
* sensitive information.
*/
public static String sanitizeKsmAttributeValue(String ksmAttributeValue) {
// Any non-empty values may contain sensitive information, and should
// be replaced by the safe placeholder value
if (ksmAttributeValue != null && !ksmAttributeValue.trim().isEmpty())
return KSM_ATTRIBUTE_PLACEHOLDER_VALUE;
// If the configuration value is empty, expose a null value
else
return null;
}
/**
* Return true if the provided input is a valid base64-encoded string,
* false otherwise.
*
* @param input
* The string to check if base-64 encoded.
*
* @return
* true if the provided input is a valid base64-encoded string,
* false otherwise.
*/
private static boolean isBase64(String input) {
try {
Base64.getDecoder().decode(input);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
/**
* Given a map of attribute values, check for the presence of the
* KSM_CONFIGURATION_ATTRIBUTE attribute. If it's set, check if it's a valid
* KSM one-time token. If so, attempt to translate it to a base-64-encoded
* json KSM config blob. If it's already a KSM config blob, validate it as
* config blob. If either validation fails, a GuacamoleException will be thrown.
* The processed attribute values will be returned.
*
* @param attributes
* The attributes for which the KSM configuration attribute
* parsing/validation should be performed.
*
* @throws GuacamoleException
* If the KSM_CONFIGURATION_ATTRIBUTE is set, but fails to validate as
* either a KSM one-time-token, or a KSM base64-encoded JSON config blob.
*/
public Map<String, String> processAttributes(
Map<String, String> attributes) throws GuacamoleException {
// Get the value of the KSM config attribute in the provided map
String ksmConfigValue = attributes.get(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
// If the placeholder value was provided, do not update the attribute
if (KsmAttributeService.KSM_ATTRIBUTE_PLACEHOLDER_VALUE.equals(ksmConfigValue)) {
// Remove the attribute from the map so it won't be updated
attributes = new HashMap<>(attributes);
attributes.remove(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
}
// Check if the attribute is set to a non-empty value
else if (ksmConfigValue != null && !ksmConfigValue.trim().isEmpty()) {
// If it's already base64-encoded, it's a KSM configuration blob,
// so validate it immediately
if (isBase64(ksmConfigValue)) {
// Attempt to validate the config as a base64-econded KSM config blob
try {
KsmConfig.parseKsmConfig(ksmConfigValue);
// If it validates, the entity can be left alone - it's already valid
return attributes;
}
catch (GuacamoleException exception) {
// If the parsing attempt fails, throw a translatable error for display
// on the frontend
throw new TranslatableGuacamoleClientException(
"Invalid base64-encoded JSON KSM config provided for "
+ KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
"CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_CONFIG_BLOB",
exception);
}
}
// It wasn't a valid base64-encoded string, it should be a one-time token, so
// attempt to validat it as such, and if valid, update the attribute to the
// base64 config blob generated by the token
try {
// Create an initially empty storage to be populated using the one-time token
InMemoryStorage storage = new InMemoryStorage();
// Populate the in-memory storage using the one-time-token
SecretsManager.initializeStorage(storage, ksmConfigValue, null);
// Create an options object using the values we extracted from the one-time token
SecretsManagerOptions options = new SecretsManagerOptions(
storage, null,
configurationService.getAllowUnverifiedCertificate());
// Attempt to fetch secrets using the options we created. This will both validate
// that the configuration works, and potentially populate missing fields that the
// initializeStorage() call did not set.
SecretsManager.getSecrets(options);
// Create a map to store the extracted values from the KSM storage
Map<String, String> configMap = new HashMap<>();
// Go through all the expected fields, extract from the KSM storage,
// and write to the newly created map
EXPECTED_KSM_FIELDS.forEach(configKey -> {
// Only write the value into the new map if non-null
String value = storage.getString(configKey);
if (value != null)
configMap.put(configKey, value);
});
// JSON-encode the value, and then base64 encode that to get the format
// that KSM would expect
String jsonString = objectMapper.writeValueAsString(configMap);
String base64EncodedJson = Base64.getEncoder().encodeToString(
jsonString.getBytes(StandardCharsets.UTF_8));
// Finally, try to parse the newly generated token as a KSM config. If this
// works, the config should be fully functional
KsmConfig.parseKsmConfig(base64EncodedJson);
// Make a copy of the existing attributes, modifying just the value for
// KSM_CONFIGURATION_ATTRIBUTE
attributes = new HashMap<>(attributes);
attributes.put(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, base64EncodedJson);
}
// The KSM SDK only throws raw Exceptions, so we can't be more specific
catch (Exception exception) {
// If the parsing attempt fails, throw a translatable error for display
// on the frontend
throw new TranslatableGuacamoleClientException(
"Invalid one-time KSM token provided for "
+ KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
"CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_ONE_TIME_TOKEN",
exception);
}
}
return attributes;
}
} }

View File

@@ -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 * Whether windows domains should be stripped off from usernames that are
* read from the KSM vault. * read from the KSM vault.
@@ -138,6 +149,21 @@ public class KsmConfigurationService extends VaultConfigurationService {
return environment.getProperty(ALLOW_UNVERIFIED_CERT, false); 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 @Override
public boolean getSplitWindowsUsernames() throws GuacamoleException { public boolean getSplitWindowsUsernames() throws GuacamoleException {
return environment.getProperty(STRIP_WINDOWS_DOMAINS, false); return environment.getProperty(STRIP_WINDOWS_DOMAINS, false);

View File

@@ -34,7 +34,6 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -45,10 +44,12 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import javax.annotation.Nullable;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.User;
import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
import org.apache.guacamole.vault.secret.WindowsUsername; import org.apache.guacamole.vault.secret.WindowsUsername;
import org.apache.guacamole.vault.ksm.GuacamoleExceptionSupplier;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -595,6 +596,38 @@ public class KsmClient {
* is invalid. * is invalid.
*/ */
public Future<String> getSecret(String notation) throws GuacamoleException { public Future<String> getSecret(String notation) throws GuacamoleException {
return getSecret(notation, null);
}
/**
* Returns the value of the secret stored within Keeper Secrets Manager and
* represented by the given Keeper notation. Keeper notation locates the
* value of a specific field, custom field, or file associated with a
* specific record. See: https://docs.keeper.io/secrets-manager/secrets-manager/about/keeper-notation
* If a fallbackFunction is provided, it will be invoked to generate
* a return value in the case where no secret is found with the given
* keeper notation.
*
* @param notation
* The Keeper 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 Keeper notation, or null if there is no such secret.
*
* @throws GuacamoleException
* If the requested secret cannot be retrieved or the Keeper notation
* is invalid.
*/
public Future<String> getSecret(
String notation,
@Nullable GuacamoleExceptionSupplier<Future<String>> fallbackFunction)
throws GuacamoleException {
validateCache(); validateCache();
cacheLock.readLock().lock(); cacheLock.readLock().lock();
try { try {
@@ -614,6 +647,11 @@ public class KsmClient {
catch (Error e) { catch (Error e) {
logger.warn("Record \"{}\" does not exist.", notation); logger.warn("Record \"{}\" does not exist.", notation);
logger.debug("Retrieval of record by Keeper notation failed.", e); logger.debug("Retrieval of record by Keeper notation failed.", e);
// If the secret is not found, invoke the fallback function
if (fallbackFunction != null)
return fallbackFunction.get();
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }

View File

@@ -19,6 +19,7 @@
package org.apache.guacamole.vault.ksm.secret; package org.apache.guacamole.vault.ksm.secret;
import com.google.common.base.Objects;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.keepersecurity.secretsManager.core.KeeperRecord; import com.keepersecurity.secretsManager.core.KeeperRecord;
@@ -26,8 +27,11 @@ import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -38,15 +42,19 @@ import java.util.concurrent.Future;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import org.apache.guacamole.GuacamoleException; 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.Connectable;
import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.ConnectionGroup;
import org.apache.guacamole.net.auth.Directory; 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.net.auth.UserContext;
import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.protocol.GuacamoleConfiguration;
import org.apache.guacamole.token.TokenFilter; import org.apache.guacamole.token.TokenFilter;
import org.apache.guacamole.vault.ksm.GuacamoleExceptionSupplier;
import org.apache.guacamole.vault.ksm.conf.KsmAttributeService; import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService; import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
import org.apache.guacamole.vault.ksm.user.KsmDirectory;
import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.secret.VaultSecretService;
import org.apache.guacamole.vault.secret.WindowsUsername; import org.apache.guacamole.vault.secret.WindowsUsername;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -144,7 +152,24 @@ public class KsmSecretService implements VaultSecretService {
// Attempt to find a KSM config for this connection or group // Attempt to find a KSM config for this connection or group
String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable); String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
return getClient(ksmConfig).getSecret(name); return getClient(ksmConfig).getSecret(name, new GuacamoleExceptionSupplier<Future<String>>() {
@Override
public Future<String> get() throws GuacamoleException {
// Get the user-supplied KSM config, if allowed by config and
// set by the user
String userKsmConfig = getUserKSMConfig(userContext, connectable);
// If the user config happens to be the same as admin-defined one,
// don't bother trying again
if (!Objects.equal(userKsmConfig, ksmConfig))
return getClient(userKsmConfig).getSecret(name);
return CompletableFuture.completedFuture(null);
}
});
} }
@Override @Override
@@ -276,7 +301,12 @@ public class KsmSecretService implements VaultSecretService {
Set<String> observedIdentifiers = new HashSet<>(); Set<String> observedIdentifiers = new HashSet<>();
observedIdentifiers.add(parentIdentifier); observedIdentifiers.add(parentIdentifier);
Directory<ConnectionGroup> connectionGroupDirectory = userContext.getConnectionGroupDirectory(); // Use the unwrapped connection group directory to avoid KSM config
// value sanitization
Directory<ConnectionGroup> connectionGroupDirectory = (
(KsmDirectory<ConnectionGroup>) userContext.getConnectionGroupDirectory()
).getUnderlyingDirectory();
while (true) { while (true) {
// Fetch the parent group, if one exists // Fetch the parent group, if one exists
@@ -304,18 +334,103 @@ public class KsmSecretService implements VaultSecretService {
} }
@Override /**
public Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable, * Returns true if user-level KSM configuration is enabled for the given
GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException { * 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) {
Map<String, Future<String>> tokens = new HashMap<>(); // User-level config is enabled IFF the appropriate attribute is set to true
Map<String, String> parameters = config.getParameters(); if (connectable instanceof Attributes)
return KsmAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get(
KsmAttributeService.KSM_USER_CONFIG_ENABLED_ATTRIBUTE));
// Attempt to find a KSM config for this connection or group // If there's no attributes to check, the user config cannot be enabled
String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable); return false;
// Get a client instance for this KSM config }
KsmClient ksm = getClient(ksmConfig);
/**
* Return the KSM config blob for the current user IFF user KSM configs
* are enabled globally, and are enabled for the given connectable. If no
* KSM config exists for the given user or KSM 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 conneciton which will be checked to see if user KSM configs
* are enabled.
*
* @return
* The base64 encoded KSM config blob for the current user if one
* exists, and if user KSM configs are enabled globally and for the
* provided connectable.
*
* @throws GuacamoleException
* If an error occurs while attempting to fetch the KSM config.
*/
private String getUserKSMConfig(
UserContext userContext, Connectable connectable) throws GuacamoleException {
// If user KSM configs are enabled globally, and for the given connectable,
// return the user-specific KSM config, if one exists
if (confService.getAllowUserConfig() && isKsmUserConfigEnabled(connectable)) {
// Get the underlying user, to avoid the KSM config sanitization
User self = (
((KsmDirectory<User>) userContext.getUserDirectory())
.getUnderlyingDirectory().get(userContext.self().getIdentifier()));
return self.getAttributes().get(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
}
// If user-specific KSM config is disabled globally or for the given
// connectable, return null to indicate that no user config exists
return null;
}
/**
* Use the provided KSM client to add parameter tokens tokens to the
* provided token map. The supplied filter will be used to replace
* existing tokens in the provided connection parameters before KSM
* record lookup. The supplied GuacamoleConfiguration instance will
* be used to check the protocol, in case RDP-specific behavior is
* needed.
* @param config
* The GuacamoleConfiguration associated with the Connectable for which
* tokens are being added.
*
* @param ksm
* The KSM client to use when fetching records.
*
* @param tokens
* The tokens to which any fetched KSM record values should be added.
*
* @param parameters
* The connection parameters associated with the Connectable for which
* tokens are being added.
*
* @throws GuacamoleException
* If an error occurs while attempting to fetch KSM records or check
* configuration settings.
*/
private void addConnectableTokens(
GuacamoleConfiguration config, KsmClient ksm, Map<String, Future<String>> tokens,
Map<String, String> parameters, TokenFilter filter) throws GuacamoleException {
// Retrieve and define server-specific tokens, if any // Retrieve and define server-specific tokens, if any
String hostname = parameters.get("hostname"); String hostname = parameters.get("hostname");
@@ -372,7 +487,6 @@ public class KsmSecretService implements VaultSecretService {
ksm.getRecordByLogin( ksm.getRecordByLogin(
filter.filter(gatewayUsername), filter.filter(gatewayUsername),
filteredGatewayDomain)); filteredGatewayDomain));
} }
else { else {
@@ -386,6 +500,27 @@ public class KsmSecretService implements VaultSecretService {
addRecordTokens(tokens, "KEEPER_USER_", addRecordTokens(tokens, "KEEPER_USER_",
ksm.getRecordByLogin(filter.filter(username), null)); ksm.getRecordByLogin(filter.filter(username), null));
} }
}
@Override
public Map<String, Future<String>> getTokens(UserContext userContext, Connectable connectable,
GuacamoleConfiguration config, TokenFilter filter) throws GuacamoleException {
Map<String, Future<String>> tokens = new HashMap<>();
Map<String, String> parameters = config.getParameters();
// Only use the user-specific KSM config if explicitly enabled in the global
// configuration, AND for the specific connectable being connected to
String userKsmConfig = getUserKSMConfig(userContext, connectable);
if (userKsmConfig != null && !userKsmConfig.trim().isEmpty())
addConnectableTokens(
config, getClient(userKsmConfig), tokens, parameters, filter);
// Add connection group or globally defined tokens after the user-specific
// ones to ensure that the user config will be overriden on collision
String ksmConfig = getConnectionGroupKsmConfig(userContext, connectable);
addConnectableTokens(
config, getClient(ksmConfig), tokens, parameters, filter);
return tokens; return tokens;

View File

@@ -0,0 +1,70 @@
/*
* 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.Map;
import org.apache.guacamole.net.auth.DelegatingConnection;
import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
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. This ensures that any such field will always
* be displayed to the user when editing a connection through the UI.
*/
public class KsmConnection 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.
*/
KsmConnection(Connection connection) {
super(connection);
}
/**
* Return the underlying wrapped connection record.
*
* @return
* The wrapped connection record.
*/
Connection getUnderlyingConnection() {
return getDelegateConnection();
}
@Override
public Map<String, String> getAttributes() {
// Make a copy of the existing map
Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
// Add the user-config-enabled configuration attribute
attributes.putIfAbsent(KsmAttributeService.KSM_USER_CONFIG_ENABLED_ATTRIBUTE, null);
return attributes;
}
}

View File

@@ -19,50 +19,42 @@
package org.apache.guacamole.vault.ksm.user; package org.apache.guacamole.vault.ksm.user;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.ConnectionGroup;
import org.apache.guacamole.net.auth.DelegatingConnectionGroup; import org.apache.guacamole.net.auth.DelegatingConnectionGroup;
import org.apache.guacamole.vault.ksm.conf.KsmAttributeService; import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
import com.google.common.collect.Maps;
/** /**
* A KSM-specific connection group implementation that always exposes * A KSM-specific connection group implementation that always exposes
* the KSM_CONFIGURATION_ATTRIBUTE attribute, even when no value is set. * the KSM_CONFIGURATION_ATTRIBUTE attribute, even when no value is set.
* This ensures that the attribute will always show up in the UI, even * The value of the attribute will be sanitized if non-empty. This ensures
* for connection groups that don't already have it set. * 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 KsmConnectionGroup extends DelegatingConnectionGroup { public class KsmConnectionGroup extends DelegatingConnectionGroup {
/** /**
* Create a new KsmConnectionGroup instance, wrapping the provided * Create a new KsmConnectionGroup wrapping the provided ConnectionGroup record.
* ConnectionGroup.
* *
* @param connectionGroup * @param connectionGroup
* The ConnectionGroup instance to wrap. * The ConnectionGroup record to wrap.
*/ */
public KsmConnectionGroup(ConnectionGroup connectionGroup) { KsmConnectionGroup(ConnectionGroup connectionGroup) {
// Wrap the provided connection group
super(connectionGroup); super(connectionGroup);
} }
@Override /**
public Map<String, String> getAttributes() { * Return the underlying wrapped connection group record.
*
// All attributes defined on the underlying connection group * @return
Map<String, String> attributes = super.getAttributes(); * The wrapped connection group record.
*/
// If the attribute is already present, there's no need to add it - return ConnectionGroup getUnderlyingConnectionGroup() {
// the existing attributes as they are return getDelegateConnectionGroup();
if (attributes.containsKey(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE))
return attributes;
// Make a copy of the existing attributes and add KSM_CONFIGURATION_ATTRIBUTE
attributes = new HashMap<>(attributes);
attributes.put(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, null);
return attributes;
} }
/** /**
@@ -75,4 +67,20 @@ import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
return getDelegateConnectionGroup(); return getDelegateConnectionGroup();
} }
@Override
public Map<String, String> getAttributes() {
// Make a copy of the existing map
Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
// Sanitize the KSM configuration attribute, and ensure the attribute
// is always present
attributes.put(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE,
KsmAttributeService.sanitizeKsmAttributeValue(
attributes.get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE)));
return attributes;
}
} }

View File

@@ -0,0 +1,93 @@
/*
* 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.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
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 KSM-specific version of DecoratingDirectory that exposes the underlying
* directory for when it's needed.
*/
public abstract class KsmDirectory<ObjectType extends Identifiable>
extends DelegatingDirectory<ObjectType> {
/**
* Create a new KsmDirectory, delegating to the provided directory.
*
* @param directory
* The directory to delegate to.
*/
public KsmDirectory(Directory<ObjectType> directory) {
super(directory);
}
/**
* Returns the underlying directory that this DecoratingDirectory is
* delegating to.
*
* @return
* The underlying directory.
*/
public Directory<ObjectType> 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<ObjectType> getAll(Collection<String> identifiers)
throws GuacamoleException {
// Process and return each object from the wrapped directory
return super.getAll(identifiers).stream().map(
superObject -> wrap(superObject)
).collect(Collectors.toList());
}
}

View File

@@ -19,30 +19,17 @@
package org.apache.guacamole.vault.ksm.user; package org.apache.guacamole.vault.ksm.user;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
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.GuacamoleException;
import org.apache.guacamole.language.TranslatableGuacamoleClientException; import org.apache.guacamole.net.auth.Connection;
import org.apache.guacamole.net.auth.Attributes;
import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.ConnectionGroup;
import org.apache.guacamole.net.auth.DecoratingDirectory; import org.apache.guacamole.net.auth.DecoratingDirectory;
import org.apache.guacamole.net.auth.DelegatingDirectory;
import org.apache.guacamole.net.auth.Directory; 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.KsmAttributeService;
import org.apache.guacamole.vault.ksm.conf.KsmConfig;
import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
import org.apache.guacamole.vault.user.VaultDirectoryService; import org.apache.guacamole.vault.user.VaultDirectoryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.keepersecurity.secretsManager.core.InMemoryStorage;
import com.keepersecurity.secretsManager.core.SecretsManager;
import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
/** /**
* A KSM-specific vault directory service that wraps the connection group directory * A KSM-specific vault directory service that wraps the connection group directory
@@ -52,174 +39,43 @@ import com.keepersecurity.secretsManager.core.SecretsManagerOptions;
public class KsmDirectoryService extends VaultDirectoryService { public class KsmDirectoryService extends VaultDirectoryService {
/** /**
* Service for retrieving KSM configuration details. * A factory for constructing new KsmUser instances.
*/ */
@Inject @Inject
private KsmConfigurationService configurationService; private KsmUserFactory ksmUserFactory;
/** /**
* A singleton ObjectMapper for converting a Map to a JSON string when * Service for retrieving any custom attributes defined for the
* generating a base64-encoded JSON KSM config blob. * current vault implementation and processing of said attributes.
*/ */
private static final ObjectMapper objectMapper = new ObjectMapper(); @Inject
private KsmAttributeService attributeService;
/** @Override
* All expected fields in the KSM configuration JSON blob. public Directory<Connection> getConnectionDirectory(
*/ Directory<Connection> underlyingDirectory) throws GuacamoleException {
private static final List<String> EXPECTED_KSM_FIELDS = (
Collections.unmodifiableList(Arrays.asList(
SecretsManager.KEY_HOSTNAME,
SecretsManager.KEY_CLIENT_ID,
SecretsManager.KEY_PRIVATE_KEY,
SecretsManager.KEY_CLIENT_KEY,
SecretsManager.KEY_APP_KEY,
SecretsManager.KEY_OWNER_PUBLIC_KEY,
SecretsManager.KEY_SERVER_PUBIC_KEY_ID
)));
/** // A Connection directory that will decorate all connections with a
* Return true if the provided input is a valid base64-encoded string, // KsmConnection wrapper to ensure that all defined KSM fields will
* false otherwise. // be exposed in the connection group attributes.
* return new DecoratingDirectory<Connection>(underlyingDirectory) {
* @param input
* The string to check if base-64 encoded.
*
* @return
* true if the provided input is a valid base64-encoded string,
* false otherwise.
*/
private static boolean isBase64(String input) {
try { @Override
Base64.getDecoder().decode(input); protected Connection decorate(Connection connection) throws GuacamoleException {
return true;
} catch (IllegalArgumentException e) { // Wrap in a KsmConnection class to ensure that all defined KSM fields will be
return false; // present
} return new KsmConnection(connection);
} }
/** @Override
* Given an attributes-enabled entity, check for the presence of the protected Connection undecorate(Connection connection) throws GuacamoleException {
* KSM_CONFIGURATION_ATTRIBUTE attribute. If it's set, check if it's a valid
* KSM one-time token. If so, attempt to translate it to a base-64-encoded
* json KSM config blob, and set it back to the provided entity.
* If it's already a KSM config blob, validate it as config blob. If either
* validation fails, a GuacamoleException will be thrown.
*
* @param entity
* The attributes-enabled entity for which the KSM configuration
* attribute parsing/validation should be performed.
*
* @throws GuacamoleException
* If the KSM_CONFIGURATION_ATTRIBUTE is set, but fails to validate as
* either a KSM one-time-token, or a KSM base64-encoded JSON config blob.
*/
public void processAttributes(Attributes entity) throws GuacamoleException {
// By default, if the KSM config attribute isn't being set, pass the // Unwrap the KsmConnection
// provided attributes through without any changes return ((KsmConnection) connection).getUnderlyingConnection();
Map<String, String> attributes = entity.getAttributes();
// Get the value of the KSM config attribute in the provided map
String ksmConfigValue = attributes.get(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
// Check if the attribute is set to a non-empty value
if (ksmConfigValue != null && !ksmConfigValue.trim().isEmpty()) {
// If it's already base64-encoded, it's a KSM configuration blob,
// so validate it immediately
if (isBase64(ksmConfigValue)) {
// Attempt to validate the config as a base64-econded KSM config blob
try {
KsmConfig.parseKsmConfig(ksmConfigValue);
// If it validates, the entity can be left alone - it's already valid
return;
}
catch (GuacamoleException exception) {
// If the parsing attempt fails, throw a translatable error for display
// on the frontend
throw new TranslatableGuacamoleClientException(
"Invalid base64-encoded JSON KSM config provided for "
+ KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
"CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_CONFIG_BLOB",
exception);
}
}
// It wasn't a valid base64-encoded string, it should be a one-time token, so
// attempt to validat it as such, and if valid, update the attribute to the
// base64 config blob generated by the token
try {
// Create an initially empty storage to be populated using the one-time token
InMemoryStorage storage = new InMemoryStorage();
// Populate the in-memory storage using the one-time-token
SecretsManager.initializeStorage(storage, ksmConfigValue, null);
// Create an options object using the values we extracted from the one-time token
SecretsManagerOptions options = new SecretsManagerOptions(
storage, null,
configurationService.getAllowUnverifiedCertificate());
// Attempt to fetch secrets using the options we created. This will both validate
// that the configuration works, and potentially populate missing fields that the
// initializeStorage() call did not set.
SecretsManager.getSecrets(options);
// Create a map to store the extracted values from the KSM storage
Map<String, String> configMap = new HashMap<>();
// Go through all the expected fields, extract from the KSM storage,
// and write to the newly created map
EXPECTED_KSM_FIELDS.forEach(configKey -> {
// Only write the value into the new map if non-null
String value = storage.getString(configKey);
if (value != null)
configMap.put(configKey, value);
});
// JSON-encode the value, and then base64 encode that to get the format
// that KSM would expect
String jsonString = objectMapper.writeValueAsString(configMap);
String base64EncodedJson = Base64.getEncoder().encodeToString(
jsonString.getBytes(StandardCharsets.UTF_8));
// Finally, try to parse the newly generated token as a KSM config. If this
// works, the config should be fully functional
KsmConfig.parseKsmConfig(base64EncodedJson);
// Make a copy of the existing attributes, modifying just the value for
// KSM_CONFIGURATION_ATTRIBUTE
attributes = new HashMap<>(attributes);
attributes.put(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE, base64EncodedJson);
// Set the newly updated attributes back to the original object
entity.setAttributes(attributes);
}
// The KSM SDK only throws raw Exceptions, so we can't be more specific
catch (Exception exception) {
// If the parsing attempt fails, throw a translatable error for display
// on the frontend
throw new TranslatableGuacamoleClientException(
"Invalid one-time KSM token provided for "
+ KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE + " attribute",
"CONNECTION_GROUP_ATTRIBUTES.ERROR_INVALID_KSM_ONE_TIME_TOKEN",
exception);
}
} }
};
} }
@Override @Override
@@ -230,40 +86,99 @@ public class KsmDirectoryService extends VaultDirectoryService {
// validate KSM configurations, and translate one-time-tokens, if possible, // validate KSM configurations, and translate one-time-tokens, if possible,
// as well as ensuring that all ConnectionGroups returned include the // as well as ensuring that all ConnectionGroups returned include the
// KSM_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI. // KSM_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI.
return new DecoratingDirectory<ConnectionGroup>(underlyingDirectory) { // The value of the KSM_CONFIGURATION_ATTRIBUTE will be sanitized if set.
return new KsmDirectory<ConnectionGroup>(underlyingDirectory) {
@Override @Override
public void add(ConnectionGroup connectionGroup) throws GuacamoleException { public void add(ConnectionGroup connectionGroup) throws GuacamoleException {
// Check for the KSM config attribute and translate the one-time token // Process attribute values before saving
// if possible before adding connectionGroup.setAttributes(
processAttributes(connectionGroup); attributeService.processAttributes(
connectionGroup.getAttributes()));
super.add(connectionGroup); super.add(connectionGroup);
} }
@Override @Override
public void update(ConnectionGroup connectionGroup) throws GuacamoleException { public void update(ConnectionGroup connectionGroup) throws GuacamoleException {
// Check for the KSM config attribute and translate the one-time token // Unwrap the existing ConnectionGroup
// if possible before updating if (connectionGroup instanceof KsmConnectionGroup)
processAttributes(connectionGroup); connectionGroup = ((KsmConnectionGroup) connectionGroup).getUnderlyingConnectionGroup();
// Process attribute values before saving
connectionGroup.setAttributes(
attributeService.processAttributes(
connectionGroup.getAttributes()));
super.update(connectionGroup); super.update(connectionGroup);
}
@Override
protected ConnectionGroup decorate(ConnectionGroup connectionGroup) throws GuacamoleException {
// Wrap the existing connection group in a KsmConnection to ensure the presence of the
// KSM_CONFIGURATION_ATTRIBUTE attribute
return new KsmConnectionGroup(connectionGroup);
} }
@Override @Override
protected ConnectionGroup undecorate(ConnectionGroup connectionGroup) throws GuacamoleException { protected ConnectionGroup wrap(ConnectionGroup object) {
// Return the underlying connection group that the KsmConnectionGroup wraps // Do not process the ConnectionGroup further if it does not exist
return ((KsmConnectionGroup) connectionGroup).getUnderlyConnectionGroup(); if (object == null)
return null;
// Sanitize values when a ConnectionGroup is fetched from the directory
return new KsmConnectionGroup(object);
}
};
}
@Override
public Directory<User> getUserDirectory(
Directory<User> underlyingDirectory) throws GuacamoleException {
// A User directory that will intercept add and update calls to
// validate KSM configurations, and translate one-time-tokens, if possible
// Additionally, this directory will will decorate all users with a
// KsmUser wrapper to ensure that all defined KSM fields will be exposed
// in the user attributes. The value of the KSM_CONFIGURATION_ATTRIBUTE
// will be sanitized if set.
return new KsmDirectory<User>(underlyingDirectory) {
@Override
public void add(User user) throws GuacamoleException {
// Process attribute values before saving
user.setAttributes(
attributeService.processAttributes(
user.getAttributes()));
super.add(user);
}
@Override
public void update(User user) throws GuacamoleException {
// Unwrap the existing user
if (user instanceof KsmUser)
user = ((KsmUser) user).getUnderlyingUser();
// Process attribute values before saving
user.setAttributes(
attributeService.processAttributes(
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 ksmUserFactory.create(object);
} }

View File

@@ -0,0 +1,115 @@
/*
* 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.Map;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.net.auth.User;
import org.apache.guacamole.net.auth.DelegatingUser;
import org.apache.guacamole.vault.ksm.conf.KsmAttributeService;
import org.apache.guacamole.vault.ksm.conf.KsmConfigurationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.collect.Maps;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
/**
* A KSM-specific user implementation that exposes the
* KSM_CONFIGURATION_ATTRIBUTE attribute even if no value is set. but only
* if user-specific KSM 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 KsmUser extends DelegatingUser {
/**
* Logger for this class.
*/
private static final Logger logger = LoggerFactory.getLogger(KsmUser.class);
/**
* Service for retrieving KSM configuration details.
*/
@Inject
private KsmConfigurationService configurationService;
/**
* Create a new Ksmuser wrapping the provided User record.
*
* @param user
* The User record to wrap.
*/
@AssistedInject
KsmUser(@Assisted User user) {
super(user);
}
/**
* Return the underlying wrapped user record.
*
* @return
* The wrapped user record.
*/
User getUnderlyingUser() {
return getDelegateUser();
}
@Override
public Map<String, String> getAttributes() {
// Make a copy of the existing map
Map<String, String> attributes = Maps.newHashMap(super.getAttributes());
// Figure out if user-level KSM config is enabled
boolean userKsmConfigEnabled = false;
try {
userKsmConfigEnabled = configurationService.getAllowUserConfig();
} catch (GuacamoleException e) {
logger.warn(
"Disabling user KSM config due to exception: {}"
, e.getMessage());
logger.debug("Error looking up if user KSM config is enabled.", e);
}
// If user-specific KSM configuration is not enabled, do not expose the
// attribute at all
if (!userKsmConfigEnabled)
attributes.remove(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE);
else
// Sanitize the KSM configuration attribute, and ensure the attribute
// is always present
attributes.put(
KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE,
KsmAttributeService.sanitizeKsmAttributeValue(
attributes.get(KsmAttributeService.KSM_CONFIGURATION_ATTRIBUTE)));
return attributes;
}
}

View File

@@ -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.ksm.user;
import org.apache.guacamole.net.auth.User;
/**
* Factory for creating KSM-specific users, which wrap an underlying User.
*/
public interface KsmUserFactory {
/**
* Returns a new instance of a KsmUser, wrapping the provided underlying User.
*
* @param user
* The underlying User that should be wrapped.
*
* @return
* A new instance of a KsmUser, wrapping the provided underlying User.
*/
KsmUser create(User user);
}

View File

@@ -4,12 +4,22 @@
"NAME" : "Keeper Secrets Manager" "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" : { "CONNECTION_GROUP_ATTRIBUTES" : {
"SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager", "SECTION_HEADER_KSM_CONFIG" : "Keeper Secrets Manager",
"FIELD_HEADER_KSM_CONFIG" : "KSM Service Configuration ", "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_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." "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 "
} }
} }

View File

@@ -127,6 +127,11 @@ public class DelegatingUserContext implements UserContext {
return userContext.getUserAttributes(); return userContext.getUserAttributes();
} }
@Override
public Collection<Form> getUserPreferenceAttributes() {
return userContext.getUserPreferenceAttributes();
}
@Override @Override
public Collection<Form> getUserGroupAttributes() { public Collection<Form> getUserGroupAttributes() {
return userContext.getUserGroupAttributes(); return userContext.getUserGroupAttributes();

View File

@@ -20,6 +20,8 @@
package org.apache.guacamole.net.auth; package org.apache.guacamole.net.auth;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.form.Form; import org.apache.guacamole.form.Form;
@@ -211,6 +213,21 @@ public interface UserContext {
*/ */
Collection<Form> getUserAttributes(); Collection<Form> 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<Form> 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 * Retrieves a collection of all attributes applicable to user groups. This
* collection will contain only those attributes which the current user has * collection will contain only those attributes which the current user has

View File

@@ -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.<Form[]>}
* 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 * 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 * for user group objects, returning a promise that provides an array of

View File

@@ -33,16 +33,18 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe
controller: ['$scope', '$injector', function settingsPreferencesController($scope, $injector) { controller: ['$scope', '$injector', function settingsPreferencesController($scope, $injector) {
// Get required types // Get required types
var PermissionSet = $injector.get('PermissionSet'); const Form = $injector.get('Form');
const PermissionSet = $injector.get('PermissionSet');
// Required services // Required services
var $translate = $injector.get('$translate'); const $translate = $injector.get('$translate');
var authenticationService = $injector.get('authenticationService'); const authenticationService = $injector.get('authenticationService');
var guacNotification = $injector.get('guacNotification'); const guacNotification = $injector.get('guacNotification');
var permissionService = $injector.get('permissionService'); const permissionService = $injector.get('permissionService');
var preferenceService = $injector.get('preferenceService'); const preferenceService = $injector.get('preferenceService');
var requestService = $injector.get('requestService'); const requestService = $injector.get('requestService');
var userService = $injector.get('userService'); const schemaService = $injector.get('schemaService');
const userService = $injector.get('userService');
/** /**
* An action to be provided along with the object sent to * An action to be provided along with the object sent to
@@ -56,6 +58,27 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe
} }
}; };
/**
* An action which closes the current dialog, and refreshes
* the user data on dialog close.
*/
const ACKNOWLEDGE_ACTION_RELOAD = {
name : 'SETTINGS_PREFERENCES.ACTION_ACKNOWLEDGE',
// Handle action
callback : function acknowledgeCallback() {
userService.getUser(dataSource, username)
.then(user => $scope.user = user)
.then(() => guacNotification.showStatus(false));
}
};
/**
* The user being modified.
*
* @type User
*/
$scope.user = null;
/** /**
* The username of the current user. * The username of the current user.
* *
@@ -78,6 +101,15 @@ angular.module('settings').directive('guacSettingsPreferences', [function guacSe
*/ */
$scope.preferences = preferenceService.preferences; $scope.preferences = preferenceService.preferences;
/**
* 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 * The fields which should be displayed for choosing locale
* preferences. Each field name must be a property on * preferences. Each field name must be a property on
@@ -197,7 +229,33 @@ 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'
},
// Reload the user on successful save in case any attributes changed
actions : [ ACKNOWLEDGE_ACTION_RELOAD ]
}),
guacNotification.SHOW_REQUEST_ERROR);
}; };
// Fetch the user record
userService.getUser(dataSource, username).then(function saveUserData(user) {
$scope.user = user;
})
// Fetch all user preference attribute forms defined
schemaService.getUserPreferenceAttributes(dataSource).then(function saveAttributes(attributes) {
$scope.attributes = attributes;
});
}]
};
}]); }]);

View File

@@ -89,4 +89,13 @@
</div> </div>
</div> </div>
<!-- User attributes section -->
<div class="attributes" ng-show="attributes.length">
<guac-form namespace="'USER_ATTRIBUTES'" content="attributes"
model="user.attributes"></guac-form>
<!-- User attributes save button -->
<button ng-show="attributes.length" ng-click="saveUser()" class="save">{{'SETTINGS_PREFERENCES.ACTION_SAVE' | translate}}</button>
</div>
</div> </div>

View File

@@ -916,6 +916,7 @@
"ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE", "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
"ACTION_CANCEL" : "@:APP.ACTION_CANCEL", "ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
"ACTION_SAVE" : "@:APP.ACTION_SAVE",
"ACTION_UPDATE_PASSWORD" : "@:APP.ACTION_UPDATE_PASSWORD", "ACTION_UPDATE_PASSWORD" : "@:APP.ACTION_UPDATE_PASSWORD",
"DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR", "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.", "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_PASSWORD_CHANGED" : "Password changed.",
"INFO_PREFERENCE_ATTRIBUTES_CHANGED" : "User settings saved.",
"NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE", "NAME_INPUT_METHOD_NONE" : "@:CLIENT.NAME_INPUT_METHOD_NONE",
"NAME_INPUT_METHOD_OSK" : "@:CLIENT.NAME_INPUT_METHOD_OSK", "NAME_INPUT_METHOD_OSK" : "@:CLIENT.NAME_INPUT_METHOD_OSK",

View File

@@ -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<Form> getUserPreferenceAttributes()
throws GuacamoleException {
// Retrieve all possible user preference attributes
return userContext.getUserPreferenceAttributes();
}
/** /**
* Retrieves the possible attributes of a user group object. * Retrieves the possible attributes of a user group object.
* *

View File

@@ -59,10 +59,23 @@ public class UserObjectTranslator
public void filterExternalObject(UserContext userContext, APIUser object) public void filterExternalObject(UserContext userContext, APIUser object)
throws GuacamoleException { throws GuacamoleException {
// Filter object attributes by defined schema // If a user is editing themselves ...
if (object.getUsername().equals(userContext.self().getIdentifier())) {
// ... they may only edit preference attributes
object.setAttributes(filterAttributes(userContext.getUserPreferenceAttributes(),
object.getAttributes()));
}
else {
// In all other cases, filter object attributes by defined schema
object.setAttributes(filterAttributes(userContext.getUserAttributes(), object.setAttributes(filterAttributes(userContext.getUserAttributes(),
object.getAttributes())); object.getAttributes()));
} }
}
} }

View File

@@ -21,6 +21,7 @@ package org.apache.guacamole.rest.user;
import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject; import com.google.inject.assistedinject.AssistedInject;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@@ -145,9 +146,15 @@ public class UserResource
@Override @Override
public void updateObject(APIUser modifiedObject) throws GuacamoleException { public void updateObject(APIUser modifiedObject) throws GuacamoleException {
// A user may not use this endpoint to modify himself // A user may not use this endpoint to update their password
if (userContext.self().getIdentifier().equals(modifiedObject.getUsername())) User currentUser = userContext.self();
throw new GuacamoleSecurityException("Permission denied."); if (
currentUser.getIdentifier().equals(modifiedObject.getUsername())
&& modifiedObject.getPassword() != null) {
throw new GuacamoleSecurityException(
"Permission denied. The password update endpoint must"
+ " be used to change the current user's password.");
}
super.updateObject(modifiedObject); super.updateObject(modifiedObject);