Remove user from all Microsoft 365 groups and roles (and more) via the Graph API (non-interactive)

The script to remove users from all groups across Microsoft 365 has been one of the more popular entries in my GitHub repo for a while now. It is also generating a lot of improvement requests, the most common one being to be able to run the script in an automated fashion. I can certainly understand the desire and use cases for a fully automated solution, but at the same time I am always a bit reluctant to recommend and support such approach, due to the greater potential of unwanted changes being performed. And considering the script allows for bulk execution, it has some resume-generating potential 🙂

With that in mind, I always advertise a more cautious approach and try to include a lot of additional/verbose information for my script samples, as well as leverage PowerShell’s ShouldProcess where it makes sense. We will follow the same approach in this article, where I will introduce you to a new version of the script, with support for automated execution. But that’s just one of many improvements made, so let’s dig in!

The script now leverages the Graph API

The new script uses the Graph API methods, which has some pros and cons. The biggest downside of going the Graph route is that it has almost no support for Exchange objects and operations. Thus, while the Graph will happily return you a list of groups that includes Exchange distribution groups and mail-enabled security groups, it will refuse to make any changes to such objects. For that reason, the script also leverages Exchange Online methods, but more on that a bit later. Another downside is that the Graph almost exclusively works with GUIDs, and you can rarely use any human-readable identifier.

In terms of pros, the Graph API certainly performs faster compared to Exchange Online, in most cases. It allows you to use a unified endpoint and methods to work across a variety of objects, i.e. you don’t have to use one method to remove a member from Security group and another one for Microsoft 365 Groups. It also gives you “fuller” picture, as Exchange Online only cares about mail-enabled (recipient) objects.

Another pro, or an inconsistency if you will, comes from the way Graph processes Microsoft 365 Group members. Unlike the Exchange Online cmdlets, it will happily allow you to remove a given user from the group’s membership list, even if said user is designated as an Owner as well. Both APIs will prevent you from removing the last Owner of an M365 Group though, thus the script allows you to designate a “substitute owner” in order to address such scenarios. Due to “owner” having a different meaning in Exchange Online, the script will not make any “ownership” changes to Exchange objects though!

Anatomy of the script

Let’s describe the anatomy of the script. Two thirds of the code is taken by helper functions that help us handle various tasks, such as processing the input or handling errors. Within the “main” section of the script, we first handle authentication, then proceed with validating the input values. As the script supports bulk processing, we want to get  rid of any duplicate values, as well as remove entries that do not correspond to a valid object within the organization. After that, the script will process each user.

In order to fetch the membership of the user, we call the Graph’s memberOf method, which has the added benefit of also returning any Directory role and Administrative unit the user is assigned to. So, it’s only natural for the script to support removal from such as well. Extending this approach, the script will also make a call to the ownedObjects method and fetch all directory objects “owned” by the user. This in turn allows us to remove any “owner” assignments from group, application and service principal objects. The “substitute owner” value is also validated at this point. Do note that we only leverage it for group ownership.

As already mentioned above, the script will also make calls to Exchange Online in order to remove the user from distribution and mail-enabled security groups. So why not also remove the user from any Exchange role assignments then? Boom, done! While there is certainly more we can do on the Exchange front, tasks like removing mailbox- or folder-level permissions are best handled with dedicated script instead, so for this version at least, the script will only remove Exchange group and role group membership, as well as any direct role assignments.

To finish up on the privileged access scenarios, the script will also process any scoped Directory role assignments. This type of role assignment is however not returned by the memberOf call. While we can leverage the scopedRoleMemberOf endpoint instead, the only way to remove scoped assignments is via the PIM methods, which is what we end up with. This has the added benefit of also allowing us to cover any eligible role assignments. We can also handle PIM Groups in similar way, however the corresponding LIST method does not seem to work correctly with filters based solely on the user ID, which is unfortunate. We can iterate over each PIM group instead, but that’s just stupid… so support for this scenario will have to wait until Microsoft fixes the filtering capabilities.

Roles are however not the only way a user can get access, so the script will also go over any delegate permissions granted to/by the user and remove them. This is done by leveraging the /user/{userId}/oauth2PermissionGrants endpoint to get any potential grants, then removing them via the directory-wide /oauth2PermissionGrants endpoint. Do have in mind that this does not cover delegate permissions for which an admin has granted tenant-wide consent.

