001/*
002 *  Copyright 2025 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.extrausermgt.groups.entraid;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashSet;
021import java.util.List;
022import java.util.Map;
023import java.util.Objects;
024import java.util.Set;
025import java.util.concurrent.atomic.AtomicInteger;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.activity.Disposable;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.StringUtils;
033import org.slf4j.Logger;
034
035import org.ametys.core.cache.AbstractCacheManager;
036import org.ametys.core.cache.AbstractCacheManager.CacheType;
037import org.ametys.core.cache.Cache;
038import org.ametys.core.group.Group;
039import org.ametys.core.group.GroupIdentity;
040import org.ametys.core.group.directory.GroupDirectory;
041import org.ametys.core.user.UserIdentity;
042import org.ametys.core.user.UserManager;
043import org.ametys.core.user.directory.UserDirectory;
044import org.ametys.core.user.population.UserPopulationDAO;
045import org.ametys.core.util.SizeUtils.ExcludeFromSizeCalculation;
046import org.ametys.plugins.extrausermgt.users.entraid.EntraIDUserDirectory;
047import org.ametys.runtime.i18n.I18nizableText;
048import org.ametys.runtime.i18n.I18nizableTextParameter;
049import org.ametys.runtime.plugin.component.AbstractLogEnabled;
050
051import com.azure.identity.ClientSecretCredential;
052import com.azure.identity.ClientSecretCredentialBuilder;
053import com.microsoft.graph.core.tasks.PageIterator;
054import com.microsoft.graph.models.GroupCollectionResponse;
055import com.microsoft.graph.models.User;
056import com.microsoft.graph.models.UserCollectionResponse;
057import com.microsoft.graph.serviceclient.GraphServiceClient;
058
059/**
060 * {@link GroupDirectory} listing groups from Entra ID (Azure Active Directory).
061 */
062public class EntraIDGroupDirectory extends AbstractLogEnabled implements GroupDirectory, Serviceable, Disposable
063{
064    private static final String __PARAM_ASSOCIATED_USERDIRECTORY_ID = "org.ametys.plugins.extrausermgt.groups.entraid.userdirectory";
065    private static final String __PARAM_APP_ID = "org.ametys.plugins.extrausermgt.groups.entraid.appid";
066    private static final String __PARAM_CLIENT_SECRET = "org.ametys.plugins.extrausermgt.groups.entraid.clientsecret";
067    private static final String __PARAM_TENANT_ID = "org.ametys.plugins.extrausermgt.groups.entraid.tenant";
068    private static final String __PARAM_FILTER = "org.ametys.plugins.extrausermgt.groups.entraid.filter";
069    
070    private static final String[] __GROUP_ATTRIBUTES_SELECT = new String[]{"id", "displayName"};
071    
072    private static final String __ENTRAID_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX = EntraIDGroupDirectory.class.getName() + "$group.by.id$";
073    private static final String __ENTRAID_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX = EntraIDGroupDirectory.class.getName() + "$groups.by.user$";
074    private static final String __ENTRAID_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX = EntraIDGroupDirectory.class.getName() + "$users.by.group$";
075    private static final String __ENTRAID_GROUPDIRECTORY_ALL_GROUPS_CACHE_NAME_PREFIX = EntraIDGroupDirectory.class.getName() + "$all.groups$";
076    
077    private String _id;
078    private I18nizableText _label;
079    private String _groupDirectoryModelId;
080    private Map<String, Object> _paramValues;
081    private GraphServiceClient _graphClient;
082    private String _filter;
083    
084    private String _associatedUserDirectoryId;
085    private String _associatedPopulationId;
086    
087    // Cannot use _id as two GroupDirectories with same id can co-exist during a short amount of time (during GroupDirectoryDAO#_read)
088    private final String _uniqueCacheSuffix = org.ametys.core.util.StringUtils.generateKey();
089    
090    private AbstractCacheManager _cacheManager;
091    private UserPopulationDAO _userPopulationDAO;
092    private UserManager _userManager;
093
094    @Override
095    public void service(ServiceManager serviceManager) throws ServiceException
096    {
097        _userManager = (UserManager) serviceManager.lookup(UserManager.ROLE);
098        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
099        _userPopulationDAO = (UserPopulationDAO) serviceManager.lookup(UserPopulationDAO.ROLE);
100    }
101    
102    @Override
103    public String getId()
104    {
105        return _id;
106    }
107
108    @Override
109    public I18nizableText getLabel()
110    {
111        return _label;
112    }
113
114    @Override
115    public void setId(String id)
116    {
117        _id = id;
118    }
119
120    @Override
121    public void setLabel(I18nizableText label)
122    {
123        _label = label;
124    }
125
126    @Override
127    public String getGroupDirectoryModelId()
128    {
129        return _groupDirectoryModelId;
130    }
131
132    @Override
133    public Map<String, Object> getParameterValues()
134    {
135        return _paramValues;
136    }
137    
138    private void _createCaches()
139    {
140        _cacheManager.createMemoryCache(__ENTRAID_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
141                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_GROUP_BY_ID_LABEL"), 
142                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_GROUP_BY_ID_DESC"),
143                                        true,
144                                        null);
145
146        _cacheManager.createMemoryCache(__ENTRAID_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
147                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_GROUPS_BY_USER_LABEL"), 
148                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_GROUPS_BY_USER_DESC"),
149                                        true,
150                                        null);
151
152        _cacheManager.createMemoryCache(__ENTRAID_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
153                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_USERS_BY_GROUP_LABEL"), 
154                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_USERS_BY_GROUP_DESC"),
155                                        true,
156                                        null);
157
158        _cacheManager.createMemoryCache(__ENTRAID_GROUPDIRECTORY_ALL_GROUPS_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), 
159                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_ALL_GROUPS_LABEL"), 
160                                        _buildI18n("PLUGINS_EXTRAUSERMGT_GROUPS_ENTRA_CACHE_ALL_GROUPS_DESC"),
161                                        true,
162                                        null);
163    }
164    
165    private I18nizableText _buildI18n(String i18nKey)
166    {
167        String catalogue = "plugin.extra-user-management";
168        I18nizableText groupDirectoryId = new I18nizableText(getId());
169        Map<String, I18nizableTextParameter> labelParams = Map.of("id", groupDirectoryId);
170        return new I18nizableText(catalogue, i18nKey, labelParams);
171    }
172    
173    private Cache<String, Group> _getCacheGroupById()
174    {
175        return _cacheManager.get(__ENTRAID_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
176    }
177    
178    private Cache<UserIdentity, Set<String>> _getCacheGroupsByUser()
179    {
180        return _cacheManager.get(__ENTRAID_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
181    }
182    
183    private Cache<GroupIdentity, Set<UserIdentity>> _getCacheUsersByGroup()
184    {
185        return _cacheManager.get(__ENTRAID_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
186    }
187    
188    private Cache<String, Set<String>> _getCacheAllGroups()
189    {
190        return _cacheManager.get(__ENTRAID_GROUPDIRECTORY_ALL_GROUPS_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix());
191    }
192    
193    private String getUniqueCacheIdSuffix()
194    {
195        return _uniqueCacheSuffix;
196    }
197    
198    public void init(String groupDirectoryModelId, Map<String, Object> paramValues) throws Exception
199    {
200        _groupDirectoryModelId = groupDirectoryModelId;
201        _paramValues = paramValues;
202        
203        String populationAndUserDirectory = (String) paramValues.get(__PARAM_ASSOCIATED_USERDIRECTORY_ID);
204        String[] split = populationAndUserDirectory.split("#");
205        _associatedPopulationId = split[0];
206        _associatedUserDirectoryId = split[1];
207        
208        String clientID = (String) paramValues.get(__PARAM_APP_ID);
209        String clientSecret = (String) paramValues.get(__PARAM_CLIENT_SECRET);
210        String tenant = (String) paramValues.get(__PARAM_TENANT_ID);
211        _filter = (String) paramValues.get(__PARAM_FILTER);
212        
213        ClientSecretCredential clientSecretCredential = new ClientSecretCredentialBuilder().clientId(clientID)
214                                                                                           .clientSecret(clientSecret)
215                                                                                           .tenantId(tenant)
216                                                                                           .build();
217        
218        _graphClient = new GraphServiceClient(clientSecretCredential);
219        
220        _createCaches();
221    }
222
223    public Group getGroup(String groupID)
224    {
225        return _getCacheGroupById().get(groupID, id -> {
226            try
227            {
228                com.microsoft.graph.models.Group graphGroup = _graphClient.groups().byGroupId(groupID).get();
229                return new EntraIDGroup(graphGroup.getId(), graphGroup.getDisplayName(), this, getLogger());
230            }
231            catch (Exception e)
232            {
233                getLogger().warn("Unable to retrieve group '{}' from Entra ID", groupID, e);
234            }
235            
236            return null;
237        });
238    }
239    
240    public Set<String> getUserGroups(UserIdentity userIdentity)
241    {
242        return _getCacheGroupsByUser().get(userIdentity, userId -> {
243            String populationId = userIdentity.getPopulationId();
244
245            UserDirectory userDirectory = _userManager.getUserDirectory(populationId, userIdentity.getLogin());
246            
247            if (userDirectory == null || !populationId.equals(_associatedPopulationId) || !_associatedUserDirectoryId.equals(userDirectory.getId()))
248            {
249                // The user does not belong to the population or the user directory is not the Entra ID one
250                return Set.of();
251            }
252
253            if (!(userDirectory instanceof EntraIDUserDirectory entraUserDirectory))
254            {
255                getLogger().warn("The Entra ID group directory '{}' must be associated with a Entra ID user directory.", getId());
256                return Set.of();
257            }
258            
259            Set<String> groups = new HashSet<>();
260            
261            try
262            {
263                String userIdentifier = userIdentity.getLogin();
264                
265                Map<String, Object> paramValues = entraUserDirectory.getParameterValues();
266                String loginAttribute = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.loginattribute");
267                
268                // If we're using SAM account names, we need to find the user first to get their UPN
269                String userPrincipalNameForQuery = userIdentifier;
270                if (EntraIDUserDirectory.ON_PREMISES_SAM_ACCOUNT_NAME.equals(loginAttribute))
271                {
272                    // Search for the user by SAM account name to get their UPN
273                    try
274                    {
275                        List<User> users = _graphClient.users().get(requestConfiguration -> {
276                            requestConfiguration.headers.add("ConsistencyLevel", "eventual");
277                            requestConfiguration.queryParameters.count = true;
278                            requestConfiguration.queryParameters.filter = "onPremisesSamAccountName eq '" + userIdentifier + "'";
279                            requestConfiguration.queryParameters.select = new String[]{"userPrincipalName", "onPremisesSamAccountName"};
280                        }).getValue();
281                        
282                        if (!users.isEmpty())
283                        {
284                            userPrincipalNameForQuery = users.get(0).getUserPrincipalName();
285                        }
286                        else
287                        {
288                            // If not found by SAM, assume the login is already a UPN (fallback case)
289                            getLogger().debug("Unable to find user by SAM account name '{}', trying with UPN", userIdentifier);
290                            userPrincipalNameForQuery = userIdentifier;
291                        }
292                    }
293                    catch (Exception e)
294                    {
295                        getLogger().warn("Unable to find user by SAM account name '{}', trying with UPN", userIdentifier, e);
296                        userPrincipalNameForQuery = userIdentifier;
297                    }
298                }
299                
300                // Get user groups using transitive membership with the UPN
301                GroupCollectionResponse memberOfResponse = _graphClient.users().byUserId(userPrincipalNameForQuery).memberOf().graphGroup().get(requestConfiguration -> {
302                    requestConfiguration.queryParameters.select = new String[]{"id"};
303                    
304                    // Filter to only get Microsoft 365 groups (Unified groups)
305                    String filter = "groupTypes/any(c:c eq 'Unified')";
306                    if (StringUtils.isNotEmpty(_filter))
307                    {
308                        filter += " and " + _filter;
309                    }
310                    
311                    requestConfiguration.queryParameters.filter = filter;
312                });
313                
314                // Use PageIterator to handle pagination
315                new PageIterator.Builder<com.microsoft.graph.models.Group, GroupCollectionResponse>()
316                                .client(_graphClient)
317                                .collectionPage(memberOfResponse)
318                                .collectionPageFactory(GroupCollectionResponse::createFromDiscriminatorValue)
319                                .processPageItemCallback(group -> {
320                                    groups.add(group.getId());
321                                    return true; // Continue iteration
322                                })
323                                .build()
324                                .iterate();
325            }
326            catch (Exception e)
327            {
328                getLogger().error("Error while fetching groups for user " + userIdentity.getLogin(), e);
329                return Set.of();
330            }
331            
332            return groups;
333        });
334    }
335
336    public Set<Group> getGroups()
337    {
338        String cacheKey = "ALL_GROUPS"; // Cache key for all groups
339        
340        Set<String> groupIds = _getCacheAllGroups().get(cacheKey, key -> {
341            Set<Group> groups = new HashSet<>(getGroups(-1, 0, Collections.emptyMap()));
342            
343            return groups.stream()
344                         .map(Group::getIdentity)
345                         .map(GroupIdentity::getId)
346                         .collect(Collectors.toSet());
347        });
348        
349        return groupIds.stream()
350                       .map(this::getGroup)
351                       .filter(Objects::nonNull)
352                       .collect(Collectors.toSet());
353    }
354
355    public List<Group> getGroups(int count, int offset, Map parameters)
356    {
357        GroupCollectionResponse groupCollectionResponse = _graphClient.groups().get(requestConfiguration -> {
358            requestConfiguration.headers.add("ConsistencyLevel", "eventual");
359            
360            String pattern = parameters != null ? (String) parameters.get("pattern") : null;
361            
362            if (StringUtils.isNotEmpty(pattern))
363            {
364                requestConfiguration.queryParameters.search = "\"displayName:" + pattern + "\"";
365            }
366            
367            if (count > 0 && count < Integer.MAX_VALUE)
368            {
369                requestConfiguration.queryParameters.top = Math.min(count + offset, 999); // try to do only one request to Graph API
370            }
371            
372            // List only Microsoft 365 groups
373            String filter = "groupTypes/any(c:c eq 'Unified')";
374            if (StringUtils.isNotEmpty(_filter))
375            {
376                filter += " and " + _filter;
377            }
378            
379            requestConfiguration.queryParameters.filter = filter;
380            
381            requestConfiguration.queryParameters.select = __GROUP_ATTRIBUTES_SELECT;
382        });
383        
384        List<Group> result = new ArrayList<>();
385        AtomicInteger offsetCounter = new AtomicInteger(offset); // use AtomicInteger to be able to decrement directly in the below lambda
386        
387        try
388        {
389            new PageIterator.Builder<com.microsoft.graph.models.Group, GroupCollectionResponse>()
390                            .client(_graphClient)
391                            .collectionPage(groupCollectionResponse)
392                            .collectionPageFactory(GroupCollectionResponse::createFromDiscriminatorValue)
393                            .processPageItemCallback(group -> {
394                                // If we have an offset, skip the first 'offset' groups
395                                if (offsetCounter.decrementAndGet() <= 0)
396                                {
397                                    _handleGroup(group, result);
398                                }
399                                
400                                // continue iteration if we have not reached the count limit
401                                return count <= 0 || result.size() < count;
402                            })
403                            .build()
404                            .iterate();
405        }
406        catch (Exception e)
407        {
408            getLogger().error("Error while fetching groups from Entra ID", e);
409            return List.of();
410        }
411        
412        return result;
413    }
414    
415    private void _handleGroup(com.microsoft.graph.models.Group group, List<Group> groups)
416    {
417        Group storedGroup = new EntraIDGroup(group.getId(), group.getDisplayName(), this, getLogger());
418        groups.add(storedGroup);
419        
420        // Store group in the individual cache
421        _getCacheGroupById().put(group.getId(), storedGroup);
422    }
423    
424    @Override
425    public void dispose()
426    {
427        _cacheManager.removeCache(__ENTRAID_GROUPDIRECTORY_GROUP_BY_ID_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), CacheType.MEMORY);
428        _cacheManager.removeCache(__ENTRAID_GROUPDIRECTORY_GROUPS_BY_USER_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), CacheType.MEMORY);
429        _cacheManager.removeCache(__ENTRAID_GROUPDIRECTORY_USERS_BY_GROUP_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), CacheType.MEMORY);
430        _cacheManager.removeCache(__ENTRAID_GROUPDIRECTORY_ALL_GROUPS_CACHE_NAME_PREFIX + getUniqueCacheIdSuffix(), CacheType.MEMORY);
431    }
432    
433    private static class EntraIDGroup implements Group
434    {
435        private String _id;
436        private String _label;
437        
438        @ExcludeFromSizeCalculation
439        private Logger _logger;
440        
441        @ExcludeFromSizeCalculation
442        private EntraIDGroupDirectory _directory;
443
444        public EntraIDGroup(String id, String label, EntraIDGroupDirectory directory, Logger logger)
445        {
446            _id = id;
447            _label = label;
448            _directory = directory;
449            _logger = logger;
450        }
451        
452        @Override
453        public String getLabel()
454        {
455            return _label;
456        }
457        
458        public GroupIdentity getIdentity()
459        {
460            return new GroupIdentity(_id, _directory.getId());
461        }
462        
463        @Override
464        public GroupDirectory getGroupDirectory()
465        {
466            return _directory;
467        }
468
469        public Set<UserIdentity> getUsers()
470        {
471            GroupIdentity groupIdentity = getIdentity();
472            
473            return _directory._getCacheUsersByGroup().get(groupIdentity, key -> {
474                // if not in cache, fetch the users from the directory
475                Set<UserIdentity> users = new HashSet<>();
476                
477                try
478                {
479                    UserCollectionResponse membersResponse = _directory._graphClient.groups().byGroupId(_id).members().graphUser().get(requestConfiguration -> {
480                        requestConfiguration.queryParameters.select = new String[]{"userPrincipalName", "onPremisesSamAccountName"};
481                    });
482                    
483                    // Get the associated user directory to check its login attribute configuration
484                    UserDirectory associatedUserDirectory = _directory._userPopulationDAO.getUserPopulation(_directory._associatedPopulationId).getUserDirectory(_directory._associatedUserDirectoryId);
485                    
486                    if (!(associatedUserDirectory instanceof EntraIDUserDirectory entraUserDirectory))
487                    {
488                        _logger.warn("An Entra ID group directory must be associated with an Entra ID user directory.");
489                        return Set.of();
490                    }
491                    
492                    // Utiliser PageIterator pour gérer la pagination
493                    new PageIterator.Builder<User, UserCollectionResponse>()
494                                    .client(_directory._graphClient)
495                                    .collectionPage(membersResponse)
496                                    .collectionPageFactory(UserCollectionResponse::createFromDiscriminatorValue)
497                                    .processPageItemCallback(user -> {
498                                        users.add(new UserIdentity(entraUserDirectory.getUserIdentifier(user), _directory._associatedPopulationId));
499                                        return true;
500                                    })
501                                    .build()
502                                    .iterate();
503                }
504                catch (Exception e)
505                {
506                    _logger.error("Error while fetching members for Entra ID group " + _id, e);
507                }
508                
509                return users;
510            });
511        }
512        
513        @Override
514        public boolean equals(Object another)
515        {
516            if (another == null || !(another instanceof EntraIDGroup otherGroup))
517            {
518                return false;
519            }
520            
521            return _id != null && _id.equals(otherGroup._id);
522        }
523        
524        @Override
525        public int hashCode()
526        {
527            return _id.hashCode();
528        }
529    }
530}