This is an automated email from the ASF dual-hosted git repository.

alopresto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/master by this push:
     new e973cac  NIFI-5973 Adds ShellUserGroupProvider. NIFI-5973 More 
comments and better defaults for the shell provider. NIFI-5973 Fixed bug where 
user was being retrieved by identifier when identity was provided. NIFI-5973 
Fixed a formatting string in the OS X shell commands. Updated testing 
conditions to run IT in OS X environment. Changed unit test to provide identity 
rather than identifier.
e973cac is described below

commit e973cacb2fe349028dae03b85a00ed89f3049967
Author: Troy Melhase <t...@troy.io>
AuthorDate: Tue Jun 18 16:41:44 2019 -0800

    NIFI-5973 Adds ShellUserGroupProvider.
    NIFI-5973 More comments and better defaults for the shell provider.
    NIFI-5973 Fixed bug where user was being retrieved by identifier when 
identity was provided.
    NIFI-5973 Fixed a formatting string in the OS X shell commands.
    Updated testing conditions to run IT in OS X environment.
    Changed unit test to provide identity rather than identifier.
    
    This closes #3537.
    
    Signed-off-by: Andy LoPresto <alopre...@apache.org>
---
 .../src/main/asciidoc/administration-guide.adoc    |  21 +
 .../nifi-framework-nar/pom.xml                     |   4 +
 .../nifi-framework/nifi-file-authorizer/pom.xml    |   6 +
 .../src/main/resources/conf/authorizers.xml        |  18 +
 .../nifi-framework/nifi-shell-authorizer/pom.xml   |  46 ++
 .../nifi/authorization/NssShellCommands.java       |  90 ++++
 .../nifi/authorization/OsxShellCommands.java       |  82 +++
 .../nifi/authorization/RemoteShellCommands.java    |  74 +++
 .../nifi/authorization/ShellCommandsProvider.java  | 100 ++++
 .../nifi/authorization/ShellUserGroupProvider.java | 585 +++++++++++++++++++++
 .../nifi/authorization/util/ShellRunner.java       |  77 +++
 ...org.apache.nifi.authorization.UserGroupProvider |  15 +
 .../authorization/ShellUserGroupProviderBase.java  | 176 +++++++
 .../authorization/ShellUserGroupProviderIT.java    | 283 ++++++++++
 .../src/main/webapp/js/nf/users/nf-users-table.js  |   2 +-
 .../nifi-framework-bundle/nifi-framework/pom.xml   |   1 +
 nifi-nar-bundles/nifi-framework-bundle/pom.xml     |   5 +
 pom.xml                                            |   7 +-
 18 files changed, 1590 insertions(+), 2 deletions(-)

diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc 
b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index e0a20ff..25a9769 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -452,6 +452,27 @@ The LdapUserGroupProvider has the following properties:
 
 NOTE: Any identity mapping rules specified in _nifi.properties_ will also be 
applied to the user identities. Group names are not mapped.
 
+==== ShellUserGroupProvider
+
+The ShellUserGroupProvider fetches user and group details from Unix-like 
systems using shell commands.
+
+This provider executes various shell pipelines with commands such as `getent` 
on Linux and `dscl` on MacOS.
+
+Supported systems may be configured to retrieve users and groups from an 
external source, such as LDAP or NIS.  In these cases the shell commands
+will return those external users and groups.  This provides administrators 
another mechanism to integrate user and group directory services.
+
+The ShellUserGroupProvider has the following properties:
+
+[options="header,footer"]
+|==================================================================================================================================================
+| Property Name | Description
+|`Initial Refresh Delay` | Duration of initial delay before first user and 
group refresh. (i.e. `10 secs`).  Default is `5 mins`.
+|`Refresh Delay` | Duration of delay between each user and group refresh. 
(i.e. `10 secs`).  Default is `5 mins`.
+|==================================================================================================================================================
+
+Like LdapUserGroupProvider, the ShellUserGroupProvider is commented out in the 
_authorizers.xml_ file.  Refer to that comment for usage examples.
+
+
 ==== Composite Implementations
 
 Another option for the UserGroupProvider are composite implementations. This 
