001/* 002 * (C) Copyright 2013-2018 Nuxeo (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * <a href="mailto:grenard@nuxeo.com">Guillaume</a> 018 */ 019package org.nuxeo.ecm.automation.core.operations.users; 020 021import java.io.IOException; 022import java.io.Serializable; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collections; 026import java.util.LinkedHashMap; 027import java.util.List; 028import java.util.Locale; 029import java.util.Map; 030import java.util.stream.Collectors; 031 032import org.apache.commons.collections.CollectionUtils; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.commons.logging.Log; 035import org.apache.commons.logging.LogFactory; 036import org.nuxeo.common.utils.i18n.I18NUtils; 037import org.nuxeo.ecm.automation.OperationContext; 038import org.nuxeo.ecm.automation.core.Constants; 039import org.nuxeo.ecm.automation.core.annotations.Context; 040import org.nuxeo.ecm.automation.core.annotations.Operation; 041import org.nuxeo.ecm.automation.core.annotations.OperationMethod; 042import org.nuxeo.ecm.automation.core.annotations.Param; 043import org.nuxeo.ecm.automation.features.SuggestConstants; 044import org.nuxeo.ecm.core.api.Blob; 045import org.nuxeo.ecm.core.api.Blobs; 046import org.nuxeo.ecm.core.api.DocumentModel; 047import org.nuxeo.ecm.core.api.DocumentModelList; 048import org.nuxeo.ecm.core.api.NuxeoGroup; 049import org.nuxeo.ecm.core.api.NuxeoPrincipal; 050import org.nuxeo.ecm.core.io.registry.MarshallingConstants; 051import org.nuxeo.ecm.core.query.sql.model.MultiExpression; 052import org.nuxeo.ecm.core.query.sql.model.Operator; 053import org.nuxeo.ecm.core.query.sql.model.OrderByExprs; 054import org.nuxeo.ecm.core.query.sql.model.Predicate; 055import org.nuxeo.ecm.core.query.sql.model.Predicates; 056import org.nuxeo.ecm.core.query.sql.model.QueryBuilder; 057import org.nuxeo.ecm.core.schema.SchemaManager; 058import org.nuxeo.ecm.core.schema.types.Field; 059import org.nuxeo.ecm.core.schema.types.QName; 060import org.nuxeo.ecm.core.schema.types.Schema; 061import org.nuxeo.ecm.directory.Directory; 062import org.nuxeo.ecm.directory.SizeLimitExceededException; 063import org.nuxeo.ecm.directory.api.DirectoryService; 064import org.nuxeo.ecm.platform.usermanager.UserAdapter; 065import org.nuxeo.ecm.platform.usermanager.UserManager; 066import org.nuxeo.ecm.platform.usermanager.io.NuxeoGroupJsonWriter; 067import org.nuxeo.ecm.platform.usermanager.io.NuxeoPrincipalJsonWriter; 068 069/** 070 * SuggestUser Operation. 071 * 072 * @since 5.7.3 073 */ 074@Operation(id = SuggestUserEntries.ID, category = Constants.CAT_SERVICES, label = "Get user/group suggestion", description = "Get the user/group list of the running instance. This is returning a blob containing a serialized JSON array..", addToStudio = false) 075public class SuggestUserEntries { 076 077 @SuppressWarnings("unused") 078 private static final Log log = LogFactory.getLog(SuggestUserEntries.class); 079 080 public static final String ID = "UserGroup.Suggestion"; 081 082 public static final String POWERUSERS = "powerusers"; 083 084 @Context 085 protected OperationContext ctx; 086 087 @Context 088 protected SchemaManager schemaManager; 089 090 @Param(name = "searchTerm", alias = "prefix", required = false) 091 protected String prefix; 092 093 @Param(name = "searchType", required = false) 094 protected String searchType; 095 096 @Param(name = "groupRestriction", required = false, description = "Enter the id of a group to suggest only user from this group.") 097 protected String groupRestriction; 098 099 /** 100 * @since 7.10 101 */ 102 @Param(name = "hideAdminGroups", required = false, description = "If set, remove all administrator groups from the suggestions") 103 protected boolean hideAdminGroups; 104 105 /** 106 * @since 8.3 107 */ 108 @Param(name = "hidePowerUsersGroup", required = false, description = "If set, remove power users group from the suggestions") 109 protected boolean hidePowerUsersGroup; 110 111 @Param(name = "userSuggestionMaxSearchResults", required = false) 112 protected Integer userSuggestionMaxSearchResults; 113 114 @Param(name = "firstLabelField", required = false) 115 protected String firstLabelField; 116 117 @Param(name = "secondLabelField", required = false) 118 protected String secondLabelField; 119 120 @Param(name = "thirdLabelField", required = false) 121 protected String thirdLabelField; 122 123 @Param(name = "hideFirstLabel", required = false) 124 protected boolean hideFirstLabel = false; 125 126 @Param(name = "hideSecondLabel", required = false) 127 protected boolean hideSecondLabel = false; 128 129 @Param(name = "hideThirdLabel", required = false) 130 protected boolean hideThirdLabel; 131 132 @Param(name = "displayEmailInSuggestion", required = false) 133 protected boolean displayEmailInSuggestion; 134 135 @Param(name = "hideIcon", required = false) 136 protected boolean hideIcon; 137 138 @Context 139 protected UserManager userManager; 140 141 @Context 142 protected DirectoryService directoryService; 143 144 @Param(name = "lang", required = false) 145 protected String lang; 146 147 /* 148 * @since 11.4 149 */ 150 @Param(name = "allowSubGroupsRestriction", required = false, description = "Whether to take into account subgroups when evaluating groupRestriction.") 151 protected boolean allowSubGroupsRestriction; 152 153 @OperationMethod 154 public Blob run() throws IOException { 155 List<Map<String, Object>> result = new ArrayList<>(); 156 boolean isGroupRestriction = !StringUtils.isBlank(groupRestriction); 157 boolean groupOnly = false; 158 boolean userOnly = isGroupRestriction; 159 160 if (!isGroupRestriction && searchType != null && !searchType.isEmpty()) { 161 if (searchType.equals(SuggestConstants.USER_TYPE)) { 162 userOnly = true; 163 } else if (searchType.equals(SuggestConstants.GROUP_TYPE)) { 164 groupOnly = true; 165 } 166 } 167 int limit = userSuggestionMaxSearchResults == null ? 0 : userSuggestionMaxSearchResults.intValue(); 168 try { 169 int userSize = 0; 170 int groupSize = 0; 171 if (!groupOnly) { 172 if (limit > 0 && isGroupRestriction) { 173 // we may have to iterate several times, while increasing the limit, 174 // because the group restrictions may truncate our results 175 long currentLimit = limit; 176 int prevUserListSize = -1; 177 for (;;) { 178 DocumentModelList userList = searchUsers(currentLimit); 179 result = usersToMapWithGroupRestrictions(userList); 180 int userListSize = userList.size(); 181 if (userListSize == prevUserListSize || result.size() > limit) { 182 // stop if the search didn't return more results 183 // or if we are beyond the limit anyway 184 break; 185 } 186 prevUserListSize = userListSize; 187 currentLimit *= 2; 188 if (currentLimit > Integer.MAX_VALUE) { 189 break; 190 } 191 } 192 } else { 193 DocumentModelList userList = searchUsers(limit); 194 result = usersToMapWithGroupRestrictions(userList); 195 } 196 userSize = result.size(); 197 } 198 if (!userOnly) { 199 Schema schema = schemaManager.getSchema(userManager.getGroupSchemaName()); 200 DocumentModelList groupList = userManager.searchGroups(prefix); 201 List<String> admins = new ArrayList<>(); 202 if (hideAdminGroups) { 203 admins = userManager.getAdministratorsGroups(); 204 } 205 groupLoop: for (DocumentModel group : groupList) { 206 if (hideAdminGroups) { 207 for (String adminGroupName : admins) { 208 if (adminGroupName.equals(group.getId())) { 209 break groupLoop; 210 } 211 } 212 } 213 if (hidePowerUsersGroup && POWERUSERS.equals(group.getId())) { 214 break groupLoop; 215 } 216 Map<String, Object> obj = new LinkedHashMap<>(); 217 for (Field field : schema.getFields()) { 218 QName fieldName = field.getName(); 219 String key = fieldName.getLocalName(); 220 Serializable value = group.getPropertyValue(fieldName.getPrefixedName()); 221 obj.put(key, value); 222 } 223 String groupId = group.getId(); 224 obj.put(SuggestConstants.ID, groupId); 225 obj.put(MarshallingConstants.ENTITY_FIELD_NAME, NuxeoGroupJsonWriter.ENTITY_TYPE); 226 // If the group hasn't an label, let's put the groupid 227 SuggestConstants.computeGroupLabel(obj, groupId, userManager.getGroupLabelField(), hideFirstLabel); 228 obj.put(SuggestConstants.TYPE_KEY_NAME, SuggestConstants.GROUP_TYPE); 229 obj.put(SuggestConstants.PREFIXED_ID_KEY_NAME, NuxeoGroup.PREFIX + groupId); 230 SuggestConstants.computeUserGroupIcon(obj, hideIcon); 231 result.add(obj); 232 } 233 groupSize = result.size() - userSize; 234 } 235 236 // Limit size results. 237 if (limit > 0 && (userSize > limit || groupSize > limit || userSize + groupSize > limit)) { 238 throw new SizeLimitExceededException(); 239 } 240 241 } catch (SizeLimitExceededException e) { 242 return searchOverflowMessage(); 243 } 244 245 return Blobs.createJSONBlobFromValue(result); 246 } 247 248 /** 249 * Applies group restrictions, and returns Map objects. 250 */ 251 protected List<Map<String, Object>> usersToMapWithGroupRestrictions(DocumentModelList userList) { 252 List<Map<String, Object>> result = new ArrayList<>(); 253 Schema schema = schemaManager.getSchema(userManager.getUserSchemaName()); 254 Directory userDir = directoryService.getDirectory(userManager.getUserDirectoryName()); 255 for (DocumentModel user : userList) { 256 Map<String, Object> obj = new LinkedHashMap<>(); 257 for (Field field : schema.getFields()) { 258 QName fieldName = field.getName(); 259 String key = fieldName.getLocalName(); 260 Serializable value = user.getPropertyValue(fieldName.getPrefixedName()); 261 if (key.equals(userDir.getPasswordField())) { 262 continue; 263 } 264 obj.put(key, value); 265 } 266 String userId = user.getId(); 267 obj.put(SuggestConstants.ID, userId); 268 obj.put(MarshallingConstants.ENTITY_FIELD_NAME, NuxeoPrincipalJsonWriter.ENTITY_TYPE); 269 obj.put(SuggestConstants.TYPE_KEY_NAME, SuggestConstants.USER_TYPE); 270 obj.put(SuggestConstants.PREFIXED_ID_KEY_NAME, NuxeoPrincipal.PREFIX + userId); 271 SuggestConstants.computeUserLabel(obj, firstLabelField, secondLabelField, thirdLabelField, 272 hideFirstLabel, hideSecondLabel, hideThirdLabel, displayEmailInSuggestion, userId); 273 SuggestConstants.computeUserGroupIcon(obj, hideIcon); 274 if (!StringUtils.isBlank(groupRestriction)) { 275 // We need to load all data about the user particularly its groups. 276 user = userManager.getUserModel(userId); 277 UserAdapter userAdapter = user.getAdapter(UserAdapter.class); 278 List<String> groups = userAdapter.getGroups(); 279 if (CollectionUtils.isNotEmpty(groups)) { 280 List<String> restrictedGroups = new ArrayList<>(); 281 restrictedGroups.add(groupRestriction); 282 if (allowSubGroupsRestriction) { 283 restrictedGroups.addAll(userManager.getDescendantGroups(groupRestriction)); 284 } 285 restrictedGroups.retainAll(groups); 286 if (!restrictedGroups.isEmpty()) { 287 result.add(obj); 288 } 289 } 290 } else { 291 result.add(obj); 292 } 293 } 294 return result; 295 } 296 297 /** 298 * Performs a full name user search, e.g. typing "John Do" returns the user with first name "John" and last name 299 * "Doe". Typing "John" returns the "John Doe" user and possibly other users such as "John Foo". Respectively, 300 * typing "Do" returns the "John Doe" user and possibly other users such as "Jack Donald". 301 */ 302 protected DocumentModelList searchUsers(long limit) { 303 if (StringUtils.isBlank(prefix)) { 304 // empty search term 305 return userManager.searchUsers(prefix); 306 } 307 // split search term around whitespace, e.g. "John Do" -> ["John", "Do"] 308 String[] searchTerms = prefix.trim().split("\\s", 2); 309 List<Predicate> predicates = Arrays.stream(searchTerms) 310 .map(this::getUserSearchPredicate) 311 .collect(Collectors.toList()); 312 // intersection between all search results to handle full name 313 DocumentModelList users = searchUsers(new MultiExpression(Operator.AND, predicates), limit); 314 if (users.isEmpty()) { 315 // search on whole term to handle a whitespace within the first or last name 316 users = searchUsers(getUserSearchPredicate(prefix), limit); 317 } 318 return users; 319 } 320 321 protected DocumentModelList searchUsers(MultiExpression multiExpression, long limit) { 322 QueryBuilder queryBuilder = new QueryBuilder(); 323 queryBuilder.filter(multiExpression); 324 if (limit > 0) { 325 // no need to search more than that because we throw SizeLimitExceededException is we reach it 326 queryBuilder.limit(limit + 1); 327 } 328 String sortField = StringUtils.defaultString(userManager.getUserSortField(), userManager.getUserIdField()); 329 queryBuilder.order(OrderByExprs.asc(sortField)); 330 return userManager.searchUsers(queryBuilder); 331 } 332 333 protected MultiExpression getUserSearchPredicate(String prefix) { 334 String pattern = prefix.trim() + '%'; 335 List<Predicate> predicates = userManager.getUserSearchFields() 336 .stream() 337 .map(key -> Predicates.ilike(key, pattern)) 338 .collect(Collectors.toList()); 339 return new MultiExpression(Operator.OR, predicates); 340 } 341 342 /** 343 * @return searchOverflowMessage 344 * @since 5.7.3 345 */ 346 private Blob searchOverflowMessage() throws IOException { 347 String label = I18NUtils.getMessageString("messages", "label.security.searchOverFlow", new Object[0], 348 getLocale()); 349 Map<String, Object> obj = Collections.singletonMap(SuggestConstants.LABEL, label); 350 return Blobs.createJSONBlobFromValue(Collections.singletonList(obj)); 351 } 352 353 protected String getLang() { 354 if (lang == null) { 355 lang = (String) ctx.get("lang"); 356 if (lang == null) { 357 lang = SuggestConstants.DEFAULT_LANG; 358 } 359 } 360 return lang; 361 } 362 363 protected Locale getLocale() { 364 return new Locale(getLang()); 365 } 366 367}