Merge pull request #252 from scottyaslan/NIFIREG-352

[NIFIREG-352] update frontend deps

This closes #252. 
 
Signed-off-by: Andy LoPresto <alopresto@apache.org>
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/flow/VersionedFlowSnapshot.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/flow/VersionedFlowSnapshot.java
index 4611b9e..bf6ad98 100644
--- a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/flow/VersionedFlowSnapshot.java
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/flow/VersionedFlowSnapshot.java
@@ -35,6 +35,11 @@
  * is saved as a snapshot, representing information such as the name of the flow, the
  * version of the flow, the timestamp when it was saved, the contents of the flow, etc.
  * </p>
+ * <p>
+ * With the advent of download and upload flow in NiFi, this class is now used outside of
+ * NiFi Registry to represent a snapshot of an unversioned flow, which is purely the
+ * flow contents without any versioning or snapshot metadata.
+ * </p>
  */
 @ApiModel
 @XmlRootElement
@@ -52,7 +57,7 @@
     private Map<String,ExternalControllerServiceReference> externalControllerServices;
 
     // optional parameter contexts mapped by their name
-    private Map<String,VersionedParameterContext> parameterContexts;;
+    private Map<String,VersionedParameterContext> parameterContexts;
 
     // optional encoding version that clients may specify to track how the flow contents are encoded
     private String flowEncodingVersion;
@@ -159,8 +164,14 @@
 
     @Override
     public String toString() {
-        final String flowName = (flow == null ? "null" : flow.getName());
-        return "VersionedFlowSnapshot[flowId=" + snapshotMetadata.getFlowIdentifier() + ", flowName=" + flowName
-            + ", version=" + snapshotMetadata.getVersion() + ", comments=" + snapshotMetadata.getComments() + "]";
+        // snapshotMetadata and flow will be null when this is used to represent an unversioned flow
+        if (snapshotMetadata == null) {
+            return "VersionedFlowSnapshot[flowContentsId=" + flowContents.getIdentifier() + ", flowContentsName="
+                    + flowContents.getName() + ", NoMetadataAvailable]";
+        } else {
+            final String flowName = (flow == null ? "null" : flow.getName());
+            return "VersionedFlowSnapshot[flowId=" + snapshotMetadata.getFlowIdentifier() + ", flowName=" + flowName
+                    + ", version=" + snapshotMetadata.getVersion() + ", comments=" + snapshotMetadata.getComments() + "]";
+        }
     }
-}
+}
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/pom.xml b/nifi-registry-core/nifi-registry-framework/pom.xml
index 67f77c4..e8ef77b 100644
--- a/nifi-registry-core/nifi-registry-framework/pom.xml
+++ b/nifi-registry-core/nifi-registry-framework/pom.xml
@@ -300,7 +300,7 @@
         <dependency>
             <groupId>org.eclipse.jgit</groupId>
             <artifactId>org.eclipse.jgit</artifactId>
-            <version>4.11.8.201904181247-r</version>
+            <version>4.11.9.201909030838-r</version>
         </dependency>
         <dependency>
             <groupId>commons-codec</groupId>
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionClassLoader.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionClassLoader.java
new file mode 100644
index 0000000..1411f29
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionClassLoader.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.extension;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+
+/**
+ * Extend URLClassLoader to keep track of the root directory.
+ */
+public class ExtensionClassLoader extends URLClassLoader {
+
+    private final String rootDir;
+
+    public ExtensionClassLoader(final String rootDir, final URL[] urls, final ClassLoader parent) {
+        super(urls, parent);
+        this.rootDir = rootDir;
+    }
+
+    public String getRootDir() {
+        return rootDir;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionCloseable.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionCloseable.java
index b24f950..0735f2c 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionCloseable.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionCloseable.java
@@ -40,6 +40,13 @@
         return closeable;
     }
 
+    public static ExtensionCloseable withClassLoader(final ClassLoader componentClassLoader) {
+        final ClassLoader current = Thread.currentThread().getContextClassLoader();
+        final ExtensionCloseable closeable = new ExtensionCloseable(current);
+        Thread.currentThread().setContextClassLoader(componentClassLoader);
+        return closeable;
+    }
+
     @Override
     public void close() throws IOException {
         if (toSet != null) {
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
index 455c833..edb3350 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
@@ -33,7 +33,6 @@
 import java.io.File;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.net.URLClassLoader;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -88,7 +87,7 @@
         }
     }
 
-    public ClassLoader getExtensionClassLoader(final String canonicalClassName) {
+    public ExtensionClassLoader getExtensionClassLoader(final String canonicalClassName) {
         if (StringUtils.isBlank(canonicalClassName)) {
             throw new IllegalArgumentException("Class name can not be null");
         }
@@ -199,20 +198,4 @@
         return new ExtensionClassLoader(dir, urls, parentClassLoader);
     }
 
-    /**
-     * Extend URLClassLoader to keep track of the root directory.
-     */
-    private static class ExtensionClassLoader extends URLClassLoader {
-
-        private final String rootDir;
-
-        public ExtensionClassLoader(final String rootDir, final URL[] urls, final ClassLoader parent) {
-            super(urls, parent);
-            this.rootDir = rootDir;
-        }
-
-        public String getRootDir() {
-            return rootDir;
-        }
-    }
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/AuthorizerFactory.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/AuthorizerFactory.java
index 916892b..959e29e 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/AuthorizerFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/AuthorizerFactory.java
@@ -17,6 +17,7 @@
 package org.apache.nifi.registry.security.authorization;
 
 import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.extension.ExtensionClassLoader;
 import org.apache.nifi.registry.extension.ExtensionCloseable;
 import org.apache.nifi.registry.extension.ExtensionManager;
 import org.apache.nifi.registry.properties.NiFiRegistryProperties;
@@ -30,6 +31,7 @@
 import org.apache.nifi.registry.security.authorization.generated.Prop;
 import org.apache.nifi.registry.security.exception.SecurityProviderCreationException;
 import org.apache.nifi.registry.security.exception.SecurityProviderDestructionException;
+import org.apache.nifi.registry.security.util.ClassLoaderUtils;
 import org.apache.nifi.registry.security.util.XmlUtils;
 import org.apache.nifi.registry.service.RegistryService;
 import org.slf4j.Logger;
@@ -55,7 +57,11 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -201,11 +207,12 @@
 
                         // configure each authorizer
                         for (final org.apache.nifi.registry.security.authorization.generated.Authorizer provider : authorizerConfiguration.getAuthorizer()) {
+                            if (provider.getIdentifier().equals(authorizerIdentifier)) {
+                                continue;
+                            }
                             final Authorizer instance = authorizers.get(provider.getIdentifier());
-                            final Class authorizerClass = instance instanceof WrappedAuthorizer
-                                    ? ((WrappedAuthorizer) instance).getBaseAuthorizer().getClass()
-                                    : instance.getClass();
-                            try (ExtensionCloseable extCloseable = ExtensionCloseable.withComponentClassLoader(extensionManager, authorizerClass)) {
+                            final ClassLoader instanceClassLoader = instance.getClass().getClassLoader();
+                            try (final ExtensionCloseable extClosable = ExtensionCloseable.withClassLoader(instanceClassLoader)) {
                                 instance.onConfigured(loadAuthorizerConfiguration(provider.getIdentifier(), provider.getProperty()));
                             }
                         }
@@ -216,7 +223,37 @@
                         // ensure it was found
                         if (authorizer == null) {
                             throw new AuthorizerFactoryException(String.format("The specified authorizer '%s' could not be found.", authorizerIdentifier));
+                        } else {
+                            // get the ClassLoader of the Authorizer before wrapping it with anything
+                            final ClassLoader authorizerClassLoader = authorizer.getClass().getClassLoader();
+
+                            // install integrity checks
+                            authorizer = AuthorizerFactory.installIntegrityChecks(authorizer);
+
+                            // load the configuration context for the selected authorizer
+                            AuthorizerConfigurationContext authorizerConfigurationContext = null;
+                            for (final org.apache.nifi.registry.security.authorization.generated.Authorizer provider : authorizerConfiguration.getAuthorizer()) {
+                                if (provider.getIdentifier().equals(authorizerIdentifier)) {
+                                    authorizerConfigurationContext = loadAuthorizerConfiguration(provider.getIdentifier(), provider.getProperty());
+                                    break;
+                                }
+                            }
+
+                            if (authorizerConfigurationContext == null) {
+                                throw new IllegalStateException("Unable to load configuration for authorizer with id: " + authorizerIdentifier);
+                            }
+
+                            // configure the authorizer that is wrapped with integrity checks
+                            // set the context ClassLoader the wrapped authorizer's ClassLoader
+                            try (final ExtensionCloseable extClosable = ExtensionCloseable.withClassLoader(authorizerClassLoader)) {
+                                authorizer.onConfigured(authorizerConfigurationContext);
+                            }
+
+                            // wrap the integrity checked Authorizer with the FrameworkAuthorizer
+                            authorizer = createFrameworkAuthorizer(authorizer);
                         }
+
+
                     } catch (AuthorizerFactoryException e) {
                         throw e;
                     } catch (Exception e) {
@@ -281,7 +318,6 @@
     }
 
     private UserGroupProvider createUserGroupProvider(final String identifier, final String userGroupProviderClassName) throws Exception {
-
         final UserGroupProvider instance;
 
         final ClassLoader classLoader = extensionManager.getExtensionClassLoader(userGroupProviderClassName);
@@ -289,22 +325,24 @@
             throw new IllegalStateException("Extension not found in any of the configured class loaders: " + userGroupProviderClassName);
         }
 
-        // attempt to load the class
-        Class<?> rawUserGroupProviderClass = Class.forName(userGroupProviderClassName, true, classLoader);
-        Class<? extends UserGroupProvider> userGroupProviderClass = rawUserGroupProviderClass.asSubclass(UserGroupProvider.class);
+        try (final ExtensionCloseable closeable = ExtensionCloseable.withClassLoader(classLoader)) {
+            // attempt to load the class
+            Class<?> rawUserGroupProviderClass = Class.forName(userGroupProviderClassName, true, classLoader);
+            Class<? extends UserGroupProvider> userGroupProviderClass = rawUserGroupProviderClass.asSubclass(UserGroupProvider.class);
 
-        // otherwise create a new instance
-        Constructor constructor = userGroupProviderClass.getConstructor();
-        instance = (UserGroupProvider) constructor.newInstance();
+            // otherwise create a new instance
+            Constructor constructor = userGroupProviderClass.getConstructor();
+            instance = (UserGroupProvider) constructor.newInstance();
 
-        // method injection
-        performMethodInjection(instance, userGroupProviderClass);
+            // method injection
+            performMethodInjection(instance, userGroupProviderClass);
 
-        // field injection
-        performFieldInjection(instance, userGroupProviderClass);
+            // field injection
+            performFieldInjection(instance, userGroupProviderClass);
 
-        // call post construction lifecycle event
-        instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+            // call post construction lifecycle event
+            instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+        }
 
         return instance;
     }
@@ -317,22 +355,24 @@
             throw new IllegalStateException("Extension not found in any of the configured class loaders: " + accessPolicyProviderClassName);
         }
 
