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`.
+ *
+ * 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 userLines, Map idToUser, Map usernameToUser, Map 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`.
+ *
+ * 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.
+ *
+ * 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 groupLines, Map groupsById) {
+ groupLines.forEach(line -> {
+ String[] record = line.split(":");
+ if (record.length > 1) {
+ Set users = new HashSet<>();
+ String name = record[0], id = record[1];
+
+ try {
+ List 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`.
+ *
+ * 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 uidToUser, Map gidToGroup) {
+ uidToUser.forEach((primaryGid, primaryUser) -> {
+ Group primaryGroup = gidToGroup.get(primaryGid);
+
+ if (primaryGroup == null) {
+ logger.warn("user: " + primaryUser + " primary group not found");
+ } else {
+ Set groupUsers = primaryGroup.getUsers();
+ if (!groupUsers.contains(primaryUser.getIdentity())) {
+ Set 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 0000000000..46bc1ccce6
--- /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 runShell(String command) throws IOException {
+ return runShell(command, "");
+ }
+
+ public static List runShell(String command, String description) throws IOException {
+ final ProcessBuilder builder = new ProcessBuilder(SHELL, OPTS, command);
+ final List 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 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 0000000000..fb3360ddb8
--- /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 0000000000..fa760b270e
--- /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 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 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 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 0000000000..1525ae5fab
--- /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 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 1529779517..496dba9a90 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 33b53ff281..d3136e4a60 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 @@
nifi-properties-loader
nifi-standard-prioritizers
nifi-mock-authorizer
+ nifi-shell-authorizer
diff --git a/nifi-nar-bundles/nifi-framework-bundle/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
index 2e33739fa1..e2366aca1f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
@@ -184,6 +184,11 @@
nifi-file-authorizer
1.10.0-SNAPSHOT
+
+ org.apache.nifi
+ nifi-shell-authorizer
+ 1.10.0-SNAPSHOT
+
org.apache.nifi
nifi-authorizer
diff --git a/pom.xml b/pom.xml
index 6f083ef71b..c8955b2b0d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -239,7 +239,12 @@
1.3
test
-
+
+ org.testcontainers
+ testcontainers
+ 1.11.3
+ test
+
org.eclipse.jetty