To this day, one of the most common questions I run into on technical communities is “how do I generate a list of all members of all groups in my organization”. Even though there are dozens of script samples and tools available on the internet for this task, it seems that they are either hard to find or not ticking all the boxes, therefore people are still trying to find a better solution. For this reason, as well as some recent advancements in the Microsoft Graph APIs, I decided that it’s worth publishing another article on this topic. Plus, it helps us keep the blog a truly practical resource.
Handling recursive output via the directory tools
One of the problems people face when inventorying group membership is making sure membership of nested groups is expanded, that is, the output should include any direct and indirect members of the group. It can go the other way too, by listing all of the groups a given user is a member of, including “parent” ones.
In the AD world, this is a relatively easy task, thanks to the so-called matching rule object identifiers and more specifically the LDAP_MATCHING_RULE_IN_CHAIN OID one. Designed to “traverse” the hierarchy, these constructs can be used to cycle over each parent (or child) object and match them against a filter. Although this type of filter only works against the object’s DistinguishedName value and the syntax can look scary, it gets the job done, and fast.
For example, if you want to list all AD groups a given user is a member of, including nested groups, you can use the first cmdlet below. The second one can be used to list all users that are a member of a given group, or any group nested under it. The third one generalizes this example to include any object types, not just users.
[ps]#List all AD groups a given user is a
Use of these filters is not limited to just the AD PowerShell cmdlets, in fact you can run the exact same queries via dsquery or similar tools. The AD PowerShell module does add one additional helpful method for the scenarios we examine. Namely, the –Recursive parameter for the Get-ADGroupMember cmdlet, which “flattens” out the membership list by returning only objects that don’t contain child entries. The syntax is of course much simpler compared to the filters we examined above, but on the downside, the output will only include user objects when the –Recursiveparameter is used. An example is shown below:
[ps]Get-ADGroupMember DG -Recursive[/ps]
In Office 365 and the underlying Azure AD, the methods outlined above are not available. The good news is that we just recently got support for the so-called transitive membership queries, which practically function the same. For example, the below query will return all direct and indirect members of a given group, including users, contacts, groups and so on:
This method is currently only available when querying the Graph API directly, and only when using the /beta endpoint, but hopefully, it will be exposed as a parameter for cmdlets in the Azure AD module. Unfortunately, as with the AD methods, it only covers objects which the underlying directory recognizes, meaning it’s not applicable to all group types.
Handling Exchange recipient types
Which brings us to the next common issue, the fact that most solutions out there don’t cover objects such as Office 365 Groups, Dynamic Distribution Groups, mail-enabled Public folders and so on. Some of these object types exist only in the Exchange directory, others span multiple workloads and handling them requires special treatment, and some are simply more “exotic” and usually neglected.
This in turn means that if we want a proper inventory of all recipient types recognized by Exchange, we cannot use the methods outlined above. The first logical action then is to look at the Exchange tools and use any methods exposed therein. As it’s often the case, the Get-Recipient cmdlet can offer a potential solution. Indeed, you can use the following filter to get all the valid Exchange recipients that are member of a given group:
[ps]Get-Recipient -Filter “MemberOfGroup -eq ‘CN=MESG,CN=Users,DC=michev,DC=info’”[/ps]
Unfortunately, this method does not expand the membership of any nested groups. In turn, this means that if you want to collect a comprehensive inventory of all your Exchange (Online) group objects and their members, you will have to iterate against each group, expand its membership, then rinse and repeat for any nested groups. The logic to do this in code is not very complex, and we’ve had PowerShell script samples that cover this for years. The main problem is the amount of resources consumed and the time it will take to complete the script.
With that in mind, I’ve decided to put together a script that follows some of the best practices for running against Exchange Remote PowerShell. We will utilize my preferred method of using implicit remoting and minimizing the amount of data returned by selecting just the properties we need via Invoke-Command. Using server-side filtering where possible is also a very good idea. You will find a link to the script at the end of the article, so if you aren’t interested in the details, then skip the next sections.
One additional limitation of the Get-Recipient cmdlet is that it does not return any objects of type User and ExchangeSecurityGroup, that is not mail-enabled objects which are synchronized from Azure AD. Although in general, you can just ignore these, other cmdlets such as Get-DistributionGroupMember might return them in the list of members.
Group types recognized by Exchange (Online)
When using long-running scripts, it’s always a good idea to exclude any objects you’re not interested in. With that in mind, the script attached to this article is designed to accept several parameters, designating the different types of Exchange groups for which you want to generate the membership inventory. Those include:
- “Traditional” Distribution groups, which are included by default if you don’t specify any parameters, or use the –IncludeDGs switch
- Mail-enabled Security groups, for which the above logic applies
- Office 365 (or Modern) Groups, included when you specify the –IncludeOffice365Groups switch
- Dynamic Distribution groups, included when you specify the –IncludeDynamicDGs switch
- All of the above, which is the behavior used when you specify the –IncludeAll switch
One particular group type I have excluded are RoomLists, which in my experience people simply don’t want listed in these reports. If you do want to include them, feel free to make the relevant changes in the code (line 111, 225). If you are running the script against on-premises Exchange install, you might want to remove any references to GroupMailbox objects as well. Although the script runs just fine in Exchange 2019 EMS, I haven’t checked older versions, and not all of them will recognize these object types.
Handling Office 365 Groups
Office 365 Groups, also known as Modern Groups, are often neglected when generating membership inventory. As Office 365 Groups do not support nesting, they are relatively simple to handle. However, different cmdlet needs to be used to list their membership, namely the Get-UnifiedGroupLinks cmdlet. Here’s an example:
[ps]Get-UnifiedGroupLinks firstgroup -LinkType member[/ps]
If you specify the –IncludeOffice365Groups switch, the script will ensure that all Office 365 Groups in your organization are enumerated and their membership included in the output. In addition, the script will also include these types of objects in the output whenever it finds an Office 365 Group nested inside another group, and will expand their membership if you specify the corresponding switch parameters. But, I’ll speak more on that later.
Handling Dynamic Distribution Groups
Exchange Dynamic Distribution Groups are a special case, as they don’t have preset membership. Instead of “listing” their members, we can “preview” the current list of recipients under the scope of the DDG filter, by means of using the Get-Recipient cmdlet. Here’s an example:
[ps]Get-Recipient -RecipientPreviewFilter (Get-DynamicDistributionGroup DDG).RecipientFilter[/ps]
While using cmdlets such as the above one isn’t anything particularly complicated, it’s not uncommon for DDGs to have filters that include the entire organization or large parts of it. As any valid Exchange recipient is included by default, sans some system objects, it’s more than likely that a DDG can have multiple other group objects “nested”, including other DDGs. And, in some cases the initial DDG can be included as a member of some of these groups. Of course, this scenario is not limited to just DDGs, it’s simply more common with them because of the membership model used.
Handling nested groups
In order to handle nested groups, we need a solution that can detect recursion and break processing as needed. To help with that, I’ve broken down the script into several smaller functions, with the “master” one trying to keep a track of whether a “child” group was already processed, or links back to the “parent”. As I am not a programmer by trade, my solution is hardly the best in terms of code practices, but it seems to do the job just fine, at least for the scenarios I could think of. If you have groups nested 10 levels deep with recurrence on every level, the script will most likely still loop indefinitely.
Assuming the code part is handled correctly, one must also decide how to handle the output. Some people will be fine just knowing that a given group has nested groups in its membership, and simply treat them as another “regular” member. Others will want to get a “flattened” list of members, with the membership of any nested groups expanded and added to the list of members of the parent group. This is the behavior when you invoke the script with the –RecursiveOutput switch. Lastly, if you want to get both the flattened membership and the email address of any nested groups, use the –RecursiveOutputListGroups switch together with the –RecursiveOutput one. Examples of the output in the different scenarios can be found in the screenshot below:
In all three examples, the script run only against a single distribution group, “DG”. The top example list just direct members of the group, the middle one includes any members of the nested “empty” group as well, since the –RecursiveOutput switch was used. Lastly, the bottom example was run with both the –RecursiveOutput and –RecursiveOutputListGroups switches, and thus includes the membership of any nested groups, as well as an entry that lists the address (or identifier) for the actual nested group.
Most of the building blocks of the script were explained in the previous sections, however there are few additional things to mention. First of all, the script doesn’t handle connectivity to Exchange, this part is up to you. It will invoke the Check-Connectivity helper function to detect and reuse any existing Exchange Remote PowerShell sessions, including EMS ones. Failing that, it will try to establish a session to ExO using basic auth, but that’s all. If you are connecting to any of the “sovereign” clouds or your admin credentials are protected by MFA, do the connect part manually, then invoke the script.
By default, the script will export the results to a CSV file in the current directory and will also store it in the $varGroupMembership global variable so that you can reuse it directly in the current session if you need to sort or filter it further. If you want to generate a separate CSV file for each group, uncomment line 86. Be warned though, if the script ends up in an infinite loop because of recursively nested groups, this will have quite an unpleasant impact on the filesystem!
An alternative approach might be to dot-source the script or simply reuse the function in your own scripts. If you do this, be aware that the Get-Membership function should not be called directly, as it relies on other functions for error handling and expects a properly formatted object. For the same reasons, no help is provided for the function, but I have put detailed comments around the important parts. One scenario where you will want to edit this function is when you want to use different identifier for the group member, in which case you will have to update the script block between lines 106-113.
Speaking of identifiers, all the group objects are represented by their PrimarySmtpAddress. However, as the group members wont necessarily have an email address, a different identifier might be used for them. For example, Mail Contacts or Guest Mail Users might be represented by their ExternalEmailAddress attribute instead. Among other properties that might be used as the identifier you can find UPN, WindowsLiveID, ExchangeGUID or ExternalDirectoryObjectId. In all cases though, the member should be represented by an unique-valued property which you can use to identify the corresponding Azure AD object, if such exists.
While I’ve tried to optimize the script as much as possible, in a large environment it will still end up issuing thousands of cmdlets and you will most likely be throttled. Adding some artificial delay to the script is a simple way to combat this, so every time the script processes a new “top level” group, 300ms delay is added as part of the connectivity check (line 21). This seems to be sufficient to properly run the script against a medium-sized tenant (10k+ objects, 800+ groups), and it resulted in no throttling during my tests. A more comprehensive solution will require you to monitor the throttling balance, as detailed for example in this article.
While large number of groups can cause issues with throttling, a different type of issue might arise if you have groups with large number of members. Since the output CSV file contains the lists of all the group members in the “Members” column, if you are opening the file with Excel you might run into the single cell size limit, which might mess up the display as well. In my tests, groups with over 1500 members (4000+ with the nested group membership flattened) caused Excel to misbehave. Your mileage will vary, but you can always rely on other text editors or even PowerShell to work with the full member list in such scenarios.
Lastly, if the script fails or doesn’t return the results you expect, you might try running it with the –Verbose switch. Or you can also drop a comment here, over at the T