-        // attempt to load the class
-        Class<?> rawAccessPolicyProviderClass = Class.forName(accessPolicyProviderClassName, true, classLoader);
-        Class<? extends AccessPolicyProvider> accessPolicyClass = rawAccessPolicyProviderClass.asSubclass(AccessPolicyProvider.class);
+        try (final ExtensionCloseable closeable = ExtensionCloseable.withClassLoader(classLoader)) {
+            // attempt to load the class
+            Class<?> rawAccessPolicyProviderClass = Class.forName(accessPolicyProviderClassName, true, classLoader);
+            Class<? extends AccessPolicyProvider> accessPolicyClass = rawAccessPolicyProviderClass.asSubclass(AccessPolicyProvider.class);
 
-        // otherwise create a new instance
-        Constructor constructor = accessPolicyClass.getConstructor();
-        instance = (AccessPolicyProvider) constructor.newInstance();
+            // otherwise create a new instance
+            Constructor constructor = accessPolicyClass.getConstructor();
+            instance = (AccessPolicyProvider) constructor.newInstance();
 
-        // method injection
-        performMethodInjection(instance, accessPolicyClass);
+            // method injection
+            performMethodInjection(instance, accessPolicyClass);
 
-        // field injection
-        performFieldInjection(instance, accessPolicyClass);
+            // field injection
+            performFieldInjection(instance, accessPolicyClass);
 
-        // call post construction lifecycle event
-        instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+            // call post construction lifecycle event
+            instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+        }
 
         return instance;
     }
