blob: 4e201d2fe3dbdae2a910f6321cedfa0b9287fc76 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.registry.security.authorization.shell;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.registry.security.authorization.AuthorizerConfigurationContext;
import org.apache.nifi.registry.security.authorization.Group;
import org.apache.nifi.registry.security.authorization.User;
import org.apache.nifi.registry.security.authorization.UserAndGroups;
import org.apache.nifi.registry.security.authorization.UserGroupProvider;
import org.apache.nifi.registry.security.authorization.UserGroupProviderInitializationContext;
import org.apache.nifi.registry.security.authorization.exception.AuthorizationAccessException;
import org.apache.nifi.registry.security.exception.SecurityProviderCreationException;
import org.apache.nifi.registry.security.exception.SecurityProviderDestructionException;
import org.apache.nifi.registry.util.FormatUtils;
import org.apache.nifi.registry.util.PropertyValue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/*
* ShellUserGroupProvider implements UserGroupProvider by way of shell commands.
*/
public class ShellUserGroupProvider implements UserGroupProvider {
private final static Logger logger = LoggerFactory.getLogger(ShellUserGroupProvider.class);
private final static String OS_TYPE_ERROR = "Unsupported operating system.";
private final static String SYS_CHECK_ERROR = "System check failed - cannot provide users and groups.";
private final static Map<String, User> usersById = new HashMap<>(); // id == identifier
private final static Map<String, User> usersByName = new HashMap<>(); // name == identity
private final static Map<String, Group> groupsById = new HashMap<>();
public static final String REFRESH_DELAY_PROPERTY = "Refresh Delay";
private static final long MINIMUM_SYNC_INTERVAL_MILLISECONDS = 10_000;
public static final String EXCLUDE_USER_PROPERTY = "Exclude Users";
public static final String EXCLUDE_GROUP_PROPERTY = "Exclude Groups";
public static final String COMMAND_TIMEOUT_PROPERTY = "Command Timeout";
private static final String DEFAULT_COMMAND_TIMEOUT = "60 seconds";
private long fixedDelay;
private Pattern excludeUsers;
private Pattern excludeGroups;
private int timeoutSeconds;
// Our scheduler has one thread for users, one for groups:
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// Commands selected during initialization:
private ShellCommandsProvider selectedShellCommands;
private ShellRunner shellRunner;
// Start of the UserGroupProvider implementation. Javadoc strings
// copied from the interface definition for reference.
/**
* Retrieves all users. Must be non null
*
* @return a list of users
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public Set<User> getUsers() throws AuthorizationAccessException {
synchronized (usersById) {
logger.debug("getUsers has user set of size: " + usersById.size());
return new HashSet<>(usersById.values());
}
}
/**
* Retrieves the user with the given identifier.
*
* @param identifier the id of the user to retrieve
* @return the user with the given id, or null if no matching user was found
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public User getUser(String identifier) throws AuthorizationAccessException {
User user;
synchronized (usersById) {
user = usersById.get(identifier);
}
if (user == null) {
logger.debug("getUser (by id) user not found: " + identifier);
} else {
logger.debug("getUser (by id) found user: " + user + " for id: " + identifier);
}
return user;
}
/**
* Retrieves the user with the given identity.
*
* @param identity the identity of the user to retrieve
* @return the user with the given identity, or null if no matching user was found
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public User getUserByIdentity(String identity) throws AuthorizationAccessException {
User user;
synchronized (usersByName) {
user = usersByName.get(identity);
}
if (user == null) {
refreshOneUser(selectedShellCommands.getUserByName(identity), "Get Single User by Name");
user = usersByName.get(identity);
}
if (user == null) {
logger.debug("getUser (by name) user not found: " + identity);
} else {
logger.debug("getUser (by name) found user: " + user.getIdentity() + " for name: " + identity);
}
return user;
}
/**
* Retrieves all groups. Must be non null
*
* @return a list of groups
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public Set<Group> getGroups() throws AuthorizationAccessException {
synchronized (groupsById) {
logger.debug("getGroups has group set of size: " + groupsById.size());
return new HashSet<>(groupsById.values());
}
}
/**
* Retrieves a Group by Id.
*
* @param identifier the identifier of the Group to retrieve
* @return the Group with the given identifier, or null if no matching group was found
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public Group getGroup(String identifier) throws AuthorizationAccessException {
Group group;
synchronized (groupsById) {
group = groupsById.get(identifier);
}
if (group == null) {
refreshOneGroup(selectedShellCommands.getGroupById(identifier), "Get Single Group by Id");
group = groupsById.get(identifier);
}
if (group == null) {
logger.debug("getGroup (by id) group not found: " + identifier);
} else {
logger.debug("getGroup (by id) found group: " + group.getName() + " for id: " + identifier);
}
return group;
}
/**
* Gets a user and their groups.
*
* @return the UserAndGroups for the specified identity
* @throws AuthorizationAccessException if there was an unexpected error performing the operation
*/
@Override
public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
User user = getUserByIdentity(identity);
logger.debug("Retrieved user {} for identity {}", new Object[]{user, identity});
Set<Group> groups = new HashSet<>();
if (user != null) {
for (Group g : getGroups()) {
if (g.getUsers().contains(user.getIdentifier())) {
logger.debug("User {} belongs to group {}", new Object[]{user.getIdentity(), g.getName()});
groups.add(g);
}
}
}
if (groups.isEmpty()) {
logger.debug("User {} belongs to no groups", user);
}
return new UserAndGroups() {
@Override
public User getUser() {
return user;
}
@Override
public Set<Group> getGroups() {
return groups;
}
};
}
/**
* Called immediately after instance creation for implementers to perform additional setup
*
* @param initializationContext in which to initialize
*/
@Override
public void initialize(UserGroupProviderInitializationContext initializationContext) throws SecurityProviderCreationException {
}
/**
* Called to configure the Authorizer.
*
* @param configurationContext at the time of configuration
* @throws SecurityProviderCreationException for any issues configuring the provider
*/
@Override
public void onConfigured(AuthorizerConfigurationContext configurationContext) throws SecurityProviderCreationException {
logger.info("Configuring ShellUserGroupProvider");
fixedDelay = getDelayProperty(configurationContext, REFRESH_DELAY_PROPERTY, "5 mins");
timeoutSeconds = getTimeoutProperty(configurationContext, COMMAND_TIMEOUT_PROPERTY, DEFAULT_COMMAND_TIMEOUT);
shellRunner = new ShellRunner(timeoutSeconds);
logger.debug("Configured ShellRunner with command timeout of '{}' seconds", new Object[]{timeoutSeconds});
// Our next init step is to select the command set based on the operating system name:
ShellCommandsProvider commands = getCommandsProvider();
if (commands == null) {
commands = getCommandsProviderFromName(null);
setCommandsProvider(commands);
}
// Our next init step is to run the system check from that command set to determine if the other commands
// will work on this host or not.
try {
shellRunner.runShell(commands.getSystemCheck());
} catch (final Exception e) {
logger.error("initialize exception: " + e + " system check command: " + commands.getSystemCheck());
throw new SecurityProviderCreationException(SYS_CHECK_ERROR, e);
}
// The next step is to add the user and group exclude regexes:
try {
excludeGroups = Pattern.compile(getProperty(configurationContext, EXCLUDE_GROUP_PROPERTY, ""));
excludeUsers = Pattern.compile(getProperty(configurationContext, EXCLUDE_USER_PROPERTY, ""));
} catch (final PatternSyntaxException e) {
throw new SecurityProviderCreationException(e);
}
// With our command set selected, and our system check passed, we can pull in the users and groups:
refreshUsersAndGroups();
// And finally, our last init step is to fire off the refresh thread:
scheduler.scheduleWithFixedDelay(() -> {
try {
refreshUsersAndGroups();
}catch (final Throwable t) {
logger.error("", t);
}
}, fixedDelay, fixedDelay, TimeUnit.MILLISECONDS);
logger.info("Completed configuration of ShellUserGroupProvider");
}
private static ShellCommandsProvider getCommandsProviderFromName(String osName) {
if (osName == null) {
osName = System.getProperty("os.name");
}
ShellCommandsProvider commands;
if (osName.startsWith("Linux")) {
logger.debug("Selected Linux command set.");
commands = new NssShellCommands();
} else if (osName.startsWith("Mac OS X")) {
logger.debug("Selected OSX command set.");
commands = new OsxShellCommands();
} else {
throw new SecurityProviderCreationException(OS_TYPE_ERROR);
}
return commands;
}
private String getProperty(AuthorizerConfigurationContext authContext, String propertyName, String defaultValue) {
final PropertyValue property = authContext.getProperty(propertyName);
final String value;
if (property != null && property.isSet()) {
value = property.getValue();
} else {
value = defaultValue;
}
return value;
}
private long getDelayProperty(AuthorizerConfigurationContext authContext, String propertyName, String defaultValue) {
final PropertyValue intervalProperty = authContext.getProperty(propertyName);
final String propertyValue;
final long syncInterval;
if (intervalProperty.isSet()) {
propertyValue = intervalProperty.getValue();
} else {
propertyValue = defaultValue;
}
try {
syncInterval = Math.round(FormatUtils.getPreciseTimeDuration(propertyValue, TimeUnit.MILLISECONDS));
} catch (final IllegalArgumentException ignored) {
throw new SecurityProviderCreationException(String.format("The %s '%s' is not a valid time interval.", propertyName, propertyValue));
}
if (syncInterval < MINIMUM_SYNC_INTERVAL_MILLISECONDS) {
throw new SecurityProviderCreationException(String.format("The %s '%s' is below the minimum value of '%d ms'", propertyName, propertyValue, MINIMUM_SYNC_INTERVAL_MILLISECONDS));
}
return syncInterval;
}
private int getTimeoutProperty(AuthorizerConfigurationContext authContext, String propertyName, String defaultValue) {
final PropertyValue timeoutProperty = authContext.getProperty(propertyName);
final String propertyValue;
if (timeoutProperty.isSet()) {
propertyValue = timeoutProperty.getValue();
} else {
propertyValue = defaultValue;
}
final long timeoutValue;
try {
timeoutValue = Math.round(FormatUtils.getPreciseTimeDuration(propertyValue, TimeUnit.SECONDS));
} catch (final IllegalArgumentException ignored) {
throw new SecurityProviderCreationException(String.format("The %s '%s' is not a valid time interval.", propertyName, propertyValue));
}
return Math.toIntExact(timeoutValue);
}
/**
* Called immediately before instance destruction for implementers to release resources.
*
* @throws SecurityProviderDestructionException If pre-destruction fails.
*/
@Override
public void preDestruction() throws SecurityProviderDestructionException {
try {
scheduler.shutdownNow();
} catch (final Exception e) {
logger.warn("Error shutting down refresh scheduler: " + e.getMessage(), e);
}
try {
shellRunner.shutdown();
} catch (final Exception e) {
logger.warn("Error shutting down ShellRunner: " + e.getMessage(), e);
}
}
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() {
final long startTime = System.currentTimeMillis();
Map<String, User> uidToUser = new HashMap<>();
Map<String, User> usernameToUser = new HashMap<>();
Map<String, User> gidToUser = new HashMap<>();
Map<String, Group> gidToGroup = new HashMap<>();
List<String> userLines;
List<String> groupLines;
try {
userLines = shellRunner.runShell(selectedShellCommands.getUsersList(), "Get Users List");
groupLines = shellRunner.runShell(selectedShellCommands.getGroupsList(), "Get Groups List");
} catch (final IOException ioexc) {
logger.error("refreshUsersAndGroups shell exception: " + ioexc);
return;
}
rebuildUsers(userLines, uidToUser, usernameToUser, gidToUser);
rebuildGroups(groupLines, gidToGroup);
reconcilePrimaryGroups(gidToUser, gidToGroup);
synchronized (usersById) {
usersById.clear();
usersById.putAll(uidToUser);
if (logger.isTraceEnabled()) {
logger.trace("=== Users by id...");
Set<User> sortedUsers = new TreeSet<>(Comparator.comparing(User::getIdentity));
sortedUsers.addAll(usersById.values());
sortedUsers.forEach(u -> logger.trace("=== " + u.toString()));
}
}
synchronized (usersByName) {
usersByName.clear();
usersByName.putAll(usernameToUser);
logger.debug("users now size: " + usersByName.size());
}
synchronized (groupsById) {
groupsById.clear();
groupsById.putAll(gidToGroup);
logger.debug("groups now size: " + groupsById.size());
if (logger.isTraceEnabled()) {
logger.trace("=== Groups by id...");
Set<Group> sortedGroups = new TreeSet<>(Comparator.comparing(Group::getName));
sortedGroups.addAll(groupsById.values());
sortedGroups.forEach(g -> logger.trace("=== " + g.toString()));
}
}
final long endTime = System.currentTimeMillis();
logger.info("Refreshed users and groups, took {} seconds", (endTime - startTime) / 1000);
}
/**
* This method parses the output of the `getUsersList()` shell command, where we expect the output
* to look like `user-name:user-id:primary-group-id`.
* <p>
* This method splits each output line on the ":" and attempts to build a User object
* from the resulting name, uid, and primary gid. Unusable records are logged.
*/
private void rebuildUsers(List<String> userLines, Map<String, User> idToUser, Map<String, User> usernameToUser, Map<String, User> gidToUser) {
userLines.forEach(line -> {
logger.trace("Processing user: {}", new Object[]{line});
String[] record = line.split(":");
if (record.length > 2) {
String userIdentity = record[0], userIdentifier = record[1], primaryGroupIdentifier = record[2];
if (!StringUtils.isBlank(userIdentifier) && !StringUtils.isBlank(userIdentity) && !excludeUsers.matcher(userIdentity).matches()) {
User user = new User.Builder()
.identity(userIdentity)
.identifierGenerateFromSeed(getUserIdentifierSeed(userIdentity))
.build();
idToUser.put(user.getIdentifier(), user);
usernameToUser.put(userIdentity, user);
logger.debug("Refreshed user {}", new Object[]{user});
if (!StringUtils.isBlank(primaryGroupIdentifier)) {
// create a temporary group to deterministically generate the group id and associate this user
Group group = new Group.Builder()
.name(primaryGroupIdentifier)
.identifierGenerateFromSeed(getGroupIdentifierSeed(primaryGroupIdentifier))
.build();
gidToUser.put(group.getIdentifier(), user);
logger.debug("Associated primary group {} with user {}", new Object[]{group.getIdentifier(), userIdentity});
} else {
logger.warn("Null or empty primary group id for: " + userIdentity);
}
} else {
logger.warn("Null, empty, or skipped user name: " + userIdentity + " or id: " + userIdentifier);
}
} else {
logger.warn("Unexpected record format. Expected 3 or more colon separated values per line.");
}
});
}
/**
* This method parses the output of the `getGroupsList()` shell command, where we expect the output
* to look like `group-name:group-id`.
* <p>
* This method splits each output line on the ":" and attempts to build a Group object
* from the resulting name and gid. Unusable records are logged.
* <p>
* This command also runs the `getGroupMembers(username)` command once per group. The expected output
* of that command should look like `group-name-1,group-name-2`.
*/
private void rebuildGroups(List<String> groupLines, Map<String, Group> groupsById) {
groupLines.forEach(line -> {
logger.trace("Processing group: {}", new Object[]{line});
String[] record = line.split(":");
if (record.length > 1) {
Set<String> users = new HashSet<>();
String groupName = record[0], groupIdentifier = record[1];
try {
String groupMembersCommand = selectedShellCommands.getGroupMembers(groupName);
List<String> memberLines = shellRunner.runShell(groupMembersCommand);
// Use the first line only, and log if the line count isn't exactly one:
if (!memberLines.isEmpty()) {
String memberLine = memberLines.get(0);
if (!StringUtils.isBlank(memberLine)) {
String[] members = memberLine.split(",");
for (String userIdentity : members) {
if (!StringUtils.isBlank(userIdentity)) {
User tempUser = new User.Builder()
.identity(userIdentity)
.identifierGenerateFromSeed(getUserIdentifierSeed(userIdentity))
.build();
users.add(tempUser.getIdentifier());
logger.debug("Added temp user {} for group {}", new Object[]{tempUser, groupName});
}
}
} else {
logger.debug("list membership returned no members");
}
} else {
logger.debug("list membership returned zero lines.");
}
if (memberLines.size() > 1) {
logger.error("list membership returned too many lines, only used the first.");
}
} catch (final IOException ioexc) {
logger.error("list membership shell exception: " + ioexc);
}
if (!StringUtils.isBlank(groupIdentifier) && !StringUtils.isBlank(groupName) && !excludeGroups.matcher(groupName).matches()) {
Group group = new Group.Builder()
.name(groupName)
.identifierGenerateFromSeed(getGroupIdentifierSeed(groupIdentifier))
.addUsers(users)
.build();
groupsById.put(group.getIdentifier(), group);
logger.debug("Refreshed group {}", new Object[] {group});
} else {
logger.warn("Null, empty, or skipped group name: " + groupName + " or id: " + groupIdentifier);
}
} else {
logger.warn("Unexpected record format. Expected 1 or more comma separated values.");
}
});
}
/**
* This method parses the output of the `getGroupsList()` shell command, where we expect the output
* to look like `group-name:group-id`.
* <p>
* This method splits each output line on the ":" and attempts to build a Group object
* from the resulting name and gid.
*/
private void reconcilePrimaryGroups(Map<String, User> uidToUser, Map<String, Group> gidToGroup) {
uidToUser.forEach((primaryGid, primaryUser) -> {
Group primaryGroup = gidToGroup.get(primaryGid);
if (primaryGroup == null) {
logger.warn("Primary group {} not found for {}", new Object[]{primaryGid, primaryUser.getIdentity()});
} else if (!excludeGroups.matcher(primaryGroup.getName()).matches()) {
Set<String> groupUsers = primaryGroup.getUsers();
if (!groupUsers.contains(primaryUser.getIdentifier())) {
Set<String> updatedUserIdentifiers = new HashSet<>(groupUsers);
updatedUserIdentifiers.add(primaryUser.getIdentifier());
Group updatedGroup = new Group.Builder()
.identifier(primaryGroup.getIdentifier())
.name(primaryGroup.getName())
.addUsers(updatedUserIdentifiers)
.build();
gidToGroup.put(updatedGroup.getIdentifier(), updatedGroup);
logger.debug("Added user {} to primary group {}", new Object[]{primaryUser, updatedGroup});
} else {
logger.debug("Primary group {} already contains user {}", new Object[]{primaryGroup, primaryUser});
}
} else {
logger.debug("Primary group {} excluded from matcher for {}", new Object[]{primaryGroup.getName(), primaryUser.getIdentity()});
}
});
}
private String getUserIdentifierSeed(final String userIdentifier) {
return userIdentifier + "-user";
}
private String getGroupIdentifierSeed(final String groupIdentifier) {
return groupIdentifier + "-group";
}
/**
* @return The fixed refresh delay.
*/
public long getRefreshDelay() {
return fixedDelay;
}
/**
* Testing concession for clearing the internal caches.
*/
void clearCaches() {
synchronized (usersById) {
usersById.clear();
}
synchronized (usersByName) {
usersByName.clear();
}
synchronized (groupsById) {
groupsById.clear();
}
}
/**
* @return The size of the internal user cache.
*/
public int userCacheSize() {
return usersById.size();
}
/**
* @return The size of the internal group cache.
*/
public int groupCacheSize() {
return groupsById.size();
}
}