While the script does support unattended execution, we’ve still made sure that it can output a report of any changes made, so the rest of the script is dedicated to helper functions, error handling and handling the output. Nothing fancy really, but once you add all these bits together, you end up with 900 lines of code. Sheesh!

Parameters and permission requirements

Here is the set of parameters the script supports:

  • Identity – A comma-separated list of users to process. Only GUIDs and UPNs are supported. Mandatory.
  • Exceptions – A comma-separated list of directory objects to exclude from processing. GUIDs only. Maximum of 1000 entries are supported, as we use a single call against the getByIds method to validate the values. This in turn means that only objects supported by said call can be excluded!
  • ProcessOwnership – Switch parameter, indicates whether to remove the user from any “ownership” assignments.
  • SubstituteOwner – The UPN or GUID of the user to use as a substitute owner for groups where the user we are removing is the only owner.
  • ProcessExchangeGroups – Switch parameter, indicates whether to perform operations against any Exchange groups and roles. Comes with additional permission requirements (more details below).
  • ProcessOauthGrants – Switch parameter, indicates whether to remove any delegate permission grants for the user.
  • IncludeDirectoryRoles – Switch parameter, indicates whether to remove the user from any Entra ID Directory roles he is currently assigned to. Requires RoleManagement.ReadWrite.Directory permissions. Can be combined with the –ProcessExchangeGroups switch to ensure removal from any Exchange Online roles, too.
  • IncludeAdministrativeUnits – Switch parameter, indicates whether to remove the user from any Administrative units.
  • Quiet – switch parameter, used to suppress output to the console window.

In addition, the script supports the set of “common” parameters, such as –Verbose, –WhatIf, –OutVariable, etc. More importantly, it supports PowerShell’s “risk management” parameter, –Confirm. I know, I know, it breaks automation. Still, it’s important to have it when needed. I’d even recommend always using –Verbose as well, as it gives you a lot more context. By default, no confirmation will be required, so you can still execute the script without any interaction.

Before we move on to examples on how to run the script, we need to discuss authentication and permission requirements. As we want the script to run without any interaction, we leverage the client credentials flow. Thus, application permissions are required (as opposed to delegate permissions). For best results with “get” operations, the Directory.Read.All role is recommended. For removal, Directory.ReadWrite.All will do, but you still need Application.ReadWrite.All if you want to remove Owner assignments for application objects and RoleManagement.ReadWrite.Directory for managing directory role assignments. If you want to go granular (and you should!), you’d need the following:

  • Group.ReadWrite.All for removal of group members/owners (mandatory)
  • RoleManagement.ReadWrite.Directory for removal of Entra ID role membership (only needed if you use the
    IncludeDirectoryRoles switch)
  • DelegatedPermissionGrant.ReadWrite.All for removal of delegate permission grants (only needed if you use the
    ProcessOauthGrants switch)
  • AdministrativeUnit.ReadWrite.All for removal of AU membership (only needed if you use the
    IncludeAdministrativeUnits switch)
  • Application.ReadWrite.All for removal of application/service principal owners (only needed if you use the ProcessOwnership switch)
  • Exchange.ManageAsApp and the Exchange administrator role assigned to the service principal (needed for processing Exchange objects)

Read the next section and the references therein for details on how to grant granular permissions for Exchange scenarios.

How to run the script

OK, now that you understand what the script does, you can download it from GitHub. Make sure to configure the required variables (lines 707-709) and that the application used has been granted all the required permissions. For example, here are the permissions I used when testing the script:

RemoveUser2

To better illustrate how the script works, our first example will leverage (almost) all its parameters. Adding the –Verbose switch will cause it to spill detailed information as it progresses, so we can get a clue about its inner workings. And, the –WhatIf switch will ensure that no changes are actually committed. It also has the added benefit of generating a “report” of all the relevant permission our selected user(s) have. And yes, the script supports bulk processing, so we can enter a list of GUIDs or UPNs:

#Run the script with -WhatIf to get a "report"

#Set exceptions, only needed when using the -Exceptions parameter
$aaa = @("b75a5ab2-fe55-4463-bd31-d21ad555c6e0","44a4ce2e-4e35-41d7-a47a-51900e3e36bf","8e33a849-faa1-4bf7-be0b-6f79e175ceb3")

