diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc index e0a20ffed4..25a97692dc 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 d5495d61e7..d67b68afd4 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 @@ -35,6 +35,10 @@ org.apache.nifi nifi-file-authorizer + + org.apache.nifi + nifi-shell-authorizer + org.apache.nifi nifi-authorizer 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 705ccfd84f..11134f4820 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 @@ nifi-expression-language test + + org.testcontainers + testcontainers + 1.11.3 + test + 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 d6d3c45901..2210c435bb 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 @@ -166,6 +166,23 @@ To enable the ldap-user-group-provider remove 2 lines. This is 2 of 2. --> + + + + + + + nifi-framework + org.apache.nifi + 1.10.0-SNAPSHOT + + 4.0.0 + nifi-shell-authorizer + + + org.apache.nifi + nifi-framework-api + compile + + + org.testcontainers + testcontainers + 1.11.3 + test + + + org.apache.nifi + nifi-mock + + + org.apache.nifi + nifi-utils + 1.10.0-SNAPSHOT + + + 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 0000000000..fe49200160 --- /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 0000000000..85dca066c2 --- /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 0000000000..3c26ba71b7 --- /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 0000000000..14c7de4dbc --- /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 0000000000..e499b6e367 --- /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 usersById = new HashMap<>(); // id == identifier + private final static Map usersByName = new HashMap<>(); // name == identity + private final static Map 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 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 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 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 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 idToUser = new HashMap<>(); + Map usernameToUser = new HashMap<>(); + Map gidToUser = new HashMap<>(); + List 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 gidToGroup = new HashMap<>(); + List 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 uidToUser = new HashMap<>(); + Map usernameToUser = new HashMap<>(); + Map gidToUser = new HashMap<>(); + Map gidToGroup = new HashMap<>(); + + List userLines; + List 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