As I continue updating my PowerShell script samples to the latest and greatest, it’s time to revamp the Group membership script. The task is simple – provide an easy way to enumerate all group members across all (or some) groups within your Microsoft 365 tenant, and preferably account for nested groups too. The last time we explored this tasks, we selected Exchange Online PowerShell as the best approach, but since then the Graph API has matured a bit and now offers an acceptable alternative. There are still some areas where the Graph cannot help though, such as covering Dynamic Distribution Groups. Vice versa – Exchange Online does not have a good way to enumerate Azure AD Security Groups and their membership. Thus to obtain the full picture, you end up needing two separate solutions. In this article we will cover the Azure AD side of the picture, by means of running direct Graph API queries or leveraging the Graph SDK for PowerShell. The Exchange story will be covered in an updated version of the DG_members_recursive.ps1 script, to follow in few days.
As with any Graph-based solution, we need to discuss authentication and authorization first. If you plan to use the script in an automated manner, the best approach is to leverage the client credentials flow, i.e. authenticate via client secret or certificate. This is the approach taken in our first script sample and if this is your preferred approach make sure to replace the relevant variables at lines 15-17 before running it. For day to day tasks and running the script on demand, you might instead consider a user-centric flow, which is what our second script sample does, by means of leveraging the Microsoft Graph SDK for PowerShell. In both cases, you will need the relevant permissions to obtain Group and other directory object’s data. The Directory.Read.All scope or equivalent should do, but you can go more granular if you want, at the expense of some of the details gathered by the script.
Next, lets talk about the scripts input. Usually, when we examine some “inventory” type of script, the presented solution tries to be as broad as possible. Most often, this means including all objects by default, or adding some script parameters to control the inclusion of all objects of specific type(s). Due to popular demand, this time the logic is changed a bit – while the script will cover all group objects by default, you are given an option to run it against a subset of the groups by providing a (comma-separated) list of group IDs via the –GroupList parameter. Do note that since we are using the Graph API, you will need to provide GUIDs as identifiers. Each entry will then be resolved against a generic GET /groups query, or the corresponding Get-MGGroup cmdlet, and if no match is found it will be removed. Below are examples of how to do that:
.\AAD_Groups_Members_inventory.ps1 -GroupList @("DG@michev.onmicrosoft.com","c91cd116-a8a5-443b-9ae1-e1f0bade4a23","c20a48cc-3931-47e7-95fd-911224c600bb")
Once the list of groups is obtained, the script will proceed with generating the list of members for each group. Here, we leverage the /members endpoint, which will return any of the following types of members: user, group, device, orgContact, service principal. To account for the latter, we make use of the /beta endpoint, as service principal objects are currently not returned via /v1.0. And since groups can be nested, we are introducing the –TransitiveMembership script parameter, with default value of False. Using this switch will replace the /members query with a /transitivemembers one, making sure that any nested group’s membership is expanded and returned in the output. The output will still contain an entry for each individual group, which is one of the differences between handling recursive output in Exchange Online vs Graph API queries.
And that’s pretty much all there is to the script, the remaining bits all cover handling of the output. Here, in a somewhat uncharacteristic move, we will actually praise some of the Graph functionality. Being able to use a single request to list all members, including those in nested groups is certainly a plus. Being able to use the same exact request regardless of the group type is even bigger plus. And the way the Graph handles output when multiple object types are returned is also appreciated, as it allows us to include a list of various object-specific properties, without worrying too much. To illustrate this, take a look at the screenshot below, where the output of a /members query for a sample group is shown. The group members include another group, a device object and a service principal. Yet, we can get all “important” properties of each object type via a single query, by simply providing a list of all properties in our $select statement.
Back to the script output. Another point of much discussion has been how to present the output. On one hand, having a “compact” output, with a single line per group object and a list of members concatenated into a single cell is handy, and still allows you to properly filter (and expand) in Excel. On the other hand, having each member listed on a separate line allows to surface even more information about the object, while at the same time preserving the filtering capabilities. We’ve been experimenting back and forth between the two types of output for years now. This time, in what is called a “big brain move”, we decided to include both types, at the same time!
So, once the script obtains all groups and their members, two separate CSV files will be generated. Both files will be found in the script directory and will contain a list of all the groups. The “compact” CSV file, named something like 2023_01_30_AADGroupMembers.csv will use the one group per line format, whereas the “expanded” CSV (2023_01_30_AADGroupMembersExpanded.csv) will use the one member per line format. Below are examples of the two types of output:
The set of properties gathered for each group object and each member are selected to mimic the Azure AD blade UI. At a glance, you will be able to tell the type of group, the membership type (dynamic or assigned) and rule, whether admin roles can be assigned to the group, who are the owners, is the group used for licensing. Members will be represented by an unique identifier, which depending on the object type is either the UPN, email address, deviceID or the generic ObjectID value. In the “compact” CSV, you will also get quick counts of the members of each type. The type of user (member or Guest) will be included in the expanded CSV, allowing you to quickly filter Groups that contain guests, take some counts, etc.
If needed, you can of course modify the set of properties returned. The Graph SDK for PowerShell continues to suffer from bad design/implementation however, so if you plan to use the corresponding version of the script, word of caution. While you can of course still modify the script, for example to expose any additional group or member properties, pay attention to the capitalization of property names – those are still case sensitive. This applies to both the cmdlet input and output! Apart from such annoyances, the script should give you the exact same output as the API-based variant. Here are some additional examples on how to run the scripts:
#Obtain a full inventory of all groups within the tenant .\AAD_Groups_Members_inventory.ps1 #The same, but with the Graph PowerShell based script .\AAD_Groups_Members_inventoryMG.ps1 #Obtain a full inventory, including transitive membership .\AAD_Groups_Members_inventory.ps1 -TransitiveMembership #Use the Graph PowerShell based script to obtain transitive membership for a list of groups .\AAD_Groups_Members_inventoryMG.ps1 -TransitiveMembership -GroupList @("DG@michev.onmicrosoft.com","c91cd116-a8a5-443b-9ae1-e1f0bade4a23")
Before closing the article, it’s worth making some comparisons with the Exchange output. As mentioned already, not all group object types will be included, as the Graph API does not provide support for Dynamic DGs currently. Thus the list of group object supported by the scripts includes: Azure AD Security groups, Mail-enabled Security groups, Distribution groups, Microsoft 365 groups. Dynamic Azure AD group membership is also something that the script can cover (but again, no support for Dynamic Exchange DGs). And while “regular” distribution groups are covered, not all their properties are exposed in the Graph API. Thus, the set of Owners for any Distribution group object will give you the incomplete info, as it will not match the actual managers of the group (as reported by the ManagedBy property in Exchange Online).
As usual, let me know if you run into any issues with the scripts, or have any suggestions. For example, does it make sense to include Device and Service principal members by default, or perhaps it’s better to add another script parameter to handle those? Which additional properties need to be exposed? “Last activity” fields are generally quite useful, but there are some caveats with including those. In any case, let me know what you think.
Report on Azure AD Group members via the Graph API: AAD_Groups_Members_inventory.ps1
Report on Azure AD Group members via the Graph API PowerShell: AAD_Groups_Members_inventoryMG.ps1
Man, I love you. This is what I was looking for since the last month-ish to make a report and check useless and empty groups in our company! I really appreciate your contribution