.\Remove_User_All_Groups_GraphAPI.ps1 30c2b6b5-11b2-405b-913d-65e9f8670a1c,AdeleV@M365x84802758.OnMicrosoft.com,AlexW@M365x84802758.OnMicrosoft.com  -IncludeDirectoryRoles -IncludeAdministrativeUnits -ProcessExchangeGroups -ProcessOauthGrants -Exceptions $aaa -ProcessOwnership  -SubstituteOwner admin@M365x84802758.OnMicrosoft.com -Confirm -WhatIf -Verbose

RemoveUser0

On the screenshot above, we’ve highlighted few important bits. First, the Identity values were resolved, invalid entries and duplicates removed. Same for the SubstituteOwner value. Next, an eligible Directory role assignment has been detected, before processing Exchange role assignments. Ownership and membership is processed next (output is trimmed), before moving to the next user. Here, ownership of an application object is found. Lastly, an exception is encountered. At the end of the run, the output is dumped to the console and a CSV file, allowing you to review all the “access” info.

I would strongly recommend running the script few times with –Verbose and –WhatIf switches, until you are familiar with it. Even then, I’d still recommend always using the –Confirm switch, as the script will NOT ask for confirmation otherwise! And, as it only terminates on errors related to missing permissions, a whoopsie can be costly. Just saying.

Without further ado, here is how you can run the script in a fully-automated manner:

#Remove (set of) user's membership and ownership automated/non-interactive

.\Remove_User_All_Groups_GraphAPI.ps1 30c2b6b5-11b2-405b-913d-65e9f8670a1c,AdeleV@M365x84802758.OnMicrosoft.com,AlexW@M365x84802758.OnMicrosoft.com -IncludeDirectoryRoles -IncludeAdministrativeUnits -ProcessExchangeGroups -ProcessOauthGrants -Exceptions $aaa -ProcessOwnership -SubstituteOwner admin@M365x84802758.OnMicrosoft.com -Confirm:$false

If you do not specify the –Quiet parameter, output will still be dumped to the screen even in this scenario. Which gives us the opportunity to address few more things. First, when users are added to roles such as Exchange admin or Security reader, on Exchange Online’s side of things this is “translated” into membership of “managed” role groups. There is no good way to filter out such entries, unless you want to hardcode the group names, so we process them instead. As you cannot process any Exchange roles without doing Directory roles first, this should not be a problem – removing the Entra ID role membership will ensure the membership of the Exchange role group is also updated (hence the warning).

RemoveUser3

Another thing to note on the screenshot above is processing the scenario where the user is the sole owner of a group. As we use the –SubstituteOwner parameter, the removal operation is retried only after we add the designate “substitute owner”. Thus, we get the “Owner add” entry in the output as well. This logic applies only to the removal of owners of security groups and Microsoft 365 Groups, no other objects!

As mentioned above, output is also exported to a CSV file. This happens regardless of the parameters you invoked the script with, so you have a proper record of any changes that were made (or failed to be made). So as our next example, here’s how to run the script non-interactively and without any additional output (just the CSV file).

.\Remove_User_All_Groups_GraphAPI.ps1 NestorW@M365x84802758.OnMicrosoft.com -IncludeDirectoryRoles -IncludeAdministrativeUnits -ProcessExchangeGroups -ProcessOauthGrants -ProcessOwnership -SubstituteOwner admin@M365x84802758.OnMicrosoft.com -Quiet

While there isn’t much to show as a output from this example, we can still leverage the resulting CSV file, where all the changes should be diligently noted. I’ve tried to use human-readable identifiers where possible, but you might see a GUID every now and then, mostly because I didn’t want to include any additional Graph API calls to resolve such. Whenever we try to make a change in the ownership of some object, its name will be prefixed with “[Owner]” and we’re also adding “owner” string to the Result column. Some of the directory role entries will also have a prefix, which uses the “[scope]:roleId” format. As role assignments can be scoped to AUs or even individual objects, I thought this is important information to display. Thus a “[/]” prefix indicates directory-wide assignment, “[/GUID]” indicated application-scoped one, and so on. Oh, and as roleId cannot be resolved by a call to the getByIds method, I again decided against making additional calls… enjoy those GUIDS!

RemoveUser4

Lastly, for the sake of completeness, and to justify the name of the script, here’s how to run it to simply remove a given user from any groups he is in:

