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}