/** * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file * distributed with this work for additional information regarding copyright ownership. Apereo * 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 the * following location: * * <p>http://www.apache.org/licenses/LICENSE-2.0 * * <p>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.apereo.portal.portlets.lookup; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.annotation.PostConstruct; import javax.portlet.PortletPreferences; import javax.portlet.PortletRequest; import org.apereo.portal.EntityIdentifier; import org.apereo.portal.portlets.search.DisplayNameComparator; import org.apereo.portal.security.IAuthorizationPrincipal; import org.apereo.portal.security.IPermission; import org.apereo.portal.security.IPerson; import org.apereo.portal.services.AuthorizationService; import org.jasig.services.persondir.IPersonAttributeDao; import org.jasig.services.persondir.IPersonAttributes; import org.jasig.services.persondir.support.NamedPersonImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.webflow.context.ExternalContext; /** * Implements logic and helper methods for the person-lookup web flow. * */ public class PersonLookupHelperImpl implements IPersonLookupHelper { protected final Logger logger = LoggerFactory.getLogger(this.getClass()); private IPersonAttributeDao personAttributeDao; public IPersonAttributeDao getPersonAttributeDao() { return personAttributeDao; } /** The {@link IPersonAttributeDao} used to perform lookups. */ public void setPersonAttributeDao(IPersonAttributeDao personLookupDao) { this.personAttributeDao = personLookupDao; } private int maxResults = 10; public void setMaxResults(int maxResults) { this.maxResults = maxResults; } public int getMaxResults() { return maxResults; } private int searchThreadCount = 10; // Default to a high enough value that it doesn't cause issues during a load test private long searchThreadTimeoutSeconds = 60; /** * Set the number of concurrent threads process person search results in parallel. * * @param searchThreadCount number of concurrent threads for processing search results */ public void setSearchThreadCount(int searchThreadCount) { this.searchThreadCount = searchThreadCount; } /** * Set the max number of seconds the threads doing the person search results should wait for a * result. <br> * TODO This is to prevent waiting indefinitely on a hung worker thread. In the future, would be * nice to get the portlet's timeout value and set this to a second or two fewer. * * @param searchThreadTimeoutSeconds Maximum number of seconds to wait for thread to respond */ public void setSearchThreadTimeoutSeconds(long searchThreadTimeoutSeconds) { this.searchThreadTimeoutSeconds = searchThreadTimeoutSeconds; } private ExecutorService executor; @PostConstruct public void initializeSearchExecutor() { executor = Executors.newFixedThreadPool(searchThreadCount); } /* (non-Javadoc) * @see org.apereo.portal.portlets.swapper.IPersonLookupHelper#getQueryAttributes(org.springframework.webflow.context.ExternalContext) */ public Set<String> getQueryAttributes(ExternalContext externalContext) { final PortletRequest portletRequest = (PortletRequest) externalContext.getNativeRequest(); final PortletPreferences preferences = portletRequest.getPreferences(); final Set<String> queryAttributes; final String[] configuredAttributes = preferences.getValues(PERSON_LOOKUP_PERSON_LOOKUP_QUERY_ATTRIBUTES, null); final String[] excludedAttributes = preferences.getValues(PERSON_LOOKUP_PERSON_LOOKUP_QUERY_ATTRIBUTES_EXCLUDES, null); //If attributes are configured in portlet prefs just use them if (configuredAttributes != null) { queryAttributes = new LinkedHashSet<String>(Arrays.asList(configuredAttributes)); } //Otherwise provide all available attributes from the IPersonAttributeDao else { final Set<String> availableAttributes = this.personAttributeDao.getAvailableQueryAttributes(); queryAttributes = new TreeSet<String>(availableAttributes); } //Remove excluded attributes if (excludedAttributes != null) { for (final String excludedAttribute : excludedAttributes) { queryAttributes.remove(excludedAttribute); } } return queryAttributes; } /* (non-Javadoc) * @see org.apereo.portal.portlets.swapper.IPersonLookupHelper#getDisplayAttributes(org.springframework.webflow.context.ExternalContext) */ public Set<String> getDisplayAttributes(ExternalContext externalContext) { final PortletRequest portletRequest = (PortletRequest) externalContext.getNativeRequest(); final PortletPreferences preferences = portletRequest.getPreferences(); final Set<String> displayAttributes; final String[] configuredAttributes = preferences.getValues(PERSON_LOOKUP_PERSON_DETAILS_DETAILS_ATTRIBUTES, null); final String[] excludedAttributes = preferences.getValues( PERSON_LOOKUP_PERSON_DETAILS_DETAILS_ATTRIBUTES_EXCLUDES, null); //If attributes are configured in portlet prefs use those the user has if (configuredAttributes != null) { displayAttributes = new LinkedHashSet<String>(); displayAttributes.addAll(Arrays.asList(configuredAttributes)); } //Otherwise provide all available attributes from the IPersonAttributes else { displayAttributes = new TreeSet<String>(personAttributeDao.getPossibleUserAttributeNames()); } //Remove any excluded attributes if (excludedAttributes != null) { for (final String excludedAttribute : excludedAttributes) { displayAttributes.remove(excludedAttribute); } } return displayAttributes; } /* (non-Javadoc) * @see org.apereo.portal.portlets.lookup.IPersonLookupHelper#getSelf(org.springframework.webflow.context.ExternalContext) */ public IPersonAttributes getSelf(ExternalContext externalContext) { final PortletRequest portletRequest = (PortletRequest) externalContext.getNativeRequest(); final String username = portletRequest.getRemoteUser(); return this.personAttributeDao.getPerson(username); } /* (non-Javadoc) * @see org.apereo.portal.portlets.lookup.IPersonLookupHelper#searchForPeople(org.apereo.portal.security.IPerson, java.util.Map) */ public List<IPersonAttributes> searchForPeople( final IPerson searcher, final Map<String, Object> query) { // get the IAuthorizationPrincipal for the searching user final IAuthorizationPrincipal principal = getPrincipalForUser(searcher); // build a set of all possible user attributes the current user has // permission to view final Set<String> permittedAttributes = getPermittedAttributes(principal); // remove any query attributes that the user does not have permission // to view final Map<String, Object> inUseQuery = new HashMap<>(); for (Map.Entry<String, Object> queryEntry : query.entrySet()) { final String attr = queryEntry.getKey(); if (permittedAttributes.contains(attr)) { inUseQuery.put(attr, queryEntry.getValue()); } else { this.logger.warn( "User '" + searcher.getName() + "' attempted searching on attribute '" + attr + "' which is not allowed in the current configuration. The attribute will be ignored."); } } // ensure the query has at least one search attribute defined if (inUseQuery.keySet().size() == 0) { throw new IllegalArgumentException("Search query is empty"); } // get the set of people matching the search query final Set<IPersonAttributes> people = this.personAttributeDao.getPeople(inUseQuery); if (people == null) { return Collections.emptyList(); } // To improve efficiency and not do as many permission checks or person directory searches, // if we have too many results and all people in the returned set of personAttributes have // a displayName, pre-sort the set and limit it to maxResults. The typical use case is that // LDAP returns results that have the displayName populated. Note that a disadvantage of this // approach is that the smaller result set may have entries that permissions prevent the // current users from viewing the person and thus reduce the number of final results, but // that is rare (typical use case is users can't view administrative internal accounts or the // system account, none of which tend to be in LDAP). We could retain a few more than maxResults // to offset that chance, but IMHO not worth the cost of extra external queries. List<IPersonAttributes> peopleList = new ArrayList<>(people); if (peopleList.size() > maxResults && allListItemsHaveDisplayName(peopleList)) { logger.debug( "All items contained displayName; pre-sorting list of size {} and truncating to", peopleList.size(), maxResults); // sort the list by display name Collections.sort(peopleList, new DisplayNameComparator()); peopleList = peopleList.subList(0, maxResults); } // Construct a new representation of the persons limited to attributes the searcher // has permissions to view. Will change order of the list. List<IPersonAttributes> list = getVisiblePersons(principal, permittedAttributes, peopleList); // Sort the list by display name Collections.sort(list, new DisplayNameComparator()); // limit the list to a maximum number of returned results if (list.size() > maxResults) { list = list.subList(0, maxResults); } return list; } /** * Returns a list of the personAttributes that this principal has permission to view. This * implementation does the check on the list items in parallel because personDirectory is * consulted for non-admin principals to get the person attributes which is really slow if done * on N entries in parallel because personDirectory often goes out to LDAP or another external * source for additional attributes. This processing will not retain list order. * * @param principal user performing the search * @param permittedAttributes set of attributes the principal has permission to view * @param peopleList list of people returned from the search * @return UNORDERED list of visible persons */ private List<IPersonAttributes> getVisiblePersons( final IAuthorizationPrincipal principal, final Set<String> permittedAttributes, List<IPersonAttributes> peopleList) { List<Future<IPersonAttributes>> futures = new ArrayList<>(); List<IPersonAttributes> list = new ArrayList<>(); // Ugly. PersonDirectory requires RequestContextHolder to be set for each thread, so pass it // into the callable. RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); // For each person in the list, check to see if the current user has permission to view this user for (IPersonAttributes person : peopleList) { Callable<IPersonAttributes> worker = new FetchVisiblePersonCallable(principal, person, permittedAttributes, requestAttributes); Future<IPersonAttributes> task = executor.submit(worker); futures.add(task); } for (Future<IPersonAttributes> future : futures) { try { final IPersonAttributes visiblePerson = future.get(searchThreadTimeoutSeconds, TimeUnit.SECONDS); if (visiblePerson != null) { list.add(visiblePerson); } } catch (InterruptedException e) { logger.error("Processing person search interrupted", e); } catch (ExecutionException e) { logger.error("Error Processing person search", e); } catch (TimeoutException e) { future.cancel(true); logger.warn( "Exceeded {} ms waiting for getVisiblePerson to return result", searchThreadTimeoutSeconds); } } logger.debug("Found {} results", list.size()); return list; } /** * Utility class for executor framework for search persons processing. Ugly - person directory * needs the requestAttributes in the RequestContextHolder set for each thread from the caller. */ private class FetchVisiblePersonCallable implements Callable<IPersonAttributes> { private IAuthorizationPrincipal principal; private IPersonAttributes person; private Set<String> permittedAttributes; private RequestAttributes requestAttributes; public FetchVisiblePersonCallable( IAuthorizationPrincipal principal, IPersonAttributes person, Set<String> permittedAttributes, RequestAttributes requestAttributes) { this.principal = principal; this.person = person; this.permittedAttributes = permittedAttributes; this.requestAttributes = requestAttributes; } /** * If the current user has permission to view this person, construct a new representation of * the person limited to attributes the searcher has permissions to view, else return null * if user cannot view. * * @return person attributes user can view. Null if user can't view person. * @throws Exception */ @Override public IPersonAttributes call() throws Exception { RequestContextHolder.setRequestAttributes(requestAttributes); return getVisiblePerson(principal, person, permittedAttributes); } } /** * Utility class to determine if all items in the list of people have a displayName. * * @param people list of personAttributes * @return true if all list items have an attribute displayName */ private boolean allListItemsHaveDisplayName(List<IPersonAttributes> people) { for (IPersonAttributes person : people) { if (person.getAttributeValue("displayName") == null) { return false; } } return true; } /* (non-Javadoc) * @see org.apereo.portal.portlets.lookup.IPersonLookupHelper#findPerson(org.apereo.portal.security.IPerson, java.lang.String) */ public IPersonAttributes findPerson(final IPerson searcher, final String username) { // get the IAuthorizationPrincipal for the searching user final IAuthorizationPrincipal principal = getPrincipalForUser(searcher); // build a set of all possible user attributes the current user has // permission to view final Set<String> permittedAttributes = getPermittedAttributes(principal); // get the set of people matching the search query final IPersonAttributes person = this.personAttributeDao.getPerson(username); if (person == null) { logger.info("No user found with username matching " + username); return null; } // if the current user has permission to view this person, construct // a new representation of the person limited to attributes the // searcher has permissions to view return getVisiblePerson(principal, person, permittedAttributes); } /** * Get the authoriztaion principal matching the supplied IPerson. * * @param person * @return */ protected IAuthorizationPrincipal getPrincipalForUser(final IPerson person) { final EntityIdentifier ei = person.getEntityIdentifier(); return AuthorizationService.instance().newPrincipal(ei.getKey(), ei.getType()); } /** * Get the set of all user attribute names defined in the portal for which the specified * principal has the attribute viewing permission. * * @param principal * @return */ protected Set<String> getPermittedAttributes(final IAuthorizationPrincipal principal) { final Set<String> attributeNames = personAttributeDao.getPossibleUserAttributeNames(); return getPermittedAttributes(principal, attributeNames); } /** * Filter the specified set of user attribute names to contain only those that the specified * principal may perform <code>IPermission.VIEW_USER_ATTRIBUTE_ACTIVITY</code>. These are * user attributes the user has general permission to view, for all visible users. * * @param principal * @param attributeNames * @return */ protected Set<String> getPermittedAttributes( final IAuthorizationPrincipal principal, final Set<String> attributeNames) { final Set<String> permittedAttributes = new HashSet<>(); for (String attr : attributeNames) { if (principal.hasPermission( IPermission.PORTAL_USERS, IPermission.VIEW_USER_ATTRIBUTE_ACTIVITY, attr)) { permittedAttributes.add(attr); } } return permittedAttributes; } /** * Provide a complete set of user attribute names that the specified principal may view within * his or her own collection. These will be attributes for which the user has <em>either</em> * <code>IPermission.VIEW_USER_ATTRIBUTE_ACTIVITY</code> or * <code>IPermission.VIEW_OWN_USER_ATTRIBUTE_ACTIVITY</code>. * * @param principal Represents a portal user who wishes to view user attributes * @param generallyPermittedAttributes The collection of user attribute name this user may view * for any visible user * @since 5.0 */ protected Set<String> getPermittedOwnAttributes( final IAuthorizationPrincipal principal, final Set<String> generallyPermittedAttributes) { // The permttedOwnAttributes collection includes all the generallyPermittedAttributes final Set<String> rslt = new HashSet<>(generallyPermittedAttributes); for (String attr : personAttributeDao.getPossibleUserAttributeNames()) { if (principal.hasPermission( IPermission.PORTAL_USERS, IPermission.VIEW_OWN_USER_ATTRIBUTE_ACTIVITY, attr)) { rslt.add(attr); } } return rslt; } /** * Filter an IPersonAttributes for a specified viewing principal. The returned person will * contain only the attributes provided in the permitted attributes list. <code>null</code> if * the principal does not have permission to view the user. * * @param principal * @param person * @param generallyPermittedAttributes * @return */ protected IPersonAttributes getVisiblePerson( final IAuthorizationPrincipal principal, final IPersonAttributes person, final Set<String> generallyPermittedAttributes) { // first check to see if the principal has permission to view this person. Unfortunately for // non-admin users, this will result in a call to PersonDirectory (which may go out to LDAP or // other external systems) to find out what groups the person is in to see if the principal // has permission or deny through one of the contained groups. if (person.getName() != null && principal.hasPermission( IPermission.PORTAL_USERS, IPermission.VIEW_USER_ACTIVITY, person.getName())) { // If the user has permission, filter the person attributes according // to the specified permitted attributes; the collection of permitted // attributes can be different based on whether the user is trying to // access information about him/herself. final Set<String> permittedAttributes = person.getName().equals(principal.getKey()) ? getPermittedOwnAttributes(principal, generallyPermittedAttributes) : generallyPermittedAttributes; final Map<String, List<Object>> visibleAttributes = new HashMap<>(); for (String attr : person.getAttributes().keySet()) { if (permittedAttributes.contains(attr)) { visibleAttributes.put(attr, person.getAttributeValues(attr)); } } // use the filtered attribute list to create and return a new // person object final IPersonAttributes visiblePerson = new NamedPersonImpl(person.getName(), visibleAttributes); return visiblePerson; } else { logger.debug( "Principal " + principal.getKey() + " does not have permissions to view user " + person.getName()); return null; } } }