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