@@ -340,33 +380,51 @@
     private Authorizer createAuthorizer(final String identifier, final String authorizerClassName, final String classpathResources) throws Exception {
         final Authorizer instance;
 
-        final ClassLoader classLoader = extensionManager.getExtensionClassLoader(authorizerClassName);
-        if (classLoader == null) {
+        ClassLoader classLoader;
+
+        final ExtensionClassLoader extensionClassLoader = extensionManager.getExtensionClassLoader(authorizerClassName);
+        if (extensionClassLoader == null) {
             throw new IllegalStateException("Extension not found in any of the configured class loaders: " + authorizerClassName);
         }
 
-        // attempt to load the class
-        Class<?> rawAuthorizerClass = Class.forName(authorizerClassName, true, classLoader);
-        Class<? extends Authorizer> authorizerClass = rawAuthorizerClass.asSubclass(Authorizer.class);
+        // if additional classpath resources were specified, replace with a new ClassLoader that contains
+        // the combined resources of the original ClassLoader + the additional resources
+        if (StringUtils.isNotEmpty(classpathResources)) {
+            logger.info(String.format("Replacing Authorizer ClassLoader for '%s' to include additional resources: %s", identifier, classpathResources));
+            final URL[] originalUrls = extensionClassLoader.getURLs();
+            final URL[] additionalUrls = ClassLoaderUtils.getURLsForClasspath(classpathResources, null, true);
 
-        // otherwise create a new instance
-        Constructor constructor = authorizerClass.getConstructor();
-        instance = (Authorizer) constructor.newInstance();
+            final Set<URL> combinedUrls = new HashSet<>();
+            combinedUrls.addAll(Arrays.asList(originalUrls));
+            combinedUrls.addAll(Arrays.asList(additionalUrls));
 
-        // method injection
-        performMethodInjection(instance, authorizerClass);
+            final URL[] urls = combinedUrls.toArray(new URL[combinedUrls.size()]);
+            classLoader = new URLClassLoader(urls, extensionClassLoader.getParent());
+        } else {
+            // no additional resources so just use the ExtensionClassLoader
+            classLoader = extensionClassLoader;
+        }
 
-        // field injection
-        performFieldInjection(instance, authorizerClass);
+        try (final ExtensionCloseable closeable = ExtensionCloseable.withClassLoader(classLoader)) {
+            // attempt to load the class
+            Class<?> rawAuthorizerClass = Class.forName(authorizerClassName, true, classLoader);
+            Class<? extends Authorizer> authorizerClass = rawAuthorizerClass.asSubclass(Authorizer.class);
 
-        // call post construction lifecycle event
-        instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+            // otherwise create a new instance
+            Constructor constructor = authorizerClass.getConstructor();
+            instance = (Authorizer) constructor.newInstance();
 
-        // wrap the instance Authorizer with checks to ensure integrity of data
-        final Authorizer integrityCheckAuthorizer = installIntegrityChecks(instance);
+            // method injection
+            performMethodInjection(instance, authorizerClass);
 
-        // wrap the integrity checked Authorizer with the FrameworkAuthorizer
-        return createFrameworkAuthorizer(integrityCheckAuthorizer);
+            // field injection
+            performFieldInjection(instance, authorizerClass);
+
+            // call post construction lifecycle event
+            instance.initialize(new StandardAuthorizerInitializationContext(identifier, this, this, this));
+        }
+
+        return instance;
     }
 
     private Authorizer createFrameworkAuthorizer(final Authorizer baseAuthorizer) {
@@ -611,7 +669,7 @@
 
                                 @Override
                                 public User addUser(User user) throws AuthorizationAccessException {
-                                    if (tenantExists(baseConfigurableUserGroupProvider, user.getIdentifier(), user.getIdentity())) {
+                                    if (userExists(baseConfigurableUserGroupProvider, user.getIdentifier(), user.getIdentity())) {
                                         throw new IllegalStateException(String.format("User/user group already exists with the identity '%s'.", user.getIdentity()));
                                     }
                                     return baseConfigurableUserGroupProvider.addUser(user);
@@ -624,7 +682,7 @@
 
                                 @Override
                                 public User updateUser(User user) throws AuthorizationAccessException {
-                                    if (tenantExists(baseConfigurableUserGroupProvider, user.getIdentifier(), user.getIdentity())) {
+                                    if (userExists(baseConfigurableUserGroupProvider, user.getIdentifier(), user.getIdentity())) {
                                         throw new IllegalStateException(String.format("User/user group already exists with the identity '%s'.", user.getIdentity()));
                                     }
                                     if (!baseConfigurableUserGroupProvider.isConfigurable(user)) {
@@ -651,7 +709,7 @@
 
                                 @Override
                                 public Group addGroup(Group group) throws AuthorizationAccessException {
-                                    if (tenantExists(baseConfigurableUserGroupProvider, group.getIdentifier(), group.getName())) {
+                                    if (groupExists(baseConfigurableUserGroupProvider, group.getIdentifier(), group.getName())) {
                                         throw new IllegalStateException(String.format("User/user group already exists with the identity '%s'.", group.getName()));
                                     }
                                     if (!allGroupUsersExist(baseUserGroupProvider, group)) {
@@ -667,7 +725,7 @@
 
                                 @Override
                                 public Group updateGroup(Group group) throws AuthorizationAccessException {
-                                    if (tenantExists(baseConfigurableUserGroupProvider, group.getIdentifier(), group.getName())) {
+                                    if (groupExists(baseConfigurableUserGroupProvider, group.getIdentifier(), group.getName())) {
                                         throw new IllegalStateException(String.format("User/user group already exists with the identity '%s'.", group.getName()));
                                     }
                                     if (!baseConfigurableUserGroupProvider.isConfigurable(group)) {
@@ -796,14 +854,14 @@
 
             // ensure that only one group exists per identity
             for (User user : userGroupProvider.getUsers()) {
-                if (tenantExists(userGroupProvider, user.getIdentifier(), user.getIdentity())) {
+                if (userExists(userGroupProvider, user.getIdentifier(), user.getIdentity())) {
                     throw new SecurityProviderCreationException(String.format("Found multiple users/user groups with identity '%s'.", user.getIdentity()));
                 }
             }
 
             // ensure that only one group exists per identity
             for (Group group : userGroupProvider.getGroups()) {
-                if (tenantExists(userGroupProvider, group.getIdentifier(), group.getName())) {
+                if (groupExists(userGroupProvider, group.getIdentifier(), group.getName())) {
                     throw new SecurityProviderCreationException(String.format("Found multiple users/user groups with name '%s'.", group.getName()));
                 }
             }
@@ -896,7 +954,7 @@
      * @param identity identity of the tenant
      * @return true if another user exists with the same identity, false otherwise
      */
-    private static boolean tenantExists(final UserGroupProvider userGroupProvider, final String identifier, final String identity) {
+    private static boolean userExists(final UserGroupProvider userGroupProvider, final String identifier, final String identity) {
         for (User user : userGroupProvider.getUsers()) {
             if (!user.getIdentifier().equals(identifier)
                     && user.getIdentity().equals(identity)) {
@@ -904,6 +962,10 @@
             }
         }
 
+        return false;
+    }
+
+    private static boolean groupExists(final UserGroupProvider userGroupProvider, final String identifier, final String identity) {
         for (Group group : userGroupProvider.getGroups()) {
             if (!group.getIdentifier().equals(identifier)
                     && group.getName().equals(identity)) {
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/NssShellCommands.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/NssShellCommands.java
new file mode 100644
index 0000000..eef58b0
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/NssShellCommands.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+/**
+ * Provides shell commands to read users and groups on NSS-enabled systems.
+ *
+ * See `man 5 nsswitch.conf` for more info.
+ */
+class NssShellCommands implements ShellCommandsProvider {
+    /**
+     * @return Shell command string that will return a list of users.
+     */
+    public String getUsersList() {
+        return "getent passwd | cut -f 1,3,4 -d ':'";
+    }
+
+    /**
+     * @return Shell command string that will return a list of groups.
+     */
+    public String getGroupsList() {
+        return "getent group | cut -f 1,3 -d ':'";
+    }
+
+    /**
+     * @param groupName name of group.
+     * @return Shell command string that will return a list of users for a group.
+     */
+    public String getGroupMembers(String groupName) {
+        return String.format("getent group %s | cut -f 4 -d ':'", groupName);
+    }
+
+    /**
+     * Gets the command for reading a single user by id.
+     *
+     * When executed, this command should output a single line, in the format used by `getUsersList`.
+     *
+     * @param userId name of user.
+     * @return Shell command string that will read a single user.
+     */
+    @Override
+    public String getUserById(String userId) {
+        return String.format("getent passwd %s | cut -f 1,3,4 -d ':'", userId);
+    }
+
+    /**
+     * This method reuses `getUserById` because the getent command is the same for
+     * both uid and username.
+     *
+     * @param userName name of user.
+     * @return Shell command string that will read a single user.
+     */
+    public String getUserByName(String userName) {
+        return getUserById(userName);
+    }
+
+    /**
+     * This method supports gid or group name because getent does.
+     *
+     * @param groupId name of group.
+     * @return Shell command string that will read a single group.
+     */
+    public String getGroupById(String groupId) {
+        return String.format("getent group %s | cut -f 1,3,4 -d ':'", groupId);
+    }
+
+    /**
+     * This gives exit code 0 on all tested distributions.
+     *
+     * @return Shell command string that will exit normally (0) on a suitable system.
+     */
+    public String getSystemCheck() {
+        return "getent --version";
+    }
+}
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/OsxShellCommands.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/OsxShellCommands.java
new file mode 100644
index 0000000..0591662
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/OsxShellCommands.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+/**
+ * Provides shell commands to read users and groups on Mac OSX systems.
+ *
+ * See `man dscl` for more info.
+ */
+class OsxShellCommands implements ShellCommandsProvider {
+    /**
+     * @return Shell command string that will return a list of users.
+     */
+    public String getUsersList() {
+        return "dscl . -readall /Users UniqueID PrimaryGroupID | awk 'BEGIN { OFS = \":\"; ORS=\"\\n\"; i=0;} /RecordName: / {name = $2;i = 0;}" +
+                "/PrimaryGroupID: / {gid = $2;} /^ / {if (i == 0) { i++; name = $1;}} /UniqueID: / {uid = $2;print name, uid, gid;}' | grep -v ^_";
+    }
+
+    /**
+     * @return Shell command string that will return a list of groups.
+     */
+    public String getGroupsList() {
+        return "dscl . -list /Groups PrimaryGroupID  | grep -v '^_' | sed 's/ \\{1,\\}/:/g'";
+    }
+
+    /**
+     *
+     * @param groupName name of group.
+     * @return Shell command string that will return a list of users for a group.
+     */
+    public String getGroupMembers(String groupName) {
+        return String.format("dscl . -read /Groups/%s GroupMembership | cut -f 2- -d ' ' | sed 's/\\ /,/g'", groupName);
+    }
+
+    /**
+     * @param userId name of user.
+     * @return Shell command string that will read a single user.
+     */
+    @Override
+    public String getUserById(String userId) {
+        return String.format("id -P %s | cut -f 1,3,4 -d ':'", userId);
+    }
+
+    /**
+     * @param userName name of user.
+     * @return Shell command string that will read a single user.
+     */
+    public String getUserByName(String userName) {
+        return getUserById(userName); // 'id' command works for both uid/username
+    }
+
+    /**
+     * @param groupId name of group.
+     * @return Shell command string that will read a single group.
+     */
+    public String getGroupById(String groupId) {
+        return String.format(" dscl . -read /Groups/`dscl . -search /Groups gid %s | head -n 1 | cut -f 1` RecordName PrimaryGroupID | awk 'BEGIN { OFS = \":\"; ORS=\"\\n\"; i=0;} " +
+                "/RecordName: / {name = $2;i = 1;}/PrimaryGroupID: / {gid = $2;}; {if (i==1) {print name,gid,\"\"}}'", groupId);
+    }
+
+    /**
+     * @return Shell command string that will exit normally (0) on a suitable system.
+     */
+    public String getSystemCheck() {
+        return "which dscl";
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/RemoteShellCommands.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/RemoteShellCommands.java
new file mode 100644
index 0000000..f622409
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/RemoteShellCommands.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+class RemoteShellCommands implements ShellCommandsProvider {
+    // Carefully crafted command replacement string:
+    private final static String remoteCommand = "ssh " +
+            "-o 'StrictHostKeyChecking no' " +
+            "-o 'PasswordAuthentication no' " +
+            "-o \"RemoteCommand %s\" " +
+            "-i %s -p %s -l root %s";
+
+    private ShellCommandsProvider innerProvider;
+    private String privateKeyPath;
+    private String remoteHost;
+    private Integer remotePort;
+
+    private RemoteShellCommands() {
+    }
+
+    public static ShellCommandsProvider wrapOtherProvider(ShellCommandsProvider otherProvider, String keyPath, String host, Integer port) {
+        RemoteShellCommands remote = new RemoteShellCommands();
+
+        remote.innerProvider = otherProvider;
+        remote.privateKeyPath = keyPath;
+        remote.remoteHost = host;
+        remote.remotePort = port;
+
+        return remote;
+    }
+
+    public String getUsersList() {
+        return String.format(remoteCommand, innerProvider.getUsersList(), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getGroupsList() {
+        return String.format(remoteCommand, innerProvider.getGroupsList(), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getGroupMembers(String groupName) {
+        return String.format(remoteCommand, innerProvider.getGroupMembers(groupName), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getUserById(String userId) {
+        return String.format(remoteCommand, innerProvider.getUserById(userId), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getUserByName(String userName) {
+        return String.format(remoteCommand, innerProvider.getUserByName(userName), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getGroupById(String groupId) {
+        return String.format(remoteCommand, innerProvider.getGroupById(groupId), privateKeyPath, remotePort, remoteHost);
+    }
+
+    public String getSystemCheck() {
+        return String.format(remoteCommand, innerProvider.getSystemCheck(), privateKeyPath, remotePort, remoteHost);
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellCommandsProvider.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellCommandsProvider.java
new file mode 100644
index 0000000..ce3e6a4
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellCommandsProvider.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+/**
+ * Common interface for shell command strings to read users and groups.
+ *
+ */
+interface ShellCommandsProvider {
+    /**
+     * Gets the command for listing users.
+     *
+     * When executed, this command should output one record per line in this format:
+     *
+     * `username:user-id:primary-group-id`
+     *
+     * @return Shell command string that will return a list of users.
+     */
+    String getUsersList();
+
+    /**
+     * Gets the command for listing groups.
+     *
+     * When executed, this command should output one record per line in this format:
+     *
+     * `group-name:group-id`
+     *
+     * @return Shell command string that will return a list of groups.
+     */
+    String getGroupsList();
+
+    /**
+     * Gets the command for listing the members of a group.
+     *
+     * When executed, this command should output one line in this format:
+     *
+     * `user-name-1,user-name-2,user-name-n`
+     *
+     * @param groupName name of group.
+     * @return Shell command string that will return a list of users for a group.
+     */
+    String getGroupMembers(String groupName);
+
+    /**
+     * Gets the command for reading a single user by id.  Implementations may return null if reading a single
+     * user by id is not supported.
+     *
+     * When executed, this command should output a single line, in the format used by `getUsersList`.
+     *
+     * @param userId name of user.
+     * @return Shell command string that will read a single user.
+     */
+    String getUserById(String userId);
+
+    /**
+     * Gets the command for reading a single user.  Implementations may return null if reading a single user by
+     * username is not supported.
+     *
+     * When executed, this command should output a single line, in the format used by `getUsersList`.
+     *
+     * @param userName name of user.
+     * @return Shell command string that will read a single user.
+     */
+    String getUserByName(String userName);
+
+    /**
+     * Gets the command for reading a single group.  Implementations may return null if reading a single group
+     * by name is not supported.
+     *
+     * When executed, this command should output a single line, in the format used by `getGroupsList`.
+     *
+     * @param groupId name of group.
+     * @return Shell command string that will read a single group.
+     */
+    String getGroupById(String groupId);
+
+    /**
+     * Gets the command for checking the suitability of the host system.
+     *
+     * The command is expected to exit with status 0 (zero) to indicate success, and any other status
+     * to indicate failure.
+     *
+     * @return Shell command string that will exit normally (0) on a suitable system.
+     */
+    String getSystemCheck();
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellRunner.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellRunner.java
new file mode 100644
index 0000000..ee7ef41
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellRunner.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+public class ShellRunner {
+    private final static Logger logger = LoggerFactory.getLogger(ShellRunner.class);
+
+    static String SHELL = "sh";
+    static String OPTS = "-c";
+    static Integer TIMEOUT = 60;
+
+    public static List<String> runShell(String command) throws IOException {
+        return runShell(command, "<unknown>");
+    }
+
+    public static List<String> runShell(String command, String description) throws IOException {
+        final ProcessBuilder builder = new ProcessBuilder(SHELL, OPTS, command);
+        final List<String> builderCommand = builder.command();
+
+        logger.debug("Run Command '" + description + "': " + builderCommand);
+        final Process proc = builder.start();
+
+        boolean completed;
+        try {
+            completed = proc.waitFor(TIMEOUT, TimeUnit.SECONDS);
+        } catch (InterruptedException irexc) {
+            throw new IOException(irexc.getMessage(), irexc.getCause());
+        }
+
+        if (!completed) {
+            throw new IllegalStateException("Shell command '" + command + "' did not complete during the allotted time period");
+        }
+
+        if (proc.exitValue() != 0) {
+            try (final Reader stderr = new InputStreamReader(proc.getErrorStream());
+                 final BufferedReader reader = new BufferedReader(stderr)) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    logger.warn(line.trim());
+                }
+            }
+            throw new IOException("Command exit non-zero: " + proc.exitValue());
+        }
+
+        final List<String> lines = new ArrayList<>();
+        try (final Reader stdin = new InputStreamReader(proc.getInputStream());
+             final BufferedReader reader = new BufferedReader(stdin)) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                lines.add(line.trim());
+            }
+        }
+
+        return lines;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellUserGroupProvider.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellUserGroupProvider.java
new file mode 100644
index 0000000..1d709ac
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/authorization/shell/ShellUserGroupProvider.java
@@ -0,0 +1,678 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.authorization.shell;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.security.authorization.AuthorizerConfigurationContext;
+import org.apache.nifi.registry.security.authorization.Group;
+import org.apache.nifi.registry.security.authorization.User;
+import org.apache.nifi.registry.security.authorization.UserAndGroups;
+import org.apache.nifi.registry.security.authorization.UserGroupProvider;
+import org.apache.nifi.registry.security.authorization.UserGroupProviderInitializationContext;
+import org.apache.nifi.registry.security.authorization.exception.AuthorizationAccessException;
+import org.apache.nifi.registry.security.exception.SecurityProviderCreationException;
+import org.apache.nifi.registry.security.exception.SecurityProviderDestructionException;
+import org.apache.nifi.registry.util.FormatUtils;
+import org.apache.nifi.registry.util.PropertyValue;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/*
+ * ShellUserGroupProvider implements UserGroupProvider by way of shell commands.
+ */
+public class ShellUserGroupProvider implements UserGroupProvider {
+    private final static Logger logger = LoggerFactory.getLogger(ShellUserGroupProvider.class);
+
+    private final static String OS_TYPE_ERROR = "Unsupported operating system.";
+    private final static String SYS_CHECK_ERROR = "System check failed - cannot provide users and groups.";
+    private final static Map<String, User> usersById = new HashMap<>();   // id == identifier
+    private final static Map<String, User> usersByName = new HashMap<>(); // name == identity
+    private final static Map<String, Group> groupsById = new HashMap<>();
+
+    public static final String REFRESH_DELAY_PROPERTY = "Refresh Delay";
+    private static final long MINIMUM_SYNC_INTERVAL_MILLISECONDS = 10_000;
+
+    public static final String EXCLUDE_USER_PROPERTY = "Exclude Users";
+    public static final String EXCLUDE_GROUP_PROPERTY = "Exclude Groups";
+
+    private long fixedDelay;
+    private Pattern excludeUsers;
+    private Pattern excludeGroups;
+
+    // Our scheduler has one thread for users, one for groups:
+    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
+
+    // Our shell timeout, in seconds:
+    @SuppressWarnings("FieldCanBeLocal")
+    private final Integer shellTimeout = 10;
+
+    // Commands selected during initialization:
+    private ShellCommandsProvider selectedShellCommands;
+
+    // Start of the UserGroupProvider implementation.  Javadoc strings
+    // copied from the interface definition for reference.
+
+    /**
+     * Retrieves all users. Must be non null
+     *
+     * @return a list of users
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public Set<User> getUsers() throws AuthorizationAccessException {
+        synchronized (usersById) {
+            logger.debug("getUsers has user set of size: " + usersById.size());
+            return new HashSet<>(usersById.values());
+        }
+    }
+
+    /**
+     * Retrieves the user with the given identifier.
+     *
+     * @param identifier the id of the user to retrieve
+     * @return the user with the given id, or null if no matching user was found
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public User getUser(String identifier) throws AuthorizationAccessException {
+        User user;
+
+        synchronized (usersById) {
+            user = usersById.get(identifier);
+        }
+
+        if (user == null) {
+            logger.debug("getUser (by id) user not found: " + identifier);
+        } else {
+            logger.debug("getUser (by id) found user: " + user + " for id: " + identifier);
+        }
+        return user;
+    }
+
+    /**
+     * Retrieves the user with the given identity.
+     *
+     * @param identity the identity of the user to retrieve
+     * @return the user with the given identity, or null if no matching user was found
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public User getUserByIdentity(String identity) throws AuthorizationAccessException {
+        User user;
+
+        synchronized (usersByName) {
+            user = usersByName.get(identity);
+        }
+
+        if (user == null) {
+            refreshOneUser(selectedShellCommands.getUserByName(identity), "Get Single User by Name");
+            user = usersByName.get(identity);
+        }
+
+        if (user == null) {
+            logger.debug("getUser (by name) user not found: " + identity);
+        } else {
+            logger.debug("getUser (by name) found user: " + user.getIdentity() + " for name: " + identity);
+        }
+        return user;
+    }
+
+    /**
+     * Retrieves all groups. Must be non null
+     *
+     * @return a list of groups
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public Set<Group> getGroups() throws AuthorizationAccessException {
+        synchronized (groupsById) {
+            logger.debug("getGroups has group set of size: " + groupsById.size());
+            return new HashSet<>(groupsById.values());
+        }
+    }
+
+    /**
+     * Retrieves a Group by Id.
+     *
+     * @param identifier the identifier of the Group to retrieve
+     * @return the Group with the given identifier, or null if no matching group was found
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public Group getGroup(String identifier) throws AuthorizationAccessException {
+        Group group;
+
+        synchronized (groupsById) {
+            group = groupsById.get(identifier);
+        }
+
+        if (group == null) {
+            refreshOneGroup(selectedShellCommands.getGroupById(identifier), "Get Single Group by Id");
+            group = groupsById.get(identifier);
+        }
+
+        if (group == null) {
+            logger.debug("getGroup (by id) group not found: " + identifier);
+        } else {
+            logger.debug("getGroup (by id) found group: " + group.getName() + " for id: " + identifier);
+        }
+        return group;
+
+    }
+
+    /**
+     * Gets a user and their groups.
+     *
+     * @return the UserAndGroups for the specified identity
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    @Override
+    public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
+        User user = getUserByIdentity(identity);
+        logger.debug("Retrieved user {} for identity {}", new Object[]{user, identity});
+
+        Set<Group> groups = new HashSet<>();
+        if (user != null) {
+            for (Group g : getGroups()) {
+                if (g.getUsers().contains(user.getIdentifier())) {
+                    logger.debug("User {} belongs to group {}", new Object[]{user.getIdentity(), g.getName()});
+                    groups.add(g);
+                }
+            }
+        }
+
+        if (groups.isEmpty()) {
+            logger.debug("User {} belongs to no groups", user);
+        }
+
+        return new UserAndGroups() {
+            @Override
+            public User getUser() {
+                return user;
+            }
+
+            @Override
+            public Set<Group> getGroups() {
+                return groups;
+            }
+        };
+    }
+
+    /**
+     * Called immediately after instance creation for implementers to perform additional setup
+     *
+     * @param initializationContext in which to initialize
+     */
+    @Override
+    public void initialize(UserGroupProviderInitializationContext initializationContext) throws SecurityProviderCreationException {
+    }
+
+    /**
+     * Called to configure the Authorizer.
+     *
+     * @param configurationContext at the time of configuration
+     * @throws SecurityProviderCreationException for any issues configuring the provider
+     */
+    @Override
+    public void onConfigured(AuthorizerConfigurationContext configurationContext) throws SecurityProviderCreationException {
+        fixedDelay = getDelayProperty(configurationContext, REFRESH_DELAY_PROPERTY, "5 mins");
+
+        // Our next init step is to select the command set based on the operating system name:
+        ShellCommandsProvider commands = getCommandsProvider();
+
+        if (commands == null) {
+            commands = getCommandsProviderFromName(null);
+            setCommandsProvider(commands);
+        }
+
+        // Our next init step is to run the system check from that command set to determine if the other commands
+        // will work on this host or not.
+        try {
+            ShellRunner.runShell(commands.getSystemCheck());
+        } catch (final Exception e) {
+            logger.error("initialize exception: " + e + " system check command: " + commands.getSystemCheck());
+            throw new SecurityProviderCreationException(SYS_CHECK_ERROR, e);
+        }
+
+        // The next step is to add the user and group exclude regexes:
+        try {
+            excludeGroups = Pattern.compile(getProperty(configurationContext, EXCLUDE_GROUP_PROPERTY, ""));
+            excludeUsers = Pattern.compile(getProperty(configurationContext, EXCLUDE_USER_PROPERTY, ""));
+        } catch (final PatternSyntaxException e) {
+            throw new SecurityProviderCreationException(e);
+        }
+
+        // With our command set selected, and our system check passed, we can pull in the users and groups:
+        refreshUsersAndGroups();
+
+        // And finally, our last init step is to fire off the refresh thread:
+        scheduler.scheduleWithFixedDelay(() -> {
+            try {
+                refreshUsersAndGroups();
+            }catch (final Throwable t) {
+                logger.error("", t);
+            }
+        }, fixedDelay, fixedDelay, TimeUnit.MILLISECONDS);
+
+    }
+
+    private static ShellCommandsProvider getCommandsProviderFromName(String osName) {
+        if (osName == null) {
+            osName = System.getProperty("os.name");
+        }
+
+        ShellCommandsProvider commands;
+        if (osName.startsWith("Linux")) {
+            logger.debug("Selected Linux command set.");
+            commands = new NssShellCommands();
+        } else if (osName.startsWith("Mac OS X")) {
+            logger.debug("Selected OSX command set.");
+            commands = new OsxShellCommands();
+        } else {
+            throw new SecurityProviderCreationException(OS_TYPE_ERROR);
+        }
+        return commands;
+    }
+
+    private String getProperty(AuthorizerConfigurationContext authContext, String propertyName, String defaultValue) {
+        final PropertyValue property = authContext.getProperty(propertyName);
+        final String value;
+
+        if (property != null && property.isSet()) {
+            value = property.getValue();
+        } else {
+            value = defaultValue;
+        }
+        return value;
+
+    }
+
+    private long getDelayProperty(AuthorizerConfigurationContext authContext, String propertyName, String defaultValue) {
+        final PropertyValue intervalProperty = authContext.getProperty(propertyName);
+        final String propertyValue;
+        final long syncInterval;
+
+        if (intervalProperty.isSet()) {
+            propertyValue = intervalProperty.getValue();
+        } else {
+            propertyValue = defaultValue;
+        }
+
+        try {
+            syncInterval = Math.round(FormatUtils.getPreciseTimeDuration(propertyValue, TimeUnit.MILLISECONDS));
+        } catch (final IllegalArgumentException ignored) {
+            throw new SecurityProviderCreationException(String.format("The %s '%s' is not a valid time interval.", propertyName, propertyValue));
+        }
+
+        if (syncInterval < MINIMUM_SYNC_INTERVAL_MILLISECONDS) {
+            throw new SecurityProviderCreationException(String.format("The %s '%s' is below the minimum value of '%d ms'", propertyName, propertyValue, MINIMUM_SYNC_INTERVAL_MILLISECONDS));
+        }
+        return syncInterval;
+    }
+
+    /**
+     * Called immediately before instance destruction for implementers to release resources.
+     *
+     * @throws SecurityProviderDestructionException If pre-destruction fails.
+     */
+    @Override
+    public void preDestruction() throws SecurityProviderDestructionException {
+        try {
+            scheduler.shutdownNow();
+        } catch (final Exception ignored) {
+        }
+    }
+
+    public ShellCommandsProvider getCommandsProvider() {
+        return selectedShellCommands;
+    }
+
+    public void setCommandsProvider(ShellCommandsProvider commandsProvider) {
+        selectedShellCommands = commandsProvider;
+    }
+
+    /**
+     * Refresh a single user.
+     *
+     * @param command     Shell command to read a single user.  Pre-formatted by caller.
+     * @param description Shell command description.
+     */
+    private void refreshOneUser(String command, String description) {
+        if (command != null) {
+            Map<String, User> idToUser = new HashMap<>();
+            Map<String, User> usernameToUser = new HashMap<>();
+            Map<String, User> gidToUser = new HashMap<>();
+            List<String> userLines;
+
+            try {
+                userLines = ShellRunner.runShell(command, description);
+                rebuildUsers(userLines, idToUser, usernameToUser, gidToUser);
+            } catch (final IOException ioexc) {
+                logger.error("refreshOneUser shell exception: " + ioexc);
+            }
+
+            if (idToUser.size() > 0) {
+                synchronized (usersById) {
+                    usersById.putAll(idToUser);
+                }
+            }
+
+            if (usernameToUser.size() > 0) {
+                synchronized (usersByName) {
+                    usersByName.putAll(usernameToUser);
+                }
+            }
+        } else {
+            logger.info("Get Single User not supported on this system.");
+        }
+    }
+
+    /**
+     * Refresh a single group.
+     *
+     * @param command     Shell command to read a single group.  Pre-formatted by caller.
+     * @param description Shell command description.
+     */
+    private void refreshOneGroup(String command, String description) {
+        if (command != null) {
+            Map<String, Group> gidToGroup = new HashMap<>();
+            List<String> groupLines;
+
+            try {
+                groupLines = ShellRunner.runShell(command, description);
+                rebuildGroups(groupLines, gidToGroup);
+            } catch (final IOException ioexc) {
+                logger.error("refreshOneGroup shell exception: " + ioexc);
+            }
+
+            if (gidToGroup.size() > 0) {
+                synchronized (groupsById) {
+                    groupsById.putAll(gidToGroup);
+                }
+            }
+        } else {
+            logger.info("Get Single Group not supported on this system.");
+        }
+    }
+
+    /**
+     * This is our entry point for user and group refresh.  This method runs the top-level
+     * `getUserList()` and `getGroupsList()` shell commands, then passes those results to the
+     * other methods for record parse, extract, and object construction.
+     */
+    private void refreshUsersAndGroups() {
+        Map<String, User> uidToUser = new HashMap<>();
+        Map<String, User> usernameToUser = new HashMap<>();
+        Map<String, User> gidToUser = new HashMap<>();
+        Map<String, Group> gidToGroup = new HashMap<>();
+
+        List<String> userLines;
+        List<String> groupLines;
+
+        try {
+            userLines = ShellRunner.runShell(selectedShellCommands.getUsersList(), "Get Users List");
+            groupLines = ShellRunner.runShell(selectedShellCommands.getGroupsList(), "Get Groups List");
+        } catch (final IOException ioexc) {
+            logger.error("refreshUsersAndGroups shell exception: " + ioexc);
+            return;
+        }
+
+        rebuildUsers(userLines, uidToUser, usernameToUser, gidToUser);
+        rebuildGroups(groupLines, gidToGroup);
+        reconcilePrimaryGroups(gidToUser, gidToGroup);
+
+        synchronized (usersById) {
+            usersById.clear();
+            usersById.putAll(uidToUser);
+
+            if (logger.isTraceEnabled()) {
+                logger.trace("=== Users by id...");
+                Set<User> sortedUsers = new TreeSet<>(Comparator.comparing(User::getIdentity));
+                sortedUsers.addAll(usersById.values());
+                sortedUsers.forEach(u -> logger.trace("=== " + u.toString()));
+            }
+        }
+
+        synchronized (usersByName) {
+            usersByName.clear();
+            usersByName.putAll(usernameToUser);
+            logger.debug("users now size: " + usersByName.size());
+        }
+
+        synchronized (groupsById) {
+            groupsById.clear();
+            groupsById.putAll(gidToGroup);
+            logger.debug("groups now size: " + groupsById.size());
+
+            if (logger.isTraceEnabled()) {
+                logger.trace("=== Groups by id...");
+                Set<Group> sortedGroups = new TreeSet<>(Comparator.comparing(Group::getName));
+                sortedGroups.addAll(groupsById.values());
+                sortedGroups.forEach(g -> logger.trace("=== " + g.toString()));
+            }
+        }
+    }
+
+    /**
+     * This method parses the output of the `getUsersList()` shell command, where we expect the output
+     * to look like `user-name:user-id:primary-group-id`.
+     * <p>
+     * This method splits each output line on the ":" and attempts to build a User object
+     * from the resulting name, uid, and primary gid.  Unusable records are logged.
+     */
+    private void rebuildUsers(List<String> userLines, Map<String, User> idToUser, Map<String, User> usernameToUser, Map<String, User> gidToUser) {
+        userLines.forEach(line -> {
+            logger.trace("Processing user: {}", new Object[]{line});
+
+            String[] record = line.split(":");
+            if (record.length > 2) {
+                String userIdentity = record[0], userIdentifier = record[1], primaryGroupIdentifier = record[2];
+
+                if (!StringUtils.isBlank(userIdentifier) && !StringUtils.isBlank(userIdentity) && !excludeUsers.matcher(userIdentity).matches()) {
+                    User user = new User.Builder()
+                            .identity(userIdentity)
+                            .identifierGenerateFromSeed(getUserIdentifierSeed(userIdentity))
+                            .build();
+                    idToUser.put(user.getIdentifier(), user);
+                    usernameToUser.put(userIdentity, user);
+                    logger.debug("Refreshed user {}", new Object[]{user});
+
+                    if (!StringUtils.isBlank(primaryGroupIdentifier)) {
+                        // create a temporary group to deterministically generate the group id and associate this user
+                        Group group = new Group.Builder()
+                                .name(primaryGroupIdentifier)
+                                .identifierGenerateFromSeed(getGroupIdentifierSeed(primaryGroupIdentifier))
+                                .build();
+                        gidToUser.put(group.getIdentifier(), user);
+                        logger.debug("Associated primary group {} with user {}", new Object[]{group.getIdentifier(), userIdentity});
+                    } else {
+                        logger.warn("Null or empty primary group id for: " + userIdentity);
+                    }
+
+                } else {
+                    logger.warn("Null, empty, or skipped user name: " + userIdentity + " or id: " + userIdentifier);
+                }
+            } else {
+                logger.warn("Unexpected record format.  Expected 3 or more colon separated values per line.");
+            }
+        });
+    }
+
+    /**
+     * This method parses the output of the `getGroupsList()` shell command, where we expect the output
+     * to look like `group-name:group-id`.
+     * <p>
+     * This method splits each output line on the ":" and attempts to build a Group object
+     * from the resulting name and gid.  Unusable records are logged.
+     * <p>
+     * This command also runs the `getGroupMembers(username)` command once per group.  The expected output
+     * of that command should look like `group-name-1,group-name-2`.
+     */
+    private void rebuildGroups(List<String> groupLines, Map<String, Group> groupsById) {
+        groupLines.forEach(line -> {
+            logger.trace("Processing group: {}", new Object[]{line});
+
+            String[] record = line.split(":");
+            if (record.length > 1) {
+                Set<String> users = new HashSet<>();
+                String groupName = record[0], groupIdentifier = record[1];
+
+                try {
+                    String groupMembersCommand = selectedShellCommands.getGroupMembers(groupName);
+                    List<String> memberLines = ShellRunner.runShell(groupMembersCommand);
+                    // Use the first line only, and log if the line count isn't exactly one:
+                    if (!memberLines.isEmpty()) {
+                        String memberLine = memberLines.get(0);
+                        if (!StringUtils.isBlank(memberLine)) {
+                            String[] members = memberLine.split(",");
+                            for (String userIdentity : members) {
+                                if (!StringUtils.isBlank(userIdentity)) {
+                                    User tempUser = new User.Builder()
+                                            .identity(userIdentity)
+                                            .identifierGenerateFromSeed(getUserIdentifierSeed(userIdentity))
+                                            .build();
+                                    users.add(tempUser.getIdentifier());
+                                    logger.debug("Added temp user {} for group {}", new Object[]{tempUser, groupName});
+                                }
+                            }
+                        } else {
+                            logger.debug("list membership returned no members");
+                        }
+                    } else {
+                        logger.debug("list membership returned zero lines.");
+                    }
+                    if (memberLines.size() > 1) {
+                        logger.error("list membership returned too many lines, only used the first.");
+                    }
+
+                } catch (final IOException ioexc) {
+                    logger.error("list membership shell exception: " + ioexc);
+                }
+
+                if (!StringUtils.isBlank(groupIdentifier) && !StringUtils.isBlank(groupName) && !excludeGroups.matcher(groupName).matches()) {
+                    Group group = new Group.Builder()
+                            .name(groupName)
+                            .identifierGenerateFromSeed(getGroupIdentifierSeed(groupIdentifier))
+                            .addUsers(users)
+                            .build();
+                    groupsById.put(group.getIdentifier(), group);
+                    logger.debug("Refreshed group {}", new Object[] {group});
+                } else {
+                    logger.warn("Null, empty, or skipped group name: " + groupName + " or id: " + groupIdentifier);
+                }
+            } else {
+                logger.warn("Unexpected record format.  Expected 1 or more comma separated values.");
+            }
+        });
+    }
+
+    /**
+     * This method parses the output of the `getGroupsList()` shell command, where we expect the output
+     * to look like `group-name:group-id`.
+     * <p>
+     * This method splits each output line on the ":" and attempts to build a Group object
+     * from the resulting name and gid.
+     */
+    private void reconcilePrimaryGroups(Map<String, User> uidToUser, Map<String, Group> gidToGroup) {
+        uidToUser.forEach((primaryGid, primaryUser) -> {
+            Group primaryGroup = gidToGroup.get(primaryGid);
+
+            if (primaryGroup == null) {
+                logger.warn("Primary group {} not found for {}", new Object[]{primaryGid, primaryUser.getIdentity()});
+            } else if (!excludeGroups.matcher(primaryGroup.getName()).matches()) {
+                Set<String> groupUsers = primaryGroup.getUsers();
+                if (!groupUsers.contains(primaryUser.getIdentifier())) {
+                    Set<String> updatedUserIdentifiers = new HashSet<>(groupUsers);
+                    updatedUserIdentifiers.add(primaryUser.getIdentifier());
+
+                    Group updatedGroup = new Group.Builder()
+                            .identifier(primaryGroup.getIdentifier())
+                            .name(primaryGroup.getName())
+                            .addUsers(updatedUserIdentifiers)
+                            .build();
+                    gidToGroup.put(updatedGroup.getIdentifier(), updatedGroup);
+                    logger.debug("Added user {} to primary group {}", new Object[]{primaryUser, updatedGroup});
+                } else {
+                    logger.debug("Primary group {} already contains user {}", new Object[]{primaryGroup, primaryUser});
+                }
+            } else {
+                logger.debug("Primary group {} excluded from matcher for {}", new Object[]{primaryGroup.getName(), primaryUser.getIdentity()});
+            }
+        });
+    }
+
+    private String getUserIdentifierSeed(final String userIdentifier) {
+        return userIdentifier + "-user";
+    }
+
+    private String getGroupIdentifierSeed(final String groupIdentifier) {
+        return groupIdentifier + "-group";
+    }
+
+
+    /**
+     * @return The fixed refresh delay.
+     */
+    public long getRefreshDelay() {
+        return fixedDelay;
+    }
+
+    /**
+     * Testing concession for clearing the internal caches.
+     */
+    void clearCaches() {
+        synchronized (usersById) {
+            usersById.clear();
+        }
+
+        synchronized (usersByName) {
+            usersByName.clear();
+        }
+
+        synchronized (groupsById) {
+            groupsById.clear();
+        }
+    }
+
+    /**
+     * @return The size of the internal user cache.
+     */
+    public int userCacheSize() {
+        return usersById.size();
+    }
+
+    /**
+     * @return The size of the internal group cache.
+     */
+    public int groupCacheSize() {
+        return groupsById.size();
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/util/ClassLoaderUtils.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/util/ClassLoaderUtils.java
new file mode 100644
index 0000000..1f05cd3
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/security/util/ClassLoaderUtils.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.security.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+
+public class ClassLoaderUtils {
+
+    static final Logger LOGGER = LoggerFactory.getLogger(ClassLoaderUtils.class);
+
+    public static ClassLoader getCustomClassLoader(String modulePath, ClassLoader parentClassLoader, FilenameFilter filenameFilter) throws MalformedURLException {
+        URL[] classpaths = getURLsForClasspath(modulePath, filenameFilter, false);
+        return createModuleClassLoader(classpaths, parentClassLoader);
+    }
+
+    /**
+     *
+     * @param modulePath a module path to get URLs from, the module path may be
+     * a comma-separated list of paths
+     * @param filenameFilter a filter to apply when a module path is a directory
+     * and performs a listing, a null filter will return all matches
+     * @param suppressExceptions indicates whether to suppress exceptions
+     * @return an array of URL instances representing all of the modules
+     * resolved from processing modulePath
+     * @throws MalformedURLException if a module path does not exist
+     */
+    public static URL[] getURLsForClasspath(String modulePath, FilenameFilter filenameFilter, boolean suppressExceptions) throws MalformedURLException {
+        return getURLsForClasspath(modulePath == null ? Collections.emptySet() : Collections.singleton(modulePath), filenameFilter, suppressExceptions);
+    }
+
+    /**
+     *
+     * @param modulePaths one or modules paths to get URLs from, each module
+     * path may be a comma-separated list of paths
+     * @param filenameFilter a filter to apply when a module path is a directory
+     * and performs a listing, a null filter will return all matches
+     * @param suppressExceptions if true then all modules will attempt to be
+     * resolved even if some throw an exception, if false the first exception
+     * will be thrown
+     * @return an array of URL instances representing all of the modules
+     * resolved from processing modulePaths
+     * @throws MalformedURLException if a module path does not exist
+     */
+    public static URL[] getURLsForClasspath(Set<String> modulePaths, FilenameFilter filenameFilter, boolean suppressExceptions) throws MalformedURLException {
+        // use LinkedHashSet to maintain the ordering that the incoming paths are processed
+        Set<String> modules = new LinkedHashSet<>();
+        if (modulePaths != null) {
+            modulePaths.stream()
+                    .flatMap(path -> Arrays.stream(path.split(",")))
+                    .filter(path -> isNotBlank(path))
+                    .map(String::trim)
+                    .forEach(m -> modules.add(m));
+        }
+        return toURLs(modules, filenameFilter, suppressExceptions);
+    }
+
+    private static boolean isNotBlank(final String value) {
+        return value != null && !value.trim().isEmpty();
+    }
+
+    protected static URL[] toURLs(Set<String> modulePaths, FilenameFilter filenameFilter, boolean suppressExceptions) throws MalformedURLException {
+        List<URL> additionalClasspath = new LinkedList<>();
+        if (modulePaths != null) {
+            for (String modulePathString : modulePaths) {
+                // If the path is already a URL, just add it (but don't check if it exists, too expensive and subject to network availability)
+                boolean isUrl = true;
+                try {
+                    additionalClasspath.add(new URL(modulePathString));
+                } catch (MalformedURLException mue) {
+                    isUrl = false;
+                }
+                if (!isUrl) {
+                    try {
+                        File modulePath = new File(modulePathString);
+
+                        if (modulePath.exists()) {
+
+                            additionalClasspath.add(modulePath.toURI().toURL());
+
+                            if (modulePath.isDirectory()) {
+                                File[] files = modulePath.listFiles(filenameFilter);
+
+                                if (files != null) {
+                                    for (File classpathResource : files) {
+                                        if (classpathResource.isDirectory()) {
+                                            LOGGER.warn("Recursive directories are not supported, skipping " + classpathResource.getAbsolutePath());
+                                        } else {
+                                            additionalClasspath.add(classpathResource.toURI().toURL());
+                                        }
+                                    }
+                                }
+                            }
+                        } else {
+                            throw new MalformedURLException("Path specified does not exist");
+                        }
+                    } catch (MalformedURLException e) {
+                        if (!suppressExceptions) {
+                            throw e;
+                        }
+                    }
+                }
+            }
+        }
+        return additionalClasspath.toArray(new URL[additionalClasspath.size()]);
+    }
+
+    protected static ClassLoader createModuleClassLoader(URL[] modules, ClassLoader parentClassLoader) {
+        return new URLClassLoader(modules, parentClassLoader);
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.security.authorization.UserGroupProvider b/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.security.authorization.UserGroupProvider
index ee28c07..a4ac129 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.security.authorization.UserGroupProvider
+++ b/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.security.authorization.UserGroupProvider
@@ -15,4 +15,5 @@
 org.apache.nifi.registry.security.authorization.CompositeUserGroupProvider
 org.apache.nifi.registry.security.authorization.CompositeConfigurableUserGroupProvider
 org.apache.nifi.registry.security.authorization.file.FileUserGroupProvider
-org.apache.nifi.registry.security.ldap.tenants.LdapUserGroupProvider
\ No newline at end of file
+org.apache.nifi.registry.security.ldap.tenants.LdapUserGroupProvider
+org.apache.nifi.registry.security.authorization.shell.ShellUserGroupProvider
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/groovy/org/apache/nifi/registry/security/authorization/AuthorizerFactorySpec.groovy b/nifi-registry-core/nifi-registry-framework/src/test/groovy/org/apache/nifi/registry/security/authorization/AuthorizerFactorySpec.groovy
index 34dfe6b..bd79e55 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/groovy/org/apache/nifi/registry/security/authorization/AuthorizerFactorySpec.groovy
+++ b/nifi-registry-core/nifi-registry-framework/src/test/groovy/org/apache/nifi/registry/security/authorization/AuthorizerFactorySpec.groovy
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.registry.security.authorization
 
+import org.apache.nifi.registry.extension.ExtensionClassLoader
 import org.apache.nifi.registry.extension.ExtensionManager
 import org.apache.nifi.registry.properties.NiFiRegistryProperties
 import org.apache.nifi.registry.security.authorization.resource.ResourceFactory
@@ -32,7 +33,7 @@
 
     // runs before every feature method
     def setup() {
-        mockExtensionManager.getExtensionClassLoader(_) >> this.getClass().getClassLoader()
+        mockExtensionManager.getExtensionClassLoader(_) >> new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader())
         mockProperties.getPropertyKeys() >> new HashSet<String>() // Called by IdentityMappingUtil.getIdentityMappings()
 
         authorizerFactory = new AuthorizerFactory(mockProperties, mockExtensionManager, null, mockRegistryService)
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
index 0d23110..7fb8dee 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
@@ -17,6 +17,7 @@
 package org.apache.nifi.registry.provider;
 
 import org.apache.nifi.registry.extension.BundlePersistenceProvider;
+import org.apache.nifi.registry.extension.ExtensionClassLoader;
 import org.apache.nifi.registry.extension.ExtensionManager;
 import org.apache.nifi.registry.flow.FlowPersistenceProvider;
 import org.apache.nifi.registry.properties.NiFiRegistryProperties;
@@ -24,6 +25,7 @@
 import org.mockito.Mockito;
 
 import javax.sql.DataSource;
+import java.net.URL;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
@@ -38,7 +40,8 @@
         props.setProperty(NiFiRegistryProperties.PROVIDERS_CONFIGURATION_FILE, "src/test/resources/provider/providers-good.xml");
 
         final ExtensionManager extensionManager = Mockito.mock(ExtensionManager.class);
-        when(extensionManager.getExtensionClassLoader(any(String.class))).thenReturn(this.getClass().getClassLoader());
+        when(extensionManager.getExtensionClassLoader(any(String.class)))
+                .thenReturn(new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader()));
 
         final DataSource dataSource = Mockito.mock(DataSource.class);
 
@@ -68,7 +71,8 @@
         props.setProperty(NiFiRegistryProperties.PROVIDERS_CONFIGURATION_FILE, "src/test/resources/provider/providers-good.xml");
 
         final ExtensionManager extensionManager = Mockito.mock(ExtensionManager.class);
-        when(extensionManager.getExtensionClassLoader(any(String.class))).thenReturn(this.getClass().getClassLoader());
+        when(extensionManager.getExtensionClassLoader(any(String.class)))
+                .thenReturn(new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader()));
 
         final DataSource dataSource = Mockito.mock(DataSource.class);
 
@@ -82,7 +86,8 @@
         props.setProperty(NiFiRegistryProperties.PROVIDERS_CONFIGURATION_FILE, "src/test/resources/provider/providers-does-not-exist.xml");
 
         final ExtensionManager extensionManager = Mockito.mock(ExtensionManager.class);
-        when(extensionManager.getExtensionClassLoader(any(String.class))).thenReturn(this.getClass().getClassLoader());
+        when(extensionManager.getExtensionClassLoader(any(String.class)))
+                .thenReturn(new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader()));
 
         final DataSource dataSource = Mockito.mock(DataSource.class);
 
@@ -96,7 +101,8 @@
         props.setProperty(NiFiRegistryProperties.PROVIDERS_CONFIGURATION_FILE, "src/test/resources/provider/providers-class-not-found.xml");
 
         final ExtensionManager extensionManager = Mockito.mock(ExtensionManager.class);
-        when(extensionManager.getExtensionClassLoader(any(String.class))).thenReturn(this.getClass().getClassLoader());
+        when(extensionManager.getExtensionClassLoader(any(String.class)))
+                .thenReturn(new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader()));
 
         final DataSource dataSource = Mockito.mock(DataSource.class);
 
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/hook/TestScriptEventHookProvider.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/hook/TestScriptEventHookProvider.java
index b7d241b..39d45ae 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/hook/TestScriptEventHookProvider.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/hook/TestScriptEventHookProvider.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.registry.provider.hook;
 
+import org.apache.nifi.registry.extension.ExtensionClassLoader;
 import org.apache.nifi.registry.extension.ExtensionManager;
 import org.apache.nifi.registry.properties.NiFiRegistryProperties;
 import org.apache.nifi.registry.provider.ProviderCreationException;
@@ -26,6 +27,8 @@
 
 import javax.sql.DataSource;
 
+import java.net.URL;
+
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.when;
 
@@ -37,7 +40,8 @@
         props.setProperty(NiFiRegistryProperties.PROVIDERS_CONFIGURATION_FILE, "src/test/resources/provider/hook/bad-script-provider.xml");
 
         final ExtensionManager extensionManager = Mockito.mock(ExtensionManager.class);
-        when(extensionManager.getExtensionClassLoader(any(String.class))).thenReturn(this.getClass().getClassLoader());
+        when(extensionManager.getExtensionClassLoader(any(String.class)))
+                .thenReturn(new ExtensionClassLoader("/tmp", new URL[0],this.getClass().getClassLoader()));
 
         final DataSource dataSource = Mockito.mock(DataSource.class);
 
diff --git a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/authorizers.xml b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/authorizers.xml
index 9f63754..38a6ee8 100644
--- a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/authorizers.xml
+++ b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/authorizers.xml
@@ -166,6 +166,25 @@
     To enable the ldap-user-group-provider remove 2 lines. This is 2 of 2. -->
 
     <!--
+        The ShellUserGroupProvider provides support for retrieving users and groups by way of shell commands
+        on systems that support `sh`.  Implementations available for Linux and Mac OS, and are selected by the
+        provider based on the system property `os.name`.
+
+        'Refresh Delay' - duration to wait between subsequent refreshes.  Default is '5 mins'.
+        'Exclude Groups' - regular expression used to exclude groups.  Default is '', which means no groups are excluded.
+        'Exclude Users' - regular expression used to exclude users.  Default is '', which means no users are excluded.
+    -->
+    <!-- To enable the shell-user-group-provider remove 2 lines. This is 1 of 2.
+    <userGroupProvider>
+        <identifier>shell-user-group-provider</identifier>
+        <class>org.apache.nifi.registry.security.authorization.shell.ShellUserGroupProvider</class>
+        <property name="Refresh Delay">5 mins</property>
+        <property name="Exclude Groups"></property>
+        <property name="Exclude Users"></property>
+    </userGroupProvider>
+    To enable the shell-user-group-provider remove 2 lines. This is 2 of 2. -->
+
+    <!--
         The CompositeUserGroupProvider will provide support for retrieving users and groups from multiple sources.
 
         - User Group Provider [unique key] - The identifier of user group providers to load from. The name of
diff --git a/nifi-registry-core/nifi-registry-utils/src/main/java/org/apache/nifi/registry/util/FormatUtils.java b/nifi-registry-core/nifi-registry-utils/src/main/java/org/apache/nifi/registry/util/FormatUtils.java
index c1e353d..aa207c5 100644
--- a/nifi-registry-core/nifi-registry-utils/src/main/java/org/apache/nifi/registry/util/FormatUtils.java
+++ b/nifi-registry-core/nifi-registry-utils/src/main/java/org/apache/nifi/registry/util/FormatUtils.java
@@ -17,12 +17,13 @@
 package org.apache.nifi.registry.util;
 
 import java.text.NumberFormat;
+import java.util.Arrays;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 public class FormatUtils {
-
     private static final String UNION = "|";
 
     // for Data Sizes
@@ -41,8 +42,9 @@
     private static final String WEEKS = join(UNION, "w", "wk", "wks", "week", "weeks");
 
     private static final String VALID_TIME_UNITS = join(UNION, NANOS, MILLIS, SECS, MINS, HOURS, DAYS, WEEKS);
-    public static final String TIME_DURATION_REGEX = "(\\d+)\\s*(" + VALID_TIME_UNITS + ")";
+    public static final String TIME_DURATION_REGEX = "([\\d.]+)\\s*(" + VALID_TIME_UNITS + ")";
     public static final Pattern TIME_DURATION_PATTERN = Pattern.compile(TIME_DURATION_REGEX);
+    private static final List<Long> TIME_UNIT_MULTIPLIERS = Arrays.asList(1000L, 1000L, 1000L, 60L, 60L, 24L);
 
     /**
      * Formats the specified count by adding commas.
@@ -58,7 +60,7 @@
      * Formats the specified duration in 'mm:ss.SSS' format.
      *
      * @param sourceDuration the duration to format
-     * @param sourceUnit the unit to interpret the duration
+     * @param sourceUnit     the unit to interpret the duration
      * @return representation of the given time data in minutes/seconds
      */
     public static String formatMinutesSeconds(final long sourceDuration, final TimeUnit sourceUnit) {
@@ -79,7 +81,7 @@
      * Formats the specified duration in 'HH:mm:ss.SSS' format.
      *
      * @param sourceDuration the duration to format
-     * @param sourceUnit the unit to interpret the duration
+     * @param sourceUnit     the unit to interpret the duration
      * @return representation of the given time data in hours/minutes/seconds
      */
     public static String formatHoursMinutesSeconds(final long sourceDuration, final TimeUnit sourceUnit) {
@@ -139,65 +141,230 @@
         return format.format(dataSize) + " bytes";
     }
 
+    /**
+     * Returns a time duration in the requested {@link TimeUnit} after parsing the {@code String}
+     * input. If the resulting value is a decimal (i.e.
+     * {@code 25 hours -> TimeUnit.DAYS = 1.04}), the value is rounded.
+     *
+     * @param value the raw String input (i.e. "28 minutes")
+     * @param desiredUnit the requested output {@link TimeUnit}
+     * @return the whole number value of this duration in the requested units
+     * @deprecated As of Apache NiFi 1.9.0, because this method only returns whole numbers, use {@link #getPreciseTimeDuration(String, TimeUnit)} when possible.
+     */
+    @Deprecated
     public static long getTimeDuration(final String value, final TimeUnit desiredUnit) {
+        return Math.round(getPreciseTimeDuration(value, desiredUnit));
+    }
+
+    /**
+     * Returns the parsed and converted input in the requested units.
+     * <p>
+     * If the value is {@code 0 <= x < 1} in the provided units, the units will first be converted to a smaller unit to get a value >= 1 (i.e. 0.5 seconds -> 500 milliseconds).
+     * This is because the underlying unit conversion cannot handle decimal values.
+     * <p>
+     * If the value is {@code x >= 1} but x is not a whole number, the units will first be converted to a smaller unit to attempt to get a whole number value (i.e. 1.5 seconds -> 1500 milliseconds).
+     * <p>
+     * If the value is {@code x < 1000} and the units are {@code TimeUnit.NANOSECONDS}, the result will be a whole number of nanoseconds, rounded (i.e. 123.4 ns -> 123 ns).
+     * <p>
+     * This method handles decimal values over {@code 1 ns}, but {@code < 1 ns} will return {@code 0} in any other unit.
+     * <p>
+     * Examples:
+     * <p>
+     * "10 seconds", {@code TimeUnit.MILLISECONDS} -> 10_000.0
+     * "0.010 s", {@code TimeUnit.MILLISECONDS} -> 10.0
+     * "0.010 s", {@code TimeUnit.SECONDS} -> 0.010
+     * "0.010 ns", {@code TimeUnit.NANOSECONDS} -> 1
+     * "0.010 ns", {@code TimeUnit.MICROSECONDS} -> 0
+     *
+     * @param value       the {@code String} input
+     * @param desiredUnit the desired output {@link TimeUnit}
+     * @return the parsed and converted amount (without a unit)
+     */
+    public static double getPreciseTimeDuration(final String value, final TimeUnit desiredUnit) {
         final Matcher matcher = TIME_DURATION_PATTERN.matcher(value.toLowerCase());
         if (!matcher.matches()) {
-            throw new IllegalArgumentException("Value '" + value + "' is not a valid Time Duration");
+            throw new IllegalArgumentException("Value '" + value + "' is not a valid time duration");
         }
 
         final String duration = matcher.group(1);
         final String units = matcher.group(2);
-        TimeUnit specifiedTimeUnit = null;
-        switch (units.toLowerCase()) {
-            case "ns":
-            case "nano":
-            case "nanos":
-            case "nanoseconds":
-                specifiedTimeUnit = TimeUnit.NANOSECONDS;
-                break;
-            case "ms":
-            case "milli":
-            case "millis":
-            case "milliseconds":
-                specifiedTimeUnit = TimeUnit.MILLISECONDS;
-                break;
-            case "s":
-            case "sec":
-            case "secs":
-            case "second":
-            case "seconds":
-                specifiedTimeUnit = TimeUnit.SECONDS;
-                break;
-            case "m":
-            case "min":
-            case "mins":
-            case "minute":
-            case "minutes":
-                specifiedTimeUnit = TimeUnit.MINUTES;
-                break;
-            case "h":
-            case "hr":
-            case "hrs":
-            case "hour":
-            case "hours":
-                specifiedTimeUnit = TimeUnit.HOURS;
-                break;
-            case "d":
-            case "day":
-            case "days":
-                specifiedTimeUnit = TimeUnit.DAYS;
-                break;
+
+        double durationVal = Double.parseDouble(duration);
+        TimeUnit specifiedTimeUnit;
+
+        // The TimeUnit enum doesn't have a value for WEEKS, so handle this case independently
+        if (isWeek(units)) {
+            specifiedTimeUnit = TimeUnit.DAYS;
+            durationVal *= 7;
+        } else {
+            specifiedTimeUnit = determineTimeUnit(units);
+        }
+
+        // The units are now guaranteed to be in DAYS or smaller
+        long durationLong;
+        if (durationVal == Math.rint(durationVal)) {
+            durationLong = Math.round(durationVal);
+        } else {
+            // Try reducing the size of the units to make the input a long
+            List wholeResults = makeWholeNumberTime(durationVal, specifiedTimeUnit);
+            durationLong = (long) wholeResults.get(0);
+            specifiedTimeUnit = (TimeUnit) wholeResults.get(1);
+        }
+
+        return desiredUnit.convert(durationLong, specifiedTimeUnit);
+    }
+
+    /**
+     * Converts the provided time duration value to one that can be represented as a whole number.
+     * Returns a {@code List} containing the new value as a {@code long} at index 0 and the
+     * {@link TimeUnit} at index 1. If the incoming value is already whole, it is returned as is.
+     * If the incoming value cannot be made whole, a whole approximation is returned. For values
+     * {@code >= 1 TimeUnit.NANOSECONDS}, the value is rounded (i.e. 123.4 ns -> 123 ns).
+     * For values {@code < 1 TimeUnit.NANOSECONDS}, the constant [1L, {@code TimeUnit.NANOSECONDS}] is returned as the smallest measurable unit of time.
+     * <p>
+     * Examples:
+     * <p>
+     * 1, {@code TimeUnit.SECONDS} -> [1, {@code TimeUnit.SECONDS}]
+     * 1.1, {@code TimeUnit.SECONDS} -> [1100, {@code TimeUnit.MILLISECONDS}]
+     * 0.1, {@code TimeUnit.SECONDS} -> [100, {@code TimeUnit.MILLISECONDS}]
+     * 0.1, {@code TimeUnit.NANOSECONDS} -> [1, {@code TimeUnit.NANOSECONDS}]
+     *
+     * @param decimal  the time duration as a decimal
+     * @param timeUnit the current time unit
+     * @return the time duration as a whole number ({@code long}) and the smaller time unit used
+     */
+    protected static List<Object> makeWholeNumberTime(double decimal, TimeUnit timeUnit) {
+        // If the value is already a whole number, return it and the current time unit
+        if (decimal == Math.rint(decimal)) {
+            return Arrays.asList(new Object[]{(long) decimal, timeUnit});
+        } else if (TimeUnit.NANOSECONDS == timeUnit) {
+            // The time unit is as small as possible
+            if (decimal < 1.0) {
+                decimal = 1;
+            } else {
+                decimal = Math.rint(decimal);
+            }
+            return Arrays.asList(new Object[]{(long) decimal, timeUnit});
+        } else {
+            // Determine the next time unit and the respective multiplier
+            TimeUnit smallerTimeUnit = getSmallerTimeUnit(timeUnit);
+            long multiplier = calculateMultiplier(timeUnit, smallerTimeUnit);
+
+            // Recurse with the original number converted to the smaller unit
+            return makeWholeNumberTime(decimal * multiplier, smallerTimeUnit);
+        }
+    }
+
+    /**
+     * Returns the numerical multiplier to convert a value from {@code originalTimeUnit} to
+     * {@code newTimeUnit} (i.e. for {@code TimeUnit.DAYS -> TimeUnit.MINUTES} would return
+     * 24 * 60 = 1440). If the original and new units are the same, returns 1. If the new unit
+     * is larger than the original (i.e. the result would be less than 1), throws an
+     * {@link IllegalArgumentException}.
+     *
+     * @param originalTimeUnit the source time unit
+     * @param newTimeUnit      the destination time unit
+     * @return the numerical multiplier between the units
+     */
+    protected static long calculateMultiplier(TimeUnit originalTimeUnit, TimeUnit newTimeUnit) {
+        if (originalTimeUnit == newTimeUnit) {
+            return 1;
+        } else if (originalTimeUnit.ordinal() < newTimeUnit.ordinal()) {
+            throw new IllegalArgumentException("The original time unit '" + originalTimeUnit + "' must be larger than the new time unit '" + newTimeUnit + "'");
+        } else {
+            int originalOrd = originalTimeUnit.ordinal();
+            int newOrd = newTimeUnit.ordinal();
+
+            List<Long> unitMultipliers = TIME_UNIT_MULTIPLIERS.subList(newOrd, originalOrd);
+            return unitMultipliers.stream().reduce(1L, (a, b) -> (long) a * b);
+        }
+    }
+
+    /**
+     * Returns the next smallest {@link TimeUnit} (i.e. {@code TimeUnit.DAYS -> TimeUnit.HOURS}).
+     * If the parameter is {@code null} or {@code TimeUnit.NANOSECONDS}, an
+     * {@link IllegalArgumentException} is thrown because there is no valid smaller TimeUnit.
+     *
+     * @param originalUnit the TimeUnit
+     * @return the next smaller TimeUnit
+     */
+    protected static TimeUnit getSmallerTimeUnit(TimeUnit originalUnit) {
+        if (originalUnit == null || TimeUnit.NANOSECONDS == originalUnit) {
+            throw new IllegalArgumentException("Cannot determine a smaller time unit than '" + originalUnit + "'");
+        } else {
+            return TimeUnit.values()[originalUnit.ordinal() - 1];
+        }
+    }
+
+    /**
+     * Returns {@code true} if this raw unit {@code String} is parsed as representing "weeks", which does not have a value in the {@link TimeUnit} enum.
+     *
+     * @param rawUnit the String containing the desired unit
+     * @return true if the unit is "weeks"; false otherwise
+     */
+    protected static boolean isWeek(final String rawUnit) {
+        switch (rawUnit) {
             case "w":
             case "wk":
             case "wks":
             case "week":
             case "weeks":
-                final long durationVal = Long.parseLong(duration);
-                return desiredUnit.convert(durationVal, TimeUnit.DAYS)*7;
+                return true;
+            default:
+                return false;
         }
+    }
 
-        final long durationVal = Long.parseLong(duration);
-        return desiredUnit.convert(durationVal, specifiedTimeUnit);
+    /**
+     * Returns the {@link TimeUnit} enum that maps to the provided raw {@code String} input. The
+     * highest time unit is {@code TimeUnit.DAYS}. Any input that cannot be parsed will result in
+     * an {@link IllegalArgumentException}.
+     *
+     * @param rawUnit the String to parse
+     * @return the TimeUnit
+     */
+    protected static TimeUnit determineTimeUnit(String rawUnit) {
+        switch (rawUnit.toLowerCase()) {
+            case "ns":
+            case "nano":
+            case "nanos":
+            case "nanoseconds":
+                return TimeUnit.NANOSECONDS;
+            case "µs":
+            case "micro":
+            case "micros":
+            case "microseconds":
+                return TimeUnit.MICROSECONDS;
+            case "ms":
+            case "milli":
+            case "millis":
+            case "milliseconds":
+                return TimeUnit.MILLISECONDS;
+            case "s":
+            case "sec":
+            case "secs":
+            case "second":
+            case "seconds":
+                return TimeUnit.SECONDS;
+            case "m":
+            case "min":
+            case "mins":
+            case "minute":
+            case "minutes":
+                return TimeUnit.MINUTES;
+            case "h":
+            case "hr":
+            case "hrs":
+            case "hour":
+            case "hours":
+                return TimeUnit.HOURS;
+            case "d":
+            case "day":
+            case "days":
+                return TimeUnit.DAYS;
+            default:
+                throw new IllegalArgumentException("Could not parse '" + rawUnit + "' to TimeUnit");
+        }
     }
 
     public static String formatUtilization(final double utilization) {
@@ -225,15 +392,15 @@
      * 3 seconds, 8 millis, 3 nanos - if includeTotalNanos = false,
      * 3 seconds, 8 millis, 3 nanos (3008000003 nanos) - if includeTotalNanos = true
      *
-     * @param nanos the number of nanoseconds to format
+     * @param nanos             the number of nanoseconds to format
      * @param includeTotalNanos whether or not to include the total number of nanoseconds in parentheses in the returned value
      * @return a human-readable String that is a formatted representation of the given number of nanoseconds.
      */
     public static String formatNanos(final long nanos, final boolean includeTotalNanos) {
         final StringBuilder sb = new StringBuilder();
 
-        final long seconds = nanos > 1000000000L ? nanos / 1000000000L : 0L;
-        long millis = nanos > 1000000L ? nanos / 1000000L : 0L;
+        final long seconds = nanos >= 1000000000L ? nanos / 1000000000L : 0L;
+        long millis = nanos >= 1000000L ? nanos / 1000000L : 0L;
         final long nanosLeft = nanos % 1000000L;
 
         if (seconds > 0) {
@@ -258,4 +425,5 @@
 
         return sb.toString();
     }
+
 }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/TestRestAPI.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/TestRestAPI.java
index 3cbc892..038b299 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/TestRestAPI.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/TestRestAPI.java
@@ -21,6 +21,7 @@
 import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
 import org.apache.nifi.registry.flow.VersionedProcessGroup;
+import org.apache.nifi.registry.revision.entity.RevisionInfo;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -92,6 +93,7 @@
         final Bucket bucket = new Bucket();
         bucket.setName("Bucket #" + num);
         bucket.setDescription("This is bucket #" + num);
+        bucket.setRevision(new RevisionInfo("clientId", 0L));
 
         final Bucket createdBucket = client.target(REGISTRY_API_BUCKETS_URL)
                 .request()
@@ -107,6 +109,7 @@
         final VersionedFlow versionedFlow = new VersionedFlow();
         versionedFlow.setName(bucket.getName() + " Flow #" + num);
         versionedFlow.setDescription("This is " + bucket.getName() + " flow #" + num);
+        versionedFlow.setRevision(new RevisionInfo("clientId", 0L));
 
         final VersionedFlow createdFlow = client.target(REGISTRY_API_BUCKETS_URL)
                 .path("/{bucketId}/flows")
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/login/dialogs/nf-registry-user-login.html b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/login/dialogs/nf-registry-user-login.html
index eee4614..e873e47 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/login/dialogs/nf-registry-user-login.html
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/login/dialogs/nf-registry-user-login.html
@@ -32,7 +32,7 @@
         </div>
         <div class="fill-available-width">
             <mat-form-field floatLabel="always" fxFlex>
-                <input #passwordInput type="password" matInput floatPlaceholder="always" placeholder="Password">
+                <input #passwordInput type="password" matInput floatPlaceholder="always" placeholder="Password" autocomplete="off">
             </mat-form-field>
         </div>
     </div>
diff --git a/nifi-registry-extensions/nifi-registry-ranger/nifi-registry-ranger-plugin/pom.xml b/nifi-registry-extensions/nifi-registry-ranger/nifi-registry-ranger-plugin/pom.xml
index fa291d5..df21832 100644
--- a/nifi-registry-extensions/nifi-registry-ranger/nifi-registry-ranger-plugin/pom.xml
+++ b/nifi-registry-extensions/nifi-registry-ranger/nifi-registry-ranger-plugin/pom.xml
@@ -212,4 +212,70 @@
 
     </dependencies>
 
+    <profiles>
+        <!-- Includes hadoop-aws for accessing HDFS with an s3a:// filesystem -->
+        <profile>
+            <id>include-hadoop-aws</id>
+            <activation>
+                <activeByDefault>false</activeByDefault>
+            </activation>
+            <dependencies>
+                <dependency>
+                    <groupId>org.apache.hadoop</groupId>
+                    <artifactId>hadoop-aws</artifactId>
+                    <version>${ranger.hadoop.version}</version>
+                </dependency>
+            </dependencies>
+        </profile>
+        <!-- Includes hadoop-azure and hadoop-azure-datalake for accessing HDFS with wasb://, abfs://, and adl:// filesystems -->
+        <profile>
+            <id>include-hadoop-azure</id>
+            <activation>
+                <activeByDefault>false</activeByDefault>
+            </activation>
+            <dependencies>
+                <dependency>
+                    <groupId>org.apache.hadoop</groupId>
+                    <artifactId>hadoop-azure</artifactId>
+                    <version>${ranger.hadoop.version}</version>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>com.google.guava</groupId>
+                            <artifactId>guava</artifactId>
+                        </exclusion>
+                        <exclusion>
+                            <groupId>com.fasterxml.jackson.core</groupId>
+                            <artifactId>jackson-core</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+                <dependency>
+                    <groupId>org.apache.hadoop</groupId>
+                    <artifactId>hadoop-azure-datalake</artifactId>
+                    <version>${ranger.hadoop.version}</version>
+                    <exclusions>
+                        <exclusion>
+                            <groupId>com.fasterxml.jackson.core</groupId>
+                            <artifactId>jackson-core</artifactId>
+                        </exclusion>
+                    </exclusions>
+                </dependency>
+            </dependencies>
+        </profile>
+        <!-- Includes hadoop-cloud-storage -->
+        <profile>
+            <id>include-hadoop-cloud-storage</id>
+            <activation>
+                <activeByDefault>false</activeByDefault>
+            </activation>
+            <dependencies>
+                <dependency>
+                    <groupId>org.apache.hadoop</groupId>
+                    <artifactId>hadoop-cloud-storage</artifactId>
+                    <version>${ranger.hadoop.version}</version>
+                </dependency>
+            </dependencies>
+        </profile>
+    </profiles>
+
 </project>
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 2ac512a..5e6108b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,14 +96,14 @@
         <jax.rs.api.version>2.1</jax.rs.api.version>
         <jersey.version>2.27</jersey.version>
         <jackson.version>2.9.9</jackson.version>
-        <jackson.databind.version>2.9.9.1</jackson.databind.version>
-        <spring.boot.version>2.1.6.RELEASE</spring.boot.version>
-        <spring.security.version>5.1.5.RELEASE</spring.security.version>
+        <jackson.databind.version>2.9.10.2</jackson.databind.version>
+        <spring.boot.version>2.1.12.RELEASE</spring.boot.version>
+        <spring.security.version>5.1.8.RELEASE</spring.security.version>
         <flyway.version>5.2.4</flyway.version>
         <flyway.tests.version>5.1.0</flyway.tests.version>
         <swagger.ui.version>3.12.0</swagger.ui.version>
         <testcontainers.version>1.11.2</testcontainers.version>
-	    <h2.version>1.4.197</h2.version>
+	    <h2.version>1.4.200</h2.version>
         <groovy.version>2.5.4</groovy.version>
         <groovy.eclipse.compiler.version>3.4.0-01</groovy.eclipse.compiler.version>
         <jaxb.version>2.3.2</jaxb.version>