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.

#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 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:

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 –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:

Inventorying membership of Exchange groupss

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.

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.

About the Author

Vasil Michev

Vasil Michev is an Office Servers and Services MVP, specializing in Office 365. He's currently employed as a Technical Product Manager, and in his free time he can be found helping others in the Office 365 community.

Comments

  1. Jimothy

    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?

    1. Avatar photo

      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
      }

  2. shant

    How can I get for all users mailing groups list ?

  3. Paul Mason

    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

  4. Pranav

    Do we have any script for Querying multiple group.

  5. Chris

    Hello…is the script gone?..I dont see it attached.

  6. Tom

    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

    1. Vasil.Michev

      The Real Person!

      Author Vasil.Michev acts as a real person and passed all tests against spambots. Anti-Spam by CleanTalk.

      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.

      1. VIshnu

        I have around 52k objects to Export but here the Problem is script runs well but the output File is Empty, Can anyone help

    2. VIshnu

      I have around 52k objects to Export but here the Problem is script runs well but the output File is Empty, Can anyone help

  7. Robert

    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

    1. Vasil.Michev

      The Real Person!

      Author Vasil.Michev acts as a real person and passed all tests against spambots. Anti-Spam by CleanTalk.

      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?

      1. robert

        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

        1. Vasil.Michev

          The Real Person!

          Author Vasil.Michev acts as a real person and passed all tests against spambots. Anti-Spam by CleanTalk.

          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 }

          1. Robert

            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

          2. Robert

            Sorry didnt see this reply. i will give it a try now.

            Thanks,

            Robert

  8. Jim

    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?

    1. Vasil.Michev

      The Real Person!

      Author Vasil.Michev acts as a real person and passed all tests against spambots. Anti-Spam by CleanTalk.

      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.

  9. Joshua Bines

    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.

      1. Joshua Bines

        Thanks I’ll check it out!

  10. Jake

    Hi

    How to run your script against one particular DG with tons of nested groups?

    Regards

    1. Vasil.Michev

      The Real Person!

      Author Vasil.Michev acts as a real person and passed all tests against spambots. Anti-Spam by CleanTalk.

      You will have to edit the part of the script that gets the list of groups, that’s lines 223-229.

      1. Crot

        How do I do that?? Please help if possible.

        thanks

        1. PaddyPDX

          Did you get a response on how to edit the script to only include one particular group and its nested groups?

  11. Steve Villardi

    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.

Leave a Reply