GUACAMOLE-622: Merge increase FailoverGuacamoleSocket instruction queue limit.
diff --git a/guacamole-common-js/src/main/webapp/modules/Tunnel.js b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
index 52bd20a..24ffaa8 100644
--- a/guacamole-common-js/src/main/webapp/modules/Tunnel.js
+++ b/guacamole-common-js/src/main/webapp/modules/Tunnel.js
@@ -74,6 +74,17 @@
     };
 
     /**
+     * Returns whether this tunnel is currently connected.
+     *
+     * @returns {Boolean}
+     *     true if this tunnel is currently connected, false otherwise.
+     */
+    this.isConnected = function isConnected() {
+        return this.state === Guacamole.Tunnel.State.OPEN
+            || this.state === Guacamole.Tunnel.State.UNSTABLE;
+    };
+
+    /**
      * The current state of this tunnel.
      * 
      * @type {Number}
@@ -142,7 +153,8 @@
  * use by tunnel implementations. The value of this opcode is guaranteed to be
  * the empty string (""). Tunnel implementations may use this opcode for any
  * purpose. It is currently used by the HTTP tunnel to mark the end of the HTTP
- * response, and by the WebSocket tunnel to transmit the tunnel UUID.
+ * response, and by the WebSocket tunnel to transmit the tunnel UUID and send
+ * connection stability test pings/responses.
  *
  * @constant
  * @type {String}
@@ -247,6 +259,25 @@
     var unstableTimeout = null;
 
     /**
+     * The current connection stability test ping interval ID, if any. This
+     * will only be set upon successful connection.
+     *
+     * @private
+     * @type {Number}
+     */
+    var pingInterval = null;
+
+    /**
+     * The number of milliseconds to wait between connection stability test
+     * pings.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var PING_FREQUENCY = 500;
+
+    /**
      * Additional headers to be sent in tunnel requests. This dictionary can be
      * populated with key/value header pairs to pass information such as authentication
      * tokens, etc.
@@ -315,6 +346,9 @@
         window.clearTimeout(receive_timeout);
         window.clearTimeout(unstableTimeout);
 
+        // Cease connection test pings
+        window.clearInterval(pingInterval);
+
         // Ignore if already closed
         if (tunnel.state === Guacamole.Tunnel.State.CLOSED)
             return;
@@ -342,7 +376,7 @@
     this.sendMessage = function() {
 
         // Do not attempt to send messages if not connected
-        if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
+        if (!tunnel.isConnected())
             return;
 
         // Do not attempt to send empty messages
@@ -384,7 +418,7 @@
     function sendPendingMessages() {
 
         // Do not attempt to send messages if not connected
-        if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
+        if (!tunnel.isConnected())
             return;
 
         if (outputMessageBuffer.length > 0) {
@@ -401,6 +435,8 @@
             message_xmlhttprequest.onreadystatechange = function() {
                 if (message_xmlhttprequest.readyState === 4) {
 
+                    reset_timeout();
+
                     // If an error occurs during send, handle it
                     if (message_xmlhttprequest.status !== 200)
                         handleHTTPTunnelError(message_xmlhttprequest);
@@ -462,7 +498,7 @@
         function parseResponse() {
 
             // Do not handle responses if not connected
-            if (tunnel.state !== Guacamole.Tunnel.State.OPEN) {
+            if (!tunnel.isConnected()) {
                 
                 // Clean up interval if polling
                 if (interval !== null)
@@ -675,6 +711,11 @@
             // Mark as open
             tunnel.setState(Guacamole.Tunnel.State.OPEN);
 
+            // Ping tunnel endpoint regularly to test connection stability
+            pingInterval = setInterval(function sendPing() {
+                tunnel.sendMessage("nop");
+            }, PING_FREQUENCY);
+
             // Start reading data
             handleResponse(makeRequest());
 
@@ -732,6 +773,15 @@
     var unstableTimeout = null;
 
     /**
+     * The current connection stability test ping interval ID, if any. This
+     * will only be set upon successful connection.
+     *
+     * @private
+     * @type {Number}
+     */
+    var pingInterval = null;
+
+    /**
      * The WebSocket protocol corresponding to the protocol used for the current
      * location.
      * @private
@@ -741,6 +791,16 @@
         "https:": "wss:"
     };
 
+    /**
+     * The number of milliseconds to wait between connection stability test
+     * pings.
+     *
+     * @private
+     * @constant
+     * @type {Number}
+     */
+    var PING_FREQUENCY = 500;
+
     // Transform current URL to WebSocket URL
 
     // If not already a websocket URL
@@ -817,6 +877,9 @@
         window.clearTimeout(receive_timeout);
         window.clearTimeout(unstableTimeout);
 
+        // Cease connection test pings
+        window.clearInterval(pingInterval);
+
         // Ignore if already closed
         if (tunnel.state === Guacamole.Tunnel.State.CLOSED)
             return;
