001/*
002 *  Copyright 2017 Anyware Services
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 */
016package org.ametys.plugins.core.impl.group.directory.ldap;
017
018import java.text.Normalizer;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.Comparator;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.NoSuchElementException;
030import java.util.Objects;
031import java.util.Set;
032import java.util.TreeSet;
033import java.util.function.Function;
034import java.util.regex.Matcher;
035import java.util.regex.Pattern;
036import java.util.stream.Collectors;
037
038import javax.naming.Context;
039import javax.naming.NamingEnumeration;
040import javax.naming.NamingException;
041import javax.naming.directory.Attribute;
042import javax.naming.directory.Attributes;
043import javax.naming.directory.DirContext;
044import javax.naming.directory.InitialDirContext;
045import javax.naming.directory.SearchControls;
046import javax.naming.directory.SearchResult;
047import javax.naming.ldap.InitialLdapContext;
048import javax.naming.ldap.LdapContext;
049
050import org.apache.avalon.framework.activity.Disposable;
051import org.apache.avalon.framework.service.ServiceException;
052import org.apache.avalon.framework.service.ServiceManager;
053import org.apache.commons.lang3.StringUtils;
054import org.slf4j.Logger;
055
056import org.ametys.core.cache.AbstractCacheManager;
057import org.ametys.core.cache.Cache;
058import org.ametys.core.group.Group;
059import org.ametys.core.group.GroupIdentity;
060import org.ametys.core.group.directory.GroupDirectory;
061import org.ametys.core.group.directory.GroupDirectoryModel;
062import org.ametys.core.user.UserIdentity;
063import org.ametys.core.user.UserManager;
064import org.ametys.core.user.directory.UserDirectory;
065import org.ametys.core.user.population.UserPopulationDAO;
066import org.ametys.core.util.Cacheable;
067import org.ametys.core.util.SizeUtils.ExcludeFromSizeCalculation;
068import org.ametys.core.util.ldap.AbstractLDAPConnector;
069import org.ametys.core.util.ldap.ScopeEnumerator;
070import org.ametys.plugins.core.impl.user.LdapUserIdentity;
071import org.ametys.plugins.core.impl.user.directory.LdapUserDirectory;
072import org.ametys.runtime.i18n.I18nizableText;
073import org.ametys.runtime.i18n.I18nizableTextParameter;
074
075/**
076 * Use a LDAP server for getting the groups of users
077 */
078public class LdapGroupDirectory extends AbstractLDAPConnector implements GroupDirectory, Cacheable, Disposable
079{
080    /** Name of the parameter holding the datasource id */
081    protected static final String __PARAM_DATASOURCE_ID = "runtime.groups.ldap.datasource";
082    /** Name of the parameter holding the id of the associated user directory */
083    protected static final String __PARAM_ASSOCIATED_USERDIRECTORY_ID = "runtime.groups.ldap.userdirectory";
084    /** Relative DN for groups. */
085    protected static final String __PARAM_GROUPS_RELATIVE_DN = "runtime.groups.ldap.groupDN";
086    /** Filter for limiting the search. */
087    protected static final String __PARAM_GROUPS_OBJECT_FILTER = "runtime.groups.ldap.filter";
088    /** The scope used for search. */
089    protected static final String __PARAM_GROUPS_SEARCH_SCOPE = "runtime.groups.ldap.scope";
090    /** Name of the id attribute. */
091    protected static final String __PARAM_GROUPS_ID_ATTRIBUTE = "runtime.groups.ldap.id";
092    /** Name of the decription attribute. */
093    protected static final String __PARAM_GROUPS_DESCRIPTION_ATTRIBUTE = "runtime.groups.ldap.description";
094    
095    /** Name of the user uid attribute. */
096    protected static final String __PARAM_USERS_UID_ATTRIBUTE = "runtime.users.ldap.uidAttr";
097    /** Name of the member DN attribute. */
098    protected static final String __PARAM_GROUPS_MEMBER_ATTRIBUTE = "runtime.groups.ldap.member";
099    /** Name of the member DN attribute. */
100    protected static final String __PARAM_GROUPS_MEMBEROF_ATTRIBUTE = "runtime.groups.ldap.memberof";
101    
102    private static final GroupComparator __GROUP_COMPARATOR = new GroupComparator();
103
104    private static final String __LDAP_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX = LdapGroupDirectory.class.getName() + "$group.by.id$";
105    private static final String __LDAP_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX = LdapGroupDirectory.class.getName() + "$groups.by.user$";
106    private static final String __LDAP_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX = LdapGroupDirectory.class.getName() + "$users.by.group$";
107    
108
109    /** Unique instance of group to mean a null result in the cache */
110    private static final Group NULLGROUP = new Group() 
111    {
112        public GroupIdentity getIdentity()
113        {
114            return null;
115        }
116
117        public String getLabel()
118        {
119            return null;
120        }
121
122        public GroupDirectory getGroupDirectory()
123        {
124            return null;
125        }
126
127        public Set<UserIdentity> getUsers()
128        {
129            return null;
130        }
131    };
132    
133    /** The user manager */
134    protected UserManager _userManager;
135    /** The DAO for user populations */
136    protected UserPopulationDAO _userPopulationDAO;
137    
138    /** The group DN relative to baseDN */
139    protected String _groupsRelativeDN;
140    /** The filter to find groups */
141    protected String _groupsObjectFilter;
142    /** The scope used for search. */
143    protected int _groupsSearchScope;
144    /** The group id attribute */
145    protected String _groupsIdAttribute;
146    /** The group description attribute */
147    protected String _groupsDescriptionAttribute;
148    /** The LDAP search page size. */
149    protected int _pageSize;
150    
151    /** The attribute which contains the member DN */
152    protected String _groupsMemberAttribute;
153    /** The id of the associated user directory where the LDAP group will retrieve the users */
154    protected String _associatedUserDirectoryId;
155    /** The id of the associated user population where the LDAP group will retrieve the users */
156    protected String _associatedPopulationId;
157    /** The user id in 'memberUid' attribute (on groups for retrieving the users of a group). */
158    protected String _userUidAttribute;
159    
160    /** The attribute which contains the groups of a user */
161    protected String _usersMemberOfAttribute;
162    
163    /** The id */
164    protected String _id;
165    /** The label */
166    protected I18nizableText _label;
167    /** The id of the {@link GroupDirectoryModel} */
168    private String _groupDirectoryModelId;
169    /** The map of the values of the parameters */
170    private Map<String, Object> _paramValues;
171    
172    private Pattern _groupExtractionPattern;
173
174    // Cannot use _id as two GroupDirectories with same id can co-exist during a short amount of time (during GroupDirectoryDAO#_read)
175    private final String _uniqueCacheSuffix = org.ametys.core.util.StringUtils.generateKey();
176    
177    private AbstractCacheManager _cacheManager;
178    
179    @Override
180    public String getId()
181    {
182        return _id;
183    }
184
185    @Override
186    public I18nizableText getLabel()
187    {
188        return _label;
189    }
190
191    @Override
192    public void setId(String id)
193    {
194        _id = id;
195    }
196
197    @Override
198    public void setLabel(I18nizableText label)
199    {
200        _label = label;
201    }
202
203    @Override
204    public String getGroupDirectoryModelId()
205    {
206        return _groupDirectoryModelId;
207    }
208
209    @Override
210    public Map<String, Object> getParameterValues()
211    {
212        return _paramValues;
213    }
214    
215    @Override
216    public void service(ServiceManager serviceManager) throws ServiceException
217    {
218        super.service(serviceManager);
219        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
220        _userPopulationDAO = (UserPopulationDAO) serviceManager.lookup(UserPopulationDAO.ROLE);
221        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
222    }
223    
224    @Override
225    public void dispose()
226    {
227        removeCaches();
228    }
229    
230    public AbstractCacheManager getCacheManager()
231    {
232        return _cacheManager;
233    }
234    
235    @Override
236    public Collection<SingleCacheConfiguration> getManagedCaches()
237    {
238        return Arrays.asList(
239                SingleCacheConfiguration.of(
240                        __LDAP_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
241                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_GROUP_BY_ID_LABEL"), 
242                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_GROUP_BY_ID_DESC")),
243                SingleCacheConfiguration.of(
244                        __LDAP_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
245                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_GROUPS_BY_USER_LABEL"), 
246                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_GROUPS_BY_USER_DESC")),
247                SingleCacheConfiguration.of(
248                        __LDAP_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
249                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_USERS_BY_GROUP_LABEL"), 
250                        _buildI18n("PLUGINS_CORE_GROUPS_LDAPGROUP_CACHE_USERS_BY_GROUP_DESC"))
251        );
252    }
253    
254    private I18nizableText _buildI18n(String i18nKey)
255    {
256        String catalogue = "plugin.core-impl";
257        I18nizableText groupDirectoryId = new I18nizableText(getId());
258        Map<String, I18nizableTextParameter> labelParams = Map.of("id", groupDirectoryId);
259        return new I18nizableText(catalogue, i18nKey, labelParams);
260    }
261    
262    private Cache<String, Group> getCacheGroupById()
263    {
264        return getCacheManager().get(__LDAP_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
265    }
266    
267    private Cache<UserIdentity, Set<String>> getCacheGroupsByUser()
268    {
269        return getCacheManager().get(__LDAP_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
270    }
271    
272    private Cache<GroupIdentity, Set<UserIdentity>> getCacheUsersByGroup()
273    {
274        return getCacheManager().get(__LDAP_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
275    }
276
277    
278    private String getUniqueCacheIdSuffix()
279    {
280        return _uniqueCacheSuffix;
281    }
282
283    @Override
284    public void init(String groupDirectoryModelId, Map<String, Object> paramValues) throws Exception
285    {
286        _groupDirectoryModelId = groupDirectoryModelId;
287        _paramValues = paramValues;
288        
289        String populationAndUserDirectory = (String) paramValues.get(__PARAM_ASSOCIATED_USERDIRECTORY_ID);
290        String[] split = populationAndUserDirectory.split("#");
291        _associatedPopulationId = split[0];
292        _associatedUserDirectoryId = split[1];
293        
294        // FIXME https://issues.ametys.org/browse/RUNTIME-2392 (avoid this check to prevent circular dependency)
295//        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
296//        if (!(associatedUserDirectory instanceof LdapUserDirectory))
297//        {
298//            throw new IllegalArgumentException("The parameter '" + __PARAM_ASSOCIATED_USERDIRECTORY_ID + "' must reference a LDAP user directory");
299//        }
300        
301        _groupsRelativeDN = (String) paramValues.get(__PARAM_GROUPS_RELATIVE_DN);
302        _groupsObjectFilter = (String) paramValues.get(__PARAM_GROUPS_OBJECT_FILTER);
303        _groupsSearchScope = ScopeEnumerator.parseScope((String) paramValues.get(__PARAM_GROUPS_SEARCH_SCOPE));
304        _groupsIdAttribute = (String) paramValues.get(__PARAM_GROUPS_ID_ATTRIBUTE);
305        _groupsDescriptionAttribute = (String) paramValues.get(__PARAM_GROUPS_DESCRIPTION_ATTRIBUTE);
306        
307        _userUidAttribute = (String) paramValues.get(__PARAM_USERS_UID_ATTRIBUTE);
308        
309        _groupsMemberAttribute = (String) paramValues.get(__PARAM_GROUPS_MEMBER_ATTRIBUTE);
310        
311        _usersMemberOfAttribute = (String) paramValues.get(__PARAM_GROUPS_MEMBEROF_ATTRIBUTE);
312        
313        String dataSourceId = (String) paramValues.get(__PARAM_DATASOURCE_ID);
314        try
315        {
316            _delayedInitialize(dataSourceId);
317        }
318        catch (Exception e)
319        {
320            getLogger().error("An error occured during the initialization of LDAPUserDirectory", e);
321        }
322        
323        _pageSize = __DEFAULT_PAGE_SIZE;
324        
325        _groupExtractionPattern = Pattern.compile("^" + _groupsIdAttribute + "=([^,]+),.*", Pattern.CASE_INSENSITIVE);
326        
327        createCaches();
328    }
329    
330    @Override
331    public Group getGroup(String groupID)
332    {
333        Group group = null;
334        
335        // Cache hit, return the results. 
336        if (isCachingEnabled() && getCacheGroupById().hasKey(groupID))
337        {
338            Group groupCache = getCacheGroupById().get(groupID);
339            if (NULLGROUP == groupCache)
340            {
341                return null;
342            }
343            else
344            {
345                return groupCache;
346            }
347        }
348        
349        DirContext context = null;
350        NamingEnumeration results = null;
351        
352        try
353        {
354            // Connect to ldap server
355            context = new InitialDirContext(_getContextEnv());
356            
357            // Create search filter
358            StringBuffer filter = new StringBuffer("(&");
359            filter.append(_groupsObjectFilter);
360            filter.append("(");
361            filter.append(_groupsIdAttribute);
362            filter.append("={0}))");
363            
364            // Run search
365            results = context.search(_groupsRelativeDN, filter.toString(),
366                    new Object[] {groupID}, _getSearchConstraint());
367            
368            // Check if a group matches
369            if (results.hasMoreElements())
370            {
371                // Retrieve the found group
372                group = _getUserGroup((SearchResult) results.nextElement());
373            }
374        }
375        catch (IllegalArgumentException e)
376        {
377            getLogger().error("Error missing at least one attribute or attribute value", e);
378        }
379        catch (NamingException e)
380        {
381            getLogger().error("Error communication with ldap server", e);
382        }
383        finally
384        {
385            // Close connection resources
386            _cleanup(context, results);
387        }
388
389        // Cache the results.
390        if (isCachingEnabled())
391        {
392            getCacheGroupById().put(groupID, group != null ? group : NULLGROUP);
393        }
394
395        // Return group or null
396        return group;
397    }
398
399    @Override
400    public Set<Group> getGroups()
401    {
402        Set<Group> groups = new TreeSet<>(__GROUP_COMPARATOR);
403
404        groups.addAll(getGroups(-1, 0, Collections.emptyMap()));
405
406        // Return the list of users as a collection of UserGroup, possibly empty
407        return groups;
408    }
409
410    @Override
411    public Set<String> getUserGroups(UserIdentity userIdentity)
412    {
413        String populationId = userIdentity.getPopulationId();
414        
415        if (!populationId.equals(_associatedPopulationId))
416        {
417            return Collections.emptySet();
418        }
419        
420        // Cache hit, return the results. 
421        if (isCachingEnabled() && getCacheGroupsByUser().hasKey(userIdentity))
422        {
423            Set<String> userGroups = getCacheGroupsByUser().get(userIdentity);
424            return userGroups;
425        }
426        
427        Set<String> groups;
428        
429        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
430
431        if (!(associatedUserDirectory instanceof LdapUserDirectory))
432        {
433            getLogger().warn("A Ldap group directory must be associated with a Ldap user directory.");
434            return Set.of();
435        }
436        
437        LdapUserDirectory associatedLdapUserDirectory = (LdapUserDirectory) associatedUserDirectory;
438        
439        String usersRelativeDN = (String) associatedLdapUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_RELATIVE_DN);
440        
441        // If param 'runtime.groups.ldap.memberof' is present, try to read the property from the user entries
442        if (StringUtils.isNotEmpty(_usersMemberOfAttribute))
443        {
444            groups = _getUserGroupsFromMemberofAttr(userIdentity, usersRelativeDN, associatedLdapUserDirectory);
445        }
446        // If param 'runtime.groups.ldap.memberof' is not present, 'runtime.groups.ldap.member' must be present, try to read the property from the group entries
447        else
448        {
449            groups = _getUserGroupsFromMemberAttr(userIdentity, associatedLdapUserDirectory);
450        }
451        
452        // Cache the results.
453        if (isCachingEnabled())
454        {
455            getCacheGroupsByUser().put(userIdentity, groups);
456        }
457
458        // Return the groups, possibly empty
459        return groups;
460    }
461    
462    private Set<String> _getUserGroupsFromMemberofAttr(UserIdentity userIdentity, String usersRelativeDN, LdapUserDirectory associatedUserDirectory)
463    {
464        Set<String> groups = new HashSet<>();
465        
466        String login = userIdentity.getLogin();
467        DirContext context = null;
468        NamingEnumeration userResults = null;
469        
470        try
471        {
472            // Connect to ldap server
473            context = new InitialDirContext(_getContextEnv());
474            Attributes userAttrs = null;
475            
476            if (userIdentity instanceof LdapUserIdentity)
477            {
478                // Lookup the user and get the attribute of the groups
479                String dn = ((LdapUserIdentity) userIdentity).getDn();
480                String relativeDn = _getRelativeDn(dn);
481                
482                userAttrs = context.getAttributes(relativeDn, new String[] {_usersMemberOfAttribute});
483            }
484            else
485            {
486                // Search user with given login attribute
487                String userLoginAttribute = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_LOGIN_ATTRIBUTE);
488                String usersObjectFilter = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_OBJECT_FILTER);
489                
490                // Create search filter
491                StringBuffer userFilter = new StringBuffer("(&");
492                userFilter.append(usersObjectFilter);
493                userFilter.append("(");
494                userFilter.append(userLoginAttribute);
495                userFilter.append("={0}))");
496    
497                getLogger().debug("Searching groups of user '{}' on user itself: '{}'.", login, userFilter);
498                
499                // Run search
500                userResults = context.search(usersRelativeDN, userFilter.toString(), new Object[] {login}, _getUserSearchConstraint(new String[] {userLoginAttribute, _usersMemberOfAttribute}));
501                
502                // Fill the set of groups
503                if (userResults.hasMoreElements())
504                {
505                    // The search result should not send more than one result as it is an id
506                    SearchResult userResult = (SearchResult) userResults.nextElement();
507                    userAttrs = userResult.getAttributes();
508                }
509                userResults.close();
510            }
511            
512            // The user may come from the good population (the associated population), but from a user diretory which is not the LDAP one
513            if (userAttrs != null)
514            {
515                groups.addAll(_getGroupIdsOfUser(userAttrs, context));
516            }
517        }
518        catch (NamingException e)
519        {
520            getLogger().error("Error communication with ldap server", e);
521        }
522        finally
523        {
524            // Close connection resources
525            _cleanup(context, userResults);
526        }
527        
528        getLogger().debug("{} groups found for user '{}' from '{}' attribute on users", groups.size(), login, _usersMemberOfAttribute);
529        
530        return groups;
531    }
532    
533    private Set<String> _getUserGroupsFromMemberAttr(UserIdentity userIdentity, LdapUserDirectory userDirectory)
534    {
535        Set<String> groups = new HashSet<>();
536        String login = userIdentity.getLogin();
537        
538        DirContext context = null;
539        NamingEnumeration userResults = null;
540        
541        // Create search filter
542        StringBuffer groupFilter = new StringBuffer("(&");
543        groupFilter.append(_groupsObjectFilter);
544        
545        groupFilter.append("(|");
546        
547        String dn = userDirectory.getUserDN(login);
548        
549        // If 'runtime.groups.ldap.member' references a DN
550        groupFilter.append("(");
551        groupFilter.append(_groupsMemberAttribute);
552        groupFilter.append("={0}");
553        groupFilter.append(")");
554        
555        // If 'runtime.groups.ldap.member' references a UID
556        groupFilter.append("(");
557        groupFilter.append(_groupsMemberAttribute);
558        groupFilter.append("={1})");
559        
560        groupFilter.append("))");
561        
562        getLogger().debug("Searching groups of user '{}' with base DN '{}': '{}'.", login, _groupsRelativeDN, groupFilter);
563        
564        // Run search
565        int groupCount = 0;
566        try
567        {
568            // Connect to ldap server
569            context = new InitialDirContext(_getContextEnv());
570                    
571            userResults = context.search(_groupsRelativeDN, groupFilter.toString(),
572                                     new Object[] {dn, login}, _getSearchConstraint());
573            
574            // Fill the set of groups
575            while (userResults.hasMoreElements())
576            {
577                // Retrieve the found group
578                String groupId = _getGroupId((SearchResult) userResults.nextElement());
579                if (groupId != null)
580                {
581                    groups.add(groupId);
582                    groupCount++;
583                }
584            }
585        }
586        catch (NamingException e)
587        {
588            getLogger().error("Error communication with ldap server", e);
589        }
590        finally
591        {
592            // Close connection resources
593            _cleanup(context, userResults);
594        }
595        
596        getLogger().debug("{} groups found for user '{}' from '{}' attribute on groups", groupCount, login, _groupsMemberAttribute);
597        
598        return groups;
599    }
600    
601    /**
602     * Get a group id from attributes of a ldap group entry.
603     * @param groupEntry The ldap group entry to get attributes from.
604     * @return The group id as a String.
605     * @throws IllegalArgumentException If a needed attribute is missing.
606     */
607    protected String _getGroupId(SearchResult groupEntry)
608    {
609        // Retrieve the attributes of the entry
610        Attributes attrs = groupEntry.getAttributes();
611        
612        try
613        {
614            // Retrieve the identifier of a group
615            Attribute groupIDAttr = attrs.get(_groupsIdAttribute);
616            if (groupIDAttr == null)
617            {
618                getLogger().warn("Missing group id attribute : \"{}\". Group will be ignored.", _groupsIdAttribute);
619                return null;
620            }
621            
622            return (String) groupIDAttr.get();
623        }
624        catch (NamingException e)
625        {
626            getLogger().warn("Missing at least one value for an attribute in an ldap entry.  Group will be ignored.", e);
627            return null;
628        }
629    }
630    
631    /**
632     * Get group ids from attributes of a ldap user entry.
633     * @param userAttrs The attributes of a ldap user entry
634     * @param context The context
635     * @return The group ids as a Set of String.
636     * @throws NamingException If a naming exception was encountered while retrieving the group DNs
637     * @throws IllegalArgumentException If a needed attribute is missing.
638     */
639    @SuppressWarnings("unchecked")
640    protected Set<String> _getGroupIdsOfUser(Attributes userAttrs, DirContext context) throws NamingException
641    {
642        Set<String> groups = new HashSet<>();
643        
644        // Retrieve the identifier of the groups
645        Attribute userGroups = userAttrs.get(_usersMemberOfAttribute);
646        if (userGroups != null)
647        {
648            NamingEnumeration<String> groupIds = null;
649            try
650            {
651                // Retrieve the members of the group
652                groupIds = (NamingEnumeration<String>) userGroups.getAll();
653                while (groupIds.hasMoreElements())
654                {
655                    String groupId = groupIds.nextElement();
656                    
657                    Matcher matcher = _groupExtractionPattern.matcher(groupId);
658                    if (matcher.matches())
659                    {
660                        // the group id is a DN, we can look it up in the directory
661                        try
662                        {
663                            String relativeGroupDn = _getRelativeDn(groupId);
664                            Attributes groupAttrs = context.getAttributes(relativeGroupDn, new String[] {_groupsIdAttribute});
665                            Attribute groupIdAttr = groupAttrs.get(_groupsIdAttribute);
666                            if (groupIdAttr != null)
667                            {
668                                groups.add((String) groupIdAttr.get());
669                            }
670                        }
671                        catch (NamingException e)
672                        {
673                            getLogger().warn(String.format("Unable to get the group from the LDAP DN entry: %s", groupId), e);
674                        }
675                    }
676                    else
677                    {
678                        // the group is only an id
679                        // FIXME we could try to search for this id in case it does does actually represent a group, but it has a performance cost
680                        groups.add(groupId);
681                    }
682                }
683            }
684            finally
685            {
686                _cleanup(null, groupIds);
687            }
688        }
689        
690        return groups;
691    }
692
693    @Override
694    public List<Group> getGroups(int count, int offset, Map parameters)
695    {
696        String pattern = (String) parameters.get("pattern");
697        
698        try
699        {
700            List<SearchResult> searchResults = _search(_pageSize, _groupsRelativeDN, _groupsObjectFilter, _getSearchConstraint(), count > -1 && _serverSideSorting);
701            
702            return searchResults.stream()
703                    .map(this::_getUserGroup)
704                    .filter(Objects::nonNull)
705                    .filter(group -> _filterMatchingGroup(group, pattern))
706                    .skip(offset)
707                    .limit(count < 0 ? Integer.MAX_VALUE : count)
708                    .collect(Collectors.toList());
709        }
710        catch (NamingException e)
711        {
712            getLogger().error("Error of communication with ldap server", e);
713        }
714        
715        return Collections.emptyList();
716    }
717    
718    private boolean _filterMatchingGroup(Group group, String pattern)
719    {
720        if (StringUtils.isEmpty(pattern))
721        {
722            return true;
723        }
724        Function<String, String> normalize = str -> Normalizer.normalize(str.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim();
725        String toMatch = normalize.apply(pattern);
726        if (StringUtils.isEmpty(toMatch))
727        {
728            return true;
729        }
730        
731        String groupLabel = normalize.apply(group.getLabel());
732        if (groupLabel.indexOf(toMatch) != -1)
733        {
734            return true;
735        }
736        
737        String groupIdentity = group.getIdentity() != null ? normalize.apply(group.getIdentity().getId()) : null;
738        return groupIdentity != null && groupIdentity.indexOf(toMatch) != -1;
739    }
740
741    /**
742     * Get an UserGroup from attributes of a ldap entry.
743     * @param entry The ldap entry to get attributes from.
744     * @return The group as an UserGroup.
745     * @throws IllegalArgumentException If a needed attribute is missing.
746     */
747    protected Group _getUserGroup(SearchResult entry)
748    {
749        LdapGroup group = null;
750        // Retrieve the attributes of the entry
751        Attributes attrs = entry.getAttributes();
752        
753        try
754        {
755            // Retrieve the identifier of a group
756            Attribute groupIDAttr = attrs.get(_groupsIdAttribute);
757            if (groupIDAttr == null)
758            {
759                getLogger().warn("Missing group id attribute : \"" + _groupsIdAttribute + "\". Group will be ignored.");
760                return null;
761            }
762            String groupID = (String) groupIDAttr.get();
763            
764            // Retrieve the description of a group
765            Attribute groupDESCAttr = attrs.get(_groupsDescriptionAttribute);
766            if (groupDESCAttr == null)
767            {
768                getLogger().warn("Missing group description attribute : \"" + _groupsDescriptionAttribute + "\". Group will be ignored.");
769                return null;
770            }
771            String groupDesc = (String) groupDESCAttr.get();
772
773            Attribute membersAttr = null;
774            if (StringUtils.isNotEmpty(_groupsMemberAttribute))
775            {
776                membersAttr = attrs.get(_groupsMemberAttribute);
777            }
778            group = new LdapGroup(new GroupIdentity(groupID, getId()), groupDesc, this, membersAttr, getLogger());
779        }
780        catch (NamingException e)
781        {
782            getLogger().warn("Missing at least one value for an attribute in an ldap entry. Group will be ignored.", e);
783            return null;
784        }
785        catch (IllegalArgumentException e)
786        {
787            getLogger().error("Error missing at least one attribute or attribute value", e);
788        }
789        
790        return group;
791    }
792    
793    /**
794     * If the given DN is absolute, return the relative DN. Otherwise, return the given DN.
795     * @param dn The absolute or relative DN
796     * @return The relative DN
797     */
798    protected String _getRelativeDn(String dn)
799    {
800        String relativeDn = dn;
801        String suffix = "," + _ldapBaseDN;
802        if (dn.endsWith(suffix))
803        {
804            relativeDn = StringUtils.substring(dn, 0, -suffix.length());
805        }
806        
807        return relativeDn;
808    }
809    
810    /**
811     * Gets a user according to its DN
812     * @param ldapDn The DN of the user in the LDAP
813     * @return A user
814     */
815    protected UserIdentity _getUserInLdapFromDn(String ldapDn)
816    {
817        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
818        String userLoginAttribute = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_LOGIN_ATTRIBUTE);
819        
820        String relativeDn = _getRelativeDn(ldapDn);
821        
822        LdapContext ldapContext = null;
823        try
824        {
825            ldapContext = new InitialLdapContext(_getContextEnv(), null);
826            Attributes userAttrs = ldapContext.getAttributes(relativeDn, new String[] {userLoginAttribute});
827            Attribute userLogin = userAttrs.get(userLoginAttribute);
828            if (userLogin == null)
829            {
830                getLogger().warn("User '{}' was found in LDAP but is missing the attribute {}", relativeDn, userLoginAttribute);
831                return null;
832            }
833            UserIdentity identity = new UserIdentity((String) userLogin.get(), _associatedPopulationId);
834            if (_userManager.getUser(identity) != null)
835            {
836                return identity;
837            }
838            else
839            {
840                getLogger().warn("User with login '{}' was found in LDAP but is not a user of the population {}", userLogin.get(), _associatedPopulationId);
841            }
842        }
843        catch (NamingException e)
844        {
845            getLogger().warn(String.format("Unable to get the user from the LDAP DN entry: %s", ldapDn), e);
846        }
847        finally
848        {
849            _cleanup(ldapContext, null);
850        }
851        
852        return null;
853    }
854    
855    /**
856     * Gets a user according to its UID
857     * @param ldapUid The UID of the user in the LDAP
858     * @return A user
859     */
860    protected UserIdentity _getUserInLdapFromUid(String ldapUid)
861    {
862        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
863        String userLoginAttribute = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_LOGIN_ATTRIBUTE);
864        String usersRelativeDN = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_RELATIVE_DN);
865        
866        try
867        {
868            String filter = _userUidAttribute + "=" + ldapUid;
869            SearchControls constraints = _getUserSearchConstraint(new String[] {userLoginAttribute});
870            
871            List<SearchResult> results = _search(_pageSize, usersRelativeDN, filter, constraints, false);
872            if (results.size() > 0)
873            {
874                SearchResult searchResult = results.get(0);
875                Attribute userLogin = searchResult.getAttributes().get(userLoginAttribute);
876                if (userLogin == null)
877                {
878                    getLogger().warn("User '{}' was found in LDAP but is missing the attribute {}", searchResult, userLoginAttribute);
879                    return null;
880                }
881                UserIdentity identity = new UserIdentity((String) userLogin.get(), _associatedPopulationId);
882                if (_userManager.getUser(identity) != null)
883                {
884                    return identity;
885                }
886                else
887                {
888                    getLogger().warn("User with login '{}' was found in LDAP but is not a user of the population {}", userLogin.get(), _associatedPopulationId);
889                }
890            }
891            getLogger().warn("Unable to get the user from the LDAP UID: {}", ldapUid);
892            return null;
893        }
894        catch (NamingException | NoSuchElementException e)
895        {
896            getLogger().warn(String.format("Unable to get the user from the LDAP UID: %s", ldapUid), e);
897            return null;
898        }
899    }
900    
901    /**
902     * Gets all users of a group from the 'runtime.groups.ldap.memberof' attribute on the users
903     * @param groupId The id of the group
904     * @return The users of the given group, only by looking at the 'runtime.groups.ldap.memberof' attribute on the users
905     */
906    protected Set<UserIdentity> _getUsersFromMembersOfAttr(String groupId)
907    {
908        Set<UserIdentity> identities = new LinkedHashSet<>();
909        if (_usersMemberOfAttribute == null)
910        {
911            return identities;
912        }
913        
914        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
915        String userLoginAttribute = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_LOGIN_ATTRIBUTE);
916        String usersRelativeDN = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_RELATIVE_DN);
917        String usersObjectFilter = (String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_OBJECT_FILTER);
918        
919        try
920        {
921            // FIXME work only if the group id attribute in Ametys is the same as the id in the LDAP ? Would be better if a GroupIdentity owned the DN of the group
922            String memberOfValue = _groupsIdAttribute + "=" + groupId + "," + _groupsRelativeDN + "," + _ldapBaseDN;
923            String filter = "(&" + usersObjectFilter + "(|(" + _usersMemberOfAttribute + "=" + groupId + ")(" + _usersMemberOfAttribute + "=" + memberOfValue + ")))";
924            
925            List<SearchResult> searchResults = _search(_pageSize, usersRelativeDN, filter, _getUserSearchConstraint(new String[] {userLoginAttribute}), false);
926            for (SearchResult searchResult : searchResults)
927            {
928                Attributes attrs = searchResult.getAttributes();
929                Attribute userLogin = attrs.get(userLoginAttribute);
930                if (userLogin == null)
931                {
932                    getLogger().warn("User '{}' was found in LDAP but is missing the attribute {}", searchResult, userLoginAttribute);
933                    break;
934                }
935                UserIdentity identity = new UserIdentity((String) userLogin.get(), _associatedPopulationId);
936                if (_userManager.getUser(identity) != null)
937                {
938                    identities.add(identity);
939                }
940                else
941                {
942                    getLogger().warn("User with login '{}' was found in LDAP but is not a user of the population {}", userLogin.get(), _associatedPopulationId);
943                }
944            }
945        }
946        catch (NamingException e)
947        {
948            getLogger().error("Error of communication with ldap server", e);
949        }
950        
951        return identities;
952    }
953    
954    private SearchControls _getUserSearchConstraint(String[] returningAttributes)
955    {
956        // Search parameters
957        SearchControls constraints = new SearchControls();
958
959        // Position the wanted attributes
960        constraints.setReturningAttributes(returningAttributes);
961
962        // Choose depth of search
963        UserDirectory associatedUserDirectory = _userPopulationDAO.getUserPopulation(_associatedPopulationId).getUserDirectory(_associatedUserDirectoryId);
964        int usersSearchScope = ScopeEnumerator.parseScope((String) associatedUserDirectory.getParameterValues().get(LdapUserDirectory.PARAM_USERS_SEARCH_SCOPE));
965        constraints.setSearchScope(usersSearchScope);
966        
967        return constraints;
968    }
969    
970    /**
971     * Get constraints for a search.
972     * @return The constraints as a SearchControls.
973     */
974    protected SearchControls _getSearchConstraint()
975    {
976        // Search parameters
977        SearchControls constraints = new SearchControls();
978        
979        // Only one attribute to retrieve
980        constraints.setReturningAttributes(new String [] {_groupsIdAttribute, _groupsDescriptionAttribute, _groupsMemberAttribute});
981        // Choose the depth of search
982        constraints.setSearchScope(_groupsSearchScope);
983        return constraints;
984    }
985    
986    /**
987     * Get group as JSON object
988     * @param group the group
989     * @param users true to get users' group
990     * @return the group as JSON object
991     */
992    protected Map<String, Object> _group2JSON(Group group, boolean users)
993    {
994        Map<String, Object> group2json = new HashMap<>();
995        group2json.put("id", group.getIdentity().getId());
996        group2json.put("groupDirectory", group.getIdentity().getDirectoryId());
997        group2json.put("groupDirectoryLabel", group.getGroupDirectory().getLabel());
998        group2json.put("label", group.getLabel());
999        if (users)
1000        {
1001            group2json.put("users", group.getUsers());
1002        }
1003        return group2json;
1004    }
1005    
1006    /**
1007     * Group comparator.
1008     */
1009    private static class GroupComparator implements Comparator<Group>
1010    {
1011        /**
1012         * Constructor.
1013         */
1014        public GroupComparator()
1015        {
1016            // Nothing to do.
1017        }
1018        
1019        @Override
1020        public int compare(Group g1, Group g2) 
1021        {
1022            if (g1.getIdentity().getId().equals(g2.getIdentity().getId()))
1023            {
1024                return 0;
1025            }
1026            
1027            // Case insensitive sort
1028            int compareTo = g1.getLabel().toLowerCase().compareTo(g2.getLabel().toLowerCase());
1029            if (compareTo == 0)
1030            {
1031                return g1.getIdentity().getId().compareTo(g2.getIdentity().getId());
1032            }
1033            return compareTo;
1034        }
1035    }
1036    
1037    /**
1038     * Implementation of {@link Group} for Ldap group directory
1039     */
1040    private static final class LdapGroup implements Group
1041    {
1042        private boolean _userInitialized;
1043        private Set<UserIdentity> _users;
1044        private GroupIdentity _identity;
1045        private String _groupLabel;
1046        private Attribute _membersAttr;
1047        
1048        @ExcludeFromSizeCalculation
1049        private Logger _logger;
1050        
1051        @ExcludeFromSizeCalculation
1052        private LdapGroupDirectory _groupDirectory;
1053
1054        LdapGroup(GroupIdentity identity, String label, LdapGroupDirectory groupDirectory, Attribute membersAttr, Logger logger)
1055        {
1056            _identity = identity;
1057            _groupLabel = label;
1058            _groupDirectory = groupDirectory;
1059            _membersAttr = membersAttr;
1060            _logger = logger;
1061            _userInitialized = false;
1062            _users = new HashSet<>();
1063        }
1064        
1065        @Override
1066        public GroupIdentity getIdentity()
1067        {
1068            return _identity;
1069        }
1070
1071        @Override
1072        public String getLabel()
1073        {
1074            return _groupLabel;
1075        }
1076
1077        @Override
1078        public GroupDirectory getGroupDirectory()
1079        {
1080            return _groupDirectory;
1081        }
1082
1083        @Override
1084        public Set<UserIdentity> getUsers()
1085        {
1086            if (!_userInitialized)
1087            {
1088                if (_hasUsersFromCache(_identity))
1089                {
1090                    _users.addAll(_getUsersFromCache(_identity));
1091                }
1092                else
1093                {
1094                    _users.addAll(_membersAttr != null ? _getUsersFromMembersAttr()
1095                                                       : _groupDirectory._getUsersFromMembersOfAttr(_identity.getId()));
1096                    _loadUsersInCache(_identity, _users);
1097                }
1098                _userInitialized = true;
1099            }
1100            return _users;
1101        }
1102        
1103        private boolean _hasUsersFromCache(GroupIdentity groupIdentity)
1104        {
1105            return _groupDirectory.isCachingEnabled() 
1106                    && _groupDirectory.getCacheUsersByGroup().hasKey(groupIdentity);
1107        }
1108        
1109        private Set<UserIdentity> _getUsersFromCache(GroupIdentity groupIdentity) // only call if _hasUsersFromCache returned true before
1110        {
1111            _logger.debug("Users found in cache for group '{}", groupIdentity);
1112            return _groupDirectory.getCacheUsersByGroup().get(groupIdentity);
1113        }
1114        
1115        private void _loadUsersInCache(GroupIdentity groupIdentity, Set<UserIdentity> users)
1116        {
1117            if (_groupDirectory.isCachingEnabled())
1118            {
1119                _groupDirectory.getCacheUsersByGroup().put(groupIdentity, users);
1120                _logger.debug("Users loaded in cache for group '{}", groupIdentity);
1121            }
1122        }
1123        
1124        private Set<UserIdentity> _getUsersFromMembersAttr()
1125        {
1126            Set<UserIdentity> users = new HashSet<>();
1127            
1128            // First fill an intermediate list of members as strings
1129            // Do not resolve yet in order to close enumeration ASAP
1130            NamingEnumeration members = null;
1131            List<String> userDNs = new ArrayList<>();
1132            try
1133            {
1134                // Retrieve the members of the group
1135                members = _membersAttr.getAll();
1136                while (members.hasMore())
1137                {
1138                    String userDN = (String) members.next();
1139                    userDNs.add(userDN);
1140                }
1141            }
1142            catch (NamingException e)
1143            {
1144                _logger.warn("Missing at least one value for an attribute in an ldap entry.  Group will be ignored.", e);
1145            }
1146            finally
1147            {
1148                _cleanup(null, members);
1149            }
1150            
1151            // Then resolve the users
1152            for (String userDN : userDNs)
1153            {
1154                // Retrieve the identity
1155                UserIdentity identity = _isDn(userDN) ? _groupDirectory._getUserInLdapFromDn(userDN)
1156                                                      : _groupDirectory._getUserInLdapFromUid(userDN);
1157                
1158                if (identity != null)
1159                {
1160                    // Add the curent user
1161                    users.add(identity);
1162                }
1163            }
1164            
1165            return users;
1166        }
1167        
1168        private boolean _isDn(String userDN)
1169        {
1170            // Let's say that if it contains the '=' character, it is a DN, otherwise it is a UID 
1171            return userDN.contains("=");
1172        }
1173        
1174        @SuppressWarnings("synthetic-access")
1175        private void _cleanup(Context context, NamingEnumeration members)
1176        {
1177            _groupDirectory._cleanup(context, members);
1178        }
1179        
1180        @Override
1181        public String toString()
1182        {
1183            StringBuffer sb = new StringBuffer("UserGroup[");
1184            sb.append(_identity);
1185            sb.append(" (");
1186            sb.append(_groupLabel);
1187            sb.append(") => ");
1188            if (_userInitialized)
1189            {
1190                sb.append(_users.toString());
1191            }
1192            else
1193            {
1194                sb.append("\"Users are not loaded yet\"");
1195            }
1196            sb.append("]");
1197            return sb.toString();
1198        }    
1199        
1200        @Override
1201        public boolean equals(Object another)
1202        {
1203            if (another == null || !(another instanceof LdapGroup))
1204            {
1205                return false;
1206            }
1207            
1208            LdapGroup otherGroup = (LdapGroup) another;
1209            
1210            return _identity != null && _identity.equals(otherGroup.getIdentity());
1211        }
1212        
1213        @Override
1214        public int hashCode()
1215        {
1216            return _identity.hashCode();
1217        }
1218    }
1219    
1220    @Override
1221    protected String[] getSortByFields()
1222    {
1223        return new String[] {_groupsDescriptionAttribute};
1224    }
1225}