The time has come to update the “recursive membership of groups” script, which was initially published back in 2018 over at Practical 365. The goal remains the same, gather an inventory of all supported group objects and their members, while also providing the option to expand the membership of any “nested” groups, aka handle recursive scenarios. The task will be performed via the Exchange Online PowerShell cmdlets, so some caveats apply. For a solution that tackles the same task on Azure AD side, refer to the two scripts in this article.
The first thing changed in the script is the way we handle connectivity. As Remote PowerShell is going away in the service, the script now has a hard dependence on the Exchange Online V3 module. This requirement is enforced the lazy way, via the #Requires statement, which is kinda slow and unoptimized, but saves us some code. You can save few seconds of execution time by removing it, but then again, running this script in any organization of size will take minutes, or even hours, so why bother. Among other things, this means the script no longer works against on-premises Exchange installs.
Since we are using the V3 module, connectivity is established via the Connect-ExchangeOnline cmdlet, that is if an existing session is not detected (the script will Get-ConnectionInformation use for that). And if we are starting fresh, only the following cmdlets will be loaded into the session: Get-EXORecipient, Get-DistributionGroupMember, Get-DynamicDistributionGroup, Get-Recipient, Get-UnifiedGroupLinks. Unfortunately, due to some restrictions of the Get-EXORecipient cmdlet, we still cannot get the full picture without resorting to the other cmdlets :/
Once connectivity is established, the script will proceed with obtaining a list of groups to cover. Herein you can find another change – the script now supports a –GroupList parameter, using which you can import a list of group objects to cover. You can provide any supported identifier, such as PrimarySMTPAddress, GUID or display names. Each entry will be passed against the Get-EXORecipient cmdlet to (try to) resolve it to a valid recipient and erroneous entries will be removed. As this will inevitably increase the amount of time it takes to run the script, alternatively you can switch to simple check to validate an email address value, on lines 240-241. Below are few examples on how to leverage the -GroupList parameter:
#run the script against two groups designated by displayName .\DG_members_recursiveV2.ps1 -GroupList DG,new #run the script against a set of groups imported from a CSV file .\DG_members_recursiveV2.ps1 -GroupList (Import-Csv .\Groups.csv).PrimarySmtpAddress
Alternatively, you can use the approach from the original version of the script, which included group objects by their type. The set of script parameters that handles this remains the same (as listed below), but they are now combined in parameter sets. As before, if you don’t provide any parameters, the script will cover all “traditional” distribution groups and mail-enabled security groups, without handling recursion. Here’s the full set of parameters:
- IncludeAll – use this parameter to include all supported group types (Distribution groups, mail-enabled security groups, Microsoft 365 Groups, Dynamic Distribution groups).
- IncludeDGs – use this parameter to include all distribution groups and mail-enabled security groups in the output. This is the default option.
- IncludeDynamicDGs – use this parameter to include all Dynamic distribution groups in the output.
- IncludeO365Groups – use this parameter to include all Microsoft 365 Groups in the output.
- GroupList – use this parameter to run the script against a list of groups, designated by either DisplayName, PrimarySMTPAddress or GUID.
- RecursiveOutput – use this parameter to expand the membership of any “nested” groups and include it in the output. Default value is $false, meaning the primary SMTP address of the group is returned instead of any members it might contain. Including this parameter will impact the amount of time it takes to complete the script, or even break it altogether, so handle with care.
- RecursiveOutputListGroups – use this parameter to add an entry for each “nested” group in the list of members, i.e. mimic the behavior of the Graph API’s /transitiveMembers query. Can only be used when the –RecursiveOutput parameter is specified.
- Verbose – use it to spill out additional details as the script progresses, useful for troubleshooting.
Next, the script will proceed with gathering the list of group objects to cover. Here, we no longer use the good old implicit remoting method, but the Get-EXORecipient cmdlet. Script’s performance should not be negatively impacted by this, if anything you should be seeing a noticeable improvement, as well as less reliability issues. Just in case, we are still using a simple anti-throttling mechanism, which you can find on line 67. Another fail-safe is added on line 87, uncommenting which will cause the membership of each group to be written in separate CSV file in the script directory.
After the list of groups is obtained, the script will iterate over each group, and use two helper functions to handle membership and recursion. Depending on the group type, a different cmdlet will be used to fetch the set of members as unfortunately, Get-EXORecipient is not up to the task. On the other hand, using different cmdlet per recipient type allows us to obtain a proper identifier for each member, be it the UPN value, WindowsLiveID or in some cases (*cough* service principal *cough*), a GUID. We also avoid using the Get-DynamicDistributionGroupMember cmdlet, as its output returns a lot of unwanted entries (i.e. Role Groups, SubstrateGroup objects, and so on).
The rest of the script handles the output, which by default will be written to a CSV file in the script directory. The CSV file will contain an entry for each group, providing its Name, PrimarySmtpAddress, the group type, whether it has nested groups and the list of managers, members count and a comma-separated list of members, each represented by an unique identifier. In case you want to add additional group properties, list them on line 243, 261, and 264. Duplicate entries, i.e. when a user is a direct member of the group and also a member of a “nested” group will be trimmed. Here’s an example of how the output will look like:
Let me yet again reiterate that the script will only return objects recognized by Exchange Online. This in turn means that if you make a direct comparison with the membership report as obtained from Azure AD, some discrepancies will be visible. For example, no device objects will be listed as members when running the Exchange report, and in the case of service principal objects, only those manually created in ExODS will be listed (this should change now that Microsoft has extended the RBAC model to support AAD integrated apps). Similarly, Exchange-only objects will never be returned when gathering inventory on Azure AD side. This affects objects such as Dynamic DGs, public folders, etc.
To illustrate the above point with an example, take a look of the direct comparison between Azure AD and Exchange Online for one specific group. On Azure AD side, only two members are returned, whereas the Get-DistributionGroupMember cmdlet readily shows four. The Public folder case is clear – such objects are not supported by Azure AD and have no representation in the directory. The second “missing” object is a service principal and is actually an object that has Azure AD representation. However, as at the time of writing this article Exchange Online is not yet synchronizing service principal objects from Azure AD, this object was manually provisioned via the New-ServicePrincipal cmdlet and for all intents and purposes is considered an ExODS-only object.
Get-DistributionGroupMember secgrp | select Name,RecipientTypeDetails,ExternalDirectoryObjectId Name RecipientTypeDetails ExternalDirectoryObjectId ---- -------------------- ------------------------- new MailUniversalDistributionGroup 528a4052-fa6c-4495-b39f-2820f8e1e8db USG MailUniversalSecurityGroup 9e629d33-d655-440c-89af-15738e59e667 Shared Contacts PublicFolder 2a63aee1-db17-489d-a8ab-d40971066292 ServicePrinciple ae23ad05-9b02-4946-a906-78b7a0757f5f
Before closing the article, here’s a link to the script file over at my GitHub repo. And here are few more examples on how to run the script:
#Running the script with no parameters will generate "flat" membership report for all DGs and MESGs .\DG_members_recursiveV2.ps1 #To run the script against all supported group types, use the -IncludeAll switch .\DG_members_recursiveV2.ps1 -IncludeAll #To include members of any "nested" groups, use the -RecursiveOutput parameter. The -RecursiveOutputListGroups adds an entry for each group in the output, too. .\DG_members_recursiveV2.ps1 -IncludeAll -RecursiveOutput -RecursiveOutputListGroups