#Simple example to just remove group membership

.\Remove_User_All_Groups_GraphAPI.ps1 BiancaP@M365x84802758.OnMicrosoft.com

And because I know I will inevitably get this asked, here’s how to use a CSV file as input:

.\Remove_User_All_Groups_GraphAPI.ps1 -Identity (Import-Csv C:\Temp\test.csv).UserPrincipalName -WhatIf

Additional notes

So now that we know what the script does and run some examples, let’s also talk about what the script does NOT do. It does not process or remove any of the following:

  • AppRole assignments – I simply do not consider them important enough.
  • PIM eligible group membership – this one I do consider important enough, however the corresponding Graph API endpoints currently have some issues and the workaround requires iterating over all PIM provisioned groups… which is something I want to avoid.
  • Exchange mailbox-level permissions (Full access, send as, send on behalf of) – again important, but falls into the “avoid” category. Use dedicated scripts instead.
  • Exchange folder-level permissions – as above, inefficient. Use dedicated scripts instead.
  • Site/file level permissions – even more inefficient.
  • Device ownership (and registered devices) – this one I’m not so sure about… it’s relatively easy to address – let me know if you consider it important!
  • Dynamic groups – instead of outright skipping them, should we do something else?
  • Access packages – lots of things can be done via Entitlement management, good suggestion for future improvements.
  • Teams RSC permission grants – easy enough to get… just not sure if important enough to consider.

The above list reminds me of another point – we do not explicitly address group-based Directory role assignments. Such are not directly returned via the /memberOf query, but we can cover them via the PIM endpoints. As however we already remove the user from all groups, privileged ones included, this scenario should already be covered. Let me know if my logic is faulty on this!

 

While the Graph API does have some support for Exchange Online role assignments now, said functionality is only available under the /beta endpoints. Moreover, it only allows for management of direct role assignments, as it does not currently support management of Exchange Online Role groups. In other words, you cannot use the Graph API to remove a role that is made available to the user thanks to its membership to a Role Group, unless you want to remove the assignment completely, and cause all other members of the Role Group to lose access. For this reason, we stick to using the good old Exchange cmdlets instead.

The requirements for using Exchange Online’s InvokeCommand endpoint closely resemble those for accessing Exchange Online PowerShell via app-only authentication, albeit if you have already created an app registration to leverage the Graph API, you can skip some of the steps. Do make sure to assign the necessary permissions though: the Exchange.ManageAsApp role and either an Entra ID admin role or Exchange Online Role group with sufficient permissions to perform the removal operations. At minimum, access to the Remove-DisitrbutionGroupMember cmdlet is required. As the script can also be used to remove Exchange Online management role assignments, both the Remove-ManagementRoleAssignment and Remove-RoleGroupMember cmdlets might also be called.

The script assumes that you will be leveraging a single service principal for performing both the Graph API and Exchange Online REST API operations. This is not a hard requirement, though it simplifies the connectivity block a bit. As an added benefit, using a single service principal can relax the permission requirements a bit, at least when using Entra ID roles. On that note, be warned that the script does not validate the access token(s) for the presence of the relevant permissions. Which means that the removal operation will be tried regardless, and will fail if permissions have not been granted. The script has some error handling that should detect such scenarios and terminate any further processing, but I’m sure there are many corner cases that it does not account for.

One more note on permissions. As we are leveraging the application permissions model, assigning an Entra ID role to the service principal enables our app to run queries against the Graph API, without explicitly granting consent to say Directory.Read.All. For example, while testing the script I had the Exchange administrator role assigned to the SP, which allowed him to remove Microsoft 365 Group members and owners without any additional grants. In production scenarios however you should be following the principle of least privilege, so consider using (custom) Role groups instead.

Another thing to mention here is that the script leverages the client credentials flow to obtain an access token, in other words requires you to enter a client secret value. DO NOT use this approach in your own solution! Make sure to replace the connectivity bits with your own preferred method instead. At the very least, do not store any credentials in plain text. Better, use a certificate to obtain the access token. And never trust a piece of code you’ve downloaded from the Internet 🙂

As always, do let me know if you find the script useful, any issues you run into and send me any potential improvement suggestions.

1 thought on “Remove user from all Microsoft 365 groups and roles (and more) via the Graph API (non-interactive)

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.