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}