means that multiple sources/implementations can be configured and composed. For 
instance, an admin can configure users/groups to be loaded from a file and a 
directory server. There are two composite implementations, one that supports 
multiple UserGroupProviders and one that supports multiple UserGroupProviders 
and a single configurable UserGroupProvider.
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
index d5495d6..d67b68a 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework-nar/pom.xml
@@ -37,6 +37,10 @@
         </dependency>
         <dependency>
             <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-shell-authorizer</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
             <artifactId>nifi-authorizer</artifactId>
         </dependency>
         <dependency>
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
index 705ccfd..11134f4 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/pom.xml
@@ -130,5 +130,11 @@
             <artifactId>nifi-expression-language</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <version>1.11.3</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
index d6d3c45..2210c43 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/authorizers.xml
@@ -167,6 +167,23 @@
     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`.
+
+        'Initial Refresh Delay' - duration to wait before first refresh.  
Default is '5 mins'.
+        'Refresh Delay' - duration to wait between subsequent refreshes.  
Default is '5 mins'.
+    -->
+    <!-- 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.authorization.ShellUserGroupProvider</class>
+        <property name="Initial Refresh Delay">5 mins</property>
+        <property name="Refresh Delay">5 mins</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
@@ -198,6 +215,7 @@
             NOTE: Any identity mapping rules specified in nifi.properties are 
not applied in this implementation. This behavior
             would need to be applied by the base implementation.
     -->
+
     <!-- To enable the composite-configurable-user-group-provider remove 2 
lines. This is 1 of 2.
     <userGroupProvider>
         <identifier>composite-configurable-user-group-provider</identifier>
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
new file mode 100644
index 0000000..175b831
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/pom.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <parent>
+        <artifactId>nifi-framework</artifactId>
+        <groupId>org.apache.nifi</groupId>
+        <version>1.10.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <artifactId>nifi-shell-authorizer</artifactId>
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-framework-api</artifactId>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <version>1.11.3</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-mock</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-utils</artifactId>
+            <version>1.10.0-SNAPSHOT</version>
+        </dependency>
+    </dependencies>
+</project>
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
new file mode 100644
index 0000000..fe49200
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/NssShellCommands.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.authorization;
+
+
+/**
+ * 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 passwd";
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
new file mode 100644
index 0000000..85dca06
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/OsxShellCommands.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.authorization;
+
+
+/**
+ * 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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
new file mode 100644
index 0000000..3c26ba7
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/RemoteShellCommands.java
@@ -0,0 +1,74 @@
+/*
+ * 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.authorization;
+
+
+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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellCommandsProvider.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellCommandsProvider.java
new file mode 100644
index 0000000..14c7de4
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/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.authorization;
+
+/**
+ * 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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
new file mode 100644
index 0000000..e499b6e
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
@@ -0,0 +1,585 @@
+/*
+ * 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.authorization;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.apache.nifi.authorization.exception.AuthorizationAccessException;
+import org.apache.nifi.authorization.exception.AuthorizerCreationException;
+import org.apache.nifi.authorization.exception.AuthorizerDestructionException;
+import org.apache.nifi.authorization.util.ShellRunner;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.util.FormatUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/*
+ * 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 INITIAL_REFRESH_DELAY_PROPERTY = "Initial 
Refresh Delay";
+    public static final String REFRESH_DELAY_PROPERTY = "Refresh Delay";
+
+    private static final long MINIMUM_SYNC_INTERVAL_MILLISECONDS = 10_000;
+    private long initialDelay;
+    private long fixedDelay;
+
+    // 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) {
+            refreshOneUser(selectedShellCommands.getUserById(identifier), "Get 
Single User by Id");
+            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 + " 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 + " 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<>();
+
+        for (Group g : getGroups()) {
+            if (user != null && g.getUsers().contains(user.getIdentity())) {
+                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 AuthorizerCreationException {
+    }
+
+    /**
+     * Called to configure the Authorizer.
+     *
+     * @param configurationContext at the time of configuration
+     * @throws AuthorizerCreationException for any issues configuring the 
provider
+     */
+    @Override
+    public void onConfigured(AuthorizerConfigurationContext 
configurationContext) throws AuthorizerCreationException {
+        initialDelay = getDelayProperty(configurationContext, 
INITIAL_REFRESH_DELAY_PROPERTY, "5 mins");
+        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 IOException ioexc) {
+            logger.error("initialize exception: " + ioexc + " system check 
command: " + commands.getSystemCheck());
+            throw new AuthorizerCreationException(SYS_CHECK_ERROR, 
ioexc.getCause());
+        }
+
+        // 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(this::refreshUsersAndGroups, 
initialDelay, fixedDelay, TimeUnit.SECONDS);
+    }
+
+    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 AuthorizerCreationException(OS_TYPE_ERROR);
+        }
+        return commands;
+    }
+
+    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 AuthorizerCreationException(String.format("The %s '%s' 
is not a valid time interval.", propertyName, propertyValue));
+        }
+
+        if (syncInterval < MINIMUM_SYNC_INTERVAL_MILLISECONDS) {
+            throw new AuthorizerCreationException(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 AuthorizerDestructionException If pre-destruction fails.
+     */
+    @Override
+    public void preDestruction() throws AuthorizerDestructionException {
+        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);
+        }
+
+        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());
+        }
+    }
+
+    /**
+     * 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 -> {
+            String[] record = line.split(":");
+            if (record.length > 2) {
+                String name = record[0], id = record[1], gid = record[2];
+
+                if (name != null && id != null && !name.equals("") && 
!id.equals("")) {
+
+                    User user = new 
User.Builder().identity(name).identifier(id).build();
+                    idToUser.put(id, user);
+                    usernameToUser.put(name, user);
+
+                    if (gid != null && !gid.equals("")) {
+                        gidToUser.put(gid, user);
+                    } else {
+                        logger.warn("Null or empty primary group id for: " + 
name);
+                    }
+
+                } else {
+                    logger.warn("Null or empty user name: " + name + " or id: 
" + id);
+                }
+            } 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 -> {
+            String[] record = line.split(":");
+            if (record.length > 1) {
+                Set<String> users = new HashSet<>();
+                String name = record[0], id = record[1];
+
+                try {
+                    List<String> memberLines = 
ShellRunner.runShell(selectedShellCommands.getGroupMembers(name));
+                    // Use the first line only, and log if the line count 
isn't exactly one:
+                    if (!memberLines.isEmpty()) {
+                        
users.addAll(Arrays.asList(memberLines.get(0).split(",")));
+                    } 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 (name != null && id != null && !name.equals("") && 
!id.equals("")) {
+                    Group group = new 
Group.Builder().name(name).identifier(id).addUsers(users).build();
+                    groupsById.put(id, group);
+                    logger.debug("Refreshed group: " + group);
+                } else {
+                    logger.warn("Null or empty group name: " + name + " or id: 
" + id);
+                }
+            } 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("user: " + primaryUser + " primary group not 
found");
+            } else {
+                Set<String> groupUsers = primaryGroup.getUsers();
+                if (!groupUsers.contains(primaryUser.getIdentity())) {
+                    Set<String> secondSet = new HashSet<>(groupUsers);
+                    secondSet.add(primaryUser.getIdentity());
+                    Group group = new 
Group.Builder().name(primaryGroup.getName()).identifier(primaryGid).addUsers(secondSet).build();
+                    gidToGroup.put(primaryGid, group);
+                }
+            }
+        });
+    }
+
+    /**
+     * @return The initial refresh delay.
+     */
+    public long getInitialRefreshDelay() {
+        return initialDelay;
+    }
+
+
+    /**
+     * @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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
new file mode 100644
index 0000000..46bc1cc
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/util/ShellRunner.java
@@ -0,0 +1,77 @@
+/*
+ * 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.authorization.util;
+
+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 = 30;
+
+    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();
+
+        try {
+            proc.waitFor(TIMEOUT, TimeUnit.SECONDS);
+        } catch (InterruptedException irexc) {
+            throw new IOException(irexc.getMessage(), irexc.getCause());
+        }
+
+        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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
new file mode 100755
index 0000000..fb3360d
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/resources/META-INF/services/org.apache.nifi.authorization.UserGroupProvider
@@ -0,0 +1,15 @@
+# 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.
+org.apache.nifi.authorization.ShellUserGroupProvider
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
new file mode 100644
index 0000000..fa760b2
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderBase.java
@@ -0,0 +1,176 @@
+/*
+ * 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.authorization;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+abstract class ShellUserGroupProviderBase {
+    private static final Logger logger = 
LoggerFactory.getLogger(ShellUserGroupProviderBase.class);
+
+    private final String KNOWN_USER  = "root";
+    private final String KNOWN_UID   = "0";
+
+    @SuppressWarnings("FieldCanBeLocal")
+    private final String KNOWN_GROUP = "root";
+
+    @SuppressWarnings("FieldCanBeLocal")
+    private final String OTHER_GROUP = "wheel"; // e.g., macos
+    private final String KNOWN_GID   = "0";
+
+    // We're using this knob to control the test runs on Travis.  The issue 
there is that tests
+    // running on Travis do not have `getent`, thus not behaving like a 
typical Linux installation.
+    protected static boolean systemCheckFailed = false;
+
+    /**
+     * Ensures that the test can run because Docker is available and the 
remote instance can be reached via ssh.
+     *
+     * @return true if Docker is available on this OS
+     */
+    protected boolean isSSHAvailable() {
+        return !systemCheckFailed;
+    }
+
+
+    /**
+     * Tests the provider behavior by getting its users and checking minimum 
size.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetUsersAndUsersMinimumCount(UserGroupProvider provider) {
+        assumeTrue(isSSHAvailable());
+
+        Set<User> users = provider.getUsers();
+        assertNotNull(users);
+        assertTrue(users.size() > 0);
+    }
+
+
+    /**
+     * Tests the provider behavior by getting a known user by uid.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetKnownUserByUsername(UserGroupProvider provider) {
+        // assumeTrue(isSSHAvailable());
+
+        User root = provider.getUser(KNOWN_UID);
+        assertNotNull(root);
+        assertEquals(KNOWN_USER, root.getIdentity());
+        assertEquals(KNOWN_UID, root.getIdentifier());
+    }
+
+    /**
+     * Tests the provider behavior by getting a known user by id.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetKnownUserByUid(UserGroupProvider provider) {
+        assumeTrue(isSSHAvailable());
+
+        User root = provider.getUserByIdentity(KNOWN_USER);
+        assertNotNull(root);
+        assertEquals(KNOWN_USER, root.getIdentity());
+        assertEquals(KNOWN_UID, root.getIdentifier());
+    }
+
+    /**
+     * Tests the provider behavior by getting its groups and checking minimum 
size.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetGroupsAndMinimumGroupCount(UserGroupProvider provider) {
+        assumeTrue(isSSHAvailable());
+
+        Set<Group> groups = provider.getGroups();
+        assertNotNull(groups);
+        assertTrue(groups.size() > 0);
+    }
+
+    /**
+     * Tests the provider behavior by getting a known group by GID.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetKnownGroupByGid(UserGroupProvider provider) {
+        assumeTrue(isSSHAvailable());
+
+        Group group = provider.getGroup(KNOWN_GID);
+        assertNotNull(group);
+        assertTrue(group.getName().equals(KNOWN_GROUP) || 
group.getName().equals(OTHER_GROUP));
+        assertEquals(KNOWN_GID, group.getIdentifier());
+    }
+
+    /**
+     * Tests the provider behavior by getting a known group and checking for a 
known member of it.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetGroupByGidAndGetGroupMembership(UserGroupProvider provider) {
+        assumeTrue(isSSHAvailable());
+
+        Group group = provider.getGroup(KNOWN_GID);
+        assertNotNull(group);
+
+        // These next few try/catch blocks are here for debugging.  The 
user-to-group relationship
+        // is delicate with this implementation, and this approach allows us a 
measure of control.
+        // Check your logs if you're having problems!
+
+        try {
+            assertTrue(group.getUsers().size() > 0);
+            logger.info("root group count: " + group.getUsers().size());
+        } catch (final AssertionError ignored) {
+            logger.info("root group count zero on this system");
+        }
+
+        try {
+            assertTrue(group.getUsers().contains(KNOWN_USER));
+            logger.info("root group membership: " + group.getUsers());
+        } catch (final AssertionError ignored) {
+            logger.info("root group membership unexpected on this system");
+        }
+    }
+
+    /**
+     * Tests the provider behavior by getting a known user and checking its 
group membership.
+     *
+     * @param provider {@link UserGroupProvider}
+     */
+    void testGetUserByIdentityAndGetGroupMembership(UserGroupProvider 
provider) {
+        assumeTrue(isSSHAvailable());
+
+        UserAndGroups user = provider.getUserAndGroups(KNOWN_USER);
+        assertNotNull(user);
+
+        try {
+            assertTrue(user.getGroups().size() > 0);
+            logger.info("root user group count: " + user.getGroups().size());
+        } catch (final AssertionError ignored) {
+            logger.info("root user and groups group count zero on this 
system");
+        }
+
+        Set<Group> groups = provider.getGroups();
+        assertTrue(groups.size() > user.getGroups().size());
+    }
+}
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
new file mode 100644
index 0000000..1525ae5
--- /dev/null
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/test/java/org/apache/nifi/authorization/ShellUserGroupProviderIT.java
@@ -0,0 +1,283 @@
+/*
+ * 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.authorization;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.nifi.authorization.exception.AuthorizerCreationException;
+import org.apache.nifi.authorization.util.ShellRunner;
+import org.apache.nifi.util.MockPropertyValue;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.MountableFile;
+
+
+public class ShellUserGroupProviderIT extends ShellUserGroupProviderBase {
+    private static final Logger logger = 
LoggerFactory.getLogger(ShellUserGroupProviderIT.class);
+
+    // These images are publicly available on the hub.docker.com, and the 
source to each
+    // is available on github.  In lieu of using named images, the Dockerfiles 
could be
+    // migrated into module and referenced in the testcontainer setup.
+    private final static String ALPINE_IMAGE = "natural/alpine-sshd:latest";
+    private final static String CENTOS_IMAGE = "natural/centos-sshd:latest";
+    private final static String DEBIAN_IMAGE = "natural/debian-sshd:latest";
+    private final static String UBUNTU_IMAGE = "natural/ubuntu-sshd:latest";
+    private final static List<String> TEST_CONTAINER_IMAGES =
+        Arrays.asList(
+                      ALPINE_IMAGE,
+                      CENTOS_IMAGE,
+                      DEBIAN_IMAGE,
+                      UBUNTU_IMAGE
+                      );
+
+    private final static String CONTAINER_SSH_AUTH_KEYS = 
"/root/.ssh/authorized_keys";
+    private final static Integer CONTAINER_SSH_PORT = 22;
+
+    private static String sshPrivKeyFile;
+    private static String sshPubKeyFile;
+
+    private AuthorizerConfigurationContext authContext;
+    private ShellUserGroupProvider localProvider;
+    private UserGroupProviderInitializationContext initContext;
+
+    @ClassRule
+    static public TemporaryFolder tempFolder = new TemporaryFolder();
+
+    @Rule
+    public ExpectedException expectedException = ExpectedException.none();
+
+    @BeforeClass
+    public static void setupOnce() throws IOException {
+        sshPrivKeyFile = tempFolder.getRoot().getAbsolutePath() + "/id_rsa";
+        sshPubKeyFile = sshPrivKeyFile + ".pub";
+
+        try {
+            // NB: this command is a bit perplexing: it works without prompt 
from the shell, but hangs
+            // here without the pipe from `yes`:
+            ShellRunner.runShell("yes | ssh-keygen -C '' -N '' -t rsa -f " + 
sshPrivKeyFile);
+        } catch (final IOException ioexc) {
+            systemCheckFailed = true;
+            logger.error("setupOnce() exception: " + ioexc + "; tests cannot 
run on this system.");
+            return;
+        }
+
+        // Fix the file permissions to abide by the ssh client
+        // requirements:
+        Arrays.asList(sshPrivKeyFile, sshPubKeyFile).forEach(name -> {
+                final File f = new File(name);
+                Assert.assertTrue(f.setReadable(false, false));
+                Assert.assertTrue(f.setReadable(true));
+            });
+    }
+
+    @Before
+    public void setup() throws IOException {
+        authContext = Mockito.mock(AuthorizerConfigurationContext.class);
+        initContext = 
Mockito.mock(UserGroupProviderInitializationContext.class);
+
+        
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
 MockPropertyValue("10 sec"));
+        
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.REFRESH_DELAY_PROPERTY))).thenReturn(new
 MockPropertyValue("15 sec"));
+
+        localProvider = new ShellUserGroupProvider();
+        try {
+            localProvider.initialize(initContext);
+            localProvider.onConfigured(authContext);
+        } catch (final Exception exc) {
+            systemCheckFailed = true;
+            logger.error("setup() exception: " + exc + "; tests cannot run on 
this system.");
+            return;
+        }
+        Assert.assertEquals(10000, localProvider.getInitialRefreshDelay());
+        Assert.assertEquals(15000, localProvider.getRefreshDelay());
+    }
+
+    @After
+    public void tearDown() {
+        localProvider.preDestruction();
+    }
+
+    // Our primary test methods all accept a provider; here we define 
overloads to those methods to
+    // use the local provider.  This allows the reuse of those test methods 
with the remote provider.
+
+    @Test
+    public void testGetUsersAndUsersMinimumCount() {
+        testGetUsersAndUsersMinimumCount(localProvider);
+    }
+
+    @Test
+    public void testGetKnownUserByUsername() {
+        testGetKnownUserByUsername(localProvider);
+    }
+
+    @Test
+    public void testGetKnownUserByUid() {
+        testGetKnownUserByUid(localProvider);
+    }
+
+    @Test
+    public void testGetGroupsAndMinimumGroupCount() {
+        testGetGroupsAndMinimumGroupCount(localProvider);
+    }
+
+    @Test
+    public void testGetKnownGroupByGid() {
+        testGetKnownGroupByGid(localProvider);
+    }
+
+    @Test
+    public void testGetGroupByGidAndGetGroupMembership() {
+        testGetGroupByGidAndGetGroupMembership(localProvider);
+    }
+
+    @Test
+    public void testGetUserByIdentityAndGetGroupMembership() {
+        testGetUserByIdentityAndGetGroupMembership(localProvider);
+    }
+
+    @SuppressWarnings("RedundantThrows")
+    private GenericContainer createContainer(String image) throws IOException, 
InterruptedException {
+        GenericContainer container = new GenericContainer(image)
+            .withEnv("SSH_ENABLE_ROOT", 
"true").withExposedPorts(CONTAINER_SSH_PORT);
+        container.start();
+
+        // This can go into the docker images:
+        container.execInContainer("mkdir", "-p", "/root/.ssh");
+        
container.copyFileToContainer(MountableFile.forHostPath(sshPubKeyFile),  
CONTAINER_SSH_AUTH_KEYS);
+        return container;
+    }
+
+    private UserGroupProvider createRemoteProvider(GenericContainer container) 
{
+        final ShellCommandsProvider remoteCommands =
+            RemoteShellCommands.wrapOtherProvider(new NssShellCommands(),
+                                                  sshPrivKeyFile,
+                                                  
container.getContainerIpAddress(),
+                                                  
container.getMappedPort(CONTAINER_SSH_PORT));
+
+        ShellUserGroupProvider remoteProvider = new ShellUserGroupProvider();
+        remoteProvider.setCommandsProvider(remoteCommands);
+        remoteProvider.initialize(initContext);
+        remoteProvider.onConfigured(authContext);
+        return remoteProvider;
+    }
+
+    @Test
+    public void testTooShortDelayIntervalThrowsException() throws 
AuthorizerCreationException {
+        final AuthorizerConfigurationContext authContext = 
Mockito.mock(AuthorizerConfigurationContext.class);
+        final ShellUserGroupProvider localProvider = new 
ShellUserGroupProvider();
+        
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
 MockPropertyValue("1 milliseconds"));
+
+        expectedException.expect(AuthorizerCreationException.class);
+        expectedException.expectMessage("The Initial Refresh Delay '1 
milliseconds' is below the minimum value of '10000 ms'");
+
+        localProvider.onConfigured(authContext);
+    }
+
+    @Test
+    public void testInvalidDelayIntervalThrowsException() throws 
AuthorizerCreationException {
+        final AuthorizerConfigurationContext authContext = 
Mockito.mock(AuthorizerConfigurationContext.class);
+        final ShellUserGroupProvider localProvider = new 
ShellUserGroupProvider();
+        
Mockito.when(authContext.getProperty(Mockito.eq(ShellUserGroupProvider.INITIAL_REFRESH_DELAY_PROPERTY))).thenReturn(new
 MockPropertyValue("Not an interval"));
+
+        expectedException.expect(AuthorizerCreationException.class);
+        expectedException.expectMessage("The Initial Refresh Delay 'Not an 
interval' is not a valid time interval.");
+
+        localProvider.onConfigured(authContext);
+    }
+
+    @Test
+    public void testCacheSizesAfterClearingCaches() {
+        localProvider.clearCaches();
+        assert localProvider.userCacheSize() == 0;
+        assert localProvider.groupCacheSize() == 0;
+    }
+
+    @Test
+    public void testGetOneUserAfterClearingCaches() {
+        // assert known state:  empty, testable, not empty
+        localProvider.clearCaches();
+        testGetKnownUserByUid(localProvider);
+        assert localProvider.userCacheSize() > 0;
+    }
+
+    @Test
+    public void testGetOneGroupAfterClearingCaches() {
+        Assume.assumeTrue(isSSHAvailable());
+
+        // assert known state:  empty, testable, not empty
+        localProvider.clearCaches();
+        testGetKnownGroupByGid(localProvider);
+        assert localProvider.groupCacheSize() > 0;
+    }
+
+    @Test
+    public void testVariousSystemImages() {
+        // Here we explicitly clear the system check flag to allow the remote 
checks that follow:
+        systemCheckFailed = false;
+        Assume.assumeTrue(isSSHAvailable());
+
+        TEST_CONTAINER_IMAGES.forEach(image -> {
+                GenericContainer container;
+                UserGroupProvider remoteProvider;
+                logger.debug("creating container from image: " + image);
+
+                try {
+                    container = createContainer(image);
+                } catch (final Exception exc) {
+                    logger.error("create container exception: " + exc);
+                    return;
+                }
+                try {
+                    remoteProvider = createRemoteProvider(container);
+                } catch (final Exception exc) {
+                    logger.error("create user provider exception: " + exc);
+                    return;
+                }
+
+                try {
+                    testGetUsersAndUsersMinimumCount(remoteProvider);
+                    testGetKnownUserByUsername(remoteProvider);
+                    testGetGroupsAndMinimumGroupCount(remoteProvider);
+                    testGetKnownGroupByGid(remoteProvider);
+                    testGetGroupByGidAndGetGroupMembership(remoteProvider);
+                    testGetUserByIdentityAndGetGroupMembership(remoteProvider);
+                } catch (final Exception e) {
+                    // Some environments don't allow our tests to work.
+                    logger.error("Exception running remote provider on image: 
" + image +  ", exception: " + e);
+                }
+
+                container.stop();
+                remoteProvider.preDestruction();
+                logger.debug("finished with container image: " + image);
+            });
+    }
+
+    // TODO: Make test which retrieves list of users and then 
getUserByIdentity to ensure the user is populated in the response
+}
diff --git 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
index 1529779..496dba9 100644
--- 
a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
+++ 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/users/nf-users-table.js
@@ -1342,7 +1342,7 @@
                 });
 
                 // set the rows
-                usersData.setItems(users);
+                usersData.setItems(users, 'uri');
 
                 // end the update
                 usersData.endUpdate();
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml 
b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
index 33b53ff..d3136e4 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/pom.xml
@@ -47,6 +47,7 @@
         <module>nifi-properties-loader</module>
         <module>nifi-standard-prioritizers</module>
         <module>nifi-mock-authorizer</module>
+        <module>nifi-shell-authorizer</module>
     </modules>
     <dependencies>
         <dependency>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/pom.xml 
b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
index 2e33739..e2366ac 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
@@ -186,6 +186,11 @@
             </dependency>
             <dependency>
                 <groupId>org.apache.nifi</groupId>
+                <artifactId>nifi-shell-authorizer</artifactId>
+                <version>1.10.0-SNAPSHOT</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.nifi</groupId>
                 <artifactId>nifi-authorizer</artifactId>
                 <version>1.10.0-SNAPSHOT</version>
             </dependency>
diff --git a/pom.xml b/pom.xml
index 6f083ef..c8955b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -239,7 +239,12 @@
                 <version>1.3</version>
                 <scope>test</scope>
             </dependency>
-
+            <dependency>
+                <groupId>org.testcontainers</groupId>
+                <artifactId>testcontainers</artifactId>
+                <version>1.11.3</version>
+                <scope>test</scope>
+            </dependency>
             <!-- These Jetty dependencies are required for the Jetty Web 
Server all nars extend from it so we dont want this getting overriden -->
             <dependency>
                 <groupId>org.eclipse.jetty</groupId>

Reply via email to