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
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.
#List all AD groups a given user is a member of Get-ADGroup -LDAPFilter "member:1.2.840.113556.1.4.1941:=CN=vasil,CN=Users,DC=michev,DC=info)" #List all USERS members of a given group Get-ADUser -LDAPFilter "(memberof:1.2.840.113556.1.4.1941:=CN=DG,CN=Users,DC=michev,DC=info)" #List all OBJECTS that are members of a given group Get-ADObject -LDAPFilter "(memberof:1.2.840.113556.1.4.1941:=CN=DG,CN=Users,DC=michev,DC=info)"
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 –Recursive parameter is used. An example is shown below:
Get-ADGroupMember DG -Recursive
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:
https://graph.microsoft.com/beta/groups/c91cd116-a8a5-443b-9ae1-e1f0bade4a23/transitiveMembers
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:
Get-Recipient -Filter "MemberOfGroup -eq 'CN=MESG,CN=Users,DC=michev,DC=info'"
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 t
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
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:
Get-UnifiedGroupLinks firstgroup -LinkType member
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:
Get-Recipient -RecipientPreviewFilter (Get-DynamicDistributionGroup DDG).RecipientFilter
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 –
![Inventorying membership of Exchange groupss](https://www.practical365.com/wp-content/uploads/2018/12/Screenshot.png)
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 –
Additional notes
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 TechNet Gallery or on GitHub.
Check-Connectivity keeps failing on me despite me being able to confirm the connection to ExchangeOnline is active. Is this a legacy command? It’s also not prompting me for MFA. Can I comment out this function without causing a problem?
The Real Person!
The Real Person!
The reason why it’s failing is that the script was written in 2018 when the Exchange Online management module used Remote PowerShell. It doesn’t anymore. Instead, you can do something like:
# Check if we can run an Exchange Online cmdlet. If we can, go on, else connect to Exchange Online
If ($Null -eq (Get-ConnectionInformation)) {
Connect-ExchangeOnline
}
How can I get for all users mailing groups list ?
The Real Person!
The Real Person!
Like this? https://office365itpros.com/2022/12/06/distribution-list-membership-sdk/
Why is there not a reporting function within teams or 365 or exchange to do this without having to code it – I suspect few mso administrators are code bunnies
The Real Person!
The Real Person!
Possibly because a) Microsoft has never delivered reporting for groups and b) everyone wants their reports in a different shape. That’s why PowerShell exists… Here’s another example: https://office365itpros.com/2023/01/19/microsoft-365-groups-report-v2/
Do we have any script for Querying multiple group.
Hello…is the script gone?..I dont see it attached.
The Real Person!
The Real Person!
As the text says, the script is available from GitHub.
Hi,
this is a great script and has helped me so much, thanks for sharing it.
Is it possible to exclude Contacts from the results please?
many thanks,
Tom
The Real Person!
The Real Person!
Sure, you can edit the Get-Membership and add another if clause to exclude any contacts. Something like (line 121):
if ($l.RecipientTypeDetails.Value -match “Contact”) { continue }
You’ll have to do something similar for Dynamic DGs as well.
I have around 52k objects to Export but here the Problem is script runs well but the output File is Empty, Can anyone help
I have around 52k objects to Export but here the Problem is script runs well but the output File is Empty, Can anyone help
Hey Vasil, Paul,
Great Script!! Works well in our smaller test tenant. However in the production tenant were getting this error:
PS C:\Users\robert\Desktop\DG_members_recursive> .\DG_members_recursive.ps1 -IncludeAll -RecursiveOutput -RecursiveOutputListGroups | Export-Csv Prod_Group_Report_All.csv -NoTypeInformation
Group nameremvoed-benefits_SendonBehalf_Ops@validfqdn.com not found
At C:\Users\robert\Desktop\DG_members_recursive\DG_members_recursive.ps1:71 char:17
+ if (!$DG) { Throw “Group $Identity not found” }
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : OperationStopped: (Group nameremoved…e.com not found:String) [], RuntimeException
+ FullyQualifiedErrorId : Group nameremvoed-benefits_SendonBehalf_Users@validfqdn.com not found
This is the command were running:
.\DG_members_recursive.ps1 -IncludeAll -RecursiveOutput -RecursiveOutputListGroups
If i check for the specific group that is identified by the error, I am able to find it, using the DisplayName, however if i search by the SMTP Address identified by the error i am not able to find the group.
I can see that the SMTP Address is listed as an additional address but is not the Primary SMTP Address. How can we get it so that script wont quit when it hits the error above?
There are 15K groups in this tenant and it made it all the way to about 8K then errored out.
Thanks,
Robert
The Real Person!
The Real Person!
DisplayName is hardly a good property to check against, as you can have multiple object with the same value. SMTP addresses are unique, so that’s why the script uses the primary SMTP address as identifier. It shouldnt matter if it’s primary or secondary though, the Get-Recipient cmdlet should resolve it.
What kind of group is that? What does
Get-Recipient nameremvoed-benefits_SendonBehalf_Ops@validfqdn.com
return in your tenant?
Thanks Vasil. So when run that command against the same email address that is listed in the error, I get:
The operation couldn’t be performed because object ‘nameremvoed-benefits_SendonBehalf_Ops@validfqdn.com’ couldn’t be found
If i search for that same object using get-recipient, name, then i am able to see the email address listed, but its not listed as primarysmtpaddress (a different one is) then if i search for the PrimarySMTPAddress of that same object i am able to find it.
So basically it looks like the script is trying to find the object by one of its secondary SMTP Address.
Is there a way to just set it so the script will continue past the error? I dont want to ask you to do a lot of work here :).
Thanks,
Robert
The Real Person!
The Real Person!
That shouldn’t happen, the script uses Get-Recipient to resolve objects and Get-Recipient should resolve it regardless of whether the given alias is primary or secondary. Anyway, if you want the script to continue, simply replace “throw” with “continue” on that line
if (!$DG) { Throw “Group $Identity not found” }
->
if (!$DG) { continue }
Also the group in question (the one with the error) is a MailUniversalSecurityGroup.
Also do you know how we can run the script against just “DistributionG roups” and not Mail Enabled Security groups. In our tenant we are no longer using Mail Enabled Security groups for Distro Group functions.
Thanks,
Robert
Sorry didnt see this reply. i will give it a try now.
Thanks,
Robert
Hello, I’m having problems with the script hanging when a universal distribution group contains a DDG as a member. Do you have any suggestions?
The Real Person!
The Real Person!
You’re probably running into recursion. The script has some basic logic to detect that, but I’m sure there are some scenarios I’ve missed. Try running it with -Verbose and monitor the progress.
Did you hit the Graph API 100 or 999 member limit? I’m looking at paging and creating another GET request but maybe you have a script example you could share for pull groups with a large membership via Graph API.
The Real Person!
The Real Person!
This script doesnt make any Graph calls, but you can find a sample on how to handle multiple pages in the ODFB sharing report script: https://github.com/michevnew/PowerShell/blob/master/Graph_ODFB_shared_files.ps1
Thanks I’ll check it out!
Hi
How to run your script against one particular DG with tons of nested groups?
Regards
The Real Person!
The Real Person!
You will have to edit the part of the script that gets the list of groups, that’s lines 223-229.
How do I do that?? Please help if possible.
thanks
Did you get a response on how to edit the script to only include one particular group and its nested groups?
Also worth noting while LDAP_MATCHING_RULE_IN_CHAIN is a great filter to use to recursive search an on-perm group membership, it will only work as expected in cases where all groups and underlying groups are universal group types or all groups and nested groups belong to the same domain. If Global type groups are nested inside of universal groups from different child domains then it will fail to give you all members since the global group members are not replicated for that source domain the group is being recursively searched from. Still very helpful in most cases with a single domain or domains strictly using universal groups.