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}