@@ -835,7 +898,7 @@
     this.sendMessage = function(elements) {
 
         // Do not attempt to send messages if not connected
-        if (tunnel.state !== Guacamole.Tunnel.State.OPEN)
+        if (!tunnel.isConnected())
             return;
 
         // Do not attempt to send empty messages
@@ -881,6 +944,13 @@
 
         socket.onopen = function(event) {
             reset_timeout();
+
+            // Ping tunnel endpoint regularly to test connection stability
+            pingInterval = setInterval(function sendPing() {
+                tunnel.sendMessage(Guacamole.Tunnel.INTERNAL_DATA_OPCODE,
+                    "ping", new Date().getTime());
+            }, PING_FREQUENCY);
+
         };
 
         socket.onclose = function(event) {
@@ -945,7 +1015,7 @@
                     var opcode = elements.shift();
 
                     // Update state and UUID when first instruction received
-                    if (tunnel.state !== Guacamole.Tunnel.State.OPEN) {
+                    if (tunnel.state === Guacamole.Tunnel.State.CONNECTING) {
 
                         // Associate tunnel UUID if received
                         if (opcode === Guacamole.Tunnel.INTERNAL_DATA_OPCODE)
diff --git a/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java b/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
index 0e02622..772ce64 100644
--- a/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
+++ b/guacamole-common/src/main/java/org/apache/guacamole/websocket/GuacamoleWebSocketTunnelEndpoint.java
@@ -20,6 +20,7 @@
 package org.apache.guacamole.websocket;
 
 import java.io.IOException;
+import java.util.List;
 import javax.websocket.CloseReason;
 import javax.websocket.CloseReason.CloseCode;
 import javax.websocket.Endpoint;
@@ -36,6 +37,8 @@
 import org.apache.guacamole.net.GuacamoleTunnel;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleConnectionClosedException;
+import org.apache.guacamole.protocol.FilteredGuacamoleWriter;
+import org.apache.guacamole.protocol.GuacamoleFilter;
 import org.apache.guacamole.protocol.GuacamoleInstruction;
 import org.apache.guacamole.protocol.GuacamoleStatus;
 import org.slf4j.Logger;
@@ -55,16 +58,32 @@
     private static final int BUFFER_SIZE = 8192;
 
     /**
+     * The opcode of the instruction used to indicate a connection stability
+     * test ping request or response. Note that this instruction is
+     * encapsulated within an internal tunnel instruction (with the opcode
+     * being the empty string), thus this will actually be the value of the
+     * first element of the received instruction.
+     */
+    private static final String PING_OPCODE = "ping";
+
+    /**
      * Logger for this class.
      */
     private final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelEndpoint.class);
 
     /**
      * The underlying GuacamoleTunnel. WebSocket reads/writes will be handled
-     * as reads/writes to this tunnel.
+     * as reads/writes to this tunnel. This value may be null if no connection
+     * has been established.
      */
     private GuacamoleTunnel tunnel;
-    
+
+    /**
+     * Remote (client) side of this connection. This value will always be
+     * non-null if tunnel is non-null.
+     */
+    private RemoteEndpoint.Basic remote;
+
     /**
      * Sends the numeric Guacaomle Status Code and Web Socket
      * code and closes the connection.
@@ -108,6 +127,52 @@
     }
 
     /**
+     * Sends a Guacamole instruction along the outbound WebSocket connection to
+     * the connected Guacamole client. If an instruction is already in the
+     * process of being sent by another thread, this function will block until
+     * in-progress instructions are complete.
+     *
+     * @param instruction
+     *     The instruction to send.
+     *
+     * @throws IOException
+     *     If an I/O error occurs preventing the given instruction from being
+     *     sent.
+     */
+    private void sendInstruction(String instruction)
+            throws IOException {
+
+        // NOTE: Synchronization on the non-final remote field here is
+        // intentional. The remote (the outbound websocket connection) is only
+        // sensitive to simultaneous attempts to send messages with respect to
+        // itself. If the remote changes, then the outbound websocket
+        // connection has changed, and synchronization need only be performed
+        // in context of the new remote.
+        synchronized (remote) {
+            remote.sendText(instruction);
+        }
+
+    }
+
+    /**
+     * Sends a Guacamole instruction along the outbound WebSocket connection to
+     * the connected Guacamole client. If an instruction is already in the
+     * process of being sent by another thread, this function will block until
+     * in-progress instructions are complete.
+     *
+     * @param instruction
+     *     The instruction to send.
+     *
+     * @throws IOException
+     *     If an I/O error occurs preventing the given instruction from being
+     *     sent.
+     */
+    private void sendInstruction(GuacamoleInstruction instruction)
+            throws IOException {
+        sendInstruction(instruction.toString());
+    }
+
+    /**
      * Returns a new tunnel for the given session. How this tunnel is created
      * or retrieved is implementation-dependent.
      *
@@ -126,6 +191,9 @@
     @OnOpen
     public void onOpen(final Session session, EndpointConfig config) {
 
+        // Store underlying remote for future use via sendInstruction()
+        remote = session.getBasicRemote();
+
         try {
 
             // Get tunnel
@@ -157,11 +225,6 @@
         // Prepare read transfer thread
         Thread readThread = new Thread() {
 
-            /**
-             * Remote (client) side of this connection
-             */
-            private final RemoteEndpoint.Basic remote = session.getBasicRemote();
-                
             @Override
             public void run() {
 
@@ -172,10 +235,10 @@
                 try {
 
                     // Send tunnel UUID
-                    remote.sendText(new GuacamoleInstruction(
+                    sendInstruction(new GuacamoleInstruction(
                         GuacamoleTunnel.INTERNAL_DATA_OPCODE,
                         tunnel.getUUID().toString()
-                    ).toString());
+                    ));
 
                     try {
 
@@ -187,7 +250,7 @@
 
                             // Flush if we expect to wait or buffer is getting full
                             if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
-                                remote.sendText(buffer.toString());
+                                sendInstruction(buffer.toString());
                                 buffer.setLength(0);
                             }
 
@@ -239,7 +302,43 @@
         if (tunnel == null)
             return;
 
-        GuacamoleWriter writer = tunnel.acquireWriter();
+        // Filter received instructions, handling tunnel-internal instructions
+        // without passing through to guacd
+        GuacamoleWriter writer = new FilteredGuacamoleWriter(tunnel.acquireWriter(), new GuacamoleFilter() {
+
+            @Override
+            public GuacamoleInstruction filter(GuacamoleInstruction instruction)
+                    throws GuacamoleException {
+
+                // Filter out all tunnel-internal instructions
+                if (instruction.getOpcode().equals(GuacamoleTunnel.INTERNAL_DATA_OPCODE)) {
+
+                    // Respond to ping requests
+                    List<String> args = instruction.getArgs();
+                    if (args.size() >= 2 && args.get(0).equals(PING_OPCODE)) {
+
+                        try {
+                            sendInstruction(new GuacamoleInstruction(
+                                GuacamoleTunnel.INTERNAL_DATA_OPCODE,
+                                PING_OPCODE, args.get(1)
+                            ));
+                        }
+                        catch (IOException e) {
+                            logger.debug("Unable to send \"ping\" response for WebSocket tunnel.", e);
+                        }
+
+                    }
+
+                    return null;
+
+                }
+
+                // Pass through all non-internal instructions untouched
+                return instruction;
+
+            }
+
+        });
 
         try {
             // Write received message
diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/permission/SystemPermission.java b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/permission/SystemPermission.java
index b54ba90..054caf0 100644
--- a/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/permission/SystemPermission.java
+++ b/guacamole-ext/src/main/java/org/apache/guacamole/net/auth/permission/SystemPermission.java
@@ -38,6 +38,11 @@
         CREATE_USER,
 
         /**
+         * Create user groups.
+         */
+        CREATE_USER_GROUP,
+
+        /**
          * Create connections.
          */
         CREATE_CONNECTION,
diff --git a/guacamole/pom.xml b/guacamole/pom.xml
index dc4f082..0899e78 100644
--- a/guacamole/pom.xml
+++ b/guacamole/pom.xml
@@ -308,7 +308,7 @@
         <dependency>
             <groupId>org.webjars.bower</groupId>
             <artifactId>lodash</artifactId>
-            <version>2.4.1</version>
+            <version>4.17.10</version>
             <scope>runtime</scope>
         </dependency>
 
@@ -316,7 +316,7 @@
         <dependency>
             <groupId>org.webjars.bower</groupId>
             <artifactId>jquery</artifactId>
-            <version>2.1.3</version>
+            <version>3.3.1</version>
             <scope>runtime</scope>
         </dependency>
 
diff --git a/guacamole/src/licenses/LICENSE b/guacamole/src/licenses/LICENSE
index 4474707..51f5b21 100644
--- a/guacamole/src/licenses/LICENSE
+++ b/guacamole/src/licenses/LICENSE
@@ -486,13 +486,21 @@
 jQuery (http://jquery.com/)
 ---------------------------
 
-    Version: 2.1.3
-    From: 'jQuery Foundation' (http://jquery.com/)
+    Version: 3.3.1
+    From: 'JS Foundation' (https://js.foundation/)
     License(s):
-        MIT (bundled/jquery-2.1.3/MIT-LICENSE.txt)
+        MIT (bundled/jquery-3.3.1/LICENSE.txt)
 
-Copyright 2014 jQuery Foundation and other contributors
-http://jquery.com/
+Copyright JS Foundation and other contributors, https://js.foundation/
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/jquery/jquery
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
 
 Permission is hereby granted, free of charge, to any person obtaining
 a copy of this software and associated documentation files (the
@@ -513,6 +521,13 @@
 OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+====
+
+All files located in the node_modules and external directories are
+externally maintained libraries used by this software which have their
+own licenses; we recommend you read them, as their terms may differ from
+the terms above.
+
 
 JSR-250 Reference Implementation
 (https://jcp.org/aboutJava/communityprocess/final/jsr250/index.html)
@@ -536,15 +551,25 @@
 Lodash (https://lodash.com/)
 ----------------------------
 
-    Version: 2.4.1
-    From: 'The Dojo Foundation' (http://dojofoundation.org/)
+    Version: 4.17.10
+    From: 'JS Foundation' (https://js.foundation/)
     License(s):
-        MIT (bundled/lodash-2.4.1/LICENSE.txt)
+        MIT (bundled/lodash-4.17.10/LICENSE)
 
-Copyright 2012-2013 The Dojo Foundation <http://dojofoundation.org/>
-Based on Underscore.js 1.5.2, copyright 2009-2013 Jeremy Ashkenas,
+Copyright JS Foundation and other contributors <https://js.foundation/>
+
+Based on Underscore.js, copyright Jeremy Ashkenas,
 DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
 
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/lodash/lodash
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
+
 Permission is hereby granted, free of charge, to any person obtaining
 a copy of this software and associated documentation files (the
 "Software"), to deal in the Software without restriction, including
@@ -564,6 +589,21 @@
 OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+====
+
+Copyright and related rights for sample code are waived via CC0. Sample
+code is defined as all source code displayed within the prose of the
+documentation.
+
+CC0: http://creativecommons.org/publicdomain/zero/1.0/
+
+====
+
+Files located in the node_modules and vendor directories are externally
+maintained libraries used by this software which have their own
+licenses; we recommend you read them, as their terms may differ from the
+terms above.
+
 
 Logback (http://logback.qos.ch/)
 --------------------------------
diff --git a/guacamole/src/licenses/bundled/jquery-2.1.3/MIT-LICENSE.txt b/guacamole/src/licenses/bundled/jquery-2.1.3/MIT-LICENSE.txt
deleted file mode 100644
index cdd31b5..0000000
--- a/guacamole/src/licenses/bundled/jquery-2.1.3/MIT-LICENSE.txt
+++ /dev/null
@@ -1,21 +0,0 @@
-Copyright 2014 jQuery Foundation and other contributors
-http://jquery.com/
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/guacamole/src/licenses/bundled/lodash-2.4.1/LICENSE.txt b/guacamole/src/licenses/bundled/jquery-3.3.1/LICENSE.txt
similarity index 63%
rename from guacamole/src/licenses/bundled/lodash-2.4.1/LICENSE.txt
rename to guacamole/src/licenses/bundled/jquery-3.3.1/LICENSE.txt
index 49869bb..e4e5e00 100644
--- a/guacamole/src/licenses/bundled/lodash-2.4.1/LICENSE.txt
+++ b/guacamole/src/licenses/bundled/jquery-3.3.1/LICENSE.txt
@@ -1,6 +1,13 @@
-Copyright 2012-2013 The Dojo Foundation <http://dojofoundation.org/>
-Based on Underscore.js 1.5.2, copyright 2009-2013 Jeremy Ashkenas,
-DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
+Copyright JS Foundation and other contributors, https://js.foundation/
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/jquery/jquery
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
 
 Permission is hereby granted, free of charge, to any person obtaining
 a copy of this software and associated documentation files (the
@@ -19,4 +26,11 @@
 NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+====
+
+All files located in the node_modules and external directories are
+externally maintained libraries used by this software which have their
+own licenses; we recommend you read them, as their terms may differ from
+the terms above.
diff --git a/guacamole/src/licenses/bundled/lodash-4.17.10/LICENSE b/guacamole/src/licenses/bundled/lodash-4.17.10/LICENSE
new file mode 100644
index 0000000..c6f2f61
--- /dev/null
+++ b/guacamole/src/licenses/bundled/lodash-4.17.10/LICENSE
@@ -0,0 +1,47 @@
+Copyright JS Foundation and other contributors <https://js.foundation/>
+
+Based on Underscore.js, copyright Jeremy Ashkenas,
+DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
+
+This software consists of voluntary contributions made by many
+individuals. For exact contribution history, see the revision history
+available at https://github.com/lodash/lodash
+
+The following license applies to all parts of this software except as
+documented below:
+
+====
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+====
+
+Copyright and related rights for sample code are waived via CC0. Sample
+code is defined as all source code displayed within the prose of the
+documentation.
+
+CC0: http://creativecommons.org/publicdomain/zero/1.0/
+
+====
+
+Files located in the node_modules and vendor directories are externally
+maintained libraries used by this software which have their own
+licenses; we recommend you read them, as their terms may differ from the
+terms above.
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java b/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
index a868931..6c6474b 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/AuthenticationProviderFacade.java
@@ -19,14 +19,14 @@
 
 package org.apache.guacamole.extension;
 
+import java.util.Set;
 import java.util.UUID;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.AuthenticatedUser;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.auth.Credentials;
 import org.apache.guacamole.net.auth.UserContext;
-import org.apache.guacamole.net.auth.credentials.CredentialsInfo;
-import org.apache.guacamole.net.auth.credentials.GuacamoleInvalidCredentialsException;
+import org.apache.guacamole.net.auth.credentials.GuacamoleCredentialsException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,6 +49,16 @@
     private final AuthenticationProvider authProvider;
 
     /**
+     * The set of identifiers of all authentication providers whose internal
+     * failures should be tolerated during the authentication process. If the
+     * identifier of this authentication provider is within this set, errors
+     * during authentication will result in the authentication provider being
+     * ignored for that authentication attempt. By default, errors during
+     * authentication halt the authentication process entirely.
+     */
+    private final Set<String> tolerateFailures;
+
+    /**
      * The identifier to provide for the underlying authentication provider if
      * the authentication provider could not be loaded.
      */
@@ -63,9 +73,21 @@
      *
      * @param authProviderClass
      *     The AuthenticationProvider subclass to instantiate.
+     *
+     * @param tolerateFailures
+     *     The set of identifiers of all authentication providers whose
+     *     internal failures should be tolerated during the authentication
+     *     process. If the identifier of this authentication provider is within
+     *     this set, errors during authentication will result in the
+     *     authentication provider being ignored for that authentication
+     *     attempt. By default, errors during authentication halt the
+     *     authentication process entirely.
      */
-    public AuthenticationProviderFacade(Class<? extends AuthenticationProvider> authProviderClass) {
-        authProvider = ProviderFactory.newInstance("authentication provider",
+    public AuthenticationProviderFacade(
+            Class<? extends AuthenticationProvider> authProviderClass,
+            Set<String> tolerateFailures) {
+        this.tolerateFailures = tolerateFailures;
+        this.authProvider = ProviderFactory.newInstance("authentication provider",
             authProviderClass);
     }
 
@@ -97,18 +119,124 @@
 
     }
 
+    /**
+     * Returns whether this authentication provider should tolerate internal
+     * failures during the authentication process, allowing other
+     * authentication providers to continue operating as if this authentication
+     * provider simply is not present.
+     *
+     * @return
+     *     true if this authentication provider should tolerate internal
+     *     failures during the authentication process, false otherwise.
+     */
+    private boolean isFailureTolerated() {
+        return tolerateFailures.contains(getIdentifier());
+    }
+
+    /**
+     * Logs a warning that this authentication provider is being skipped due to
+     * an internal error. If debug-level logging is enabled, the full details
+     * of the internal error are also logged.
+     *
+     * @param e
+     *     The internal error that occurred which has resulted in this
+     *     authentication provider being skipped.
+     */
+    private void warnAuthProviderSkipped(Throwable e) {
+
+        logger.warn("The \"{}\" authentication provider has been skipped due "
+                + "to an internal error. If this is unexpected or you are the "
+                + "developer of this authentication provider, you may wish to "
+                + "enable debug-level logging: {}",
+                getIdentifier(), e.getMessage());
+
+        logger.debug("Authentication provider skipped due to an internal failure.", e);
+
+    }
+
+    /**
+     * Logs a warning that the authentication process will be entirely aborted
+     * due to an internal error, advising the administrator to set the
+     * "skip-if-unavailable" property if error encountered is expected and
+     * should be tolerated.
+     */
+    private void warnAuthAborted() {
+        String identifier = getIdentifier();
+        logger.warn("The \"{}\" authentication provider has encountered an "
+                + "internal error which will halt the authentication "
+                + "process. If this is unexpected or you are the developer of "
+                + "this authentication provider, you may wish to enable "
+                + "debug-level logging. If this is expected and you wish to "
+                + "ignore such failures in the future, please set \"{}: {}\" "
+                + "within your guacamole.properties.",
+                identifier, ExtensionModule.SKIP_IF_UNAVAILABLE.getName(),
+                identifier);
+    }
+
     @Override
     public AuthenticatedUser authenticateUser(Credentials credentials)
             throws GuacamoleException {
 
         // Ignore auth attempts if no auth provider could be loaded
         if (authProvider == null) {
-            logger.warn("Authentication attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
-            throw new GuacamoleInvalidCredentialsException("Permission denied.", CredentialsInfo.USERNAME_PASSWORD);
+            logger.warn("Authentication attempt ignored because the relevant "
+                    + "authentication provider could not be loaded. Please "
+                    + "check for errors earlier in the logs.");
+            return null;
         }
 
         // Delegate to underlying auth provider
-        return authProvider.authenticateUser(credentials);
+        try {
+            return authProvider.authenticateUser(credentials);
+        }
+
+        // Pass through credential exceptions untouched, as these are not
+        // internal failures
+        catch (GuacamoleCredentialsException e) {
+            throw e;
+        }
+
+        // Pass through all other exceptions (aborting authentication entirely)
+        // only if not configured to ignore such failures
+        catch (GuacamoleException e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
+        catch (RuntimeException e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
+        catch (Error e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
 
     }
 
@@ -118,8 +246,10 @@
 
         // Ignore auth attempts if no auth provider could be loaded
         if (authProvider == null) {
-            logger.warn("Reauthentication attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
-            throw new GuacamoleInvalidCredentialsException("Permission denied.", CredentialsInfo.USERNAME_PASSWORD);
+            logger.warn("Reauthentication attempt ignored because the relevant "
+                    + "authentication provider could not be loaded. Please "
+                    + "check for errors earlier in the logs.");
+            return null;
         }
 
         // Delegate to underlying auth provider
@@ -133,13 +263,65 @@
 
         // Ignore auth attempts if no auth provider could be loaded
         if (authProvider == null) {
-            logger.warn("User data retrieval attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            logger.warn("User data retrieval attempt ignored because the "
+                    + "relevant authentication provider could not be loaded. "
+                    + "Please check for errors earlier in the logs.");
             return null;
         }
 
         // Delegate to underlying auth provider
-        return authProvider.getUserContext(authenticatedUser);
-        
+        try {
+            return authProvider.getUserContext(authenticatedUser);
+        }
+
+        // Pass through credential exceptions untouched, as these are not
+        // internal failures
+        catch (GuacamoleCredentialsException e) {
+            throw e;
+        }
+
+        // Pass through all other exceptions (aborting authentication entirely)
+        // only if not configured to ignore such failures
+        catch (GuacamoleException e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
+        catch (RuntimeException e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
+        catch (Error e) {
+
+            // Skip using this authentication provider if configured to ignore
+            // internal failures during auth
+            if (isFailureTolerated()) {
+                warnAuthProviderSkipped(e);
+                return null;
+            }
+
+            warnAuthAborted();
+            throw e;
+
+        }
+
     }
 
     @Override
@@ -149,7 +331,9 @@
 
         // Ignore auth attempts if no auth provider could be loaded
         if (authProvider == null) {
-            logger.warn("User data refresh attempt denied because the authentication system could not be loaded. Please check for errors earlier in the logs.");
+            logger.warn("User data refresh attempt ignored because the "
+                    + "relevant authentication provider could not be loaded. "
+                    + "Please check for errors earlier in the logs.");
             return null;
         }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
index cc39036..ae8c463 100644
--- a/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
+++ b/guacamole/src/main/java/org/apache/guacamole/extension/ExtensionModule.java
@@ -29,12 +29,14 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import org.apache.guacamole.auth.file.FileAuthenticationProvider;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.GuacamoleServerException;
 import org.apache.guacamole.environment.Environment;
 import org.apache.guacamole.net.auth.AuthenticationProvider;
 import org.apache.guacamole.net.event.listener.Listener;
+import org.apache.guacamole.properties.StringSetProperty;
 import org.apache.guacamole.resource.Resource;
 import org.apache.guacamole.resource.ResourceServlet;
 import org.apache.guacamole.resource.SequenceResource;
@@ -82,6 +84,25 @@
     private static final String EXTENSION_SUFFIX = ".jar";
 
     /**
+     * A comma-separated list of the identifiers of all authentication
+     * providers whose internal failures should be tolerated during the
+     * authentication process. If an authentication provider within this list
+     * encounters an internal error during the authentication process, it will
+     * simply be skipped, allowing other authentication providers to continue
+     * trying to authenticate the user. Internal errors within authentication
+     * providers that are not within this list will halt the authentication
+     * process entirely.
+     */
+    public static final StringSetProperty SKIP_IF_UNAVAILABLE = new StringSetProperty() {
+
+        @Override
+        public String getName() {
+            return "skip-if-unavailable";
+        }
+
+    };
+
+    /**
      * The Guacamole server environment.
      */
     private final Environment environment;
@@ -156,13 +177,26 @@
      *
      * @param authenticationProvider
      *     The AuthenticationProvider class to bind.
+     *
+     * @param tolerateFailures
+     *     The set of identifiers of all authentication providers whose
+     *     internal failures should be tolerated during the authentication
+     *     process. If the identifier of an authentication provider is within
+     *     this set, errors during authentication will result in the
+     *     authentication provider being ignored for that authentication
+     *     attempt, with the authentication process proceeding as if that
+     *     authentication provider were not present. By default, errors during
+     *     authentication halt the authentication process entirely.
      */
-    private void bindAuthenticationProvider(Class<? extends AuthenticationProvider> authenticationProvider) {
+    private void bindAuthenticationProvider(
+            Class<? extends AuthenticationProvider> authenticationProvider,
+            Set<String> tolerateFailures) {
 
         // Bind authentication provider
         logger.debug("[{}] Binding AuthenticationProvider \"{}\".",
                 boundAuthenticationProviders.size(), authenticationProvider.getName());
-        boundAuthenticationProviders.add(new AuthenticationProviderFacade(authenticationProvider));
+        boundAuthenticationProviders.add(new AuthenticationProviderFacade(
+                authenticationProvider, tolerateFailures));
 
     }
 
@@ -173,12 +207,24 @@
      *
      * @param authProviders
      *     The AuthenticationProvider classes to bind.
+     *
+     * @param tolerateFailures
+     *     The set of identifiers of all authentication providers whose
+     *     internal failures should be tolerated during the authentication
+     *     process. If the identifier of an authentication provider is within
+     *     this set, errors during authentication will result in the
+     *     authentication provider being ignored for that authentication
+     *     attempt, with the authentication process proceeding as if that
+     *     authentication provider were not present. By default, errors during
+     *     authentication halt the authentication process entirely.
      */
-    private void bindAuthenticationProviders(Collection<Class<AuthenticationProvider>> authProviders) {
+    private void bindAuthenticationProviders(
+            Collection<Class<AuthenticationProvider>> authProviders,
+            Set<String> tolerateFailures) {
 
         // Bind each authentication provider within extension
         for (Class<AuthenticationProvider> authenticationProvider : authProviders)
-            bindAuthenticationProvider(authenticationProvider);
+            bindAuthenticationProvider(authenticationProvider, tolerateFailures);
 
     }
 
@@ -314,6 +360,38 @@
     }
 
     /**
+     * Returns the set of identifiers of all authentication providers whose
+     * internal failures should be tolerated during the authentication process.
+     * If the identifier of an authentication provider is within this set,
+     * errors during authentication will result in the authentication provider
+     * being ignored for that authentication attempt, with the authentication
+     * process proceeding as if that authentication provider were not present.
+     * By default, errors during authentication halt the authentication process
+     * entirely.
+     *
+     * @return
+     *     The set of identifiers of all authentication providers whose
+     *     internal failures should be tolerated during the authentication
+     *     process.
+     */
+    private Set<String> getToleratedAuthenticationProviders() {
+
+        // Parse list of auth providers whose internal failures should be
+        // tolerated
+        try {
+            return environment.getProperty(SKIP_IF_UNAVAILABLE, Collections.<String>emptySet());
+        }
+
+        // Use empty set by default if property cannot be parsed
+        catch (GuacamoleException e) {
+            logger.warn("The list of authentication providers specified via the \"{}\" property could not be parsed: {}", SKIP_IF_UNAVAILABLE.getName(), e.getMessage());
+            logger.debug("Unable to parse \"{}\" property.", SKIP_IF_UNAVAILABLE.getName(), e);
+            return Collections.<String>emptySet();
+        }
+
+    }
+
+    /**
      * Loads all extensions within the GUACAMOLE_HOME/extensions directory, if
      * any, adding their static resource to the given resoure collections.
      *
@@ -324,9 +402,20 @@
      * @param cssResources
      *     A modifiable collection of static CSS resources which may receive
      *     new CSS resources from extensions.
+     *
+     * @param toleratedAuthProviders
+     *     The set of identifiers of all authentication providers whose
+     *     internal failures should be tolerated during the authentication
+     *     process. If the identifier of an authentication provider is within
+     *     this set, errors during authentication will result in the
+     *     authentication provider being ignored for that authentication
+     *     attempt, with the authentication process proceeding as if that
+     *     authentication provider were not present. By default, errors during
+     *     authentication halt the authentication process entirely.
      */
     private void loadExtensions(Collection<Resource> javaScriptResources,
-            Collection<Resource> cssResources) {
+            Collection<Resource> cssResources,
+            Set<String> toleratedAuthProviders) {
 
         // Retrieve and validate extensions directory
         File extensionsDir = new File(environment.getGuacamoleHome(), EXTENSIONS_DIRECTORY);
@@ -375,7 +464,7 @@
                 cssResources.addAll(extension.getCSSResources().values());
 
                 // Attempt to load all authentication providers
-                bindAuthenticationProviders(extension.getAuthenticationProviderClasses());
+                bindAuthenticationProviders(extension.getAuthenticationProviderClasses(), toleratedAuthProviders);
 
                 // Attempt to load all listeners
                 bindListeners(extension.getListenerClasses());
@@ -430,10 +519,11 @@
         cssResources.add(new WebApplicationResource(getServletContext(), "/guacamole.min.css"));
 
         // Load all extensions
-        loadExtensions(javaScriptResources, cssResources);
+        final Set<String> toleratedAuthProviders = getToleratedAuthenticationProviders();
+        loadExtensions(javaScriptResources, cssResources, toleratedAuthProviders);
 
         // Always bind default file-driven auth last
-        bindAuthenticationProvider(FileAuthenticationProvider.class);
+        bindAuthenticationProvider(FileAuthenticationProvider.class, toleratedAuthProviders);
 
         // Dynamically generate app.js and app.css from extensions
         serve("/app.js").with(new ResourceServlet(new SequenceResource(javaScriptResources)));
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
index 5665ccf..5296565 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/activeconnection/ActiveConnectionDirectoryResource.java
@@ -24,9 +24,12 @@
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
+import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ActiveConnection;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.directory.DirectoryResource;
@@ -67,4 +70,10 @@
         super(userContext, directory, translator, resourceFactory);
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getActiveConnectionPermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
index ce35071..88408a7 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connection/ConnectionDirectoryResource.java
@@ -24,9 +24,12 @@
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
+import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.Connection;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.directory.DirectoryResource;
@@ -66,4 +69,10 @@
         super(userContext, directory, translator, resourceFactory);
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getConnectionPermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
index 06da559..2be3a88 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/connectiongroup/ConnectionGroupDirectoryResource.java
@@ -27,7 +27,9 @@
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.ConnectionGroup;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResource;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
@@ -102,4 +104,10 @@
 
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getConnectionGroupPermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
index 9973301..ce9cb83 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/directory/DirectoryResource.java
@@ -120,6 +120,26 @@
     }
 
     /**
+     * Returns the ObjectPermissionSet defined within the given Permissions
+     * that represents the permissions affecting objects available within this
+     * DirectoryResource.
+     *
+     * @param permissions
+     *     The Permissions object from which the ObjectPermissionSet should be
+     *     retrieved.
+     *
+     * @return
+     *     The ObjectPermissionSet defined within the given Permissions object
+     *     that represents the permissions affecting objects available within
+     *     this DirectoryResource.
+     *
+     * @throws GuacamoleException
+     *     If an error prevents retrieval of permissions.
+     */
+    protected abstract ObjectPermissionSet getObjectPermissions(
+            Permissions permissions) throws GuacamoleException;
+
+    /**
      * Returns a map of all objects available within this DirectoryResource,
      * filtering the returned map by the given permission, if specified.
      *
@@ -149,7 +169,7 @@
         // Filter objects, if requested
         Collection<String> identifiers = directory.getIdentifiers();
         if (!isAdmin && permissions != null && !permissions.isEmpty()) {
-            ObjectPermissionSet objectPermissions = effective.getUserPermissions();
+            ObjectPermissionSet objectPermissions = getObjectPermissions(effective);
             identifiers = objectPermissions.getAccessibleObjects(permissions, identifiers);
         }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
index cdd9f2a..ab24ef3 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/sharingprofile/SharingProfileDirectoryResource.java
@@ -24,9 +24,12 @@
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
+import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.SharingProfile;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.directory.DirectoryResource;
@@ -67,4 +70,10 @@
         super(userContext, directory, translator, resourceFactory);
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getSharingProfilePermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
index 5aeb4e4..f93016f 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/user/UserDirectoryResource.java
@@ -24,9 +24,12 @@
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
+import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.User;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.directory.DirectoryResource;
@@ -65,4 +68,10 @@
         super(userContext, directory, translator, resourceFactory);
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getUserPermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
index b89db6d..fc4d48b 100644
--- a/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
+++ b/guacamole/src/main/java/org/apache/guacamole/rest/usergroup/UserGroupDirectoryResource.java
@@ -24,9 +24,12 @@
 import javax.ws.rs.Consumes;
 import javax.ws.rs.Produces;
 import javax.ws.rs.core.MediaType;
+import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.net.auth.UserGroup;
 import org.apache.guacamole.net.auth.Directory;
+import org.apache.guacamole.net.auth.Permissions;
 import org.apache.guacamole.net.auth.UserContext;
+import org.apache.guacamole.net.auth.permission.ObjectPermissionSet;
 import org.apache.guacamole.rest.directory.DirectoryObjectResourceFactory;
 import org.apache.guacamole.rest.directory.DirectoryObjectTranslator;
 import org.apache.guacamole.rest.directory.DirectoryResource;
@@ -65,4 +68,10 @@
         super(userContext, directory, translator, resourceFactory);
     }
 
+    @Override
+    protected ObjectPermissionSet getObjectPermissions(Permissions permissions)
+            throws GuacamoleException {
+        return permissions.getUserGroupPermissions();
+    }
+
 }
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
index e5c5db9..304e1dd 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty8/GuacamoleWebSocketTunnelServlet.java
@@ -20,6 +20,7 @@
 package org.apache.guacamole.tunnel.websocket.jetty8;
 
 import java.io.IOException;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 import org.apache.guacamole.GuacamoleException;
 import org.apache.guacamole.io.GuacamoleReader;
@@ -30,6 +31,8 @@
 import org.eclipse.jetty.websocket.WebSocketServlet;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleConnectionClosedException;
+import org.apache.guacamole.protocol.FilteredGuacamoleWriter;
+import org.apache.guacamole.protocol.GuacamoleFilter;
 import org.apache.guacamole.protocol.GuacamoleInstruction;
 import org.apache.guacamole.tunnel.http.HTTPTunnelRequest;
 import org.apache.guacamole.tunnel.TunnelRequest;
@@ -53,6 +56,15 @@
     private static final int BUFFER_SIZE = 8192;
 
     /**
+     * The opcode of the instruction used to indicate a connection stability
+     * test ping request or response. Note that this instruction is
+     * encapsulated within an internal tunnel instruction (with the opcode
+     * being the empty string), thus this will actually be the value of the
+     * first element of the received instruction.
+     */
+    private static final String PING_OPCODE = "ping";
+
+    /**
      * Sends the given numeric Guacamole and WebSocket status
      * on the given WebSocket connection and closes the
      * connection.
@@ -106,6 +118,58 @@
              */
             private GuacamoleTunnel tunnel = null;
 
+            /**
+             * The active WebSocket connection. This value will always be
+             * non-null if tunnel is non-null.
+             */
+            private Connection connection = null;
+
+            /**
+             * Sends a Guacamole instruction along the outbound WebSocket
+             * connection to the connected Guacamole client. If an instruction
+             * is already in the process of being sent by another thread, this
+             * function will block until in-progress instructions are complete.
+             *
+             * @param instruction
+             *     The instruction to send.
+             *
+             * @throws IOException
+             *     If an I/O error occurs preventing the given instruction from
+             *     being sent.
+             */
+            private void sendInstruction(String instruction)
+                    throws IOException {
+
+                // NOTE: Synchronization on the non-final remote field here is
+                // intentional. The outbound websocket connection is only
+                // sensitive to simultaneous attempts to send messages with
+                // respect to itself. If the connection changes, then
+                // synchronization need only be performed in context of the new
+                // connection
+                synchronized (connection) {
+                    connection.sendMessage(instruction);
+                }
+
+            }
+
+            /**
+             * Sends a Guacamole instruction along the outbound WebSocket
+             * connection to the connected Guacamole client. If an instruction
+             * is already in the process of being sent by another thread, this
+             * function will block until in-progress instructions are complete.
+             *
+             * @param instruction
+             *     The instruction to send.
+             *
+             * @throws IOException
+             *     If an I/O error occurs preventing the given instruction from being
+             *     sent.
+             */
+            private void sendInstruction(GuacamoleInstruction instruction)
+                    throws IOException {
+                sendInstruction(instruction.toString());
+            }
+
             @Override
             public void onMessage(String string) {
 
@@ -113,7 +177,43 @@
                 if (tunnel == null)
                     return;
 
-                GuacamoleWriter writer = tunnel.acquireWriter();
+                // Filter received instructions, handling tunnel-internal
+                // instructions without passing through to guacd
+                GuacamoleWriter writer = new FilteredGuacamoleWriter(tunnel.acquireWriter(), new GuacamoleFilter() {
+
+                    @Override
+                    public GuacamoleInstruction filter(GuacamoleInstruction instruction)
+                            throws GuacamoleException {
+
+                        // Filter out all tunnel-internal instructions
+                        if (instruction.getOpcode().equals(GuacamoleTunnel.INTERNAL_DATA_OPCODE)) {
+
+                            // Respond to ping requests
+                            List<String> args = instruction.getArgs();
+                            if (args.size() >= 2 && args.get(0).equals(PING_OPCODE)) {
+
+                                try {
+                                    sendInstruction(new GuacamoleInstruction(
+                                        GuacamoleTunnel.INTERNAL_DATA_OPCODE,
+                                        PING_OPCODE, args.get(1)
+                                    ));
+                                }
+                                catch (IOException e) {
+                                    logger.debug("Unable to send \"ping\" response for WebSocket tunnel.", e);
+                                }
+
+                            }
+
+                            return null;
+
+                        }
+
+                        // Pass through all non-internal instructions untouched
+                        return instruction;
+
+                    }
+
+                });
 
                 // Write message received
                 try {
@@ -133,6 +233,9 @@
             @Override
             public void onOpen(final Connection connection) {
 
+                // Store websocket connection for future use via sendInstruction()
+                this.connection = connection;
+
                 try {
                     tunnel = doConnect(tunnelRequest);
                 }
@@ -162,10 +265,10 @@
                         try {
 
                             // Send tunnel UUID
-                            connection.sendMessage(new GuacamoleInstruction(
+                            sendInstruction(new GuacamoleInstruction(
                                 GuacamoleTunnel.INTERNAL_DATA_OPCODE,
                                 tunnel.getUUID().toString()
-                            ).toString());
+                            ));
 
                             try {
 
@@ -177,7 +280,7 @@
 
                                     // Flush if we expect to wait or buffer is getting full
                                     if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
-                                        connection.sendMessage(buffer.toString());
+                                        sendInstruction(buffer.toString());
                                         buffer.setLength(0);
                                     }
 
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
index 0594d06..6422f57 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/jetty9/GuacamoleWebSocketTunnelListener.java
@@ -20,6 +20,7 @@
 package org.apache.guacamole.tunnel.websocket.jetty9;
 
 import java.io.IOException;
+import java.util.List;
 import org.eclipse.jetty.websocket.api.CloseStatus;
 import org.eclipse.jetty.websocket.api.RemoteEndpoint;
 import org.eclipse.jetty.websocket.api.Session;
@@ -30,6 +31,8 @@
 import org.apache.guacamole.io.GuacamoleReader;
 import org.apache.guacamole.io.GuacamoleWriter;
 import org.apache.guacamole.net.GuacamoleTunnel;
+import org.apache.guacamole.protocol.FilteredGuacamoleWriter;
+import org.apache.guacamole.protocol.GuacamoleFilter;
 import org.apache.guacamole.protocol.GuacamoleInstruction;
 import org.apache.guacamole.protocol.GuacamoleStatus;
 import org.slf4j.Logger;
@@ -46,16 +49,32 @@
     private static final int BUFFER_SIZE = 8192;
 
     /**
+     * The opcode of the instruction used to indicate a connection stability
+     * test ping request or response. Note that this instruction is
+     * encapsulated within an internal tunnel instruction (with the opcode
+     * being the empty string), thus this will actually be the value of the
+     * first element of the received instruction.
+     */
+    private static final String PING_OPCODE = "ping";
+
+    /**
      * Logger for this class.
      */
     private static final Logger logger = LoggerFactory.getLogger(RestrictedGuacamoleWebSocketTunnelServlet.class);
 
     /**
      * The underlying GuacamoleTunnel. WebSocket reads/writes will be handled
-     * as reads/writes to this tunnel.
+     * as reads/writes to this tunnel. This value may be null if no connection
+     * has been established.
      */
     private GuacamoleTunnel tunnel;
- 
+
+    /**
+     * Remote (client) side of this connection. This value will always be
+     * non-null if tunnel is non-null.
+     */
+    private RemoteEndpoint remote;
+
     /**
      * Sends the given numeric Guacamole and WebSocket status
      * codes on the given WebSocket connection and closes the
@@ -102,6 +121,52 @@
     }
 
     /**
+     * Sends a Guacamole instruction along the outbound WebSocket connection to
+     * the connected Guacamole client. If an instruction is already in the
+     * process of being sent by another thread, this function will block until
+     * in-progress instructions are complete.
+     *
+     * @param instruction
+     *     The instruction to send.
+     *
+     * @throws IOException
+     *     If an I/O error occurs preventing the given instruction from being
+     *     sent.
+     */
+    private void sendInstruction(String instruction)
+            throws IOException {
+
+        // NOTE: Synchronization on the non-final remote field here is
+        // intentional. The remote (the outbound websocket connection) is only
+        // sensitive to simultaneous attempts to send messages with respect to
+        // itself. If the remote changes, then the outbound websocket
+        // connection has changed, and synchronization need only be performed
+        // in context of the new remote.
+        synchronized (remote) {
+            remote.sendString(instruction);
+        }
+
+    }
+
+    /**
+     * Sends a Guacamole instruction along the outbound WebSocket connection to
+     * the connected Guacamole client. If an instruction is already in the
+     * process of being sent by another thread, this function will block until
+     * in-progress instructions are complete.
+     *
+     * @param instruction
+     *     The instruction to send.
+     *
+     * @throws IOException
+     *     If an I/O error occurs preventing the given instruction from being
+     *     sent.
+     */
+    private void sendInstruction(GuacamoleInstruction instruction)
+            throws IOException {
+        sendInstruction(instruction.toString());
+    }
+
+    /**
      * Returns a new tunnel for the given session. How this tunnel is created
      * or retrieved is implementation-dependent.
      *
@@ -117,6 +182,9 @@
     @Override
     public void onWebSocketConnect(final Session session) {
 
+        // Store underlying remote for future use via sendInstruction()
+        remote = session.getRemote();
+
         try {
 
             // Get tunnel
@@ -137,11 +205,6 @@
         // Prepare read transfer thread
         Thread readThread = new Thread() {
 
-            /**
-             * Remote (client) side of this connection
-             */
-            private final RemoteEndpoint remote = session.getRemote();
-                
             @Override
             public void run() {
 
@@ -152,10 +215,10 @@
                 try {
 
                     // Send tunnel UUID
-                    remote.sendString(new GuacamoleInstruction(
+                    sendInstruction(new GuacamoleInstruction(
                         GuacamoleTunnel.INTERNAL_DATA_OPCODE,
                         tunnel.getUUID().toString()
-                    ).toString());
+                    ));
 
                     try {
 
@@ -167,7 +230,7 @@
 
                             // Flush if we expect to wait or buffer is getting full
                             if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
-                                remote.sendString(buffer.toString());
+                                sendInstruction(buffer.toString());
                                 buffer.setLength(0);
                             }
 
@@ -219,7 +282,43 @@
         if (tunnel == null)
             return;
 
-        GuacamoleWriter writer = tunnel.acquireWriter();
+        // Filter received instructions, handling tunnel-internal instructions
+        // without passing through to guacd
+        GuacamoleWriter writer = new FilteredGuacamoleWriter(tunnel.acquireWriter(), new GuacamoleFilter() {
+
+            @Override
+            public GuacamoleInstruction filter(GuacamoleInstruction instruction)
+                    throws GuacamoleException {
+
+                // Filter out all tunnel-internal instructions
+                if (instruction.getOpcode().equals(GuacamoleTunnel.INTERNAL_DATA_OPCODE)) {
+
+                    // Respond to ping requests
+                    List<String> args = instruction.getArgs();
+                    if (args.size() >= 2 && args.get(0).equals(PING_OPCODE)) {
+
+                        try {
+                            sendInstruction(new GuacamoleInstruction(
+                                GuacamoleTunnel.INTERNAL_DATA_OPCODE,
+                                PING_OPCODE, args.get(1)
+                            ));
+                        }
+                        catch (IOException e) {
+                            logger.debug("Unable to send \"ping\" response for WebSocket tunnel.", e);
+                        }
+
+                    }
+
+                    return null;
+
+                }
+
+                // Pass through all non-internal instructions untouched
+                return instruction;
+
+            }
+
+        });
 
         try {
             // Write received message
diff --git a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
index a2e8b39..215cc8f 100644
--- a/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
+++ b/guacamole/src/main/java/org/apache/guacamole/tunnel/websocket/tomcat/GuacamoleWebSocketTunnelServlet.java
@@ -35,6 +35,8 @@
 import org.apache.catalina.websocket.WsOutbound;
 import org.apache.guacamole.GuacamoleClientException;
 import org.apache.guacamole.GuacamoleConnectionClosedException;
+import org.apache.guacamole.protocol.FilteredGuacamoleWriter;
+import org.apache.guacamole.protocol.GuacamoleFilter;
 import org.apache.guacamole.protocol.GuacamoleInstruction;
 import org.apache.guacamole.tunnel.http.HTTPTunnelRequest;
 import org.apache.guacamole.tunnel.TunnelRequest;
@@ -53,6 +55,15 @@
     private static final int BUFFER_SIZE = 8192;
 
     /**
+     * The opcode of the instruction used to indicate a connection stability
+     * test ping request or response. Note that this instruction is
+     * encapsulated within an internal tunnel instruction (with the opcode
+     * being the empty string), thus this will actually be the value of the
+     * first element of the received instruction.
+     */
+    private static final String PING_OPCODE = "ping";
+
+    /**
      * Logger for this class.
      */
     private final Logger logger = LoggerFactory.getLogger(GuacamoleWebSocketTunnelServlet.class);
@@ -130,6 +141,58 @@
              */
             private GuacamoleTunnel tunnel = null;
 
+            /**
+             * The outbound half of the WebSocket connection. This value will
+             * always be non-null if tunnel is non-null.
+             */
+            private WsOutbound outbound = null;
+
+            /**
+             * Sends a Guacamole instruction along the outbound WebSocket
+             * connection to the connected Guacamole client. If an instruction
+             * is already in the process of being sent by another thread, this
+             * function will block until in-progress instructions are complete.
+             *
+             * @param instruction
+             *     The instruction to send.
+             *
+             * @throws IOException
+             *     If an I/O error occurs preventing the given instruction from
+             *     being sent.
+             */
+            private void sendInstruction(CharSequence instruction)
+                    throws IOException {
+
+                // NOTE: Synchronization on the non-final remote field here is
+                // intentional. The outbound websocket connection is only
+                // sensitive to simultaneous attempts to send messages with
+                // respect to itself. If the connection changes, then
+                // synchronization need only be performed in context of the new
+                // connection
+                synchronized (outbound) {
+                    outbound.writeTextMessage(CharBuffer.wrap(instruction));
+                }
+
+            }
+
+            /**
+             * Sends a Guacamole instruction along the outbound WebSocket
+             * connection to the connected Guacamole client. If an instruction
+             * is already in the process of being sent by another thread, this
+             * function will block until in-progress instructions are complete.
+             *
+             * @param instruction
+             *     The instruction to send.
+             *
+             * @throws IOException
+             *     If an I/O error occurs preventing the given instruction from being
+             *     sent.
+             */
+            private void sendInstruction(GuacamoleInstruction instruction)
+                    throws IOException {
+                sendInstruction(instruction.toString());
+            }
+
             @Override
             protected void onTextData(Reader reader) throws IOException {
 
@@ -137,7 +200,43 @@
                 if (tunnel == null)
                     return;
 
-                GuacamoleWriter writer = tunnel.acquireWriter();
+                // Filter received instructions, handling tunnel-internal
+                // instructions without passing through to guacd
+                GuacamoleWriter writer = new FilteredGuacamoleWriter(tunnel.acquireWriter(), new GuacamoleFilter() {
+
+                    @Override
+                    public GuacamoleInstruction filter(GuacamoleInstruction instruction)
+                            throws GuacamoleException {
+
+                        // Filter out all tunnel-internal instructions
+                        if (instruction.getOpcode().equals(GuacamoleTunnel.INTERNAL_DATA_OPCODE)) {
+
+                            // Respond to ping requests
+                            List<String> args = instruction.getArgs();
+                            if (args.size() >= 2 && args.get(0).equals(PING_OPCODE)) {
+
+                                try {
+                                    sendInstruction(new GuacamoleInstruction(
+                                        GuacamoleTunnel.INTERNAL_DATA_OPCODE,
+                                        PING_OPCODE, args.get(1)
+                                    ));
+                                }
+                                catch (IOException e) {
+                                    logger.debug("Unable to send \"ping\" response for WebSocket tunnel.", e);
+                                }
+
+                            }
+
+                            return null;
+
+                        }
+
+                        // Pass through all non-internal instructions untouched
+                        return instruction;
+
+                    }
+
+                });
 
                 // Write all available data
                 try {
@@ -162,6 +261,9 @@
             @Override
             public void onOpen(final WsOutbound outbound) {
 
+                // Store outbound connection for future use via sendInstruction()
+                this.outbound = outbound;
+
                 try {
                     tunnel = doConnect(tunnelRequest);
                 }
@@ -191,10 +293,10 @@
                         try {
 
                             // Send tunnel UUID
-                            outbound.writeTextMessage(CharBuffer.wrap(new GuacamoleInstruction(
+                            sendInstruction(new GuacamoleInstruction(
                                 GuacamoleTunnel.INTERNAL_DATA_OPCODE,
                                 tunnel.getUUID().toString()
-                            ).toString()));
+                            ));
 
                             try {
 
@@ -206,7 +308,7 @@
 
                                     // Flush if we expect to wait or buffer is getting full
                                     if (!reader.available() || buffer.length() >= BUFFER_SIZE) {
-                                        outbound.writeTextMessage(CharBuffer.wrap(buffer));
+                                        sendInstruction(CharBuffer.wrap(buffer));
                                         buffer.setLength(0);
                                     }
 
diff --git a/guacamole/src/main/webapp/app/client/controllers/clientController.js b/guacamole/src/main/webapp/app/client/controllers/clientController.js
index ffbe3c5..41c6ba6 100644
--- a/guacamole/src/main/webapp/app/client/controllers/clientController.js
+++ b/guacamole/src/main/webapp/app/client/controllers/clientController.js
@@ -635,7 +635,7 @@
      *     otherwise.
      */
     $scope.isConnectionUnstable = function isConnectionUnstable() {
-        return $scope.client && $scope.client.clientState.connectionState === ManagedClientState.ConnectionState.UNSTABLE;
+        return $scope.client && $scope.client.clientState.tunnelUnstable;
     };
 
     // Show status dialog when connection status changes
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedClient.js b/guacamole/src/main/webapp/app/client/types/ManagedClient.js
index 09c96a9..a9bc3be 100644
--- a/guacamole/src/main/webapp/app/client/types/ManagedClient.js
+++ b/guacamole/src/main/webapp/app/client/types/ManagedClient.js
@@ -346,16 +346,14 @@
                             ManagedClientState.ConnectionState.CONNECTING);
                         break;
 
-                    // Connection is established
+                    // Connection is established / no longer unstable
                     case Guacamole.Tunnel.State.OPEN:
-                        ManagedClientState.setConnectionState(managedClient.clientState,
-                            ManagedClientState.ConnectionState.CONNECTED);
+                        ManagedClientState.setTunnelUnstable(managedClient.clientState, false);
                         break;
 
                     // Connection is established but misbehaving
                     case Guacamole.Tunnel.State.UNSTABLE:
-                        ManagedClientState.setConnectionState(managedClient.clientState,
-                            ManagedClientState.ConnectionState.UNSTABLE);
+                        ManagedClientState.setTunnelUnstable(managedClient.clientState, true);
                         break;
 
                     // Connection has closed
diff --git a/guacamole/src/main/webapp/app/client/types/ManagedClientState.js b/guacamole/src/main/webapp/app/client/types/ManagedClientState.js
index 1a26b0d..10f71b4 100644
--- a/guacamole/src/main/webapp/app/client/types/ManagedClientState.js
+++ b/guacamole/src/main/webapp/app/client/types/ManagedClientState.js
@@ -46,6 +46,16 @@
         this.connectionState = template.connectionState || ManagedClientState.ConnectionState.IDLE;
 
         /**
+         * Whether the network connection used by the tunnel seems unstable. If
+         * the network connection is unstable, the remote desktop connection
+         * may perform poorly or disconnect.
+         *
+         * @type Boolean
+         * @default false
+         */
+        this.tunnelUnstable = template.tunnelUnstable || false;
+
+        /**
          * The status code of the current error condition, if connectionState
          * is CLIENT_ERROR or TUNNEL_ERROR. For all other connectionState
          * values, this will be @link{Guacamole.Status.Code.SUCCESS}.
@@ -94,15 +104,6 @@
         CONNECTED : "CONNECTED",
 
         /**
-         * The Guacamole connection has been successfully established, but the
-         * network connection seems unstable. The connection may perform poorly
-         * or disconnect.
-         * 
-         * @type String
-         */
-        UNSTABLE : "UNSTABLE",
-
-        /**
          * The Guacamole connection has terminated successfully. No errors are
          * indicated.
          * 
@@ -130,7 +131,9 @@
 
     /**
      * Sets the current client state and, if given, the associated status code.
-     * If an error is already represented, this function has no effect.
+     * If an error is already represented, this function has no effect. If the
+     * client state was previously marked as unstable, that flag is implicitly
+     * cleared.
      *
      * @param {ManagedClientState} clientState
      *     The ManagedClientState to update.
@@ -153,6 +156,7 @@
 
         // Update connection state
         clientState.connectionState = connectionState;
+        clientState.tunnelUnstable = false;
 
         // Set status code, if given
         if (statusCode)
@@ -160,6 +164,22 @@
 
     };
 
+    /**
+     * Updates the given client state, setting whether the underlying tunnel
+     * is currently unstable. An unstable tunnel is not necessarily
+     * disconnected, but appears to be misbehaving and may be disconnected.
+     *
+     * @param {ManagedClientState} clientState
+     *     The ManagedClientState to update.
+     *
+     * @param {Boolean} unstable
+     *     Whether the underlying tunnel of the connection currently appears
+     *     unstable.
+     */
+    ManagedClientState.setTunnelUnstable = function setTunnelUnstable(clientState, unstable) {
+        clientState.tunnelUnstable = unstable;
+    };
+
     return ManagedClientState;
 
 }]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
index 47bc48e..5a8c3fb 100644
--- a/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
+++ b/guacamole/src/main/webapp/app/index/config/indexRouteConfig.js
@@ -171,6 +171,15 @@
             resolve       : { updateCurrentToken: updateCurrentToken }
         })
 
+        // User group editor
+        .when('/manage/:dataSource/userGroups/:id?', {
+            title         : 'APP.NAME',
+            bodyClassName : 'manage',
+            templateUrl   : 'app/manage/templates/manageUserGroup.html',
+            controller    : 'manageUserGroupController',
+            resolve       : { updateCurrentToken: updateCurrentToken }
+        })
+
         // Client view
         .when('/client/:id/:params?', {
             bodyClassName : 'client',
diff --git a/guacamole/src/main/webapp/app/index/styles/fatal-page-error.css b/guacamole/src/main/webapp/app/index/styles/fatal-page-error.css
index a6e28ba..9a50e9c 100644
--- a/guacamole/src/main/webapp/app/index/styles/fatal-page-error.css
+++ b/guacamole/src/main/webapp/app/index/styles/fatal-page-error.css
@@ -59,3 +59,18 @@
     margin: 0 0.25em;
     margin-bottom: -0.2em;
 }
+
+/* Ensure fatal error is initially hidden, fading the error message in when
+ * needed */
+
+.fatal-page-error-outer {
+    visibility: hidden;
+    opacity: 0;
+    transition: opacity, visibility;
+    transition-duration: 0.25s;
+}
+
+.shown.fatal-page-error-outer {
+    visibility: visible;
+    opacity: 1;
+}
diff --git a/guacamole/src/main/webapp/app/index/styles/lists.css b/guacamole/src/main/webapp/app/index/styles/lists.css
index 0c761ae..80df491 100644
--- a/guacamole/src/main/webapp/app/index/styles/lists.css
+++ b/guacamole/src/main/webapp/app/index/styles/lists.css
@@ -18,12 +18,14 @@
  */
 
 .user,
+.user-group,
 .connection-group,
 .connection {
     cursor: pointer;
 }
 
 .user a,
+.user-group a,
 .connection a,
 .connection-group a {
     text-decoration:none;
@@ -31,6 +33,7 @@
 }
 
 .user a:hover,
+.user-group a:hover,
 .connection a:hover,
 .connection-group a:hover {
     text-decoration:none;
@@ -38,6 +41,7 @@
 }
 
 .user a:visited,
+.user-group a:visited,
 .connection a:visited,
 .connection-group a:visited {
     text-decoration:none;
diff --git a/guacamole/src/main/webapp/app/index/styles/ui.css b/guacamole/src/main/webapp/app/index/styles/ui.css
index 434f443..58406eb 100644
--- a/guacamole/src/main/webapp/app/index/styles/ui.css
+++ b/guacamole/src/main/webapp/app/index/styles/ui.css
@@ -156,6 +156,14 @@
     background-image: url('images/action-icons/guac-user-add.png');
 }
 
+.icon.user-group {
+    background-image: url('images/user-icons/guac-user-group.png');
+}
+
+.icon.user-group.add {
+    background-image: url('images/action-icons/guac-user-group-add.png');
+}
+
 .icon.connection {
     background-image: url('images/protocol-icons/guac-plug.png');
 }
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js b/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js
index 16bb20f..5c2dc42 100644
--- a/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageUserController.js
@@ -36,9 +36,11 @@
     var $q                       = $injector.get('$q');
     var authenticationService    = $injector.get('authenticationService');
     var dataSourceService        = $injector.get('dataSourceService');
+    var membershipService        = $injector.get('membershipService');
     var permissionService        = $injector.get('permissionService');
     var requestService           = $injector.get('requestService');
     var schemaService            = $injector.get('schemaService');
+    var userGroupService         = $injector.get('userGroupService');
     var userService              = $injector.get('userService');
 
     /**
@@ -134,6 +136,46 @@
     $scope.permissionsRemoved = new PermissionSet();
 
     /**
+     * The identifiers of all user groups which can be manipulated (all groups
+     * for which the user accessing this interface has UPDATE permission),
+     * either through adding the current user as a member or removing the
+     * current user from that group. If this information has not yet been
+     * retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.availableGroups = null;
+
+    /**
+     * The identifiers of all user groups of which the user is a member,
+     * taking into account any user groups which will be added/removed when
+     * saved. If this information has not yet been retrieved, this will be
+     * null.
+     *
+     * @type String[]
+     */
+    $scope.parentGroups = null;
+
+    /**
+     * The set of identifiers of all parent user groups to which the user will
+     * be added when saved. Parent groups will only be present in this set if
+     * they are manually added, and not later manually removed before saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsAdded = [];
+
+    /**
+     * The set of identifiers of all parent user groups from which the user
+     * will be removed when saved. Parent groups will only be present in this
+     * set if they are manually removed, and not later manually added before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsRemoved = [];
+
+    /**
      * For each applicable data source, the management-related actions that the
      * current user may perform on the user account currently being created
      * or modified, as a map of data source identifier to the
@@ -166,6 +208,8 @@
         return $scope.users                 !== null
             && $scope.permissionFlags       !== null
             && $scope.managementPermissions !== null
+            && $scope.availableGroups       !== null
+            && $scope.parentGroups          !== null
             && $scope.attributes            !== null;
 
     };
@@ -204,12 +248,14 @@
     var loadExistingUser = function loadExistingUser(dataSource, username) {
         return $q.all({
             users : dataSourceService.apply(userService.getUser, dataSources, username),
-            permissions : permissionService.getPermissions(dataSource, username)
+            permissions : permissionService.getPermissions(dataSource, username),
+            parentGroups : membershipService.getUserGroups(dataSource, username)
         })
         .then(function userDataRetrieved(values) {
 
             $scope.users = values.users;
             $scope.user  = values.users[dataSource];
+            $scope.parentGroups = values.parentGroups;
 
             // Create skeleton user if user does not exist
             if (!$scope.user)
@@ -243,12 +289,15 @@
     var loadClonedUser = function loadClonedUser(dataSource, username) {
         return $q.all({
             users : dataSourceService.apply(userService.getUser, [dataSource], username),
-            permissions : permissionService.getPermissions(dataSource, username)
+            permissions : permissionService.getPermissions(dataSource, username),
+            parentGroups : membershipService.getUserGroups(dataSource, username)
         })
         .then(function userDataRetrieved(values) {
 
             $scope.users = {};
             $scope.user  = values.users[dataSource];
+            $scope.parentGroups = values.parentGroups;
+            $scope.parentGroupsAdded = values.parentGroups;
 
             // The current user will be associated with cloneSourceUsername in the
             // retrieved permission set
@@ -274,6 +323,7 @@
 
         // Use skeleton user object with no associated permissions
         $scope.user = new User();
+        $scope.parentGroups = [];
         $scope.permissionFlags = new PermissionFlagSet();
 
         // As no permissions are yet associated with the user, it is safe to
@@ -314,6 +364,7 @@
     $q.all({
         userData    : loadRequestedUser(),
         permissions : dataSourceService.apply(permissionService.getEffectivePermissions, dataSources, currentUsername),
+        userGroups  : userGroupService.getUserGroups($scope.dataSource, [ PermissionSet.ObjectPermissionType.UPDATE ]),
         attributes  : schemaService.getUserAttributes($scope.dataSource)
     })
     .then(function dataReceived(values) {
@@ -326,6 +377,12 @@
             // Determine whether data source contains this user
             var exists = (dataSource in $scope.users);
 
+            // Add the identifiers of all modifiable user groups
+            $scope.availableGroups = [];
+            angular.forEach(values.userGroups, function addUserGroupIdentifier(userGroup) {
+                $scope.availableGroups.push(userGroup.identifier);
+            });
+
             // Calculate management actions available for this specific account
             $scope.managementPermissions[dataSource] = ManagementPermissions.fromPermissionSet(
                     values.permissions[dataSource],
@@ -415,9 +472,11 @@
                 
             }
 
-            // Upon success, save any changed permissions
-            return permissionService.patchPermissions($scope.dataSource, $scope.user.username,
-                $scope.permissionsAdded, $scope.permissionsRemoved);
+            // Upon success, save any changed permissions/groups
+            return $q.all([
+                permissionService.patchPermissions($scope.dataSource, $scope.user.username, $scope.permissionsAdded, $scope.permissionsRemoved),
+                membershipService.patchUserGroups($scope.dataSource, $scope.user.username, $scope.parentGroupsAdded, $scope.parentGroupsRemoved)
+            ]);
 
         });
 
diff --git a/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js b/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js
new file mode 100644
index 0000000..229b3b8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/controllers/manageUserGroupController.js
@@ -0,0 +1,538 @@
+/*
+ * 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.
+ */
+
+/**
+ * The controller for editing user groups.
+ */
+angular.module('manage').controller('manageUserGroupController', ['$scope', '$injector',
+        function manageUserGroupController($scope, $injector) {
+            
+    // Required types
+    var ManagementPermissions = $injector.get('ManagementPermissions');
+    var PermissionFlagSet     = $injector.get('PermissionFlagSet');
+    var PermissionSet         = $injector.get('PermissionSet');
+    var UserGroup             = $injector.get('UserGroup');
+
+    // Required services
+    var $location             = $injector.get('$location');
+    var $routeParams          = $injector.get('$routeParams');
+    var $q                    = $injector.get('$q');
+    var authenticationService = $injector.get('authenticationService');
+    var dataSourceService     = $injector.get('dataSourceService');
+    var membershipService     = $injector.get('membershipService');
+    var permissionService     = $injector.get('permissionService');
+    var requestService        = $injector.get('requestService');
+    var schemaService         = $injector.get('schemaService');
+    var userGroupService      = $injector.get('userGroupService');
+    var userService           = $injector.get('userService');
+
+    /**
+     * The identifiers of all data sources currently available to the
+     * authenticated user.
+     *
+     * @type String[]
+     */
+    var dataSources = authenticationService.getAvailableDataSources();
+
+    /**
+     * The username of the current, authenticated user.
+     *
+     * @type String
+     */
+    var currentUsername = authenticationService.getCurrentUsername();
+
+    /**
+     * The identifier of the original user group from which this user group is
+     * being cloned. Only valid if this is a new user group.
+     *
+     * @type String
+     */
+    var cloneSourceIdentifier = $location.search().clone;
+
+    /**
+     * The identifier of the user group being edited. If a new user group is
+     * being created, this will not be defined.
+     *
+     * @type String
+     */
+    var identifier = $routeParams.id;
+
+    /**
+     * The unique identifier of the data source containing the user group being
+     * edited.
+     *
+     * @type String
+     */
+    $scope.dataSource = $routeParams.dataSource;
+
+    /**
+     * All user groups associated with the same identifier as the group being
+     * created or edited, as a map of data source identifier to the UserGroup
+     * object within that data source.
+     *
+     * @type Object.<String, UserGroup>
+     */
+    $scope.userGroups = null;
+
+    /**
+     * The user group being modified.
+     *
+     * @type UserGroup
+     */
+    $scope.userGroup = null;
+
+    /**
+     * All permissions associated with the user group being modified.
+     * 
+     * @type PermissionFlagSet
+     */
+    $scope.permissionFlags = null;
+
+    /**
+     * The set of permissions that will be added to the user group when the
+     * user group is saved. Permissions will only be present in this set if they
+     * are manually added, and not later manually removed before saving.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissionsAdded = new PermissionSet();
+
+    /**
+     * The set of permissions that will be removed from the user group when the
+     * user group is saved. Permissions will only be present in this set if they
+     * are manually removed, and not later manually added before saving.
+     *
+     * @type PermissionSet
+     */
+    $scope.permissionsRemoved = new PermissionSet();
+
+    /**
+     * The identifiers of all user groups which can be manipulated (all groups
+     * for which the user accessing this interface has UPDATE permission),
+     * whether that means changing the members of those groups or changing the
+     * groups of which those groups are members. If this information has not
+     * yet been retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.availableGroups = null;
+
+    /**
+     * The identifiers of all users which can be manipulated (all users for
+     * which the user accessing this interface has UPDATE permission), either
+     * through adding those users as a member of the current group or removing
+     * those users from the current group. If this information has not yet been
+     * retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.availableUsers = null;
+
+    /**
+     * The identifiers of all user groups of which this group is a member,
+     * taking into account any user groups which will be added/removed when
+     * saved. If this information has not yet been retrieved, this will be
+     * null.
+     *
+     * @type String[]
+     */
+    $scope.parentGroups = null;
+
+    /**
+     * The set of identifiers of all parent user groups to which this group
+     * will be added when saved. Parent groups will only be present in this set
+     * if they are manually added, and not later manually removed before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsAdded = [];
+
+    /**
+     * The set of identifiers of all parent user groups from which this group
+     * will be removed when saved. Parent groups will only be present in this
+     * set if they are manually removed, and not later manually added before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.parentGroupsRemoved = [];
+
+    /**
+     * The identifiers of all user groups which are members of this group,
+     * taking into account any user groups which will be added/removed when
+     * saved. If this information has not yet been retrieved, this will be
+     * null.
+     *
+     * @type String[]
+     */
+    $scope.memberGroups = null;
+
+    /**
+     * The set of identifiers of all member user groups which will be added to
+     * this group when saved. Member groups will only be present in this set if
+     * they are manually added, and not later manually removed before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberGroupsAdded = [];
+
+    /**
+     * The set of identifiers of all member user groups which will be removed
+     * from this group when saved. Member groups will only be present in this
+     * set if they are manually removed, and not later manually added before
+     * saving.
+     *
+     * @type String[]
+     */
+    $scope.memberGroupsRemoved = [];
+
+    /**
+     * The identifiers of all users which are members of this group, taking
+     * into account any users which will be added/removed when saved. If this
+     * information has not yet been retrieved, this will be null.
+     *
+     * @type String[]
+     */
+    $scope.memberUsers = null;
+
+    /**
+     * The set of identifiers of all member users which will be added to this
+     * group when saved. Member users will only be present in this set if they
+     * are manually added, and not later manually removed before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberUsersAdded = [];
+
+    /**
+     * The set of identifiers of all member users which will be removed from
+     * this group when saved. Member users will only be present in this set if
+     * they are manually removed, and not later manually added before saving.
+     *
+     * @type String[]
+     */
+    $scope.memberUsersRemoved = [];
+
+    /**
+     * For each applicable data source, the management-related actions that the
+     * current user may perform on the user group currently being created
+     * or modified, as a map of data source identifier to the
+     * {@link ManagementPermissions} object describing the actions available
+     * within that data source, or null if the current user's permissions have
+     * not yet been loaded.
+     *
+     * @type Object.<String, ManagementPermissions>
+     */
+    $scope.managementPermissions = null;
+
+    /**
+     * All available user group attributes. This is only the set of attribute
+     * definitions, organized as logical groupings of attributes, not attribute
+     * values.
+     *
+     * @type Form[]
+     */
+    $scope.attributes = null;
+
+    /**
+     * Returns whether critical data has completed being loaded.
+     *
+     * @returns {Boolean}
+     *     true if enough data has been loaded for the user group interface to
+     *     be useful, false otherwise.
+     */
+    $scope.isLoaded = function isLoaded() {
+
+        return $scope.userGroups            !== null
+            && $scope.permissionFlags       !== null
+            && $scope.managementPermissions !== null
+            && $scope.availableGroups       !== null
+            && $scope.availableUsers        !== null
+            && $scope.parentGroups          !== null
+            && $scope.memberGroups          !== null
+            && $scope.memberUsers           !== null
+            && $scope.attributes            !== null;
+
+    };
+
+    /**
+     * Returns whether the current user can edit the identifier of the user
+     * group being edited.
+     *
+     * @returns {Boolean}
+     *     true if the current user can edit the identifier of the user group
+     *     being edited, false otherwise.
+     */
+    $scope.canEditIdentifier = function canEditIdentifier() {
+        return !identifier;
+    };
+
+    /**
+     * Loads the data associated with the user group having the given
+     * identifier, preparing the interface for making modifications to that
+     * existing user group.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     to load.
+     *
+     * @param {String} identifier
+     *     The unique identifier of the user group to load.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     editing the given user group.
+     */
+    var loadExistingUserGroup = function loadExistingGroup(dataSource, identifier) {
+        return $q.all({
+            userGroups   : dataSourceService.apply(userGroupService.getUserGroup, dataSources, identifier),
+            permissions  : permissionService.getPermissions(dataSource, identifier, true),
+            parentGroups : membershipService.getUserGroups(dataSource, identifier, true),
+            memberGroups : membershipService.getMemberUserGroups(dataSource, identifier),
+            memberUsers  : membershipService.getMemberUsers(dataSource, identifier)
+        })
+        .then(function userGroupDataRetrieved(values) {
+
+            $scope.userGroups = values.userGroups;
+            $scope.userGroup  = values.userGroups[dataSource];
+            $scope.parentGroups = values.parentGroups;
+            $scope.memberGroups = values.memberGroups;
+            $scope.memberUsers = values.memberUsers;
+
+            // Create skeleton user group if user group does not exist
+            if (!$scope.userGroup)
+                $scope.userGroup = new UserGroup({
+                    'identifier' : identifier
+                });
+
+            $scope.permissionFlags = PermissionFlagSet.fromPermissionSet(values.permissions);
+
+        });
+    };
+
+    /**
+     * Loads the data associated with the user group having the given
+     * identifier, preparing the interface for cloning that existing user
+     * group.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group to
+     *     be cloned.
+     *
+     * @param {String} identifier
+     *     The unique identifier of the user group being cloned.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     cloning the given user group.
+     */
+    var loadClonedUserGroup = function loadClonedUserGroup(dataSource, identifier) {
+        return $q.all({
+            userGroups   : dataSourceService.apply(userGroupService.getUserGroup, [dataSource], identifier),
+            permissions  : permissionService.getPermissions(dataSource, identifier, true),
+            parentGroups : membershipService.getUserGroups(dataSource, identifier, true),
+            memberGroups : membershipService.getMemberUserGroups(dataSource, identifier),
+            memberUsers  : membershipService.getMemberUsers(dataSource, identifier)
+        })
+        .then(function userGroupDataRetrieved(values) {
+
+            $scope.userGroups = {};
+            $scope.userGroup  = values.userGroups[dataSource];
+            $scope.parentGroups = values.parentGroups;
+            $scope.parentGroupsAdded = values.parentGroups;
+            $scope.memberGroups = values.memberGroups;
+            $scope.memberGroupsAdded = values.memberGroups;
+            $scope.memberUsers = values.memberUsers;
+            $scope.memberUsersAdded = values.memberUsers;
+
+            $scope.permissionFlags = PermissionFlagSet.fromPermissionSet(values.permissions);
+            $scope.permissionsAdded = values.permissions;
+
+        });
+    };
+
+    /**
+     * Loads skeleton user group data, preparing the interface for creating a
+     * new user group.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared for
+     *     creating a new user group.
+     */
+    var loadSkeletonUserGroup = function loadSkeletonUserGroup() {
+
+        // No user groups exist regardless of data source if the user group is
+        // being created
+        $scope.userGroups = {};
+
+        // Use skeleton user group object with no associated permissions
+        $scope.userGroup = new UserGroup();
+        $scope.parentGroups = [];
+        $scope.memberGroups = [];
+        $scope.memberUsers = [];
+        $scope.permissionFlags = new PermissionFlagSet();
+
+        return $q.resolve();
+
+    };
+
+    /**
+     * Loads the data required for performing the management task requested
+     * through the route parameters given at load time, automatically preparing
+     * the interface for editing an existing user group, cloning an existing
+     * user group, or creating an entirely new user group.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved when the interface has been prepared
+     *     for performing the requested management task.
+     */
+    var loadRequestedUserGroup = function loadRequestedUserGroup() {
+
+        // Pull user group data and permissions if we are editing an existing
+        // user group
+        if (identifier)
+            return loadExistingUserGroup($scope.dataSource, identifier);
+
+        // If we are cloning an existing user group, pull its data instead
+        if (cloneSourceIdentifier)
+            return loadClonedUserGroup($scope.dataSource, cloneSourceIdentifier);
+
+        // If we are creating a new user group, populate skeleton user group data
+        return loadSkeletonUserGroup();
+
+    };
+
+    // Populate interface with requested data
+    $q.all({
+        userGroupData : loadRequestedUserGroup(),
+        permissions   : dataSourceService.apply(permissionService.getEffectivePermissions, dataSources, currentUsername),
+        userGroups    : userGroupService.getUserGroups($scope.dataSource, [ PermissionSet.ObjectPermissionType.UPDATE ]),
+        users         : userService.getUsers($scope.dataSource, [ PermissionSet.ObjectPermissionType.UPDATE ]),
+        attributes    : schemaService.getUserGroupAttributes($scope.dataSource)
+    })
+    .then(function dataReceived(values) {
+
+        $scope.attributes = values.attributes;
+
+        $scope.managementPermissions = {};
+        angular.forEach(dataSources, function deriveManagementPermissions(dataSource) {
+
+            // Determine whether data source contains this user group
+            var exists = (dataSource in $scope.userGroups);
+
+            // Add the identifiers of all modifiable user groups
+            $scope.availableGroups = [];
+            angular.forEach(values.userGroups, function addUserGroupIdentifier(userGroup) {
+                $scope.availableGroups.push(userGroup.identifier);
+            });
+
+            // Add the identifiers of all modifiable users
+            $scope.availableUsers = [];
+            angular.forEach(values.users, function addUserIdentifier(user) {
+                $scope.availableUsers.push(user.username);
+            });
+
+            // Calculate management actions available for this specific group
+            $scope.managementPermissions[dataSource] = ManagementPermissions.fromPermissionSet(
+                    values.permissions[dataSource],
+                    PermissionSet.SystemPermissionType.CREATE_USER_GROUP,
+                    PermissionSet.hasUserGroupPermission,
+                    exists ? identifier : null);
+
+        });
+
+    }, requestService.WARN);
+
+    /**
+     * Returns the URL for the page which manages the user group currently
+     * being edited under the given data source. The given data source need not
+     * be the same as the data source currently selected.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source that the URL is being
+     *     generated for.
+     *
+     * @returns {String}
+     *     The URL for the page which manages the user group currently being
+     *     edited under the given data source.
+     */
+    $scope.getUserGroupURL = function getUserGroupURL(dataSource) {
+        return '/manage/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier || '');
+    };
+
+    /**
+     * Cancels all pending edits, returning to the main list of user groups.
+     */
+    $scope.returnToUserGroupList = function returnToUserGroupList() {
+        $location.url('/settings/userGroups');
+    };
+
+    /**
+     * Cancels all pending edits, opening an edit page for a new user group
+     * which is prepopulated with the data from the user currently being edited.
+     */
+    $scope.cloneUserGroup = function cloneUserGroup() {
+        $location.path('/manage/' + encodeURIComponent($scope.dataSource) + '/userGroups').search('clone', identifier);
+    };
+
+    /**
+     * Saves the current user group, creating a new user group or updating the
+     * existing user group depending on context, returning a promise which is
+     * resolved if the save operation succeeds and rejected if the save
+     * operation fails.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved if the save operation succeeds and is
+     *     rejected with an {@link Error} if the save operation fails.
+     */
+    $scope.saveUserGroup = function saveUserGroup() {
+
+        // Save or create the user group, depending on whether the user group exists
+        var saveUserGroupPromise;
+        if ($scope.dataSource in $scope.userGroups)
+            saveUserGroupPromise = userGroupService.saveUserGroup($scope.dataSource, $scope.userGroup);
+        else
+            saveUserGroupPromise = userGroupService.createUserGroup($scope.dataSource, $scope.userGroup);
+
+        return saveUserGroupPromise.then(function savedUserGroup() {
+            return $q.all([
+                permissionService.patchPermissions($scope.dataSource, $scope.userGroup.identifier, $scope.permissionsAdded, $scope.permissionsRemoved, true),
+                membershipService.patchUserGroups($scope.dataSource, $scope.userGroup.identifier, $scope.parentGroupsAdded, $scope.parentGroupsRemoved, true),
+                membershipService.patchMemberUserGroups($scope.dataSource, $scope.userGroup.identifier, $scope.memberGroupsAdded, $scope.memberGroupsRemoved),
+                membershipService.patchMemberUsers($scope.dataSource, $scope.userGroup.identifier, $scope.memberUsersAdded, $scope.memberUsersRemoved)
+            ]);
+        });
+
+    };
+
+    /**
+     * Deletes the current user group, returning a promise which is resolved if
+     * the delete operation succeeds and rejected if the delete operation
+     * fails.
+     *
+     * @returns {Promise}
+     *     A promise which is resolved if the delete operation succeeds and is
+     *     rejected with an {@link Error} if the delete operation fails.
+     */
+    $scope.deleteUserGroup = function deleteUserGroup() {
+        return userGroupService.deleteUserGroup($scope.dataSource, $scope.userGroup);
+    };
+
+}]);
diff --git a/guacamole/src/main/webapp/app/manage/directives/dataSourceTabs.js b/guacamole/src/main/webapp/app/manage/directives/dataSourceTabs.js
index cf7068f..627197b 100644
--- a/guacamole/src/main/webapp/app/manage/directives/dataSourceTabs.js
+++ b/guacamole/src/main/webapp/app/manage/directives/dataSourceTabs.js
@@ -76,9 +76,12 @@
         $scope.$watch('permissions', function permissionsChanged(permissions) {
 
             $scope.pages = [];
-            angular.forEach(permissions, function addDataSourcePage(managementPermissions, dataSource) {
+
+            var dataSources = _.keys($scope.permissions).sort();
+            angular.forEach(dataSources, function addDataSourcePage(dataSource) {
 
                 // Determine whether data source contains this object
+                var managementPermissions = permissions[dataSource];
                 var exists = !!managementPermissions.identifier;
 
                 // Data source is not relevant if the associated object does not
diff --git a/guacamole/src/main/webapp/app/manage/directives/identifierSetEditor.js b/guacamole/src/main/webapp/app/manage/directives/identifierSetEditor.js
new file mode 100644
index 0000000..d2936e7
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/directives/identifierSetEditor.js
@@ -0,0 +1,300 @@
+/*
+ * 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.
+ */
+
+/**
+ * A directive for manipulating a set of objects sharing some common relation
+ * and represented by an array of their identifiers. The specific objects
+ * added or removed are tracked within a separate pair of arrays of
+ * identifiers.
+ */
+angular.module('manage').directive('identifierSetEditor', ['$injector',
+    function identifierSetEditor($injector) {
+
+    var directive = {
+
+        // Element only
+        restrict: 'E',
+        replace: true,
+
+        scope: {
+
+            /**
+             * The translation key of the text which should be displayed within
+             * the main header of the identifier set editor.
+             *
+             * @type String
+             */
+            header : '@',
+
+            /**
+             * The translation key of the text which should be displayed if no
+             * identifiers are currently present within the set.
+             *
+             * @type String
+             */
+            emptyPlaceholder : '@',
+
+            /**
+             * The translation key of the text which should be displayed if no
+             * identifiers are available to be added within the set.
+             *
+             * @type String
+             */
+            unavailablePlaceholder : '@',
+
+            /**
+             * All identifiers which are available to be added to or removed
+             * from the identifier set being edited.
+             *
+             * @type String[]
+             */
+            identifiersAvailable : '=',
+
+            /**
+             * The current state of the identifier set being manipulated. This
+             * array will be modified as changes are made through this
+             * identifier set editor.
+             *
+             * @type String[]
+             */
+            identifiers : '=',
+
+            /**
+             * The set of identifiers that have been added, relative to the
+             * initial state of the identifier set being manipulated.
+             *
+             * @type String[]
+             */
+            identifiersAdded : '=',
+
+            /**
+             * The set of identifiers that have been removed, relative to the
+             * initial state of the identifier set being manipulated.
+             *
+             * @type String[]
+             */
+            identifiersRemoved : '='
+
+        },
+
+        templateUrl: 'app/manage/templates/identifierSetEditor.html'
+
+    };
+
+    directive.controller = ['$scope', function identifierSetEditorController($scope) {
+
+        /**
+         * Whether the full list of available identifiers should be displayed.
+         * Initially, only an abbreviated list of identifiers currently present
+         * is shown.
+         *
+         * @type Boolean
+         */
+        $scope.expanded = false;
+
+        /**
+         * Map of identifiers to boolean flags indicating whether that
+         * identifier is currently present (true) or absent (false). If an
+         * identifier is absent, it may also be absent from this map.
+         *
+         * @type Object.<String, Boolean>
+         */
+        $scope.identifierFlags = {};
+
+        /**
+         * Map of identifiers to boolean flags indicating whether that
+         * identifier is editable. If an identifier is not editable, it will be
+         * absent from this map.
+         *
+         * @type Object.<String, Boolean>
+         */
+        $scope.isEditable = {};
+
+        /**
+         * Adds the given identifier to the given sorted array of identifiers,
+         * preserving the sorted order of the array. If the identifier is
+         * already present, no change is made to the array. The given array
+         * must already be sorted in ascending order.
+         *
+         * @param {String[]} arr
+         *     The sorted array of identifiers to add the given identifier to.
+         *
+         * @param {String} identifier
+         *     The identifier to add to the given array.
+         */
+        var addIdentifier = function addIdentifier(arr, identifier) {
+
+            // Determine location that the identifier should be added to
+            // maintain sorted order
+            var index = _.sortedIndex(arr, identifier);
+
+            // Do not add if already present
+            if (arr[index] === identifier)
+                return;
+
+            // Insert identifier at determined location
+            arr.splice(index, 0, identifier);
+
+        };
+
+        /**
+         * Removes the given identifier from the given sorted array of
+         * identifiers, preserving the sorted order of the array. If the
+         * identifier is already absent, no change is made to the array. The
+         * given array must already be sorted in ascending order.
+         *
+         * @param {String[]} arr
+         *     The sorted array of identifiers to remove the given identifier
+         *     from.
+         *
+         * @param {String} identifier
+         *     The identifier to remove from the given array.
+         *
+         * @returns {Boolean}
+         *     true if the identifier was present in the given array and has
+         *     been removed, false otherwise.
+         */
+        var removeIdentifier = function removeIdentifier(arr, identifier) {
+
+            // Search for identifier in sorted array
+            var index = _.sortedIndexOf(arr, identifier);
+
+            // Nothing to do if already absent
+            if (index === -1)
+                return false;
+
+            // Remove identifier
+            arr.splice(index, 1);
+            return true;
+
+        };
+
+        // Keep identifierFlags up to date when identifiers array is replaced
+        // or initially assigned
+        $scope.$watch('identifiers', function identifiersChanged(identifiers) {
+
+            // Maintain identifiers in sorted order so additions and removals
+            // can be made more efficiently
+            if (identifiers)
+                identifiers.sort();
+
+            // Convert array of identifiers into set of boolean
+            // presence/absence flags
+            $scope.identifierFlags = {};
+            angular.forEach(identifiers, function storeIdentifierFlag(identifier) {
+                $scope.identifierFlags[identifier] = true;
+            });
+
+        });
+
+        // An identifier is editable iff it is available to be added or removed
+        // from the identifier set being edited (iff it is within the
+        // identifiersAvailable array)
+        $scope.$watch('identifiersAvailable', function availableIdentifiersChanged(identifiers) {
+            $scope.isEditable = {};
+            angular.forEach(identifiers, function storeEditableIdentifier(identifier) {
+                $scope.isEditable[identifier] = true;
+            });
+        });
+
+        /**
+         * Notifies the controller that a change has been made to the flag
+         * denoting presence/absence of a particular identifier within the
+         * <code>identifierFlags</code> map. The <code>identifiers</code>,
+         * <code>identifiersAdded</code>, and <code>identifiersRemoved</code>
+         * arrays are updated accordingly.
+         *
+         * @param {String} identifier
+         *     The identifier which has been added or removed through modifying
+         *     its boolean flag within <code>identifierFlags</code>.
+         */
+        $scope.identifierChanged = function identifierChanged(identifier) {
+
+            // Determine status of modified identifier
+            var present = !!$scope.identifierFlags[identifier];
+
+            // Add/remove identifier from added/removed sets depending on
+            // change in flag state
+            if (present) {
+
+                addIdentifier($scope.identifiers, identifier);
+
+                if (!removeIdentifier($scope.identifiersRemoved, identifier))
+                    addIdentifier($scope.identifiersAdded, identifier);
+
+            }
+            else {
+
+                removeIdentifier($scope.identifiers, identifier);
+
+                if (!removeIdentifier($scope.identifiersAdded, identifier))
+                    addIdentifier($scope.identifiersRemoved, identifier);
+
+            }
+
+        };
+
+        /**
+         * Removes the given identifier, updating <code>identifierFlags</code>,
+         * <code>identifiers</code>, <code>identifiersAdded</code>, and
+         * <code>identifiersRemoved</code> accordingly.
+         *
+         * @param {String} identifier
+         *     The identifier to remove.
+         */
+        $scope.removeIdentifier = function removeIdentifier(identifier) {
+            $scope.identifierFlags[identifier] = false;
+            $scope.identifierChanged(identifier);
+        };
+
+        /**
+         * Shows the full list of available identifiers. If the full list is
+         * already shown, this function has no effect.
+         */
+        $scope.expand = function expand() {
+            $scope.expanded = true;
+        };
+
+        /**
+         * Hides the full list of available identifiers. If the full list is
+         * already hidden, this function has no effect.
+         */
+        $scope.collapse = function collapse() {
+            $scope.expanded = false;
+        };
+
+        /**
+         * Returns whether there are absolutely no identifiers that can be
+         * managed using this editor. If true, the editor is effectively
+         * useless, as there is nothing whatsoever to display.
+         *
+         * @returns {Boolean}
+         *     true if there are no identifiers that can be managed using this
+         *     editor, false otherwise.
+         */
+        $scope.isEmpty = function isEmpty() {
+            return _.isEmpty($scope.identifiers)
+                && _.isEmpty($scope.identifiersAvailable);
+        };
+
+    }];
+
+    return directive;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js b/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
index 7232263..78377a7 100644
--- a/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
+++ b/guacamole/src/main/webapp/app/manage/directives/systemPermissionEditor.js
@@ -126,6 +126,10 @@
                 value: PermissionSet.SystemPermissionType.CREATE_USER
             },
             {
+                label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_USER_GROUPS",
+                value: PermissionSet.SystemPermissionType.CREATE_USER_GROUP
+            },
+            {
                 label: "MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
                 value: PermissionSet.SystemPermissionType.CREATE_CONNECTION
             },
diff --git a/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css b/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css
new file mode 100644
index 0000000..df9e80d
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/manage-user-group.css
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+.manage-user-group .page-tabs .page-list li.read-only a[href],
+.manage-user-group .page-tabs .page-list li.unlinked  a[href],
+.manage-user-group .page-tabs .page-list li.linked    a[href] {
+    padding-right: 2.5em;
+    position: relative;
+}
+
+.manage-user-group .page-tabs .page-list li.read-only a[href]:before,
+.manage-user-group .page-tabs .page-list li.unlinked  a[href]:before,
+.manage-user-group .page-tabs .page-list li.linked    a[href]:before {
+    content: ' ';
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    top: 0;
+    width: 2.5em;
+    background-size: 1.25em;
+    background-repeat: no-repeat;
+    background-position: center;
+}
+
+.manage-user-group .page-tabs .page-list li.read-only a[href]:before {
+    background-image: url('images/lock.png');
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href]:before {
+    background-image: url('images/plus.png');
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href] {
+    opacity: 0.5;
+}
+
+.manage-user-group .page-tabs .page-list li.unlinked a[href]:hover,
+.manage-user-group .page-tabs .page-list li.unlinked a[href].current {
+    opacity: 1;
+}
+
+.manage-user-group .page-tabs .page-list li.linked a[href]:before {
+    background-image: url('images/checkmark.png');
+}
+
+.manage-user-group .notice.read-only {
+
+    background: #FDA;
+    border: 1px solid rgba(0, 0, 0, 0.125);
+    border-radius: 0.25em;
+
+    text-align: center;
+    padding: 1em;
+
+}
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/manage/styles/related-objects.css b/guacamole/src/main/webapp/app/manage/styles/related-objects.css
new file mode 100644
index 0000000..ddc85b1
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/styles/related-objects.css
@@ -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.
+ */
+
+.related-objects .abbreviated-related-objects {
+    display: table;
+    margin: 1em 0;
+}
+
+.related-objects .abbreviated-related-objects ul {
+    display: table-cell;
+    vertical-align: top;
+}
+
+.related-objects .abbreviated-related-objects ul,
+.related-objects .all-related-objects ul {
+    padding: 0;
+    list-style: none;
+}
+
+.related-objects .abbreviated-related-objects ul li {
+
+    display: inline-block;
+    margin: 0.25em;
+    padding: 0.25em;
+
+    border: 1px solid silver;
+    background: #F5F5F5;
+    -moz-border-radius: 0.25em;
+    -webkit-border-radius: 0.25em;
+    -khtml-border-radius: 0.25em;
+    border-radius: 0.25em;
+
+}
+
+.related-objects .abbreviated-related-objects ul li img.remove {
+    max-height: 0.75em;
+    max-width: 0.75em;
+    margin: 0 0.25em;
+}
+
+.related-objects .abbreviated-related-objects ul li .identifier {
+    margin: 0 0.25em;
+}
+
+.related-objects .abbreviated-related-objects img.expand,
+.related-objects .abbreviated-related-objects img.collapse {
+    display: table-cell;
+    max-height: 1.5em;
+    max-width: 1.5em;
+    margin: 0.375em 0;
+}
+
+.related-objects .all-related-objects {
+    border-top: 1px solid silver;
+}
+
+.related-objects .abbreviated-related-objects p.no-related-objects,
+.related-objects .all-related-objects p.no-objects-available {
+    font-style: italic;
+    opacity: 0.5;
+}
+
+.related-objects .abbreviated-related-objects p.no-related-objects {
+    display: table-cell;
+    vertical-align: middle;
+}
diff --git a/guacamole/src/main/webapp/app/manage/templates/identifierSetEditor.html b/guacamole/src/main/webapp/app/manage/templates/identifierSetEditor.html
new file mode 100644
index 0000000..72c235c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/identifierSetEditor.html
@@ -0,0 +1,47 @@
+<div class="related-objects" ng-hide="isEmpty()">
+    <div class="header">
+        <h2>{{ header | translate }}</h2>
+        <div class="filter">
+            <input class="search-string" type="text"
+                   placeholder="{{ 'SETTINGS_USERS.FIELD_PLACEHOLDER_FILTER' | translate }}"
+                   ng-model="filterString"/>
+        </div>
+    </div>
+
+    <div class="section">
+
+        <!-- Abbreviated list of only the currently selected objects -->
+        <div class="abbreviated-related-objects">
+            <img src="images/arrows/right.png" alt="Expand" class="expand" ng-hide="expanded" ng-click="expand()"/>
+            <img src="images/arrows/down.png" alt="Collapse" class="collapse" ng-show="expanded" ng-click="collapse()"/>
+            <p ng-hide="identifiers.length" class="no-related-objects">{{ emptyPlaceholder | translate }}</p>
+            <ul>
+                <li ng-repeat="identifier in identifiers | filter: filterString">
+                    <label><img src="images/x-red.png" alt="Remove" class="remove"
+                                ng-click="removeIdentifier(identifier)"
+                                ng-show="isEditable[identifier]"/><span class="identifier">{{ identifier }}</span>
+                    </label>
+                </li>
+            </ul>
+        </div>
+
+        <!-- Exhaustive, paginated list of all objects -->
+        <div class="all-related-objects" ng-show="expanded">
+            <p ng-hide="identifiersAvailablePage.length" class="no-objects-available">{{ unavailablePlaceholder | translate }}</p>
+            <ul>
+                <li ng-repeat="identifier in identifiersAvailablePage">
+                    <label><input type="checkbox"
+                           ng-model="identifierFlags[identifier]"
+                           ng-change="identifierChanged(identifier)"/>
+                        <span class="identifier">{{ identifier }}</span>
+                    </label>
+                </li>
+            </ul>
+
+            <!-- Pager controls for user list -->
+            <guac-pager page="identifiersAvailablePage" page-size="25"
+                        items="identifiersAvailable | orderBy | filter: filterString"></guac-pager>
+        </div>
+
+    </div>
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageUser.html b/guacamole/src/main/webapp/app/manage/templates/manageUser.html
index bb5cfb3..571f5c8 100644
--- a/guacamole/src/main/webapp/app/manage/templates/manageUser.html
+++ b/guacamole/src/main/webapp/app/manage/templates/manageUser.html
@@ -56,6 +56,17 @@
               permissions-removed="permissionsRemoved">
         </system-permission-editor>
 
+        <!-- Parent group section -->
+        <identifier-set-editor
+            header="MANAGE_USER.SECTION_HEADER_USER_GROUPS"
+            empty-placeholder="MANAGE_USER.HELP_NO_USER_GROUPS"
+            unavailable-placeholder="MANAGE_USER.INFO_NO_USER_GROUPS_AVAILABLE"
+            identifiers-available="availableGroups"
+            identifiers="parentGroups"
+            identifiers-added="parentGroupsAdded"
+            identifiers-removed="parentGroupsRemoved">
+        </identifier-set-editor>
+
         <!-- Connection permissions section -->
         <connection-permission-editor ng-show="managementPermissions[dataSource].canChangePermissions"
               data-data-source="dataSource"
diff --git a/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html b/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html
new file mode 100644
index 0000000..c659915
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/templates/manageUserGroup.html
@@ -0,0 +1,101 @@
+<div class="manage-user-group view" ng-class="{loading: !isLoaded()}">
+
+    <!-- User group header and data source tabs -->
+    <div class="header tabbed">
+        <h2>{{'MANAGE_USER_GROUP.SECTION_HEADER_EDIT_USER_GROUP' | translate}}</h2>
+        <guac-user-menu></guac-user-menu>
+    </div>
+    <data-data-source-tabs ng-hide="cloneSourceIdentifier"
+        permissions="managementPermissions"
+        url="getUserGroupURL(dataSource)">
+    </data-data-source-tabs>
+
+    <!-- Warn if user group is read-only -->
+    <div class="section" ng-hide="managementPermissions[dataSource].canSaveObject">
+        <p class="notice read-only">{{'MANAGE_USER_GROUP.INFO_READ_ONLY' | translate}}</p>
+    </div>
+
+    <!-- Sections applicable to non-read-only user groups -->
+    <div ng-show="managementPermissions[dataSource].canSaveObject">
+
+        <!-- User group name -->
+        <div class="section">
+            <table class="properties">
+                <tr>
+                    <th>{{'MANAGE_USER_GROUP.FIELD_HEADER_USER_GROUP_NAME' | translate}}</th>
+                    <td>
+                        <input ng-show="canEditIdentifier()" ng-model="userGroup.identifier" type="text"/>
+                        <span  ng-hide="canEditIdentifier()">{{userGroup.identifier}}</span>
+                    </td>
+                </tr>
+            </table>
+        </div>
+
+        <!-- User group attributes section -->
+        <div class="attributes" ng-show="managementPermissions[dataSource].canChangeAttributes">
+            <guac-form namespace="'USER_GROUP_ATTRIBUTES'" content="attributes"
+                       model="userGroup.attributes"
+                       model-only="!managementPermissions[dataSource].canChangeAllAttributes"></guac-form>
+        </div>
+
+        <!-- System permissions section -->
+        <system-permission-editor ng-show="managementPermissions[dataSource].canChangePermissions"
+              data-data-source="dataSource"
+              permission-flags="permissionFlags"
+              permissions-added="permissionsAdded"
+              permissions-removed="permissionsRemoved">
+        </system-permission-editor>
+
+        <!-- Parent group section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_USER_GROUPS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_USER_GROUPS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USER_GROUPS_AVAILABLE"
+            identifiers-available="availableGroups"
+            identifiers="parentGroups"
+            identifiers-added="parentGroupsAdded"
+            identifiers-removed="parentGroupsRemoved">
+        </identifier-set-editor>
+
+        <!-- Member group section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_MEMBER_USER_GROUPS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_MEMBER_USER_GROUPS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USER_GROUPS_AVAILABLE"
+            identifiers-available="availableGroups"
+            identifiers="memberGroups"
+            identifiers-added="memberGroupsAdded"
+            identifiers-removed="memberGroupsRemoved">
+        </identifier-set-editor>
+
+        <!-- Member user section -->
+        <identifier-set-editor
+            header="MANAGE_USER_GROUP.SECTION_HEADER_MEMBER_USERS"
+            empty-placeholder="MANAGE_USER_GROUP.HELP_NO_MEMBER_USERS"
+            unavailable-placeholder="MANAGE_USER_GROUP.INFO_NO_USERS_AVAILABLE"
+            identifiers-available="availableUsers"
+            identifiers="memberUsers"
+            identifiers-added="memberUsersAdded"
+            identifiers-removed="memberUsersRemoved">
+        </identifier-set-editor>
+
+        <!-- Connection permissions section -->
+        <connection-permission-editor ng-show="managementPermissions[dataSource].canChangePermissions"
+              data-data-source="dataSource"
+              permission-flags="permissionFlags"
+              permissions-added="permissionsAdded"
+              permissions-removed="permissionsRemoved">
+        </connection-permission-editor>
+
+        <!-- Form action buttons -->
+        <management-buttons namespace="MANAGE_USER_GROUP"
+              permissions="managementPermissions[dataSource]"
+              save="saveUserGroup()"
+              delete="deleteUserGroup()"
+              clone="cloneUserGroup()"
+              return="returnToUserGroupList()">
+        </management-buttons>
+
+    </div>
+
+</div>
diff --git a/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js b/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js
new file mode 100644
index 0000000..6853fa0
--- /dev/null
+++ b/guacamole/src/main/webapp/app/manage/types/ManageableUserGroup.js
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+/**
+ * A service for defining the ManageableUserGroup class.
+ */
+angular.module('manage').factory('ManageableUserGroup', [function defineManageableUserGroup() {
+
+    /**
+     * A pairing of an @link{UserGroup} with the identifier of its corresponding
+     * data source.
+     *
+     * @constructor
+     * @param {Object|ManageableUserGroup} template
+     */
+    var ManageableUserGroup = function ManageableUserGroup(template) {
+
+        /**
+         * The unique identifier of the data source containing this user.
+         *
+         * @type String
+         */
+        this.dataSource = template.dataSource;
+
+        /**
+         * The @link{UserGroup} object represented by this ManageableUserGroup
+         * and contained within the associated data source.
+         *
+         * @type UserGroup
+         */
+        this.userGroup = template.userGroup;
+
+    };
+
+    return ManageableUserGroup;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/navigation/services/userPageService.js b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
index a9f849b..2af426e 100644
--- a/guacamole/src/main/webapp/app/navigation/services/userPageService.js
+++ b/guacamole/src/main/webapp/app/navigation/services/userPageService.js
@@ -194,6 +194,7 @@
         var pages = [];
         
         var canManageUsers = [];
+        var canManageUserGroups = [];
         var canManageConnections = [];
         var canViewConnectionRecords = [];
 
@@ -236,6 +237,24 @@
                 canManageUsers.push(dataSource);
             }
 
+            // Determine whether the current user needs access to the group management UI
+            if (
+                    // System permissions
+                       PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.ADMINISTER)
+                    || PermissionSet.hasSystemPermission(permissions, PermissionSet.SystemPermissionType.CREATE_USER_GROUP)
+
+                    // Permission to update user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.UPDATE)
+
+                    // Permission to delete user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.DELETE)
+
+                    // Permission to administer user groups
+                    || PermissionSet.hasUserGroupPermission(permissions, PermissionSet.ObjectPermissionType.ADMINISTER)
+            ) {
+                canManageUserGroups.push(dataSource);
+            }
+
             // Determine whether the current user needs access to the connection management UI
             if (
                     // System permissions
@@ -293,6 +312,14 @@
             }));
         }
 
+        // If user can manage user groups, add link to group management page
+        if (canManageUserGroups.length) {
+            pages.push(new PageDefinition({
+                name : 'USER_MENU.ACTION_MANAGE_USER_GROUPS',
+                url  : '/settings/userGroups'
+            }));
+        }
+
         // If user can manage connections, add links for connection management pages
         angular.forEach(canManageConnections, function addConnectionManagementLink(dataSource) {
             pages.push(new PageDefinition({
diff --git a/guacamole/src/main/webapp/app/rest/services/cacheService.js b/guacamole/src/main/webapp/app/rest/services/cacheService.js
index 55b7fc1..9a32004 100644
--- a/guacamole/src/main/webapp/app/rest/services/cacheService.js
+++ b/guacamole/src/main/webapp/app/rest/services/cacheService.js
@@ -60,7 +60,8 @@
     service.schema = $cacheFactory('API-SCHEMA');
 
     /**
-     * Shared cache used by both userService and permissionService.
+     * Shared cache used by userService, userGroupService, permissionService,
+     * and membershipService.
      *
      * @type $cacheFactory.Cache
      */
diff --git a/guacamole/src/main/webapp/app/rest/services/membershipService.js b/guacamole/src/main/webapp/app/rest/services/membershipService.js
new file mode 100644
index 0000000..58181c8
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/membershipService.js
@@ -0,0 +1,385 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service for operating on user group memberships via the REST API.
+ */
+angular.module('rest').factory('membershipService', ['$injector',
+        function membershipService($injector) {
+
+    // Required services
+    var requestService        = $injector.get('requestService');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+    
+    // Required types
+    var RelatedObjectPatch = $injector.get('RelatedObjectPatch');
+
+    var service = {};
+
+    /**
+     * Creates a new array of patches which represents the given changes to an
+     * arbitrary set of objects sharing some common relation.
+     *
+     * @param {String[]} [identifiersToAdd]
+     *     The identifiers of all objects which should be added to the
+     *     relation, if any.
+     *
+     * @param {String[]} [identifiersToRemove]
+     *     The identifiers of all objects which should be removed from the
+     *     relation, if any.
+     *
+     * @returns {RelatedObjectPatch[]}
+     *     A new array of patches which represents the given changes.
+     */
+    var getRelatedObjectPatch = function getRelatedObjectPatch(identifiersToAdd, identifiersToRemove) {
+
+        var patch = [];
+
+        angular.forEach(identifiersToAdd, function addIdentifier(identifier) {
+            patch.push(new RelatedObjectPatch({
+                op    : RelatedObjectPatch.Operation.ADD,
+                value : identifier
+            }));
+        });
+
+        angular.forEach(identifiersToRemove, function removeIdentifier(identifier) {
+            patch.push(new RelatedObjectPatch({
+                op    : RelatedObjectPatch.Operation.REMOVE,
+                value : identifier
+            }));
+        });
+
+        return patch;
+
+    };
+
+    /**
+     * Returns the URL for the REST resource most appropriate for accessing
+     * the parent user groups of the user or group having the given identifier.
+     *
+     * It is important to note that a particular data source can authenticate
+     * and provide user groups for a user, even if that user does not exist
+     * within that data source (and thus cannot be found beneath
+     * "api/session/data/{dataSource}/users")
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user or
+     *     group whose parent user groups should be retrieved. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user or group for which the URL of the proper
+     *     REST resource should be derived.
+     *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
+     *
+     * @returns {String}
+     *     The URL for the REST resource representing the parent user groups of
+     *     the user or group having the given identifier.
+     */
+    var getUserGroupsResourceURL = function getUserGroupsResourceURL(dataSource, identifier, group) {
+
+        // Create base URL for data source
+        var base = 'api/session/data/' + encodeURIComponent(dataSource);
+
+        // Access parent groups directly (there is no "self" for user groups
+        // as there is for users)
+        if (group)
+            return base + '/userGroups/' + encodeURIComponent(identifier) + '/userGroups';
+
+        // If the username is that of the current user, do not rely on the
+        // user actually existing (they may not). Access their parent groups via
+        // "self" rather than the collection of defined users.
+        if (identifier === authenticationService.getCurrentUsername())
+            return base + '/self/userGroups';
+
+        // Otherwise, the user must exist for their parent groups to be
+        // accessible. Use the collection of defined users.
+        return base + '/users/' + encodeURIComponent(identifier) + '/userGroups';
+
+    };
+
+    /**
+     * Makes a request to the REST API to retrieve the identifiers of all
+     * parent user groups of which a given user or group is a member, returning
+     * a promise that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user or
+     *     group whose parent user groups should be retrieved. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user or group to retrieve the parent user
+     *     groups of.
+     *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
+     *
+     * @returns {Promise.<String[]>}
+     *     A promise for the HTTP call which will resolve with an array
+     *     containing the requested identifiers upon success.
+     */
+    service.getUserGroups = function getUserGroups(dataSource, identifier, group) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve parent groups
+        return requestService({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : getUserGroupsResourceURL(dataSource, identifier, group),
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to modify the parent user groups of
+     * which a given user or group is a member, returning a promise that can be
+     * used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user or
+     *     group whose parent user groups should be modified. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user or group to modify the parent user
+     *     groups of.
+     *
+     * @param {String[]} [addToUserGroups]
+     *     The identifier of all parent user groups to which the given user or
+     *     group should be added as a member, if any.
+     *
+     * @param {String[]} [removeFromUserGroups]
+     *     The identifier of all parent user groups from which the given member
+     *     user or group should be removed, if any.
+     *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchUserGroups = function patchUserGroups(dataSource, identifier,
+            addToUserGroups, removeFromUserGroups, group) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update parent user groups
+        return requestService({
+            method  : 'PATCH',
+            url     : getUserGroupsResourceURL(dataSource, identifier, group),
+            params  : httpParameters,
+            data    : getRelatedObjectPatch(addToUserGroups, removeFromUserGroups)
+        })
+
+        // Clear the cache
+        .then(function parentUserGroupsChanged(){
+            cacheService.users.removeAll();
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to retrieve the identifiers of all
+     * users which are members of the given user group, returning a promise
+     * that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     whose member users should be retrieved. This identifier corresponds
+     *     to an AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to retrieve the member users of.
+     *
+     * @returns {Promise.<String[]>}
+     *     A promise for the HTTP call which will resolve with an array
+     *     containing the requested identifiers upon success.
+     */
+    service.getMemberUsers = function getMemberUsers(dataSource, identifier) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve member users
+        return requestService({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier) + '/memberUsers',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to modify the member users of a given
+     * user group, returning a promise that can be used for processing the
+     * results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     whose member users should be modified. This identifier corresponds
+     *     to an AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to modify the member users of.
+     *
+     * @param {String[]} [usersToAdd]
+     *     The identifier of all users to add as members of the given user
+     *     group, if any.
+     *
+     * @param {String[]} [usersToRemove]
+     *     The identifier of all users to remove from the given user group,
+     *     if any.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchMemberUsers = function patchMemberUsers(dataSource, identifier,
+            usersToAdd, usersToRemove) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update member users
+        return requestService({
+            method  : 'PATCH',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier) + '/memberUsers',
+            params  : httpParameters,
+            data    : getRelatedObjectPatch(usersToAdd, usersToRemove)
+        })
+
+        // Clear the cache
+        .then(function memberUsersChanged(){
+            cacheService.users.removeAll();
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to retrieve the identifiers of all
+     * user groups which are members of the given user group, returning a
+     * promise that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     whose member user groups should be retrieved. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to retrieve the member user
+     *     groups of.
+     *
+     * @returns {Promise.<String[]>}
+     *     A promise for the HTTP call which will resolve with an array
+     *     containing the requested identifiers upon success.
+     */
+    service.getMemberUserGroups = function getMemberUserGroups(dataSource, identifier) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve member user groups
+        return requestService({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier) + '/memberUserGroups',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to modify the member user groups of a
+     * given user group, returning a promise that can be used for processing
+     * the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group
+     *     whose member user groups should be modified. This identifier
+     *     corresponds to an AuthenticationProvider within the Guacamole web
+     *     application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to modify the member user groups of.
+     *
+     * @param {String[]} [userGroupsToAdd]
+     *     The identifier of all user groups to add as members of the given
+     *     user group, if any.
+     *
+     * @param {String[]} [userGroupsToRemove]
+     *     The identifier of all member user groups to remove from the given
+     *     user group, if any.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     patch operation is successful.
+     */
+    service.patchMemberUserGroups = function patchMemberUserGroups(dataSource,
+            identifier, userGroupsToAdd, userGroupsToRemove) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update member user groups
+        return requestService({
+            method  : 'PATCH',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier) + '/memberUserGroups',
+            params  : httpParameters,
+            data    : getRelatedObjectPatch(userGroupsToAdd, userGroupsToRemove)
+        })
+
+        // Clear the cache
+        .then(function memberUserGroupsChanged(){
+            cacheService.users.removeAll();
+        });
+
+    };
+    
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/services/permissionService.js b/guacamole/src/main/webapp/app/rest/services/permissionService.js
index 6d3dfdf..21c5a02 100644
--- a/guacamole/src/main/webapp/app/rest/services/permissionService.js
+++ b/guacamole/src/main/webapp/app/rest/services/permissionService.js
@@ -45,6 +45,11 @@
      * within that data source (and thus cannot be found beneath
      * "api/session/data/{dataSource}/users")
      *
+     * NOTE: Unlike getPermissionsResourceURL(),
+     * getEffectivePermissionsResourceURL() CANNOT be applied to user groups.
+     * Only users have retrievable effective permissions as far as the REST API
+     * is concerned.
+     *
      * @param {String} dataSource
      *     The unique identifier of the data source containing the user whose
      *     permissions should be retrieved. This identifier corresponds to an
@@ -82,6 +87,10 @@
      * from the permissions returned via getPermissions() in that permissions
      * which are not directly granted to the user are included.
      *
+     * NOTE: Unlike getPermissions(), getEffectivePermissions() CANNOT be
+     * applied to user groups. Only users have retrievable effective
+     * permissions as far as the REST API is concerned.
+     *
      * @param {String} dataSource
      *     The unique identifier of the data source containing the user whose
      *     permissions should be retrieved. This identifier corresponds to an
@@ -113,10 +122,10 @@
 
     /**
      * Returns the URL for the REST resource most appropriate for accessing
-     * the permissions of the user having the given identifier. The permissions
-     * retrieved differ from effective permissions (those returned by
-     * getEffectivePermissions()) in that only permissions which are directly
-     * granted to the user are included.
+     * the permissions of the user or group having the given identifier. The
+     * permissions retrieved differ from effective permissions (those returned
+     * by getEffectivePermissions()) in that only permissions which are directly
+     * granted to the user or group are included.
      * 
      * It is important to note that a particular data source can authenticate
      * and provide permissions for a user, even if that user does not exist
@@ -129,18 +138,27 @@
      *     AuthenticationProvider within the Guacamole web application.
      *
      * @param {String} identifier
-     *     The identifier of the user for which the URL of the proper REST
-     *     resource should be derived.
+     *     The identifier of the user or group for which the URL of the proper
+     *     REST resource should be derived.
+     *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
      *
      * @returns {String}
-     *     The URL for the REST resource representing the user having the given
-     *     identifier.
+     *     The URL for the REST resource representing the user or group having
+     *     the given identifier.
      */
-    var getPermissionsResourceURL = function getPermissionsResourceURL(dataSource, identifier) {
+    var getPermissionsResourceURL = function getPermissionsResourceURL(dataSource, identifier, group) {
 
         // Create base URL for data source
         var base = 'api/session/data/' + encodeURIComponent(dataSource);
 
+        // Access group permissions directly (there is no "self" for user groups
+        // as there is for users)
+        if (group)
+            return base + '/userGroups/' + encodeURIComponent(identifier) + '/permissions';
+
         // If the username is that of the current user, do not rely on the
         // user actually existing (they may not). Access their permissions via
         // "self" rather than the collection of defined users.
@@ -155,36 +173,41 @@
 
     /**
      * Makes a request to the REST API to get the list of permissions for a
-     * given user, returning a promise that provides an array of
+     * given user or user group, returning a promise that provides an array of
      * @link{Permission} objects if successful. The permissions retrieved
      * differ from effective permissions (those returned by
-     * getEffectivePermissions()) in that only permissions which are directly
-     * granted to the user included.
+     * getEffectivePermissions()) in that both users and groups may be queried,
+     * and only permissions which are directly granted to the user or group are
+     * included.
      * 
      * @param {String} dataSource
-     *     The unique identifier of the data source containing the user whose
-     *     permissions should be retrieved. This identifier corresponds to an
-     *     AuthenticationProvider within the Guacamole web application.
+     *     The unique identifier of the data source containing the user or group
+     *     whose permissions should be retrieved. This identifier corresponds to
+     *     an AuthenticationProvider within the Guacamole web application.
      *
      * @param {String} identifier
-     *     The identifier of the user to retrieve the permissions for.
+     *     The identifier of the user or group to retrieve the permissions for.
+     *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
      *
      * @returns {Promise.<PermissionSet>}
      *     A promise which will resolve with a @link{PermissionSet} upon
      *     success.
      */
-    service.getPermissions = function getPermissions(dataSource, identifier) {
+    service.getPermissions = function getPermissions(dataSource, identifier, group) {
 
         // Build HTTP parameters set
         var httpParameters = {
             token : authenticationService.getCurrentToken()
         };
 
-        // Retrieve user permissions
+        // Retrieve user/group permissions
         return requestService({
             cache   : cacheService.users,
             method  : 'GET',
-            url     : getPermissionsResourceURL(dataSource, identifier),
+            url     : getPermissionsResourceURL(dataSource, identifier, group),
             params  : httpParameters
         });
 
@@ -261,6 +284,10 @@
         addObjectPatchOperations(patch, operation, "/userPermissions",
             permissions.userPermissions);
 
+        // Add user group permission operations to patch
+        addObjectPatchOperations(patch, operation, "/userGroupPermissions",
+            permissions.userGroupPermissions);
+
         // Add system operations to patch
         permissions.systemPermissions.forEach(function addSystemPatch(type) {
             patch.push({
@@ -274,18 +301,18 @@
             
     /**
      * Makes a request to the REST API to modify the permissions for a given
-     * user, returning a promise that can be used for processing the results of
-     * the call. This request affects only the permissions directly granted to
-     * the user, and may not affect permissions inherited through other means
-     * (effective permissions).
+     * user or group, returning a promise that can be used for processing the
+     * results of the call. This request affects only the permissions directly
+     * granted to the user or group, and may not affect permissions inherited
+     * through other means (effective permissions).
      * 
      * @param {String} dataSource
-     *     The unique identifier of the data source containing the user whose
-     *     permissions should be modified. This identifier corresponds to an
-     *     AuthenticationProvider within the Guacamole web application.
+     *     The unique identifier of the data source containing the user or group
+     *     whose permissions should be modified. This identifier corresponds to
+     *     an AuthenticationProvider within the Guacamole web application.
      *
      * @param {String} identifier
-     *     The identifier of the user to modify the permissions of.
+     *     The identifier of the user or group to modify the permissions of.
      *                          
      * @param {PermissionSet} [permissionsToAdd]
      *     The set of permissions to add, if any.
@@ -293,12 +320,16 @@
      * @param {PermissionSet} [permissionsToRemove]
      *     The set of permissions to remove, if any.
      *
+     * @param {Boolean} [group]
+     *     Whether the provided identifier refers to a user group. If false or
+     *     omitted, the identifier given is assumed to refer to a user.
+     *
      * @returns {Promise}
      *     A promise for the HTTP call which will succeed if and only if the
      *     patch operation is successful.
      */
     service.patchPermissions = function patchPermissions(dataSource, identifier,
-            permissionsToAdd, permissionsToRemove) {
+            permissionsToAdd, permissionsToRemove, group) {
 
         var permissionPatch = [];
         
@@ -313,10 +344,10 @@
         // Add all the remove operations to the patch
         addPatchOperations(permissionPatch, PermissionPatch.Operation.REMOVE, permissionsToRemove);
 
-        // Patch user permissions
+        // Patch user/group permissions
         return requestService({
             method  : 'PATCH', 
-            url     : getPermissionsResourceURL(dataSource, identifier),
+            url     : getPermissionsResourceURL(dataSource, identifier, group),
             params  : httpParameters,
             data    : permissionPatch
         })
diff --git a/guacamole/src/main/webapp/app/rest/services/schemaService.js b/guacamole/src/main/webapp/app/rest/services/schemaService.js
index cc871d8..61c8639 100644
--- a/guacamole/src/main/webapp/app/rest/services/schemaService.js
+++ b/guacamole/src/main/webapp/app/rest/services/schemaService.js
@@ -66,6 +66,40 @@
 
     /**
      * 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
+     * @link{Form} objects if successful. Each element of the array describes
+     * a logical grouping of possible attributes.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user groups
+     *     whose available 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.getUserGroupAttributes = function getUserGroupAttributes(dataSource) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve available user group attributes
+        return requestService({
+            cache   : cacheService.schema,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/schema/userGroupAttributes',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the list of available attributes
      * for connection objects, returning a promise that provides an array of
      * @link{Form} objects if successful. Each element of the array describes
      * a logical grouping of possible attributes.
diff --git a/guacamole/src/main/webapp/app/rest/services/userGroupService.js b/guacamole/src/main/webapp/app/rest/services/userGroupService.js
new file mode 100644
index 0000000..ad29837
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/services/userGroupService.js
@@ -0,0 +1,223 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service for operating on user groups via the REST API.
+ */
+angular.module('rest').factory('userGroupService', ['$injector',
+        function userGroupService($injector) {
+
+    // Required services
+    var requestService        = $injector.get('requestService');
+    var authenticationService = $injector.get('authenticationService');
+    var cacheService          = $injector.get('cacheService');
+
+    var service = {};
+
+    /**
+     * Makes a request to the REST API to get the list of user groups,
+     * returning a promise that provides an array of @link{UserGroup} objects if
+     * successful.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user groups
+     *     to be retrieved. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String[]} [permissionTypes]
+     *     The set of permissions to filter with. A user group must have one or
+     *     more of these permissions for a user group to appear in the result.
+     *     If null, no filtering will be performed. Valid values are listed
+     *     within PermissionSet.ObjectType.
+     *
+     * @returns {Promise.<Object.<String, UserGroup>>}
+     *     A promise which will resolve with a map of @link{UserGroup} objects
+     *     where each key is the identifier of the corresponding user group.
+     */
+    service.getUserGroups = function getUserGroups(dataSource, permissionTypes) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Add permission filter if specified
+        if (permissionTypes)
+            httpParameters.permission = permissionTypes;
+
+        // Retrieve user groups
+        return requestService({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups',
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to get the user group having the given
+     * identifier, returning a promise that provides the corresponding
+     * @link{UserGroup} if successful.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group to
+     *     be retrieved. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to retrieve.
+     *
+     * @returns {Promise.<UserGroup>}
+     *     A promise which will resolve with a @link{UserGroup} upon success.
+     */
+    service.getUserGroup = function getUserGroup(dataSource, identifier) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Retrieve user group
+        return requestService({
+            cache   : cacheService.users,
+            method  : 'GET',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(identifier),
+            params  : httpParameters
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to delete a user group, returning a
+     * promise that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group to
+     *     be deleted. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {UserGroup} userGroup
+     *     The user group to delete.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     delete operation is successful.
+     */
+    service.deleteUserGroup = function deleteUserGroup(dataSource, userGroup) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Delete user group
+        return requestService({
+            method  : 'DELETE',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(userGroup.identifier),
+            params  : httpParameters
+        })
+
+        // Clear the cache
+        .then(function userGroupDeleted(){
+            cacheService.users.removeAll();
+        });
+
+
+    };
+
+    /**
+     * Makes a request to the REST API to create a user group, returning a promise
+     * that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source in which the user group
+     *     should be created. This identifier corresponds to an
+     *     AuthenticationProvider within the Guacamole web application.
+     *
+     * @param {UserGroup} userGroup
+     *     The user group to create.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     create operation is successful.
+     */
+    service.createUserGroup = function createUserGroup(dataSource, userGroup) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Create user group
+        return requestService({
+            method  : 'POST',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups',
+            params  : httpParameters,
+            data    : userGroup
+        })
+
+        // Clear the cache
+        .then(function userGroupCreated(){
+            cacheService.users.removeAll();
+        });
+
+    };
+
+    /**
+     * Makes a request to the REST API to save a user group, returning a
+     * promise that can be used for processing the results of the call.
+     *
+     * @param {String} dataSource
+     *     The unique identifier of the data source containing the user group to
+     *     be updated. This identifier corresponds to an AuthenticationProvider
+     *     within the Guacamole web application.
+     *
+     * @param {UserGroup} userGroup
+     *     The user group to update.
+     *
+     * @returns {Promise}
+     *     A promise for the HTTP call which will succeed if and only if the
+     *     save operation is successful.
+     */
+    service.saveUserGroup = function saveUserGroup(dataSource, userGroup) {
+
+        // Build HTTP parameters set
+        var httpParameters = {
+            token : authenticationService.getCurrentToken()
+        };
+
+        // Update user group
+        return requestService({
+            method  : 'PUT',
+            url     : 'api/session/data/' + encodeURIComponent(dataSource) + '/userGroups/' + encodeURIComponent(userGroup.identifier),
+            params  : httpParameters,
+            data    : userGroup
+        })
+
+        // Clear the cache
+        .then(function userGroupUpdated(){
+            cacheService.users.removeAll();
+        });
+
+    };
+
+    return service;
+
+}]);
diff --git a/guacamole/src/main/webapp/app/rest/types/Connection.js b/guacamole/src/main/webapp/app/rest/types/Connection.js
index 76ece9d..89da4e1 100644
--- a/guacamole/src/main/webapp/app/rest/types/Connection.js
+++ b/guacamole/src/main/webapp/app/rest/types/Connection.js
@@ -84,7 +84,7 @@
          *
          * @type Object.<String, String>
          */
-        this.attributes = {};
+        this.attributes = template.attributes || {};
 
         /**
          * The count of currently active connections using this connection.
diff --git a/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js b/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js
index a40dba1..6da754c 100644
--- a/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js
+++ b/guacamole/src/main/webapp/app/rest/types/ConnectionGroup.js
@@ -95,7 +95,7 @@
          *
          * @type Object.<String, String>
          */
-        this.attributes = {};
+        this.attributes = template.attributes || {};
 
         /**
          * The count of currently active connections using this connection
diff --git a/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js b/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js
index 64b942b..f79e3b9 100644
--- a/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js
+++ b/guacamole/src/main/webapp/app/rest/types/PermissionFlagSet.js
@@ -133,7 +133,7 @@
          * true. Valid permission type strings are defined within
          * PermissionSet.ObjectPermissionType. Permissions which are not
          * granted may be set to false, but this is not required.
-         * 
+         *
          * @type Object.<String, Object.<String, Boolean>>
          */
         this.userPermissions = template.userPermissions || {
@@ -143,6 +143,24 @@
             'ADMINISTER' : {}
         };
 
+        /**
+         * The granted state of each permission for each user group, as a map of
+         * object permission type string to permission map. The permission map
+         * is, in turn, a map of group identifier to boolean value. A particular
+         * permission is granted if its corresponding boolean value is set to
+         * true. Valid permission type strings are defined within
+         * PermissionSet.ObjectPermissionType. Permissions which are not
+         * granted may be set to false, but this is not required.
+         *
+         * @type Object.<String, Object.<String, Boolean>>
+         */
+        this.userGroupPermissions = template.userGroupPermissions || {
+            'READ'       : {},
+            'UPDATE'     : {},
+            'DELETE'     : {},
+            'ADMINISTER' : {}
+        };
+
     };
 
     /**
@@ -216,6 +234,9 @@
         // Add all granted user permissions
         addObjectPermissions(permissionSet.userPermissions, permissionFlagSet.userPermissions);
 
+        // Add all granted user group permissions
+        addObjectPermissions(permissionSet.userGroupPermissions, permissionFlagSet.userGroupPermissions);
+
         return permissionFlagSet;
 
     };
diff --git a/guacamole/src/main/webapp/app/rest/types/PermissionSet.js b/guacamole/src/main/webapp/app/rest/types/PermissionSet.js
index 8fd1ef6..9dd1ac8 100644
--- a/guacamole/src/main/webapp/app/rest/types/PermissionSet.js
+++ b/guacamole/src/main/webapp/app/rest/types/PermissionSet.js
@@ -82,6 +82,15 @@
         this.userPermissions = template.userPermissions || {};
 
         /**
+         * Map of user group identifiers to the corresponding array of granted
+         * permissions. Each permission is represented by a string listed
+         * within PermissionSet.ObjectPermissionType.
+         *
+         * @type Object.<String, String[]>
+         */
+        this.userGroupPermissions = template.userGroupPermissions || {};
+
+        /**
          * Array of granted system permissions. Each permission is represented
          * by a string listed within PermissionSet.SystemPermissionType.
          *
@@ -134,6 +143,11 @@
         CREATE_USER : "CREATE_USER",
 
         /**
+         * Permission to create new user groups.
+         */
+        CREATE_USER_GROUP : "CREATE_USER_GROUP",
+
+        /**
          * Permission to create new connections.
          */
         CREATE_CONNECTION : "CREATE_CONNECTION",
@@ -306,7 +320,7 @@
     };
 
     /**
-     * Returns whether the given permission is granted for the user having the 
+     * Returns whether the given permission is granted for the user having the
      * given ID.
      *
      * @param {PermissionSet|Object} permSet
@@ -315,7 +329,7 @@
      * @param {String} type
      *     The permission to search for, as defined by
      *     PermissionSet.ObjectPermissionType.
-     *     
+     *
      * @param {String} identifier
      *     The identifier of the user to which the permission applies.
      *
@@ -327,6 +341,27 @@
     };
 
     /**
+     * Returns whether the given permission is granted for the user group having
+     * the given identifier.
+     *
+     * @param {PermissionSet|Object} permSet
+     *     The permission set to check.
+     *
+     * @param {String} type
+     *     The permission to search for, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission is present (granted), false otherwise.
+     */
+    PermissionSet.hasUserGroupPermission = function hasUserGroupPermission(permSet, type, identifier) {
+        return hasPermission(permSet.userGroupPermissions, type, identifier);
+    };
+
+    /**
      * Returns whether the given permission is granted at the system level.
      *
      * @param {PermissionSet|Object} permSet
@@ -733,6 +768,54 @@
         return removeObjectPermission(permSet.userPermissions, type, identifier);
     };
 
+    /**
+     * Adds the given user group permission applying to the user group with the
+     * given identifier to the given permission set, if not already present. If
+     * the permission is already present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to add, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to which the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was added, false if the permission was
+     *     already present in the given permission set.
+     */
+    PermissionSet.addUserGroupPermission = function addUserGroupPermission(permSet, type, identifier) {
+        permSet.userGroupPermissions = permSet.userGroupPermissions || {};
+        return addObjectPermission(permSet.userGroupPermissions, type, identifier);
+    };
+
+    /**
+     * Removes the given user group permission applying to the user group with
+     * the given identifier from the given permission set, if present. If the
+     * permission is not present, this function has no effect.
+     *
+     * @param {PermissionSet} permSet
+     *     The permission set to modify.
+     *
+     * @param {String} type
+     *     The permission to remove, as defined by
+     *     PermissionSet.ObjectPermissionType.
+     *
+     * @param {String} identifier
+     *     The identifier of the user group to whom the permission applies.
+     *
+     * @returns {Boolean}
+     *     true if the permission was removed, false if the permission was not
+     *     present in the given permission set.
+     */
+    PermissionSet.removeUserGroupPermission = function removeUserGroupPermission(permSet, type, identifier) {
+        permSet.userGroupPermissions = permSet.userGroupPermissions || {};
+        return removeObjectPermission(permSet.userGroupPermissions, type, identifier);
+    };
+
     return PermissionSet;
 
 }]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/RelatedObjectPatch.js b/guacamole/src/main/webapp/app/rest/types/RelatedObjectPatch.js
new file mode 100644
index 0000000..bb82def
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/RelatedObjectPatch.js
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service which defines the RelatedObjectPatch class.
+ */
+angular.module('rest').factory('RelatedObjectPatch', [function defineRelatedObjectPatch() {
+            
+    /**
+     * The object returned by REST API calls when representing changes to an
+     * arbitrary set of objects which share some common relation.
+     * 
+     * @constructor
+     * @param {RelatedObjectPatch|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     RelatedObjectPatch.
+     */
+    var RelatedObjectPatch = function RelatedObjectPatch(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The operation to apply to the objects indicated by the path. Valid
+         * operation values are defined within RelatedObjectPatch.Operation.
+         *
+         * @type String
+         */
+        this.op = template.op;
+
+        /**
+         * The path of the objects to modify. This will always be "/".
+         *
+         * @type String
+         * @default '/'
+         */
+        this.path = template.path || '/';
+
+        /**
+         * The identifier of the object being added or removed from the
+         * relation.
+         *
+         * @type String
+         */
+        this.value = template.value;
+
+    };
+
+    /**
+     * All valid patch operations for objects sharing some common relation.
+     * Currently, only add and remove are supported.
+     */
+    RelatedObjectPatch.Operation = {
+
+        /**
+         * Adds the specified object to the relation.
+         */
+        ADD : "add",
+
+        /**
+         * Removes the specified object from the relation.
+         */
+        REMOVE : "remove"
+
+    };
+
+    return RelatedObjectPatch;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/rest/types/SharingProfile.js b/guacamole/src/main/webapp/app/rest/types/SharingProfile.js
index ea8287d..50f1307 100644
--- a/guacamole/src/main/webapp/app/rest/types/SharingProfile.js
+++ b/guacamole/src/main/webapp/app/rest/types/SharingProfile.js
@@ -76,7 +76,7 @@
          *
          * @type Object.<String, String>
          */
-        this.attributes = {};
+        this.attributes = template.attributes || {};
 
     };
 
diff --git a/guacamole/src/main/webapp/app/rest/types/User.js b/guacamole/src/main/webapp/app/rest/types/User.js
index f796147..3ca138d 100644
--- a/guacamole/src/main/webapp/app/rest/types/User.js
+++ b/guacamole/src/main/webapp/app/rest/types/User.js
@@ -69,7 +69,7 @@
          *
          * @type Object.<String, String>
          */
-        this.attributes = {};
+        this.attributes = template.attributes || {};
 
     };
 
diff --git a/guacamole/src/main/webapp/app/rest/types/UserGroup.js b/guacamole/src/main/webapp/app/rest/types/UserGroup.js
new file mode 100644
index 0000000..f4bf26c
--- /dev/null
+++ b/guacamole/src/main/webapp/app/rest/types/UserGroup.js
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+/**
+ * Service which defines the UserGroup class.
+ */
+angular.module('rest').factory('UserGroup', [function defineUserGroup() {
+
+    /**
+     * The object returned by REST API calls when representing the data
+     * associated with a user group.
+     *
+     * @constructor
+     * @param {UserGroup|Object} [template={}]
+     *     The object whose properties should be copied within the new
+     *     UserGroup.
+     */
+    var UserGroup = function UserGroup(template) {
+
+        // Use empty object by default
+        template = template || {};
+
+        /**
+         * The name which uniquely identifies this user group.
+         *
+         * @type String
+         */
+        this.identifier = template.identifier;
+
+        /**
+         * Arbitrary name/value pairs which further describe this user group.
+         * The semantics and validity of these attributes are dictated by the
+         * extension which defines them.
+         *
+         * @type Object.<String, String>
+         */
+        this.attributes = template.attributes || {};
+
+    };
+
+    return UserGroup;
+
+}]);
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/app/settings/controllers/settingsController.js b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
index 91ef633..a462d87 100644
--- a/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
+++ b/guacamole/src/main/webapp/app/settings/controllers/settingsController.js
@@ -36,8 +36,8 @@
     $scope.settingsPages = null;
 
     /**
-     * The currently-selected settings tab. This may be 'users', 'connections',
-     * or 'sessions'.
+     * The currently-selected settings tab. This may be 'users', 'userGroups',
+     * 'connections', 'history', 'preferences', or 'sessions'.
      *
      * @type String
      */
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js
new file mode 100644
index 0000000..a8f2dd3
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUserGroups.js
@@ -0,0 +1,272 @@
+/*
+ * 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.
+ */
+
+/**
+ * A directive for managing all user groups in the system.
+ */
+angular.module('settings').directive('guacSettingsUserGroups', ['$injector',
+    function guacSettingsUserGroups($injector) {
+
+    // Required types
+    var ManageableUserGroup = $injector.get('ManageableUserGroup');
+    var PermissionSet       = $injector.get('PermissionSet');
+    var SortOrder           = $injector.get('SortOrder');
+
+    // Required services
+    var $location              = $injector.get('$location');
+    var authenticationService  = $injector.get('authenticationService');
+    var dataSourceService      = $injector.get('dataSourceService');
+    var permissionService      = $injector.get('permissionService');
+    var requestService         = $injector.get('requestService');
+    var userGroupService       = $injector.get('userGroupService');
+
+    var directive = {
+        restrict    : 'E',
+        replace     : true,
+        templateUrl : 'app/settings/templates/settingsUserGroups.html',
+        scope       : {}
+    };
+
+    directive.controller = ['$scope', function settingsUserGroupsController($scope) {
+
+        // Identifier of the current user
+        var currentUsername = authenticationService.getCurrentUsername();
+
+        /**
+         * The identifiers of all data sources accessible by the current
+         * user.
+         *
+         * @type String[]
+         */
+        var dataSources = authenticationService.getAvailableDataSources();
+
+        /**
+         * Map of data source identifiers to all permissions associated
+         * with the current user within that data source, or null if the
+         * user's permissions have not yet been loaded.
+         *
+         * @type Object.<String, PermissionSet>
+         */
+        var permissions = null;
+
+        /**
+         * All visible user groups, along with their corresponding data
+         * sources.
+         *
+         * @type ManageableUserGroup[]
+         */
+        $scope.manageableUserGroups = null;
+
+        /**
+         * Array of all user group properties that are filterable.
+         *
+         * @type String[]
+         */
+        $scope.filteredUserGroupProperties = [
+            'userGroup.identifier'
+        ];
+
+        /**
+         * SortOrder instance which stores the sort order of the listed
+         * user groups.
+         *
+         * @type SortOrder
+         */
+        $scope.order = new SortOrder([
+            'userGroup.identifier'
+        ]);
+
+        /**
+         * Returns whether critical data has completed being loaded.
+         *
+         * @returns {Boolean}
+         *     true if enough data has been loaded for the user group
+         *     interface to be useful, false otherwise.
+         */
+        $scope.isLoaded = function isLoaded() {
+            return $scope.manageableUserGroups !== null;
+        };
+
+        /**
+         * Returns the identifier of the data source that should be used by
+         * default when creating a new user group.
+         *
+         * @return {String}
+         *     The identifier of the data source that should be used by
+         *     default when creating a new user group, or null if user group
+         *     creation is not allowed.
+         */
+        $scope.getDefaultDataSource = function getDefaultDataSource() {
+
+            // Abort if permissions have not yet loaded
+            if (!permissions)
+                return null;
+
+            // For each data source
+            var dataSources = _.keys(permissions).sort();
+            for (var i = 0; i < dataSources.length; i++) {
+
+                // Retrieve corresponding permission set
+                var dataSource = dataSources[i];
+                var permissionSet = permissions[dataSource];
+
+                // Can create user groups if adminstrator or have explicit permission
+                if (PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.ADMINISTER)
+                 || PermissionSet.hasSystemPermission(permissionSet, PermissionSet.SystemPermissionType.CREATE_USER_GROUP))
+                    return dataSource;
+
+            }
+
+            // No data sources allow user group creation
+            return null;
+
+        };
+
+        /**
+         * Returns whether the current user can create new user groups
+         * within at least one data source.
+         *
+         * @return {Boolean}
+         *     true if the current user can create new user groups within at
+         *     least one data source, false otherwise.
+         */
+        $scope.canCreateUserGroups = function canCreateUserGroups() {
+            return $scope.getDefaultDataSource() !== null;
+        };
+
+        /**
+         * Returns whether the current user can create new user groups or
+         * make changes to existing user groups within at least one data
+         * source. The user group management interface as a whole is useless
+         * if this function returns false.
+         *
+         * @return {Boolean}
+         *     true if the current user can create new user groups or make
+         *     changes to existing user groups within at least one data
+         *     source, false otherwise.
+         */
+        var canManageUserGroups = function canManageUserGroups() {
+
+            // Abort if permissions have not yet loaded
+            if (!permissions)
+                return false;
+
+            // Creating user groups counts as management
+            if ($scope.canCreateUserGroups())
+                return true;
+
+            // For each data source
+            for (var dataSource in permissions) {
+
+                // Retrieve corresponding permission set
+                var permissionSet = permissions[dataSource];
+
+                // Can manage user groups if granted explicit update or delete
+                if (PermissionSet.hasUserGroupPermission(permissionSet, PermissionSet.ObjectPermissionType.UPDATE)
+                 || PermissionSet.hasUserGroupPermission(permissionSet, PermissionSet.ObjectPermissionType.DELETE))
+                    return true;
+
+            }
+
+            // No data sources allow management of user groups
+            return false;
+
+        };
+
+        /**
+         * Sets the displayed list of user groups. If any user groups are
+         * already shown within the interface, those user groups are replaced
+         * with the given user groups.
+         *
+         * @param {Object.<String, PermissionSet>} permissions
+         *     A map of data source identifiers to all permissions associated
+         *     with the current user within that data source.
+         *
+         * @param {Object.<String, Object.<String, UserGroup>>} userGroups
+         *     A map of all user groups which should be displayed, where each
+         *     key is the data source identifier from which the user groups
+         *     were retrieved and each value is a map of user group identifiers
+         *     to their corresponding @link{UserGroup} objects.
+         */
+        var setDisplayedUserGroups = function setDisplayedUserGroups(permissions, userGroups) {
+
+            var addedUserGroups = {};
+            $scope.manageableUserGroups = [];
+
+            // For each user group in each data source
+            angular.forEach(dataSources, function addUserGroupList(dataSource) {
+                angular.forEach(userGroups[dataSource], function addUserGroup(userGroup) {
+
+                    // Do not add the same user group twice
+                    if (addedUserGroups[userGroup.identifier])
+                        return;
+
+                    // Link to default creation data source if we cannot manage this user
+                    if (!PermissionSet.hasSystemPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.ADMINISTER)
+                     && !PermissionSet.hasUserGroupPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.UPDATE, userGroup.identifier)
+                     && !PermissionSet.hasUserGroupPermission(permissions[dataSource], PermissionSet.ObjectPermissionType.DELETE, userGroup.identifier))
+                        dataSource = $scope.getDefaultDataSource();
+
+                    // Add user group to overall list
+                    addedUserGroups[userGroup.identifier] = userGroup;
+                    $scope.manageableUserGroups.push(new ManageableUserGroup ({
+                        'dataSource' : dataSource,
+                        'userGroup'  : userGroup
+                    }));
+
+                });
+            });
+
+        };
+
+        // Retrieve current permissions
+        dataSourceService.apply(
+            permissionService.getEffectivePermissions,
+            dataSources,
+            currentUsername
+        )
+        .then(function permissionsRetrieved(retrievedPermissions) {
+
+            // Store retrieved permissions
+            permissions = retrievedPermissions;
+
+            // Return to home if there's nothing to do here
+            if (!canManageUserGroups())
+                $location.path('/');
+
+            // If user groups can be created, list all readable user groups
+            if ($scope.canCreateUserGroups())
+                return dataSourceService.apply(userGroupService.getUserGroups, dataSources);
+
+            // Otherwise, list only updateable/deletable users
+            return dataSourceService.apply(userGroupService.getUserGroups, dataSources, [
+                PermissionSet.ObjectPermissionType.UPDATE,
+                PermissionSet.ObjectPermissionType.DELETE
+            ]);
+
+        })
+        .then(function userGroupsReceived(userGroups) {
+            setDisplayedUserGroups(permissions, userGroups);
+        }, requestService.WARN);
+
+    }];
+
+    return directive;
+    
+}]);
diff --git a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
index bc7a601..94435ba 100644
--- a/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
+++ b/guacamole/src/main/webapp/app/settings/directives/guacSettingsUsers.js
@@ -150,9 +150,11 @@
                     return null;
 
                 // For each data source
-                for (var dataSource in $scope.permissions) {
+                var dataSources = _.keys($scope.permissions).sort();
+                for (var i = 0; i < dataSources.length; i++) {
 
                     // Retrieve corresponding permission set
+                    var dataSource = dataSources[i];
                     var permissionSet = $scope.permissions[dataSource];
 
                     // Can create users if adminstrator or have explicit permission
diff --git a/guacamole/src/main/webapp/app/settings/styles/buttons.css b/guacamole/src/main/webapp/app/settings/styles/buttons.css
index 17401c3..e530510 100644
--- a/guacamole/src/main/webapp/app/settings/styles/buttons.css
+++ b/guacamole/src/main/webapp/app/settings/styles/buttons.css
@@ -18,6 +18,7 @@
  */
 
 a.button.add-user,
+a.button.add-user-group,
 a.button.add-connection,
 a.button.add-connection-group {
     font-size: 0.8em;
@@ -26,6 +27,7 @@
 }
 
 a.button.add-user::before,
+a.button.add-user-group::before,
 a.button.add-connection::before,
 a.button.add-connection-group::before {
 
@@ -46,6 +48,10 @@
     background-image: url('images/action-icons/guac-user-add.png');
 }
 
+a.button.add-user-group::before {
+    background-image: url('images/action-icons/guac-user-group-add.png');
+}
+
 a.button.add-connection::before {
     background-image: url('images/action-icons/guac-monitor-add.png');
 }
diff --git a/guacamole/src/main/webapp/app/settings/styles/user-group-list.css b/guacamole/src/main/webapp/app/settings/styles/user-group-list.css
new file mode 100644
index 0000000..2040eb4
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/styles/user-group-list.css
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+.settings.user-groups table.user-group-list {
+    width: 100%;
+}
+
+.settings.user-groups table.user-group-list th.user-group-name,
+.settings.user-groups table.user-group-list td.user-group-name {
+    width: 100%;
+}
+
+.settings.user-groups table.user-group-list tr.user td.user-group-name a[href] {
+    display: block;
+    padding: .5em 1em;
+}
+
+.settings.user-groups table.user-group-list tr.user td.user-group-name {
+    padding: 0;
+}
diff --git a/guacamole/src/main/webapp/app/settings/templates/settings.html b/guacamole/src/main/webapp/app/settings/templates/settings.html
index b29d809..2bae3ae 100644
--- a/guacamole/src/main/webapp/app/settings/templates/settings.html
+++ b/guacamole/src/main/webapp/app/settings/templates/settings.html
@@ -13,6 +13,7 @@
 
     <!-- Selected tab -->
     <guac-settings-users                ng-if="activeTab === 'users'"></guac-settings-users>
+    <guac-settings-user-groups          ng-if="activeTab === 'userGroups'"></guac-settings-user-groups>
     <guac-settings-connections          ng-if="activeTab === 'connections'"></guac-settings-connections>
     <guac-settings-connection-history   ng-if="activeTab === 'history'"></guac-settings-connection-history>
     <guac-settings-sessions             ng-if="activeTab === 'sessions'"></guac-settings-sessions>
diff --git a/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html b/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html
new file mode 100644
index 0000000..1943773
--- /dev/null
+++ b/guacamole/src/main/webapp/app/settings/templates/settingsUserGroups.html
@@ -0,0 +1,48 @@
+<div class="settings section user-groups" ng-class="{loading: !isLoaded()}">
+
+    <!-- User group management -->
+    <p>{{'SETTINGS_USER_GROUPS.HELP_USER_GROUPS' | translate}}</p>
+
+
+    <!-- User management toolbar -->
+    <div class="toolbar">
+
+        <!-- Form action buttons -->
+        <div class="action-buttons">
+            <a class="add-user-group button" ng-show="canCreateUserGroups()"
+               href="#/manage/{{getDefaultDataSource()}}/userGroups/">{{'SETTINGS_USER_GROUPS.ACTION_NEW_USER_GROUP' | translate}}</a>
+        </div>
+
+        <!-- User group filter -->
+        <guac-filter filtered-items="filteredManageableUserGroups" items="manageableUserGroups"
+                     placeholder="'SETTINGS_USER_GROUPS.FIELD_PLACEHOLDER_FILTER' | translate"
+                     properties="filteredUserGroupProperties"></guac-filter>
+
+    </div>
+
+    <!-- List of user groups this user has access to -->
+    <table class="sorted user-group-list">
+        <thead>
+            <tr>
+                <th guac-sort-order="order" guac-sort-property="'userGroup.identifier'" class="user-group-name">
+                    {{'SETTINGS_USER_GROUPS.TABLE_HEADER_USER_GROUP_NAME' | translate}}
+                </th>
+            </tr>
+        </thead>
+        <tbody ng-class="{loading: !isLoaded()}">
+            <tr ng-repeat="manageableUserGroup in manageableUserGroupPage" class="user-group">
+                <td class="user-group-name">
+                    <a ng-href="#/manage/{{manageableUserGroup.dataSource}}/userGroups/{{manageableUserGroup.userGroup.identifier}}">
+                        <div class="icon user-group"></div>
+                        <span class="name">{{manageableUserGroup.userGroup.identifier}}</span>
+                    </a>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+
+    <!-- Pager controls for user group list -->
+    <guac-pager page="manageableUserGroupPage" page-size="25"
+                items="filteredManageableUserGroups | orderBy : order.predicate"></guac-pager>
+
+</div>
\ No newline at end of file
diff --git a/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png b/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png
new file mode 100644
index 0000000..a833433
--- /dev/null
+++ b/guacamole/src/main/webapp/images/action-icons/guac-user-group-add.png
Binary files differ
diff --git a/guacamole/src/main/webapp/images/arrows/right.png b/guacamole/src/main/webapp/images/arrows/right.png
new file mode 100644
index 0000000..1b3483e
--- /dev/null
+++ b/guacamole/src/main/webapp/images/arrows/right.png
Binary files differ
diff --git a/guacamole/src/main/webapp/images/user-icons/guac-user-group.png b/guacamole/src/main/webapp/images/user-icons/guac-user-group.png
new file mode 100644
index 0000000..4eb0aa4
--- /dev/null
+++ b/guacamole/src/main/webapp/images/user-icons/guac-user-group.png
Binary files differ
diff --git a/guacamole/src/main/webapp/images/x-red.png b/guacamole/src/main/webapp/images/x-red.png
new file mode 100644
index 0000000..e5497f3
--- /dev/null
+++ b/guacamole/src/main/webapp/images/x-red.png
Binary files differ
diff --git a/guacamole/src/main/webapp/index.html b/guacamole/src/main/webapp/index.html
index d4df20d..1d51606 100644
--- a/guacamole/src/main/webapp/index.html
+++ b/guacamole/src/main/webapp/index.html
@@ -58,7 +58,7 @@
         </div>
 
         <!-- Absolute fatal error -->
-        <div ng-if="fatalError" class="fatal-page-error-outer">
+        <div ng-if="fatalError" ng-class="{shown: fatalError}" class="fatal-page-error-outer">
             <div class="fatal-page-error-middle">
                 <div class="fatal-page-error">
                     <h1 translate="APP.DIALOG_HEADER_ERROR"></h1>
@@ -71,8 +71,8 @@
         <script type="text/javascript" src="relocateParameters.js"></script>
 
         <!-- Utility libraries -->
-        <script type="text/javascript" src="webjars/jquery/2.1.3/dist/jquery.min.js"></script>
-        <script type="text/javascript" src="webjars/lodash/2.4.1/dist/lodash.min.js"></script>
+        <script type="text/javascript" src="webjars/jquery/3.3.1/dist/jquery.min.js"></script>
+        <script type="text/javascript" src="webjars/lodash/4.17.10/dist/lodash.min.js"></script>
 
         <!-- AngularJS -->
         <script type="text/javascript" src="webjars/angular/1.6.9/angular.min.js"></script>
diff --git a/guacamole/src/main/webapp/translations/en.json b/guacamole/src/main/webapp/translations/en.json
index 9fa242d..fe80dde 100644
--- a/guacamole/src/main/webapp/translations/en.json
+++ b/guacamole/src/main/webapp/translations/en.json
@@ -21,6 +21,7 @@
         "ACTION_MANAGE_SETTINGS"    : "Settings",
         "ACTION_MANAGE_SESSIONS"    : "Active Sessions",
         "ACTION_MANAGE_USERS"       : "Users",
+        "ACTION_MANAGE_USER_GROUPS" : "Groups",
         "ACTION_NAVIGATE_BACK"      : "Back",
         "ACTION_NAVIGATE_HOME"      : "Home",
         "ACTION_SAVE"               : "Save",
@@ -293,6 +294,7 @@
         "FIELD_HEADER_ADMINISTER_SYSTEM"             : "Administer system:",
         "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "Change own password:",
         "FIELD_HEADER_CREATE_NEW_USERS"              : "Create new users:",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "Create new user groups:",
         "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "Create new connections:",
         "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "Create new connection groups:",
         "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "Create new sharing profiles:",
@@ -302,17 +304,64 @@
 
         "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
 
-        "INFO_READ_ONLY" : "Sorry, but this user account cannot be edited.",
+        "HELP_NO_USER_GROUPS" : "This user does not currently belong to any groups. Expand this section to add groups.",
+
+        "INFO_READ_ONLY"                : "Sorry, but this user account cannot be edited.",
+        "INFO_NO_USER_GROUPS_AVAILABLE" : "No groups available.",
 
         "SECTION_HEADER_ALL_CONNECTIONS"     : "All Connections",
         "SECTION_HEADER_CONNECTIONS"         : "Connections",
         "SECTION_HEADER_CURRENT_CONNECTIONS" : "Current Connections",
         "SECTION_HEADER_EDIT_USER"           : "Edit User",
         "SECTION_HEADER_PERMISSIONS"         : "Permissions",
+        "SECTION_HEADER_USER_GROUPS"         : "Groups",
 
         "TEXT_CONFIRM_DELETE" : "Users cannot be restored after they have been deleted. Are you sure you want to delete this user?"
 
     },
+
+    "MANAGE_USER_GROUP" : {
+
+        "ACTION_ACKNOWLEDGE"   : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_CANCEL"        : "@:APP.ACTION_CANCEL",
+        "ACTION_CLONE"         : "@:APP.ACTION_CLONE",
+        "ACTION_DELETE"        : "@:APP.ACTION_DELETE",
+        "ACTION_SAVE"          : "@:APP.ACTION_SAVE",
+
+        "DIALOG_HEADER_CONFIRM_DELETE" : "Delete Group",
+        "DIALOG_HEADER_ERROR"          : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_HEADER_ADMINISTER_SYSTEM"             : "@:MANAGE_USER.FIELD_HEADER_ADMINISTER_SYSTEM",
+        "FIELD_HEADER_CHANGE_OWN_PASSWORD"           : "@:MANAGE_USER.FIELD_HEADER_CHANGE_OWN_PASSWORD",
+        "FIELD_HEADER_CREATE_NEW_USERS"              : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USERS",
+        "FIELD_HEADER_CREATE_NEW_USER_GROUPS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_USER_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTIONS"        : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTIONS",
+        "FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS"  : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_CONNECTION_GROUPS",
+        "FIELD_HEADER_CREATE_NEW_SHARING_PROFILES"   : "@:MANAGE_USER.FIELD_HEADER_CREATE_NEW_SHARING_PROFILES",
+        "FIELD_HEADER_USER_GROUP_NAME"               : "Group name:",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "HELP_NO_USER_GROUPS"        : "This group does not currently belong to any groups. Expand this section to add groups.",
+        "HELP_NO_MEMBER_USER_GROUPS" : "This group does not currently contain any groups. Expand this section to add groups.",
+        "HELP_NO_MEMBER_USERS"       : "This group does not currently contain any users. Expand this section to add users.",
+
+        "INFO_READ_ONLY"                : "Sorry, but this group cannot be edited.",
+        "INFO_NO_USER_GROUPS_AVAILABLE" : "@:MANAGE_USER.INFO_NO_USER_GROUPS_AVAILABLE",
+        "INFO_NO_USERS_AVAILABLE"       : "No users available.",
+
+        "SECTION_HEADER_ALL_CONNECTIONS"     : "@:MANAGE_USER.SECTION_HEADER_ALL_CONNECTIONS",
+        "SECTION_HEADER_CONNECTIONS"         : "@:MANAGE_USER.SECTION_HEADER_CONNECTIONS",
+        "SECTION_HEADER_CURRENT_CONNECTIONS" : "@:MANAGE_USER.SECTION_HEADER_CURRENT_CONNECTIONS",
+        "SECTION_HEADER_EDIT_USER_GROUP"     : "Edit Group",
+        "SECTION_HEADER_MEMBER_USERS"        : "Member Users",
+        "SECTION_HEADER_MEMBER_USER_GROUPS"  : "Member Groups",
+        "SECTION_HEADER_PERMISSIONS"         : "@:MANAGE_USER.SECTION_HEADER_PERMISSIONS",
+        "SECTION_HEADER_USER_GROUPS"         : "Parent Groups",
+
+        "TEXT_CONFIRM_DELETE" : "Groups cannot be restored after they have been deleted. Are you sure you want to delete this group?"
+
+    },
     
     "PROTOCOL_RDP" : {
 
@@ -744,7 +793,26 @@
         "TABLE_HEADER_USERNAME"    : "Username"
 
     },
-    
+
+    "SETTINGS_USER_GROUPS" : {
+
+        "ACTION_ACKNOWLEDGE"    : "@:APP.ACTION_ACKNOWLEDGE",
+        "ACTION_NEW_USER_GROUP" : "New Group",
+
+        "DIALOG_HEADER_ERROR" : "@:APP.DIALOG_HEADER_ERROR",
+
+        "FIELD_PLACEHOLDER_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER",
+
+        "FORMAT_DATE" : "@:APP.FORMAT_DATE_TIME_PRECISE",
+
+        "HELP_USER_GROUPS" : "Click or tap on a group below to manage that group. Depending on your access level, groups can be added and deleted, and their member users and groups can be changed.",
+
+        "SECTION_HEADER_USER_GROUPS" : "Groups",
+
+        "TABLE_HEADER_USER_GROUP_NAME" : "Group Name"
+
+    },
+
     "SETTINGS_SESSIONS" : {
         
         "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
@@ -790,6 +858,7 @@
         "ACTION_MANAGE_SESSIONS"    : "@:APP.ACTION_MANAGE_SESSIONS",
         "ACTION_MANAGE_SETTINGS"    : "@:APP.ACTION_MANAGE_SETTINGS",
         "ACTION_MANAGE_USERS"       : "@:APP.ACTION_MANAGE_USERS",
+        "ACTION_MANAGE_USER_GROUPS" : "@:APP.ACTION_MANAGE_USER_GROUPS",
         "ACTION_NAVIGATE_HOME"      : "@:APP.ACTION_NAVIGATE_HOME",
         "ACTION_VIEW_HISTORY"       : "@:APP.ACTION_VIEW_HISTORY"