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