Vue lecture

Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.
✇Office 365 for IT Pros

The Right Way to Revoke Access from Azure AD Accounts with PowerShell

Use the Revoke-MgUserSignInSession cmdlet to Revoke Access for Azure AD Accounts

Microsoft’s documentation for how to revoke access to an Azure AD account describes the use of the Revoke-AzureADUserAllRefreshToken cmdlet from the Azure AD PowerShell module. That’s unfortunate because of the upcoming deprecation of that module. If we consult Microsoft’s cmdlet map to find the appropriate replacement cmdlet from the Microsoft Graph PowerShell SDK, it turns out to be Invoke-MgInvalidateUserRefreshToken, which “Invalidates all of the user’s refresh tokens issued to applications (as well as session cookies in a user’s browser), by resetting the refreshTokensValidFromDateTime user property to the current date-time.”

The guidance could not be clearer. Any script using the Revoke-AzureADUserAllRefreshToken should replace it with the Invoke-MgInvalidateUserRefreshToken cmdlet. Except when you discover that the SDK also includes the Revoke-MgUserSignInSession cmdlet. This cmdlet is in beta and its documentation is less than perfect (or totally inadequate), but the salient fact is that it performs the same task. These two commands have the same effect:

$RevokeStatus = Revoke-MgUserSignInSession -UserId $UserId
$InvalidateStatus = Invoke-MgInvalidateUserRefreshToken -UserId $UserId

Up to now, the Office 365 for IT Pros eBook (chapter 5) documented how to use the Invoke-MgInvalidateUserRefreshToken cmdlet to block an Azure AD user account. Finding the alternative cmdlet used in a Microsoft example provoked a query to ask why two cmdlets did the same thing.

Microsoft’s response is that they built Invoke-MgInvalidateUserRefreshToken for a specific purpose. The cmdlet still works and has the significant benefit of being part of the production (V1.0) module. However, Microsoft’s recommendation is to use Revoke-MgUserSignInSession in the future, even if it is in the beta module.

Use the Beta SDK Module

Using cmdlets from the beta module might seem problematic but it’s not. For instance, if you want to do any license management with the Microsoft Graph PowerShell SDK, you must use the beta module because the production version of cmdlets like Get-MgUser don’t return any license information. It’s one of those important to know things when converting scripts to use the SDK.

Revoking Access for an Azure AD Account is the Start

Of course, revoking access for an Azure AD account might just be the first step in the process of securing the account. Revoking access will force the user to reauthenticate, but if you want to stop further access to the account, you must:

Disabling the account and changing the password are both critical events that force Azure AD to signal applications that support continuous access evaluation (CAE) to terminate sessions. Many of the important Microsoft 365 apps like Outlook and SharePoint Online support CAE (see current list).

This PowerShell code does the necessary, if the account signing into the Microsoft Graph PowerShell SDK holds at least the User Administrator role:

Connect-MgGraph -Scopes Directory.AccessAsUser.All
Select-MgProfile Beta
$Account = Read-Host "Enter the User Principal Name of the account to block"
$User = (Get-MgUser -UserId $Account -ErrorAction SilentlyContinue)
If (!($User)) { Write-Host ("Can't find an Azure AD account for {0}" -f $Account); break }
Write-Host ("Revoking access and changing password for account {0}" -f $User.DisplayName)  
# Disable the account
Update-MgUser -UserId $User.Id -AccountEnabled:$False
# Create a password profile with details of a new password
$NewPassword = @{}
$NewPassword["Password"]= "!NewYorkCity2022?"
$NewPassword["ForceChangePasswordNextSignIn"] = $True
Update-MgUser -UserId $User.Id -PasswordProfile $NewPassword
# Revoke signed in sessions and refresh tokens
$RevokeStatus = Revoke-MgUserSignInSession -UserId $User.Id
# Disable registered devices
[array]$UserDevices = Get-MgUserRegisteredDevice -UserId $User.Id
If ($UserDevices) {
ForEach ($Device in $UserDevices) {
    Update-MgDevice -DeviceId $Device.Id -AccountEnabled $False}
}

Figure 1 shows that after running the script, the user account is disabled and the SignInSessionsValidFromDateTime property (referred to as refreshTokensValidFromDateTime above) is set to the time when the Revoke-MgUserSignInSession cmdlet ran.

Running PowerShell to revoke access for an Azure AD account
Figure 1: Running PowerShell to revoke access for an Azure AD account

Consequences of Disabling an Azure AD Account

In a scenario like a departing employee, losing access to some teams might not be important. If it is, or in situations where it’s necessary to preserve the account in full working order, an alternative to disabling an account is to change its password and revoke access. The account remains active but is inaccessible unless those attempting to sign-in know the new password.

Example of Knowledge Gap

In July 2022, I wrote about the opening of a knowledge gap as tenants transitioned from the depreciated Azure AD and Microsoft Online Services (MSOL) modules. Having two cmdlets that revoke user access to pick from is one too many. It doesn’t help people migrate scripts to use the Microsoft Graph PowerShell SDK. But at least the recommendation is clear: use Revoke-MgUserSignInSession.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Microsoft Removes Remote PowerShell for Compliance Sessions

No Remote PowerShell Connections for Compliance Endpoint

Following the December 15, 2022 announcement to deprecate Remote PowerShell connections to Exchange Online, the news in MC541649 (April 14) that the connection to the compliance endpoint with Connect-IPPSSession cmdlet will follow suit is no surprise. The only surprise is that the text of the announcement is quite so confusing.

Connect-IPPSSession establishes a PowerShell connection to what used to be called the Security and Compliance endpoint (SCC). Microsoft also refers to EOP in the announcement because some cmdlets loaded (like Get-PhishSimOverridePolicy) are associated with Exchange Online Protection.

Today, the endpoint might be called the Microsoft Purview endpoint or compliance endpoint because the cmdlets loaded after establishing the connection allow access to objects like retention labels, sensitivity labels, and their respective publishing policies.

REST Rather than Remote PowerShell

Microsoft says that “in line with our vision to enhance the security of our cloud,” the compliance cmdlets will now use a REST API instead of the traditional (established in Exchange 2010) Remote PowerShell approach. Once you install V3.2 of the Exchange Online management module (apparently available on May 1, 2023), the REST-base cmdlets are available and Remote PowerShell is no longer required. You won’t see this kind of message when connecting to the endpoint:

WARNING: Your connection has been redirected to the following URI:
"https://eur01b.ps.compliance.protection.outlook.com/Powershell-LiveId?BasicAuthToOAuthConversion=true;PSVersion=5.1.22621.963"

Existing scripts don’t need to be updated. As Microsoft says “Simply using the new module will ensure REST is used rather than RPS.”

Part of the confusion in this announcement is the need to use a version of the Exchange Online management module that is currently unavailable. The current version doesn’t support the UseRPSSession parameter mentioned by Microsoft in their text:

Connect-IPPSSsession -UseRPSSession:$false

Microsoft says that Remote PowerShell connections to the compliance endpoint will not be available after July 15, 2023. This is a tad ahead of the announced schedule for the depreciation of Remote PowerShell for the main Exchange module (due on October 1, 2023).

Using a REST API instead of Remote PowerShell should make cmdlets more reliable and better performing. Remote PowerShell is very much a mechanism rooted in a period when Microsoft needed to support management of Exchange servers from workstations without the need to log into the servers. It worked well for Exchange 2010 and 2013 but its deficiencies are obvious with cloud services when connecting to a service is more important than connecting to a server.

More to Do

Welcome as it is to see the compliance cmdlets transition to a REST-based endpoint, there’s still more to do to fully modernize these cmdlets. Adding support for Azure managed identifies is a big step that needs to happen. It can be argued that the compliance cmdlets are less heavily accessed than those in the main Exchange module, but this ignores the fact that many of the tasks that you might want to run on a scheduled basis using an Azure Automation runbook might need to access compliance elements, like the list of sensitivity labels defined in a tenant (Figure 1).

 Listing sensitivity labels after connecting to the compliance endpoint
Figure 1: Listing sensitivity labels after connecting to the compliance endpoint

Good Change

There’s no doubt that moving the compliance endpoint away from a dependency on Remote PowerShell is a good thing. Throwing away the baggage of on-premises implementations to make things work smoother in the cloud is always positive for those who need to automate Microsoft 365 operations. This is especially so when discussing compliance because the range of compliance functionality available in Microsoft 365 is so much wider and deeper than in the on-premises servers.

At this point, we don’t have the V3.2 release of the Exchange Online management module available so it’s hard to verify Microsoft’s assertion that nothing needs to be done to move the compliance cmdlets from Remote PowerShell to REST-based APIs. However, given the progress seen in the main Exchange Online management module, Microsoft is progressing down a well-known path and the change should be smooth. At least. I hope it will be.

✇Office 365 for IT Pros

Microsoft Releases Cmdlet to Retrieve Disposition Review Items

Export Details of Disposition Review Items

Message Center notification MC521457 (Microsoft 365 roadmap item 106102) might have passed you buy on February 27 when Microsoft announced a new PowerShell cmdlet for disposition review. Relatively few people are concerned with Microsoft Purview Data Lifecycle Management to care that a new cmdlet is available to export (not just “to support”) disposition review items, so it’s entirely natural that you might have gone on to read about other announcements occurring around the same time, like Exchange Online’s improved message recall feature.

Roll-out of the new Get-ReviewItems cmdlet is now complete. The cmdlet is available after loading the latest version of the Exchange Online management module.

Disposition Items

Microsoft 365 retention labels often result in the deletion of items after the lapse of their retention periods. This is enough for most organizations, but those that want oversight over the final processing of selected items can configure retention labels to invoke a disposition review, part of the Microsoft Purview records management solution. Disposition reviews are often used to retain messages and documentations such as those for project documentation until the organization is absolutely sure that it’s safe to remove individual items.

Using a disposition review with retention labels requires advanced licenses, like Office 365 E5. An organization can put items through a single-stage or multi-stage review (Figure1) leading to final deletion, retention for another period, or assignment of a new retention label. The reviewers who decide on the disposition of content are selected by the organization because they have the expertise and experience to know if items are still needed or can progress to final disposition. It’s also possible to configure a custom automated disposition process using Power Automate.

Viewing disposition review items for a retention label
Figure 1: Viewing disposition review items for a retention label

Exporting Disposition Review Items

The Get-ReviewItems cmdlet doesn’t affect disposition outcomes. It’s a utility cmdlet to export details of disposition review items for a specific retention label in a pending or disposed (processed) state. The reason why the cmdlet exists is that the Purview GUI (Figure 1) supports export of up to 50,000 items. Although it’s unlikely that an organization will have more than 50,000 items awaiting disposition review, it is possible that they might have more than 50,000 disposed (processed) items. The Get-ReviewItems cmdlet can export details of all those items.

Microsoft’s documentation for Get-ReviewItems includes examples of using the cmdlet. One in particular is noteworthy because it explains how to fetch pages of review items until all items have been recovered. Fetching pages of data is common practice in the Graph API world and it’s done to reduce the strain on the service imposed if administrators requested very large numbers of items at one time.

I expanded the example to create a report of all disposition review items for a tenant (all items for all retention labels with a disposition review). Here’s the code:

Connect-IPPSSession

[array]$ReviewTags = Get-ComplianceTag | Where-Object {$_.IsReviewTag -eq $True} | Sort-Object Name
If (!($ReviewTags)) { Write-Host "No retention tags with manual disposition found - exiting"; break }

Write-Host ("Looking for Review Items for {0} retention tags: {1}" -f $ReviewTags.count, ($ReviewTags.Name -join ", "))

$Report = [System.Collections.Generic.List[Object]]::new() 

[array]$ItemsForReport = $Null
ForEach ($ReviewTag in $ReviewTags) {
 Write-Host ("Processing disposition items for the {0} label" -f $ReviewTag.Name)
 [array]$ItemDetails = $Null; [array]$ItemDetailsExport = $Null
 # Fetch first page of review items for the tag and extract the items to an array
 [array]$ReviewItems = Get-ReviewItems -TargetLabelId $ReviewTag.ImmutableId -IncludeHeaders $True -Disposed $False  
 $ItemDetails += $ReviewItems.ExportItems
 # If more pages of data are available, fetch them and add to the Item details array
 While (![string]::IsNullOrEmpty($ReviewItems.PaginationCookie))
 {
    $ReviewItems = Get-ReviewItems -TargetLabelId $ReviewTag.ImmutableId -IncludeHeaders $True -PagingCookie $ReviewItems.PaginationCookie
    $ItemDetails += $ReviewItems.ExportItems
 }
 # Convert data from CSV
 If ($ItemDetails) {
   [array]$ItemDetailsExport = $ItemDetails | ConvertFrom-Csv -Header $ReviewItems.Headers 
   ForEach ($Item in $ItemDetailsExport) {
     # Sometimes the data doesn't include the label name, so we add the label name to be sure
     $Item | Add-Member -NotePropertyName Label -NotePropertyValue $ReviewTag.Name }
   $ItemsForReport += $ItemDetailsExport
 }
}

ForEach ($Record in $ItemsForReport) {
  If ($Record.ItemCreationTime) {
   $RecordCreationDate =  Get-Date($Record.ItemCreationTime) -format g 
  } Else {
   $RecordCreationDate = "Unknown" }
 
   $DataLine  = [PSCustomObject] @{
     TimeStamp       = $RecordCreationDate
     Subject         = $Record.Subject
     Label           = $Record.Label
     AppliedBy       = $Record.LabelAppliedBy
     RecordType      = $Record.RecordType
     'Last Reviewed' = Get-Date($Record.ItemLastModifiedTime) -format g
     'Review Action' = $Record.ReviewAction
     Comment         = $Record.Comment
     'Deleted Date'  = $Record.DeletedDate
     Author          = $Record.Author
     Link            = $Record.InternetMessageId
     Location        = $Record.Location
   } 
   $Report.Add($DataLine)
}

Everything works – until you meet an item with a comma in its subject or the comment captured when a reviewer decides upon a disposition outcome. After discussing the issue with Microsoft, its root cause is that the export is in CSV format and the comma in these fields causes problems when converting from CSV format. Microsoft is working on a fix which might be present as you read this.

The Lesson of Export

The Get-ReviewItems cmdlet will be a useful tool for those involved in disposition processing. They can extract details of items and report that information in whatever way they wish. The comma issue proves that documentation is not always perfect. It’s important to test examples to make sure that they work as they should.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Generate a HTML Report of Managers and Direct Reports with the Graph SDK

Creating a Report From Azure AD Manager and Direct Reports Data with PowerShell

It’s always good to be able to build on the knowledge contributed by someone else. This brings me to a post by Vasil Michev, the esteemed technical editor for the Office 365 for IT Pros eBook. The post covers how to Create an All Managers group in Microsoft 365 and covers how to do this in different ways for different types of group. It brought back some memories of Microsoft’s initiative in April 2017 to auto-generate a Microsoft 365 group for every manager with its membership populated with the manager’s direct report.

Retrieving Azure AD Manager and Direct Reports

In any case, Vasil discussed how the Get-Recipient (but not Get­ExoRecipient) and Get-User cmdlets have a filter to find accounts that have direct reports using the backlink from users to their managers. By definition, these accounts are managers, so you can use the commands as the basis to control the membership of distribution lists, dynamic distribution lists, or Microsoft 365 groups.

Get-Recipient -Filter {DirectReports -ne $Null}
Get-User -Filter {DirectReports -ne $Null}

The only problem is that the output of the two cmdlets is imperfect. The cmdlets find accounts with direct reports, but their results include some accounts that don’t have any direct reports. In my tenant, I found that the cmdlets found three accounts with no direct reports. I believe that these accounts had direct reports at some point in the past, but they don’t now. For instance, when I queried the accounts to see the set of direct reports reported by Get-User, I see a blank:

Get-User -Identity Ben.Owens | Select-Object Name, Manager, DirectReports

Name      Manager      DirectReports
----      -------      -------------
Ben Owens tony.redmond {}

The same is true when viewing details of the account through Exchange address lists, the organization chart in Teams, or the Outlook Org Explorer (Figure 1).

Outlook Org Explorer lists no direct reports for a manager
Figure 1: Outlook Org Explorer lists no direct reports for a manager

According to message center notification MC492902 (updated 7 February 2023), the Outlook Org Explorer is only available to users with the “Microsoft Viva Suite” or “Microsoft Viva Suite with Glint” licenses, which is why you might not be seeing it. Originally, Microsoft said that the Org Explorer would be available to accounts with Microsoft 365 E3/E5 or Microsoft 365 Business licenses, but they decided to align this feature with the Viva initiative. The Org Explorer is not available for OWA.

My conclusion is that synchronization between Azure AD and Exchange Online leaves some vestige behind in the DirectReports property following the removal of the last direct report for a manager. It’s enough to stop the filter working accurately.

Reporting Azure AD Managers and Direct Reports

Which brings me back to considering how to report the links between managers and employees using the information stored in Azure AD. I covered this ground in an article two years ago, but I didn’t realize the flaw in Get-User at the time, so the script I wrote (available from GitHub) can produce incorrect results. A different approach is needed.

Given that Azure AD is the source of the information, it makes sense to use Graph APIs to retrieve data. I chose to use the Microsoft Graph PowerShell SDK to avoid the necessity to create a registered app.

The new script (also available from GitHub) does the following:

  • Finds user accounts with at least one assigned license. This step filters out accounts created for purposes like room and shared mailboxes.
  • Use the Get-MgUserManager cmdlet to check each account to see if it has a manager. If not, note this fact.
  • Use the Get-MgUserDirectReport cmdlet to see if the account has direct reports. If it does, record the details of the manager’s reports.
  • Create an HTML report detailing each manager and their reports.
  • At the end of the report, add a section detailing accounts without managers.
  • Output the HTML file and a CSV file containing details of managers and reports.

Figure 2 shows some example output. Because the code is PowerShell, it’s easy to tweak it to include other information about each employee.

Reporting managers and their direct reports

Azure AD Managers
Figure 2: Reporting managers and their direct reports

Go to the Source to Find Azure AD Managers and Direct Reports

It’s never nice to discover that a technique you thought worked well is no longer fit for purpose and it’s necessary to rework a script. The Get-User and Get-Recipient cmdlets return accurate information about managers and direct reports, but only if managers always have at least one report. I guess that’s possible, but it’s better to make sure by using Graph APIs to retrieve data about managers and their direct reports. At least then you’ll know that your reports show the same reporting relationships that surface elsewhere in Microsoft 365.


Support the work of the Office 365 for IT Pros team by subscribing to the Office 365 for IT Pros eBook. Your support pays for the time we need to track, analyze, and document the changing world of Microsoft 365 and Office 365.

✇Office 365 for IT Pros

Microsoft Limits Graph API Requests for User Account Data

Old Limit with SignInActivity was 999 – New Limit for Azure AD Accounts is 120

Because it retrieves details of Azure AD accounts, the List Users API is one of the most heavily used of the Microsoft Graph APIs. It also underpins the Get-MgUser cmdlet from the Microsoft Graph PowerShell SDK. Microsoft generates the cmdlet from the API using a process called AutoRest, which means that changes made to the API show up soon afterward in the cmdlet.

I’ve documented some of the issues that developers must deal with when coding with the cmdlets from the Microsoft Graph PowerShell SDK. The cmdlets have been stable recently, which is a relief because tenants are migrating scripts from the Azure AD and MSOL modules. However, last week an issue erupted in a GitHub discussion that caused a lot of disruption.

In a nutshell, if you use List Users to fetch Azure AD accounts and include the SignInActivity property, the API limits the page size for results to 120 items. Calls made without specifying SignInActivity can set the page size to be anything up to 999 items.

An Unannounced Change

To help manage demand on the service, all Graph API requests limit the number of items that they return. To retrieve all matching items for a request, developers must fetch pages of results until nothing remains. When a developer knows that large numbers of items must be fetched, they often increase the page size to reduce the number of requests.

Microsoft didn’t say anything about the new restriction on requests that fetch Azure AD account data with sign-in activity. Developers only discovered the problem when programs and scripts failed. I first learned of the issue when some of the users of the Office 365 for IT Pros GitHub repository reported that a Graph request which included a $top query parameter to increase the page size to 999 items failed. For example:

$uri = "https://graph.microsoft.com/beta/users?`$select=displayName,userPrincipalName,mail,id,CreatedDateTime,signInActivity,UserType&`$top=999"
[array]$Data = Invoke-RestMethod -Method GET -Uri $Uri -ContentType "application/json" -Headers $Headers
Invoke-RestMethod : The remote server returned an error: (400) Bad Request.
At line:1 char:16
+ ... ray]$Data = Invoke-RestMethod -Method GET -Uri $Uri -ContentType "app ...
+                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest)
   [Invoke-RestMethod], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.I

As shown in Figure 2, testing with the Get-MgUser cmdlet revealed some more information in the error (“Cannot query data for more than 120 users at a time”). This was the first time I learned about a query limit:

Get-MgUser reports more useful error information

Cannot query data for more than 120 users at a time (SignInActivity)
Figure 2: Get-MgUser reports more useful error information

According to a response reported in the GitHub discussion, Microsoft support reported

The PG have confirmed that this endpoint will be transitioning from beta to General Availability (GA).

As part of this transition, changes to its behavior has been made, this includes not requesting more than 120 results per call. They recommend requesting less than 120 results per call, which can be done by setting the top parameter to, say 100.”

It’s likely that Microsoft made the change because retrieving sign-in activity data for Azure AD accounts is an expensive operation. Reducing the page size to 120 possibly makes it easier to process a request than if it asked for 999 items.

Beta Version of List Users Moving to Production

When the product group (PG) says that the endpoint is transitioning from beta to GA, it means that instead of needing to use https://graph.microsoft.com/beta/users to access sign-in activity, the data will be available through https://graph.microsoft.com/V1.0/users. If you use the Microsoft Graph PowerShell SDK, you won’t have to run the Select-MgProfile cmdlet to choose the beta endpoint. Moving the beta version of the API to the production endpoint is a good thing because there are many other account properties now only available through the beta endpoint (like license assignments).

If you use the Microsoft Graph PowerShell SDK, the Get-MgUser cmdlet is unaffected by the change if you specify the All parameter. This is because the cmdlet handles pagination internally and fetches all pages automatically without the need to specify a page size. For instance, this works:

$AccountProperties = @( ‘Id’, ‘DisplayName’, ‘SignInActivity’)
[array]$Users = Get-MgUser -All -Property $AccountProperties | Select-Object $AccountProperties

Moving to Production

Although it’s good that Microsoft is (slowly) moving the beta versions of the List Users API towards production, it’s a pity that they introduced a change that broke so many scripts and programs without any warning. At worse, this so exhibits a certain contempt for the developer community. At best, it’s a bad sign when communication with the developer community is not a priority. That’s just sad.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Time Running Out for Azure AD and MSOL PowerShell Modules

Last Gasp for Azure AD PowerShell Deprecation as June Deadline Approaches

Microsoft’s original announcement about the deprecation of the Azure AD and Microsoft Online Services (MSOL) PowerShell modules goes back to 26 August, 2021. At that time, Microsoft wanted to have the retirement done by June 30, 2022. Customer pushback duly ensued and Microsoft decided to push the dates out another year to allow customers more time to upgrade their scripts.

This was the only sensible course of action. The Graph APIs for dealing with many Azure AD account interactions, especially license assignments, were sadly undocumented. The suggestion of using cmdlets from the Microsoft Graph PowerShell SDK ran into difficulties because the production version (V1.0) of cmdlets like Get-MgUser didn’t return license information. Allied to that, the documentation for the SDK cmdlets remains poor and inscrutable at times.

Time Helped Improve the Situation

Time is a great healer and allows for improvements to be made. The Graph Explorer works better and the Graph X-Ray tool reveals details about how Microsoft uses Graph calls in places like the Azure AD admin center (or rather, the Microsoft Entra admin center).

In addition, Microsoft developed documentation to help people migrate scripts, including a cmdlet map to translate old cmdlets to new. The important thing to realize here is that automatic translation from one set of cmdlets to the other is difficult. People code in PowerShell in different ways and it’s not always clear how to translate code to a new cmdlet. Some community-based projects do exist (here’s a new one that is spinning up), but any attempt to covert to SDK cmdlets must take the SDK foibles into consideration, like its fundamental disregard for the PowerShell pipeline.

But mostly time allowed people to share their knowledge about how to use SDK cmdlets to automate administrative tasks like user and group management. For instance, here’s a writeup I did about license management for Azure AD accounts using the SDK, and here’s another covering how to create a license report for Azure AD accounts.

What Will Happen Between Now and June 30, 2023

But time eventually runs out and we are now at the point where Microsoft is progressing the retirement of the Azure AD and MSOL modules. Here’s my understanding of the situation based on some discussions with Microsoft:

  • The licensing cmdlets from the Azure AD and MSOL modules do not work for tenants created after November 1, 2022. These tenants must use Graph APIs or SDK cmdlets to manage license assignments for Azure AD accounts.
  • For all tenants, March 31, 2023, marked the official retirement date for the licensing cmdlets in the Azure AD and MSOL modules.
  • Retirement doesn’t mean “stop working on March 31.” Instead, Microsoft now throttles cmdlets that assign licenses to Azure AD accounts so that they’re not as responsive as before. This is in line with the warning posted on July 29, 2022, that “Customers may notice performance delays as we approach the retirement deadline,” The affected cmdlets are:
    • Set-MsolUserLicenseSet-AzureADUserLicense
    • New-MsolUser (where the creation of an account includes a license assignment)
The Set-AzureADUserLicense cmdlet will stop working before June 30, 2023

Azure AD PowerShell deprecation
Figure 1: The Set-AzureADUserLicense cmdlet will stop working before June 30, 2023
  • From now on, Microsoft will increase the throttling rate to make the licensing cmdlets less attractive. Shortly, Microsoft will initiate short outages to gauge the effect of stopping the cmdlets completely. Doing this allows Microsoft to understand if any major pain is caused to customers.
  • Before or on June 30, 2023, the licensing cmdlets “will no longer receive a successful response.” In other words, no throttling, no short delays, just nothing. The exact date when the shut-off happens depends on the information Microsoft gains about customer usage. What’s for sure is that the licensing cmdlets in the Azure AD and MSOL modules will stop working soon.
  • After June 30, 2023, the Azure AD and MSOL modules are unsupported. Cmdlets may still run, but no guarantees exist that they will be successful. Given that the modules have been around for many years, you could anticipate that the cmdlets that don’t interact with the Microsoft 365 licensing platform will be OK. You might be right, but you don’t know how long that state will last because the modules are officially retired.

The Bottom Line About Azure AD PowerShell Deprecation

The Azure AD and MSOL modules are now on borrowed time. If you haven’t already started to upgrade scripts to use the Graph APIs or the Microsoft Graph PowerShell SDK, scripts that use these modules could encounter an unpleasant failure very soon. It’s time to get busy to make sure that all scripts can run after June 30, 2023.


Stay updated with developments across the Microsoft 365 ecosystem by subscribing to the Office 365 for IT Pros eBook. We do the research to make sure that our readers understand the technology.

✇Office 365 for IT Pros

Microsoft Releases Version 5 of the Microsoft Teams PowerShell Module

Major Update for the Get-CsOnlineUser Cmdlet

I don’t normally write about a new version of the Microsoft Teams PowerShell module and confine myself to updating the post covering recent module updates. However, the release of a major version is worth comment, which is the case with V5.0 of the Teams module, now available from the PowerShell Gallery (Figure 1).

V5.0 of the Microsoft Teams PowerShell module

Get-CsOnlineUser
Figure 1: V5.0 of the Microsoft Teams PowerShell module

Over the past few releases, Microsoft concentrated on “modernizing” the policy management cmdlets that Teams inherited from the Skype for Business connector. Modernization is a term to describe updating the cmdlets to recent standards to make them more reliable and robust. The Get-CsOnlineUser cmdlet is the focus for the V5.0 release.

The Use of Get-CsOnlineUser

Get-CsOnlineUser fetches details of user accounts enabled for Teams. I only use this cmdlet when I need to view details of the Teams policies assigned to accounts as I prefer using the Get-MgUser cmdlet to retrieve information about user accounts. The Get-CsOnlineUser cmdlet can return details of the Teams service plans assigned to an account (like the MCO_VIRTUAL_APPT and TEAMS_WEBINAR service plans assigned to accounts with the Teams Premium license), but these are also retrievable with Get-MgUser.

In the past, Get-CsOnlineUser hasn’t been very performant or flexible when retrieving accounts. Microsoft says that they’ve improved performance, especially when using filters to find accounts. In addition, a set of new filterable properties are available (Alias, City, CompanyName, CompanyName, HostingProvider, UserValidationErrors, OnPremEnterpriseVoiceEnabled, OnPremHostingProvider, OnPremLineURI, OnPremSIPEnabled, SipAddress, SoftDeletionTimestamp, State, Street, TeamsOwnersPolicy, WhenChanged, WhenCreated, FeatureTypes, PreferredDataLocation, andLastName).

Changes to Filtering

Another improvement is in the support of filtering operators to bring the cmdlet in line with other cmdlets that fetch user information like Get-ExoMailbox. This is server-side filtering, meaning that the server only returns items that match the filter. It’s faster to retrieve data with a server-side filter than it is to fetch items and then apply a filter on the workstation (client-side filtering).

For instance, this use of the like operator now works:

Get-CsOnlineUser -Filter {City -like "*York*"} | Format-Table DisplayName, City

DisplayName   City
-----------   ----
Terry Hegarty New York

Previous versions of the module generate the error: Get-CsOnlineUser : The filter attribute ‘city’ is not supported.

Get-CsOnlineUser now supports use of the gt (greater than), lt (less than), and le (less than or equal to) operators to filter against string properties. For instance, this works:

Get-CsOnlineUser -Filter {DisplayName -gt "James"} | Sort-Object DisplayName | Format-Table DisplayName, City

DisplayName                             City
-----------                             ----
James Abrahams                          Foxrock
James Ryan                              Foxrock
Jane Sixsmith                           Dublin

The contains operator now supports properties that contain arrays. For instance, this command returns the set of accounts enabled for Teams:

Get-CsOnlineUser -Filter {FeatureTypes -contains "Teams"} | Format-Table DisplayName

The ge operator supports filters against Teams policies (previous versions only support the eq and ne operators):

Get-CsOnlineUser -Filter {TeamsFilesPolicy -ge "*NoSP*"} | Format-Table DisplayName, TeamsFilesPolicy

My attempts to use the cmdlet to filter against the Teams Channel policy failed. I also saw inconsistent results when filtering against other policies. For instance, this returns no accounts:

Get-CsOnlineUser -Filter {TeamsMessagingPolicy -ge "B"}

Adding wildcards generates some results, but it’s hard to accept that a policy called “Advanced” has a name greater or equal to “B”:

Get-CsOnlineUser -Filter {TeamsMessagingPolicy -ge "*B*"} | Format-Table DisplayName, TeamsMessagingPolicy

DisplayName                 TeamsMessagingPolicy
-----------                 --------------------
Jane Sixsmith               Advanced
Marc Vigneau                Advanced

Interestingly, a client-side filter has problems too:

$Users = Get-CsOnlineUser | Where-Object {$_.TeamsMessagingPolicy -ge "B"} | Format-Table DisplayName
Where-Object : Cannot compare "Advanced" because it is not IComparable.
At line:1 char:29

I might be doing things in a way unanticipated by the Teams PowerShell developers, but I have been around PowerShell long enough to know when things don’t work quite the way they should. Some tweaks might still be necessary to make sure that filters work against all Teams policies in the same way.

Soft Deleted Users

Apart from the filtering changes, Get-CsOnlineUser now returns details of unlicensed users for 30 days after license removal and indicates soft-deleted users (accounts in the Azure AD recycle bin awaiting permanent removal) by showing the date and time of deletion in the SoftDeletionTimestamp property. You can find the soft-deleted users with:

Get-CsOnlineUser -Filter {SoftDeletionTimestamp -ne $Null} | Format-Table DisplayName, SoftDeletionTimestamp

DisplayName SoftDeletionTimestamp
----------- ---------------------
Ben James   04/03/2023 23:11:41

Work Still to Do

Get-CsOnlineUser is an important cmdlet used in many scripts to automate administrative processes. It’s good that Microsoft invested effort to make the Get-CsOnlineUser cmdlet work better, even if some issues still exist. Crack out the update procedure you use to refresh Microsoft 365 modules (or use my script, which handles Exchange Online, SharePoint Online, and the Microsoft Graph PowerShell SDK too) and upgrade to V5.0 of the Microsoft Teams module.


Support the work of the Office 365 for IT Pros team by subscribing to the Office 365 for IT Pros eBook. Your support pays for the time we need to track, analyze, and document the changing world of Microsoft 365 and Office 365.

✇Office 365 for IT Pros

How to Disable the Viva Engage Core Service Plan

Viva Engage Core Service Plan for Continuity and New Features

Along with their announcements that Yammer is becoming Viva Engage, Microsoft blogged about a new Viva Engage admin center (just what we need – another admin portals). The admin blog contained the news of new service plans that Microsoft has added to user account license assignments. For example, accounts with Office 365 E3 and E1 licenses now have the Viva Engage Core and Viva Learning Seeded service plans (Figure 1).

Viva Engage Core listed in the apps (service plans) available to a user account
Figure 1: Viva Engage Core listed in the apps (service plans) available to a user account

I don’t see any trace of the Viva Engage Core service plan in Office 365 E5 licenses. This might be because some accounts have Viva Suite licenses.

Good Intentions but Bad Outcome

Microsoft added the Viva Engage Core service plan to make sure that Viva users could continue to use Yammer services (like Q&A) after the switchover, saying “The service plans have been enabled for all users to provide a smooth and easily controlled feature roll out process.” The Viva Engage Code service plan will control new features and Microsoft wanted to put the service plan in place so that no one would miss out.

That’s a laudable intention, but they missed one very important point. Microsoft failed to disable the Viva Engage Core service plan for accounts where administrators had previously disabled the Yammer Enterprise service plan. Because the Viva Engage Core service plan enables Yammer services, the newly enabled license option means that people who previously couldn’t use Yammer can now do so.

Disabling the Viva Engage Core Service Plan

Most users won’t realize that they can go to yammer.com and launch Yammer with a URL like https://web.yammer.com/main/org/office365itpros.com. Anyway, if they did, they probably wouldn’t find much because the organization obviously doesn’t want to use Yammer. Considering those facts, you might think that little damage is done, but workers councils and unions might not take the same view.

Some PowerShell can fix the damage. Many organizations have a general-purpose script to remove service plans from Microsoft 365 licenses (here’s my version – make sure that you use the Graph-based script). In this case, I repurposed a script that I wrote to remove the Kaizala service plan from licenses, if only because it’s more recent work and includes logging of license updates.

To check user accounts for disabled service plans, we need to know what to look for. In this instance, the script must check accounts to see if the Yammer Enterprise service plan (7547a3fe-08ee-4ccb-b430-5077c5041653) is disabled and if so, disable the Viva Engage Core service plan (a82fbf69-b4d7-49f4-83a6-915b2cf354f4). The source for this information is Microsoft’s Azure AD license reference page.

The outline of the script is:

  • Find licensed user accounts.
  • For each account, check if it has an Office 365 license.
  • If so, check if Yammer Enterprise is disabled.
  • If so, disable Viva Engage Core.

You can download a copy of the full script from GitHub. I know the script will remove Viva Core Engage from Office 365 E3 licenses, but I don’t know how Microsoft assigned the service plan to other licenses. Because the code is PowerShell, it should be easy to amend to handle other license conditions.

Evolving License Management with PowerShell

PowerShell is a great way to automate license management operations if you don’t have something more sophisticated to help, like Azure AD group-based licensing. But remember that Microsoft will retire the license management cmdlets from the Azure AD and MSOL modules on March 31, 2023. Make sure that any PowerShell you write to work with user licenses uses Graph API requests or cmdlets from the Microsoft Graph PowerShell SDK.

P.S. Microsoft’s graphic to support the rebranding announcement in tweets and other social media was really quite clever. (Figure 1), even if it hid what must have been a bruising transition for some.

Yammer and Viva Engage
Figure 2: Yammer and Viva Engage

Learn how to exploit the data available to Microsoft 365 tenant administrators through the Office 365 for IT Pros eBook. We love figuring out how things work. We don’t like when Microsoft rebrands software products because it means that we’ve then got to update references in the book. There were 298 mentions of Yammer in the February 2023 update for the Office 365 for IT Pros eBook. March will see that number drop dramatically…

✇Office 365 for IT Pros

Mastering the Foibles of the Microsoft Graph PowerShell SDK

He looks happy, but he hasn't hit some of the Microsoft Graph PowerShell SDK foibles yet...
He looks happy, but he hasn’t hit some of the SDK foibles yet…

Translating Graph API Requests to PowerShell Cmdlets Sometimes Doesn’t Go So Well

The longer you work with a technology, the more you come to know about its strengths and weaknesses. I’ve been working with the Microsoft Graph PowerShell SDK for about two years now. I like the way that the SDK makes Graph APIs more accessible to people accustomed to developing PowerShell scripts, but I hate some of the SDK’s foibles.

This article describes the Microsoft Graph PowerShell SDK idiosyncrasies that cause me most heartburn. All are things to look out for when converting scripts from the Azure AD and MSOL modules before their deprecation (speaking of which, here’s an interesting tool that might help with this work).

No Respect for $Null

Sometimes you just don’t want to write something into a property and that’s what PowerShell’s $Null variable is for. But the Microsoft Graph PowerShell SDK cmdlets don’t like it when you use $Null. For example, let’s assume you want to create a new Azure AD user account. This code creates a hash table with the properties of the new account and then runs the New-MgUser cmdlet.

$NewUserProperties = @{
    GivenName = $FirstName
    Surname = $LastName
    DisplayName = $DisplayName
    JobTitle = $JobTitle
    Department = $Null
    MailNickname = $NickName
    Mail = $PrimarySmtpAddress
    UserPrincipalName = $UPN
    Country = $Country
    PasswordProfile = $NewPasswordProfile
    AccountEnabled = $true }
$NewGuestAccount = New-MgUser @NewUserProperties

New-MgUser fails because of an invalid value for the department property, even though $Null is a valid PowerShell value.

New-MgUser : Invalid value specified for property 'department' of resource 'User'.
At line:1 char:2
+  $NewGuestAccount = New-MgUser @NewUserProperties
+  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: ({ body = Micros...oftGraphUser1 }:<>f__AnonymousType64`1) [New-MgUser
   _CreateExpanded], RestException`1
    + FullyQualifiedErrorId : Request_BadRequest,Microsoft.Graph.PowerShell.Cmdlets.NewMgUser_CreateExpanded

One solution is to use a variable that holds a single space. Another is to pass $Null by running the equivalent Graph request using the Invoke-MgGraphRequest cmdlet. Neither are good answers to what should not happen (and we haven’t even mentioned the inability to filter on null values).

Ignoring the Pipeline

The pipeline is a fundamental building block of PowerShell. It allows objects retrieve by a cmdlet to pass to another cmdlet for processing. But despite the usefulness of the pipeline, the SDK cmdlets don’t support it and the pipeline stops stone dead whenever an SDK cmdlet is asked to process incoming objects. For example:

Get-MgUser -Filter "userType eq 'Guest'" -All | Update-MgUser -Department "Guest Accounts"
Update-MgUser : The pipeline has been stopped

Why does this happen? The cmdlet that receives objects must be able to distinguish between the different objects before it can work on them. In this instance, Get-MgUser delivers a set of guest accounts, but the Update-MgUser cmdlet does not know how to process each object because it identifies an object is through the UserId parameter whereas the inbound objects offer an identity in the Id property.

The workaround is to store the set of objects in an array and then process the objects with a ForEach loop.

Property Casing and Fetching Data

I’ve used DisplayName to refer to the display name of objects since I started to use PowerShell with Exchange Server 2007. I never had a problem with uppercasing the D and N in the property name until the Microsoft Graph PowerShell SDK came along only to find that sometimes SDK cmdlets insist on a specific form of casing for property names. Fail to comply, and you don’t get your data.

What’s irritating is that the restriction is inconsistent. For instance, both these commands work:

Get-MgGroup -Filter "DisplayName eq 'Ultra Fans'"
Get-MgGroup -Filter "displayName eq 'Ultra Fans'"

But let’s say that I want to find the group members with the Get-MgGroupMember cmdlet:

[array]$GroupMembers = Get-MgGroupMember -GroupId (Get-MgGroup -Filter "DisplayName eq 'Ultra Fans'" | Select-Object -ExpandProperty Id)

This works, but I end up with a set of identifiers pointing to individual group members. Then I remember from experience gained from building scripts to report group membership that Get-MgGroupMember (like other cmdlets dealing with membership like Get-MgAdministrationUnitMember) returns a property called AdditionalProperties holding extra information about members. So I try:

$GroupMembers.AdditionalProperties.DisplayName

Nope! But if I change the formatting to displayName, I get the member names:

$GroupMembers.AdditionalProperties.displayName
Tony Redmond
Kim Akers
James Ryan
Ben James
John C. Adams
Chris Bishop

Talk about frustrating confusion! It’s not just display names. Reference to any property in AdditionalProperties must use the same casing as used the output, like userPrincipalName and assignedLicenses.

Another example is when looking for sign-in logs. This command works because the format of the user principal name is the same way as stored in the sign-in log data:

[array]$Logs = Get-MgAuditLogSignIn -Filter "UserPrincipalName eq 'james.ryan@office365itpros.com'" -All

Uppercasing part of the user principal name causes the command to return zero hits:

[array]$Logs = Get-MgAuditLogSignIn -Filter "UserPrincipalName eq 'James.Ryan@office365itpros.com'" -All

Two SDK foibles are on show here. First, the way that cmdlets return sets of identifiers and stuff information into AdditionalProperties (something often overlooked by developers who don’t expect this to be the case). Second, the inconsistent insistence by cmdlets on exact matching for property casing.

I’m told that this is all due to the way Graph APIs work. My response is that it’s not beyond the ability of software engineering to hide complexities from end users by ironing out these kinds of issues.

GUIDs and User Principal Names

Object identification for Graph requests depends on globally unique identifiers (GUIDs). Everything has a GUID. Both Graph requests and SDK cmdlets use GUIDs to find information. But some SDK cmdlets can pass user principal names instead of GUIDs when looking for user accounts. For instance, this works:

Get-MgUser -UserId Tony.Redmond@office365itpros.com

Unless you want to include the latest sign-in activity date for the account.

Get-MgUser -UserId Tony.Redmond@office365itpros.com -Property signInActivity
Get-MgUser :
{"@odata.context":"http://reportingservice.activedirectory.windowsazure.com/$metadata#Edm.String","value":"Get By Key
only supports UserId and the key has to be a valid Guid"}

The reason is that the sign-in data comes from a different source which requires a GUID to lookup the sign-in activity for the account, so we must pass the object identifier for the account for the command to work:

Get-MgUser -UserId "eff4cd58-1bb8-4899-94de-795f656b4a18" -Property signInActivity

It’s safer to use GUIDs everywhere. Don’t depend on user principal names because a cmdlet might object – and user principal names can change.

No Fix for Problems in V2 of the Microsoft Graph PowerShell SDK

V2.0 of the Microsoft Graph PowerShell SDK is now in preview. The good news is that V2.0 delivers some nice advances. The bad news is that it does nothing to cure the weaknesses outlined here. I’ve expressed a strong opinion that Microsoft should fix the fundamental problems in the SDK before doing anything else.

I’m told that the root cause of many of the issues is the AutoRest process Microsoft uses to generate the Microsoft Graph PowerShell SDK cmdlets from Graph API metadata. It looks like we’re stuck between a rock and a hard place. We benefit enormously by having the SDK cmdlets but the process that makes the cmdlets available introduces its own issues. Let’s hope that Microsoft gets to fix (or replace) AutoRest and deliver an SDK that’s better aligned with PowerShell standards before our remaining hair falls out due to the frustration of dealing with unpredictable cmdlet behavior.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Reporting Exchange Online Meeting Room Usage Patterns

Calculating Statistics for Room Mailboxes

A Practical365.com article I wrote explaining how to extract and report statistics for room mailboxes is quite popular. The script uses Microsoft Graph API requests to fetch data about events from the calendars of the meeting rooms and analyzes the data. Apparently, many people need this data for one reason or another.

As I noted last week, when you publish a PowerShell script and make it available publicly, you’re likely to get requests for enhancements. Most of the time I don’t mind people sharing their ideas with me because I like hearing what others think and the issues they grapple with. Being forced to respond to questions also encourages research to find the right answers, and that’s a good way to acquire more knowledge.

In a minority of cases, I wonder why the person making a request doesn’t simply amend the code to do what they want. It could be that they don’t feel too confident with PowerShell or don’t know how to make a change. Basic familiarity with PowerShell and the modules used with Microsoft 365 is a core competency for administrators. At least, it is if you want to automate administrative operations.

Report Daily Usage Patterns for Room Mailboxes

In any case, this week a request came in to report the most popular days for meetings. Given that we already have the data about meetings and report statistics like the total events for a room, total minutes booked, average event duration, average attendees, and so on, it’s logical to ask when is a meeting room popular.

The information recorded for each meeting has a start and end date, so finding out the day of the week that the meeting occurred on is easily done with the PowerShell Get-Date cmdlet:

$Day = (Get-Date($MeetingStart)).DayOfWeek

Storing the day of the week for each event allows the script to analyze the information when it generates the other statistics. The basic approach I took is:

  • Count the total events for each day.
  • Compute the percentage of the overall events for each day.
  • Build a very basic chart element for the day. The idea is to build a simple bar chart where the larger the bar, the higher the daily room usage is. I’ve no doubt that those with more artistic minds than mine can come up with a much nicer solution.
  • Store the information.

After processing all room mailboxes, the script generates summary information, including the daily usage pattern for all rooms (Figure 1).

Daily usage pattern for room mailboxes included with the other report statistics
Figure 1: Daily usage pattern for room mailboxes included with the other report statistics

The daily usage data is stored for each room mailbox and the script outputs the same kind of chart for the individual rooms (Figure 2).

Figure 2: Daily usage patterns for individual room mailboxes

After I published the updated script, I was asked how the script aligns the bars. That’s simple. The script inserts a tab character when creating the output. That’s another old PowerShell trick. If the tab character wasn’t there, the bar chart wouldn’t line up properly.

Download Script from GitHub – But Check Article Comments

If you have issues running the script (downloadable from GitHub), check out my article about the most common errors people encounter when running PowerShell with Graph queries. Many of these issues are debated and resolved in the comments for the original article. Remember, it’s PowerShell, so the code is there to be amended. Enjoy!


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Cleaning up Teams Premium Trial Licenses

Remove Teams Premium Licenses from Azure AD User Accounts After 30-Day Trial Finishes

Microsoft makes a Teams Premium trial license to allow customers test whether the functionality available in Teams Premium is worth the $10/user/month cost. Some of the features, like meeting templates, might be less obviously worth the money. Others, like the advanced webinar functionality (like having a waitlist for webinar participants) might just be what you need. The trial allows you to try before you buy by testing all the features with up to 25 users for 30 days.

Once the 30-day period finishes, Microsoft automatically terminates the license validity and users lose access to the premium features. Even if you decide to go ahead with Teams Premium, it’s a good idea to clean up by removing the licenses from the user accounts that participated in the trial. This is easily done in the Microsoft 365 admin center by selecting the license, selecting all accounts holding the license and choosing Unassign licenses (Figure 1).

Removing the Teams Premium license from user accounts in the Microsoft 365 admin center

Remove Teams Premium licenses from Azure AD accounts
Figure 1: Removing the Teams Premium license from user accounts in the Microsoft 365 admin center

Remove Teams Premium Licenses with PowerShell

Given that we’re all learning how to manage licenses with the Microsoft Graph because of the imminent retirement of the Azure AD and MSOL modules, it’s good to know how to remove licenses. Let’s examine what’s needed to remove the Teams Premium trial licenses.

First, we must know the SKU identifier for the license. To do this, run the Get-MgSubscribedSku cmdlet and look through the set of licenses known to the tenant to find Teams Premium:

Get-MgSubscribedSku | Format-List SkuId, SkuPartNumber, ServicePlans

SkuId         : 36a0f3b3-adb5-49ea-bf66-762134cf063a

SkuPartNumber : Microsoft_Teams_Premium

ServicePlans  : {MCO_VIRTUAL_APPT, MICROSOFT_ECDN, TEAMSPRO_VIRTUALAPPT, TEAMSPRO_CUST...}

According to the Azure AD list of licenses and identifiers, the SKU identifier for Teams Premium is 989a1621-93bc-4be0-835c-fe30171d6463 rather than the 36a0f3b3-adb5-49ea-bf66-762134cf063a shown here. This is because the first value is for the paid license. The second is for the trial license. Both SKUs have the same part number and display name (which is why the license shown in Figure 1 is called Microsoft Teams Premium). It would be nice if Microsoft added a trial suffix for its trial licenses.

In any case, both SKUs include seven separate service plans. A service plan is a license for a piece of functionality that cannot be bought. Instead, it’s bundled into a product (SKU) like Teams Premium. Service plans allow administrators to selectively disable functionality enabled by a license. For instance, you could disable advanced virtual appointments without affecting the other elements in Teams Premium. Table 1 lists the service plans covered by Teams Premium.

Service plan identifierService plan nameDisplay name
85704d55-2e73-47ee-93b4-4b8ea14db92bMICROSOFT_ECDNMicrosoft Content Delivery Network
0504111f-feb8-4a3c-992a-70280f9a2869TEAMSPRO_MGMTMicrosoft Teams Premium Management
cc8c0802-a325-43df-8cba-995d0c6cb373TEAMSPRO_CUSTMicrosoft Teams Premium Branded Meetings
f8b44f54-18bb-46a3-9658-44ab58712968TEAMSPRO_PROTECTIONMicrosoft Teams Premium Advanced Meeting Protection
9104f592-f2a7-4f77-904c-ca5a5715883fTEAMSPRO_VIRTUALAPPTMicrosoft Teams Premium Virtual Appointment
711413d0-b36e-4cd4-93db-0a50a4ab7ea3MCO_VIRTUAL_APPTMicrosoft Teams Premium Virtual Appointments
78b58230-ec7e-4309-913c-93a45cc4735bTEAMSPRO_WEBINARMicrosoft Teams Premium Webinar
Table 1: Teams Premium service plans

PowerShell Code to Remove Teams Premium Licenses from Azure AD Accounts

Now that we know the SKU identifier, we can run some PowerShell to:

  • Find user accounts with the Teams Premium license. This is done using a lambda filter against the assignedLicenses property of each account.
  • Remove the license from those accounts.

Connect-MgGraph -Scope User.ReadWrite.All
Select-MgProfile Beta
# Populate identifier for target product (SKU)
$TeamsPremiumSku = "36a0f3b3-adb5-49ea-bf66-762134cf063a"
[array]$Users = Get-MgUser -filter "assignedLicenses/any(s:s/skuId eq $TeamsPremiumSku)" -All
If (!($Users)) { Write-Host "No Teams Premium Trial licenses found - exiting" ; break }
Write-Host ("Removing {0} Teams trial licenses from {1}..." -f $Users.count, ($Users.displayName -join ", "))

ForEach($User in $Users) {
  Try {
    $Status = Set-MgUserLicense -UserId $User.Id -RemoveLicenses $TeamsPremiumSku -AddLicenses @{}  }
  Catch {
    Write-Host "Error removing Teams Premium Trial license from {0}" -f $User.displayName }
}

Updated with an appropriate SKU identifier, the code will remove licenses for other Microsoft 365 products.

Remove Teams Premium Licenses to Avoid Confusion

It doesn’t matter if you leave expired licenses in place. They won’t affect how people use Microsoft 365. However, given that the paid-for and trial versions of the Teams Premium licenses have the same display name, it’s best to remove trial licenses to avoid potential future confusion.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

How to Purge Guest Accounts with Unredeemed Invitations from Azure AD

Use PowerShell to Find and Remove Azure AD Guest Accounts Who Don’t Want to Join Your Party

A January 30 post by Microsoft’s Jef Kazimer about using Azure Automation with Managed Identities to remove unredeemed guests from Azure AD promised to be a good read. Jef is a Principal Program Manager in the Microsoft Entra organization. Apart from using Azure Automation (something that every tenant administrator should master), highlighting the Microsoft Graph PowerShell SDK V2.0 (currently in early preview) gave me another reason to read the article.

I have expressed some concerns about Microsoft’s plans for the V2.0 of the Microsoft Graph PowerShell SDK. Leaving those concerns aside, it’s always good to learn how others approach a problem, especially as I’ve recently covered similar ground in terms of how to decide to remove guest accounts from Azure AD using the SDK. The differences between the two methods of reviewing guest accounts is that Jef looks for instances where guest accounts never went through the invitation redemption process to fully validate their accounts. On the other hand, my script looks at how long it’s been since a guest signed into the tenant and the number of groups the account is a member of to determine “staleness.” Let’s consider how to review guest accounts based on unredeemed invitations.

Outlining the Process

On paper, the steps involved to find and remove guest accounts with unredeemed invitations are straightforward:

  • Find guest accounts that have not redeemed the invitations received to join the tenant.
  • Remove the accounts from Azure AD.

Jef’s article suggests that this should be a regular process executed by an Azure Automation job using a managed identity to sign into the Graph and run the necessary PowerShell commands. I agree and think this is a good way to make sure to clear out unwanted guest accounts periodically.

Where I disagree is the detail of how to find the guests and the need for V2.0 of the SDK. It’s possible to do everything outlined in the article with SDK V1.0 cmdlets.

The Need for Administrative Units

Jef uses a dynamic administrative unit (currently a preview feature) to manage guest accounts. While it’s certainly convenient to create a dynamic administrative unit and assign the user management role for the administrative unit to the managed identity, this approach is optional and creates a potential requirement for Azure AD Premium P1 licenses. If your organization has those licenses, using a dynamic administrative unit offers the advantage of reducing the scope for the managed identity to process Azure AD accounts.

In some organizations, using administrative units (both the standard and dynamic variants) could be overkill because user management is a task performed by one or two administrators. In larger organizations, granularity in user management can be a desirable aspect, which is why administrative units exist.

Finding Azure AD Guest Accounts with Unredeemed Invitations

The first step is to find the target set of guest accounts. The simplest way is to run the Get-MgUser cmdlet and filter accounts to look for guests:

Connect-MgGraph -Scope Directory.ReadWrite.All
Select-MgProfile Beta
[array]$Guests = Get-MgUser -Filter "userType eq 'Guest'" -All

The guest accounts we want are those that have the ExternalUserState property set to “PendingAcceptance.” In other words, Azure AD issued an invitation to the guest’s email address, but the guest never followed up to redeem their invitation. This amended call to Get-MgUser fetches the set of guest accounts with unredeemed invitations:

[array]$Guests = Get-MgUser -Filter "userType eq 'Guest' and ExternalUserState eq 'PendingAcceptance'" -All

Jef’s version uses the Get-MsIDUnredeemedInviteUser cmdlet from the MSIdentityTools module to find guest accounts with unredeemed invitations. It’s certainly worth considering using the MSIdentityTools module to manage Azure AD, but it’s also worth understanding how to do a job with the basic tools, which is what I do here.

Determining the Age of an Unredeemed Invitation

It would be unwise to remove any Azure AD guest accounts without giving their owners a little time to respond. Taking vacation periods into account, 45 days seem sufficient time for anyone to make their minds up. The loop to remove unredeemed guest accounts needs to check how long it’s been since Azure AD issued the invitation and only process the accounts that exceed the age threshold.

Our script can check when Azure AD created an invitation by checking the ExternalUserStateChangeDateTime property, which holds a timestamp for the last time the state of the account changed. The only state change for the accounts we’re interested in occurred when Azure AD created the invitations to join the tenant, so we can use the property to measure how long it’s been since a guest received their invitation.

This code shows how to loop through the set of guests with unredeemed invitations, check if their invitation is more than 45 days old, and remove the account that satisfy the test. To keep a record of what it does, the script logs the deletions.

[datetime]$Deadline = (Get-Date).AddDays(-45)
$Report = [System.Collections.Generic.List[Object]]::new()
ForEach ($Guest in $Guests) {
  # Check Date
  [datetime]$InvitationSent = $Guest.ExternalUserStateChangeDateTime
  If ($InvitationSent -le $Deadline) {
     $DateInvitation = Get-Date($InvitationSent) -format g
     $DaysOld = (New-TimeSpan ($InvitationSent)).Days
     Try { 
        Remove-MgUser -UserId $Guest.Id
        $ReportLine = [PSCustomObject][Ordered]@{  
          Date        = Get-Date
          User        = $Guest.displayName
          UPN         = $Guest.UserPrincipalName
          Invited     = $DateInvitation
          "Days old"  = $DaysOld }
        $Report.Add($ReportLine)
      }
      Catch {
        Write-Error $_
      }
   } #End if
} #End Foreach Guest
Write-Host "Guest Accounts removed for" ($Report.User -Join ", ")

Figure 1 shows some data from the report generated for the deletions. In an Azure Automation scenario, you could create a report in SharePoint Online, send email to administrators, or post a message to a Teams channel to advise people about the removed accounts.

Old Azure AD guest accounts with unredeemed invitations
Figure 1: Old Azure AD guest accounts with unredeemed invitations

Caveats Before Removing Azure AD Guest Accounts

The code works and stale guest account disappear to the Azure AD recycle bin. However, the danger exists that some of the accounts might be in active use. Take guest accounts created to represent the email addresses of Teams channels. These email addresses represent a connector to import messages into Teams channels. No one can sign into these non-existent mailboxes so no one  will ever redeem the guest invitations. However, the mail user objects created by Exchange Online for these guest accounts allow them to be included in distribution lists, added to address lists, and so on.

Another example is when a guest joins an Outlook group (a Microsoft 365 group whose membership communicates via email). Guest members of these groups do not need to redeem their invitation unless they intend to sign into the tenant to access Teams or SharePoint Online or another application that supports Azure B2B Collaboration. If you remove these guest accounts based on their invitation redemption status, some important email-based communication might fail, and that would be a bad thing.

One way around the issue is to mark Azure AD guest accounts used for these purposes by writing a value into an appropriate property. For instance, set the department to EMAIL. Here’s how to mark the set of guest accounts used to route email to Teams channels:

[array]$MailGuests = $Guests | Where-Object {$_.Mail -Like "*teams.ms*"}  
ForEach ($MG in $MailGuests) { Update-MgUser -UserId $MG.Id -Department "EMAIL" }

And here’s how to mark the guest members for an Outlook group using cmdlets from the Exchange Online management module:

[array]$Members = Get-UnifiedGroupLinks -Identity 'Exchange Grumpy Alumni' -LinkType Member
ForEach ($Member in $Members) { 
  If ($Member.RecipientType -eq "MailUser")  { Set-User -Identity $Member.Name -Department "EMAIL" -Confirm:$False }
}

After marking some guest accounts as exceptions, we can find the set of guest accounts to process with:

[array]$Guests = Get-MgUser -Filter "userType eq 'Guest'" -All | Where-Object {$_.ExternalUserState -eq "PendingAcceptance" -and $_.Department -ne "EMAIL"}

All of this goes to prove that setting out to automate what appears to be a straightforward administrative task might lead to unforeseen consequences if you don’t think through the different ways applications use the objects.

Using SDK V2.0

Coming back to using V2.0 of the Microsoft Graph PowerShell SDK, nothing done so far needs V2.0. The only mention of a V2.0-specific feature is the support for a managed identity when connecting to the Graph. The code used to connect is:

Connect-MgGraph -Identity

A one-liner is certainly convenient, but it’s possible to connect to a managed identity with the Graph SDK with code that is just a little more complicated. Here’s what I do:

Connect-AzAccount -Identity
$AccessToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com"
Connect-MgGraph -AccessToken $AccessToken.Token

Going from three lines to one is probably not a huge benefit!


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across Office 365. Subscribe to the Office 365 for IT Pros eBook to receive monthly insights into what happens, why it happens, and what new features and capabilities mean for your tenant.

✇Office 365 for IT Pros

Microsoft Retires Azure Automation Run As Accounts in September 2023

Azure Automation for IT Pros

I’ve spent a lot of time working with Azure Automation over the last few years. It’s an extremely useful facility for tenant administrators who want to run PowerShell scripts using a more modern mechanism than offered by Windows Scheduler. This is especially true so in large tenants where processing hundreds or thousands of objects is common, which is why I started to use Run As accounts with Azure Automation.

Converting scripts to run on Azure Automation isn’t too difficult, once you understand the headless nature of the beast and that PowerShell runs on virtual machines spun up for the purpose. The biggest issue often faced when moving scripts from running interactively to being an Azure Automation runbook is how to create output from scripts, but it’s possible to send email, post to Teams channels, and create files in SharePoint document libraries.

Microsoft seems to communicate with developers and administrators (aka IT Pros) in different ways. For instance, the news about the retirement of Azure Automation Run As accounts on September 30, 2023, didn’t appear in any notification in the Microsoft 365 admin center. In fact, apart from the notices posted in Azure Automation documentation (like that shown in Figure 1), I can’t find a formal announcement from Microsoft.

Microsoft notice about the retirement of Run As accounts
Figure 1: Microsoft notice about the retirement of Run As accounts

Informing the Technical Community About the Run As Retirement

The possibility exists that I might not be looking hard enough. Normally, I am reasonably proficient with search (Google), but the first hit I find is a 27 September 2022 Microsoft Answers post saying “On 30 September 2023, we’ll retire the Azure Automation Run As account that you use for Runbook authentication.” I can find an earlier “plan for change” note for July 2022 in the What’s new in Azure Automation page. Apart from that, Microsoft seems to have updated the documentation on 18 October 2022 (here’s the FAQ).

I suppose that it’s reasonable to expect people to learn about developments from documentation. In this instance, I think Microsoft dropped the ball and didn’t do a great job of telling people what’s going to happen when Run As accounts retire.

Managed Identities Are a Better Solution

The logic for retiring Run As accounts is undeniable. A better and more secure solution (managed identities) exists. Run As accounts authenticate using a self-signed certificate that needs to be renewed yearly. Microsoft has removed the ability to renew these certificates from the Azure portal, meaning that Run As accounts are counting down to a time when they won’t be able to authenticate. Microsoft has a script to renew certificates for Run As accounts and the script will run after September 30, 2023. However, Run As accounts will then be unsupported, which isn’t a great situation for production components.

The nice thing about managed identities from an Office 365 perspective is that the important PowerShell modules used for automation support managed identities. Some do so very smoothly (like the latest Exchange Online management module, where even the latest RBAC for applications feature supports managed identities) and some do it with a little extra work. For example, V1.0 of the Microsoft Graph PowerShell SDK needs to get an access token from the Azure Automation account that owns a managed identity while V2.0 will be able to sign in using a managed identity. Here’s an example of a simple runbook that:

  • Connects to the Azure Automation account using a managed identity.
  • Gets an access token from Azure AD.
  • Uses the access token to connect to the Graph with Connect-MgGraph.
  • Retrieves the service domain (like office365itpros.onmicrosoft.com) using the Get-MgOrganization cmdlet.
  • Uses the service domain and a managed identity to connect to Exchange Online.
  • Lists details of user mailboxes.
# Connect to Microsoft Graph with Azure Automation
Connect-AzAccount -Identity
$AccessToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com"
Connect-MgGraph -AccessToken $AccessToken.Token
# Get Tenant service domain using Get-MgOrganization
$TenantName = (Get-MgOrganization).VerifiedDomains | Where-Object {$_.IsInitial -eq $True} | Select-Object -ExpandProperty Name
# Connect to Exchange Online
Connect-ExchangeOnline -ManagedIdentity -Organization $TenantName 
Get-ExoMailbox -RecipientTypeDetails UserMailbox | Format-Table DisplayName, UserPrincipalName

When V2.0 of the Microsoft Graph PowerShell SDK is available, you’ll be able to replace the first three lines of code with a simple Connect-MgGraph -Identity.

Another example of using a managed identity with Exchange Online is to monitor events gathered in the audit log to detect and report events that might indicate potential tenant compromise. Running the script on an Azure Automation schedule makes sure that audit events are checked without human intervention.

Time to Move Forward

Apart from the poor communication, I don’t have any problem with Microsoft’s decision to retire Run As accounts. They worked as a mechanism to connect resources to Azure Automation. We’re just moving on to adopt a new approach. Microsoft documents the migration steps to move from a Run As account to use managed identities. It’s a manual process, but not onerous.

✇Office 365 for IT Pros

Tweaking the Teams and Groups Report Script

PowerShell Tricks Help Get Real Work Done

As dedicated readers of this blog might have noticed, I write quite a few PowerShell scripts. Notice that I use the word “write” instead of “develop.” That’s because my time of professional development lie in the days VAX BASIC and VAX COBOL in the 1980s. Today, I write PowerShell for fun to illustrate concepts and principals. I don’t write code that is bulletproof or a thing of beauty. In fact, I represent the archetype hacker (in its original meaning).

Upgrading Scripts

All of which means that I am constantly discovering techniques when I go looking for solutions to problems. Some would say that I am always on the lookout for new PowerShell tricks, but that stretches the point.

Take the Microsoft 365 Groups and Teams report script (Figure 1). I have worked on many iterations of this script since 2016 (the latest version is 5.10). The code evolved from using Exchange Online cmdlets to interrogate group mailboxes to the current version that’s mostly based on Graph API requests and is much faster. Some people have run the report for over 20,000 groups.

HTML output of the Microsoft 365 Groups and Teams Report script

PowerShell tricks
Figure 1: HTML output of the Microsoft 365 Groups and Teams Report script

In some cases, the changes I make are driven by need. For example, the Get-ExoMailboxFolderStatistics and Get-MailboxFolderStatistics cmdlets from V3.01 of the Exchange Online management module have an annoying habit of returning system objects (arrays) for the date of last received email and the number of items in the inbox folder of group mailboxes. In some cases, the cmdlets report that a group mailbox has two dates for the last received email. This is crazy stuff because the Get-MailboxFolderStatistics cmdlet has been around since 2006 and Microsoft really shouldn’t be screwing it up at this point. The solution isn’t based on any PowerShell tricks. Instead, the script now includes code to handle the erratic behavior of Exchange Online cmdlets.

Outputting Email Addresses on Separate Lines

In other cases, I make changes in response to requests. One recent request was to include the email address of a group owner, which I did in V5.8. Then someone asked if they could have the email addresses of all group owners output in the report. Easy, I said, edit your copy of the script to get the owner display names for each group and join the names together into a string. But that didn’t work because they needed each email address to be on a separate line to import the data into Power BI.

Some search brought me to a May 2006 blog written by Jeffrey Snover, who was then the architect evangelizing PowerShell within Microsoft. Jeffrey subsequently became a Microsoft Fellow before leaving to go to Google in the Fall of 2022, just after he wrote the foreword for the 2023 edition of the Office 365 for IT Pros eBook.

Jeffrey’s blog covered what he called the “Ouptut Field Sperator,” or OFS, defined as a special variable containing the string used as a separator when PowerShell converts an array into a string. By default, OFS is a space, but you can change it. This knowledge and some searching brought me to another blog that explained how to use escape characters in the OFS. New line is an escape character, so presto, a solution appeared.

Take this example where we fetch the owners of a group and store the data in an array:

$GroupId = (Get-MgGroup -Filter "displayname eq 'Ultra Fans'").Id
[array]$GroupData = Get-MgGroupOwner -GroupId $GroupId
[array]$GroupOwners = $GroupData.AdditionalProperties.mail

The email addresses of the group owners are now in the array:

Tony.Redmond@office365itpros.com
Ben.James@Office365itpros.com
Chris.Bishop@office365itpros.com

If I convert the array to a string, the output is a line of names separated by spaces:

[string]$Owners = $GroupOwners
$Owners
Tony.Redmond@office365itpros.com Ben.James@Office365itpros.com Chris.Bishop@office365itpros.com

But if I define the special $OFS variable to be a new line character, I get this:

$OFS="`n`n"
[string]$ContactEmail = $GroupOwners
$ContactEmail
Tony.Redmond@office365itpros.com

Ben.James@Office365itpros.com

Chris.Bishop@office365itpros.com

Problem solved and the output has email addresses on separate lines. In some cases, a carriage return and new line might be better for the output. To do this, set $OFS to “`r`n” (see this post). Either way, being able to change the output space character is a nice example of the kind of PowerShell tricks and techniques that you can find on the internet.

Overcoming Export-CSV Limitations

I often use the Export-CSV cmdlet to export report data from PowerShell to a CSV file where people can open and work on the data using tools like Excel. Recently, a French MVP reported that the Teams and Groups Activity report script worked great, but the CSV output dropped the accented characters used in French (like é) from essential information like group names.

It’s not at all surprising that this should happen. CSV files are comma delimited plain-text ASCII files and by default, the cmdlet generates 7-bit ASCII output (other encoding schemes are available). If you want to more precise control over formatting and extended characters, you need something more sophisticated, which is where the ImportExcel module comes in. Exporting to an XLSX file preserves the formatting and group names appear in all their glory. The lesson learned here is that Export-CSV does a good but limited job. If you work with non-ASCII data, seek another solution.

Time to write some code and discover a few more PowerShell tricks to investigate!

✇Office 365 for IT Pros

Reporting Operating System Versions for Azure AD Registered Devices

Know What Operating System Used by Azure AD Registered Devices

After reading an article about populating extension attributes for Azure AD registered devices, a reader asked me how easy it would be to create a report about the operating systems used for registered devices. Microsoft puts a lot of effort into encouraging customers to upgrade to Windows 11 and it’s a good idea to know what’s the device inventory. Of course, products like Intune have the ability to report this kind of information, but it’s more fun (and often more flexible) when you can extract the information yourself.

As it turns out, reporting the operating systems used by registered devices is very easy because the Microsoft Graph reports this information in the set of properties retrieved by the Get-MgDevice cmdlet from the Microsoft Graph PowerShell SDK.

PowerShell Script to Report Azure AD Registered Devices

The script described below creates a report of all registered devices and sorts the output by the last sign in date. Microsoft calls this property ApproximateLastSignInDateTime. As the name indicates, the property stores the approximate date for the last sign in. Azure AD doesn’t update the property every time someone uses the device to connect. I don’t have a good rule for when property updates occur. It’s enough (and approximate) that the date is somewhat accurate for the purpose of identifying if a device is in use, which is why the script sorts devices by that date.

Any Windows device that hasn’t been used to sign into Azure AD in the last six months is likely not active. This isn’t true for mobile phones because they seem to sign in once and never appear again. The report generated for my tenant still has a record for a Windows Phone which last signed in on 2 December 2015. I think I can conclude that it’s safe to remove this device from my inventory.

Figuring Out Device Owners

In the last script I wrote using the Get-MgDevice cmdlet, I figured out the owner of the device by extracting the user identifier from the PhysicalIds property. While this approach works, it’s complicated. A much better approach is to use the Get-MgDeviceRegisteredOwner cmdlet which returns the user identifier for the Azure AD account of the registered owner. With this identifier, we can retrieve any account property that makes sense, such as the display name, user principal name, department, city, and country. You could easily add other properties that make sense to your organization. See this article for more information about using the Get-MgUser cmdlet to interact with Azure AD user accounts.

The Big Caveat About Operating System Information

The problem that exists in using registered devices to report operating system information is that it’s not accurate. The operating system details noted for a device are accurate at the point of registration but degrade over time. If you want to generate accurate reports, you need to use the Microsoft Graph API for Intune.

With that caveat in mind, here’s the code to report the operating system information that Azure AD stores for registered devices:

Connect-MgGraph -Scope User.Read.All, Directory.Read.All
Select-MgProfile Beta

Write-Host "Finding registered devices"
[array]$Devices = Get-MgDevice -All
If (!($Devices)) { Write-Host "No registered devices found - exiting" ; break }
Write-Host ("Processing details for {0} devices" -f $Devices.count)
$Report = [System.Collections.Generic.List[Object]]::new() 
$i = 0
ForEach ($Device in $Devices) {
  $i++
  Write-Host ("Reporting device {0} ({1}/{2}" -f $Device.DisplayName, $i, $Devices.count)
  $DeviceOwner = $Null
  Try {
    [array]$OwnerIds = Get-MgDeviceRegisteredOwner -DeviceId $Device.Id
    $DeviceOwner = Get-MgUser -UserId $OwnerIds[0].Id }
  Catch {}

  $ReportLine = [PSCustomObject][Ordered]@{
   Device             = $Device.DisplayName
   Id                 = $Device.Id
   LastSignIn         = $Device.ApproximateLastSignInDateTime
   Owner              = $DeviceOwner.DisplayName
   OwnerUPN           = $DeviceOwner.UserPrincipalName
   Department         = $DeviceOwner.Department
   Office             = $DeviceOwner.OfficeLocation
   City               = $DeviceOwner.City
   Country            = $DeviceOwner.Country
   "Operating System" = $Device.OperatingSystem
   "O/S Version"      = $Device.OperatingSystemVersion
   Registered         = $Device.RegistrationDateTime
   "Account Enabled"  = $Device.AccountEnabled
   DeviceId           = $Device.DeviceId
   TrustType          = $Device.TrustType }
  $Report.Add($ReportLine)

} #End Foreach Device

# Sort in order of last signed in date
$Report = $Report | Sort-Object {$_.LastSignIn -as [datetime]} -Descending

$Report | Out-GridView

Figure 1 is an example of the report as viewed through the Out-GridView cmdlet.

Reporting operating system information for Azure AD registered devices
Figure 1: Reporting operating system information for Azure AD registered devices

An Incomplete Help

I’ve no idea whether this script will help anyone. It’s an incomplete answer to a question. However, even an incomplete answer can be useful in the right circumstances. After all, it’s just PowerShell, so use the code as you like.


Learn how to exploit the data available to Microsoft 365 tenant administrators through the Office 365 for IT Pros eBook. We love figuring out how things work.

✇Office 365 for IT Pros

Fetching Group Membership Information for an Azure AD Account

Discover Group Membership with the Graph SDK

Group membership with the Graph SDK

I’ve updated some scripts recently to remove dependencies on the Azure AD and Microsoft Online Services (MSOL) modules, which are due for deprecation on June 30, 2023 (retirement happens at the end of March for the license management cmdlets). In most cases, the natural replacement is cmdlets from the Microsoft Graph PowerShell SDK.

One example is when retrieving the groups an Azure AD user account belongs to. This is an easy task when dealing with the membership of individual groups using cmdlets like:

  • Get-DistributionGroupMember (fetch distribution list members).
  • Get-DynamicDistributionGroupMember (fetch dynamic distribution group members).
  • Get-UnifiedGroupLinks (fetch members of a Microsoft 365 group).
  • Get-MgGroupMember (fetch members of an Azure AD group).

Things are a little more complex when answering a question like “find all the groups that Sean Landy belongs to.” Let’s see how we can answer the request.

The Exchange Online Approach

One method of attacking the problem often found in Exchange scripts is to use the Get-Recipient cmdlet with a filter based on the distinguished name of the mailbox belonging to an account: For example, this code reports a user’s membership of Microsoft 365 groups:

$User = Get-EXOMailbox -Identity Sean.Landy
$DN = $User.DistinguishedName
$Groups = (Get-Recipient -ResultSize Unlimited -RecipientTypeDetails GroupMailbox -Filter "Members -eq '$DN'" )
Write-Host (“User is a member of {0} groups” -f $Groups.count)

The method works if the distinguished name doesn’t include special characters like apostrophes for users with names like Linda O’Shea. In these cases, extra escaping is required to make PowerShell handle the name correctly. This problem will reduce when Microsoft switches the naming mechanism for Exchange Online objects to be based on the object identifier instead of mailbox display name. However, there’s still many objects out there with distinguished names based on display names.

The Graph API Request

As I go through scripts, I check if I can remove cmdlets from other modules to make future maintenance easier. Using Get-Recipient means that a script must connect to the Exchange Online management module, so let’s remove that need by using a Graph API request. Here’s what we can do, using the Invoke-MgGraphRequest cmdlet to run the request:

$UserId = $User.ExternalDirectoryObjectId
$Uri = ("https://graph.microsoft.com/V1.0/users/{0}/memberOf/microsoft.graph.group?`$filter=groupTypes/any(a:a eq 'unified')&`$top=200&$`orderby=displayName&`$count=true" -f $UserId)
[array]$Data = Invoke-MgGraphRequest -Uri $Uri
[array]$Groups = $Data.Value
Write-Host (“User is a member of {0} groups” -f $Groups.count) 

We get the same result (always good) and the Graph request runs about twice as fast as Get-Recipient does.

Because the call is limited to Microsoft 365 groups, I don’t have to worry about transitive membership. If I did, then I’d use the group transitive memberOf API.

Using the SDK Get-MgUserMemberOf Cmdlet

The Microsoft Graph PowerShell SDK contains cmdlets based on Graph requests. The equivalent cmdlet is Get-MgUserMemberOf. This returns memberships of all group types known to Azure AD, so it includes distribution lists and security groups. To return the set of Microsoft 365 groups, apply a filter after retrieving the group information from the Graph.

[array]$Groups = Get-MgUserMemberOf -UserId $UserId -All | Where-Object {$_.AdditionalProperties["groupTypes"] -eq "Unified"}
Write-Host (“User is a member of {0} groups” -f $Groups.count) 

Notice that the filter looks for a specific type of group in a value in the AdditionalProperties property of each group. If you run Get-MgUserMemberOf without any other processing. the cmdlet appears to return a simple list of group identifiers. For example:

$Groups

Id                                   DeletedDateTime
--                                   ---------------
b62b4985-bcc3-42a6-98b6-8205279a0383
64d314bb-ea0c-46de-9044-ae8a61612a6a
87b6079d-ddd4-496f-bff6-28c8d02e9f8e
82ae842d-61a6-4776-b60d-e131e2d5749c

However, the AdditionalProperties property is also available for each group. This property contains a hash table holding other group properties that can be interrogated. For instance, here’s how to find out whether the group supports private or public access:

$Groups[0].AdditionalProperties['visibility']
Private

When looking up a property in the hash table, remember to use the exact form of the key. For instance, this works to find the display name of a group:

$Groups[0].AdditionalProperties['displayName']

But this doesn’t because the uppercase D creates a value not found in the hash table:

$Groups[0].AdditionalProperties['DisplayName']

People starting with the Microsoft Graph PowerShell SDK are often confused when they see just the group identifiers apparently returned by cmdlets like Get-MgUserMemberOf, Get-MgGroup, and Get-MgGroupMember because they don’t see or grasp the importance of the AdditionalProperties property. It literally contains the additional properties for the group excepting the group identifier.

Here’s another example of using information from AdditionalProperties. The details provided for a group don’t include its owners. To fetch the owner information for a group, run the Get-MgGroupOwner cmdlet like this:

$Group = $Groups[15]
[array]$Owners = Get-MgGroupOwner -GroupId $Group.Id | Select-Object -ExpandProperty AdditionalProperties
$OwnersOutput = $Owners.displayName -join ", "
Write-Host (“The owners of the {0} group are {1}” -f $Group.AdditionalProperties[‘displayName’], $OwnersOutput)

If necessary, use the Get-MgGroupTransitiveMember cmdlet to fetch transitive memberships of groups.

The Graph SDK Should be More Intelligent

It would be nice if the Microsoft Graph PowerShell SDK didn’t hide so much valuable information in AdditionalProperties and wasn’t quite so picky about the exact format of property names. Apparently, the SDK cmdlets behave in this manner because it’s how Graph API requests work when they return sets of objects. That assertion might well be true, but it would be nice if the SDK applied some extra intelligence in the way it handles data.


Insight like this doesn’t come easily. You’ve got to know the technology and understand how to look behind the scenes. Benefit from the knowledge and experience of the Office 365 for IT Pros team by subscribing to the best eBook covering Office 365 and the wider Microsoft 365 ecosystem.

✇Office 365 for IT Pros

Upgrading the Microsoft 365 Groups and Teams Membership Report Script

Moving the Microsoft 365 Groups Report Script from Azure AD to the Graph SDK

Two years ago, I wrote a script to report the membership of Microsoft 365 groups and teams. The script processes user accounts to find accounts they are members of and generates detailed and summary reports.

As it turned out, I ended up writing two versions of the script: one using standard PowerShell cmdlets from the Exchange Online PowerShell and Azure AD modules, the other using Graph API requests. The Graph version is faster but some people don’t like Graph-based scripts because of the requirement to register an Azure AD app, consent to permissions, and so on.

Time and technology march on and it’s time to review any script that uses the Azure AD module because of its imminent deprecation in June 2023. Imminent sounds like a strange word to use about something that will happen in five and a half months but time slips away and there’s always something different to be done. I had the time and was already committed to upgrading the script to report “stale” guest accounts, so it seemed like a good idea to plunge into the code and replace the Azure AD and Exchange Online cmdlets with the Microsoft Graph PowerShell SDK.

Scripts to Process Azure AD Accounts and Groups

I’ve come to the view that it’s now best to use the SDK for anything to do with Azure AD accounts and groups. Because the Exchange Online management module contains cmdlets that operate against Microsoft 365 groups, I could have used those cmdlets in the script, but it’s easier when a script uses just the one module.

The two versions of the scripts are available from GitHub:

Changes to Upgrade to the SDK

Among the changes made to upgrade the script were:

  • Connect to the Graph with Connect-MgGraph, setting appropriate permissions and selecting the beta endpoint.
  • Replace the Exchange Get-Organization cmdlet with SDK Get-MgOrganization to fetch tenant name.
  • Replace Get-AzureADUser with Get-MgUser. The filter used with Get-MgUser fetches only licensed accounts (excludes guests and accounts used for room and resource mailboxes). Replacing Get-AzureADUser is one of the more common changes that people will make as they upgrade scripts. See this article for more information.
  • Replace Get-UnifiedGroup with Get-MgTeam to fetch a list of team-enabled groups.
  • Replace Get-Recipient with the Graph MemberOf API to find the set of groups a user is a member of. The Invoke-MgGraphRequest cmdlet runs the Graph query to remove the need to register an app.
  • Use Get-MgGroupOwner to return group owners instead of fetching this information from the ManagedBy property available with Get-UnifiedGroup.
  • Other miscellaneous changes of the type that you find you make when reviewing code.

The code generates the same reports as before (HTML report  – Figure 1 – and two CSV files). All the change is in the plumbing. Nothing is different above water.

HTML version of the Microsoft 365 Groups and Teams report

Microsoft 365 Groups Report
Figure 1: HTML version of the Microsoft 365 Groups and Teams report

Unpredictable Upgrade Effort

It’s hard to estimate how long it will take to upgrade a script to use the Microsoft Graph PowerShell SDK. Factors include:

  • The number of lines of code in the script.
  • The number of Azure AD cmdlets to replace.
  • How easy it is to replace a cmdlet. Microsoft publishes a cmdlet map to guide developers. The caveat is that sometimes the suggested SDK cmdlet generates different output to its Azure AD counterpart, meaning that some additional processing is necessary. Dealing with group members and owners are examples where changes are likely.

One thing’s for sure. The sooner an organization starts to inventory and upgrade its scripts, the sooner the work will be done and the less likely the effort will run into a time crunch when Microsoft deprecates the Azure AD and MSOL modules. Deprecation doesn’t mean that cmdlets necessarily stop working (some will, like the license management cmdlets). Support ceases and no further development of the deprecated modules happen, and that’s not a state you want for operational scripts. Time’s ebbing away…


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across Office 365. Subscribe to the Office 365 for IT Pros eBook to receive monthly insights into what happens, why it happens, and what changes to PowerShell modules mean for your tenant.

✇Office 365 for IT Pros

Reporting Group Membership for Azure AD Guest Accounts with the Microsoft Graph PowerShell SDK

Finding Azure AD Guest Accounts in Microsoft 365 Groups

The article explaining how to report old guest accounts and their membership of Microsoft 365 Groups (and teams) in a tenant is very popular and many people use its accompanying script. The idea is to find guest accounts above a certain age (365 days – configurable in the script) and report the groups these guests are members of. Any old guest accounts that aren’t in any groups are candidates for removal.

The script uses an old technique featuring the distinguished name of guest accounts to scan for group memberships using the Get-Recipient cmdlet. The approach works, but the variation of values that can exist in distinguished names due to the inclusion of characters like apostrophes and vertical lines means that some special processing is needed to make sure that lookups work. Achieving consistency in distinguished names might be one of the reasons for Microsoft’s plan to make Exchange Online mailbox identification more effective.

In any case, time moves on and code degrades. I wanted to investigate how to use the Microsoft Graph PowerShell SDK to replace Get-Recipient. The script already uses the SDK to find Azure AD guest accounts with the Get-MgUser cmdlet.

The Graph Foundation

Graph APIs provide the foundation for all SDK cmdlets. Graph APIs provide the foundation for all SDK cmdlets. The first thing to find is an appropriate API to find group membership. I started off with getMemberGroups. The PowerShell example for the API suggests that the Get-MgDirectoryObjectMemberGroup cmdlet is the one to use. For example:

$UserId = (Get-MgUser -UserId Terry.Hegarty@Office365itpros.com).id 
[array]$Groups = Get-MgDirectoryObjectMemberGroup  -DirectoryObjectId $UserId -SecurityEnabledOnly:$False

The cmdlet works and returns a list of group identifiers that can be used to retrieve information about the groups that the user belongs to. For example:

Get-MgGroup -GroupId $Groups[0] | Format-Table DisplayName, Id, GroupTypes

DisplayName                     Id                                   GroupTypes
-----------                     --                                   ----------
All Tenant Member User Accounts 05ecf033-b39a-422c-8d30-0605965e29da {DynamicMembership, Unified}

However, because Get-MgDirectoryObjectMemberGroup returns a simple list of group identifiers, the developer must do extra work to call Get-MgGroup for each group to retrieve group properties. Not only is this extra work, calling Get-MgGroup repeatedly becomes very inefficient as the number of guests and their membership in groups increase.

Looking Behind the Scenes with Graph X-Ray

The Azure AD admin center (and the Entra admin center) both list the groups that user accounts (tenant and guests) belong to. Performance is snappy and it seemed unlikely that the code used was making multiple calls to retrieve the properties for each group. Many of the sections in these admin centers use Graph API requests to fetch information, and the Graph X-Ray tool reveals those requests. Looking at the output, it’s interesting to see that the admin center uses the beta Graph endpoint with the groups memberOf API (Figure 1).

Using the Graph X-Ray tool to find the Graph API for group membership

Azure AD Guest Accounts
Figure 1: Using the Graph X-Ray tool to find the Graph API for group membership

We can reuse the call used by the Azure AD center to create the query (containing the object identifier for the user account) and run the query using the SDK Invoke-MgGraphRequest cmdlet. One change made to the command is to include a filter to select only Microsoft 365 groups. If you omit the filter, the Graph returns all the groups a user belongs to, including security groups and distribution lists. The group information is in an array that’s in the Value property returned by the Graph request. For convenience, we put the data into a separate array.

$Uri = ("https://graph.microsoft.com/beta/users/{0}/memberOf/microsoft.graph.group?`$filter=groupTypes/any(a:a eq 'unified')&`$top=200&$`orderby=displayName&`$count=true" -f $Guest.Id)
[array]$Data = Invoke-MgGraphRequest -Uri $Uri
[array]$GuestGroups = $Data.Value

Using the Get-MgUserMemberOf Cmdlet

The equivalent SDK cmdlet is Get-MgUserMemberOf. To return the set of groups an account belongs to, the command is:

[array]$Data = Get-MgUserMemberOf -UserId $Guest.Id -All
[array]$GuestGroups = $Data.AdditionalProperties

The format of returned data marks a big difference between the SDK cmdlet and the Graph API request. The cmdlet returns group information in a hash table in the AdditionalProperties array while the Graph API request returns a simple array called Value. To retrieve group properties from the hash table, we must enumerate through its values. For instance, to return the names of the Microsoft 365 groups in the hash table, we do something like this:

[Array]$GroupNames = $Null
ForEach ($Item in $GuestGroups.GetEnumerator() ) {
   If ($Item.groupTypes -eq "unified") { $GroupNames+= $Item.displayName }
}
$GroupNames= $GroupNames -join ", "

SDK cmdlets can be inconsistent in how they return data. It’s just one of the charms of working with cmdlets that are automatically generated from code. Hopefully, Microsoft will do a better job of ironing out inconsistencies when they release V2.0 of the SDK sometime later in 2023.

A Get-MgUserTransitiveMemberOf cmdlet is also available to return the membership of nested groups. We don’t need to do this because we’re only interested in Microsoft 365 groups, which don’t support nesting. The cmdlet works in much the same way:

[array]$TransitiveData = Get-MgUserTransitiveMemberOf -UserId Kim.Akers@office365itpros.com -All

The Script Based on the SDK

Because of the extra complexity in accessing group properties, I decided to use a modified version of the Graph API request from the Azure AD admin center. It’s executed using the Invoke-MgGraphRequest cmdlet, so I think the decision is justified.

When revising the script, I made some other improvements, including adding a basic assessment of whether a guest account is stale or very stale. The assessment is intended to highlight if I should consider removing these accounts because they’re obviously not being used. Figure 2 shows the output of the report.

Report highlighting potentially obsolete guest accounts
Figure 2: Report highlighting potentially obsolete Azure AD guest accounts

You can download a copy of the script from GitHub.

Cleaning up Obsolete Azure AD Guest Accounts

Reporting obsolete Azure AD guest accounts is nice. Cleaning up old junk from Azure AD is even better. The script generates a PowerShell list with details of all guests over a certain age and the groups they belong to. To generate a list of the very stale guest accounts, filter the list:

[array]$DeleteAccounts = $Report | Where-Object {$_.StaleNess -eq "Very Stale"}

To complete the job and remove the obsolete guest accounts, a simple loop to call Remove-MgUser to process each account:

ForEach ($Account in $DeleteAccounts) {
   Write-Host ("Removing guest account for {0} with UPN {1}" -f $Account.Name, $Account.UPN) 
   Remove-MgUser -UserId $Account.Id }

Obsolete or stale guest accounts are not harmful, but their presence slows down processing like PowerShell scripts. For that reason, it’s a good idea to clean out unwanted guests periodically.


Learn about mastering the Microsoft Graph PowerShell SDK and the Microsoft 365 PowerShell modules by subscribing to the Office 365 for IT Pros eBook. Use our experience to understand what’s important and how best to protect your tenant.

✇Office 365 for IT Pros

How to Enable Exchange Online Mailbox Archives Based on Mailbox Size

Automatically Enable Archive Mailboxes Once the Primary Mailbox Exceeds a Threshold

A question following my article about how to transition from Exchange Online mailbox retention policies to Microsoft Purview retention policies asked:

Is there a way in legacy or M365 online archiving policies , that it can be enabled based on primary mailbox data size ,say for example mailbox size crosses 40 gb , it’s online archive gets enabled automatically and older data gets move to online archive to keep primary mailbox at 40 gb limit.”

It’s a reasonable request. Essentially, the organization wants users to keep all email in their primary mailboxes until the mailboxes get to 40GB. Once that point is reached, the organization wants to enable archives for those mailboxes and start to move old email from the primary mailboxes to the archives to keep the size of the primary under 40 GB.

Archive Mailboxes and Sizing

These are proper archive mailboxes and not Outlook’s archive folder. Real archive mailboxes can grow to up to 1.5 TB using the Exchange Online auto-expanding mechanism. Note: if you enable auto-expanding archives, you cannot move those archive mailboxes back to an on-premises Exchange server.

Exchange Online enterprise mailboxes have quotas of between 50 GB and 100 GB based on the license assigned to the account, so the 40 GB threshold is a tad arbitrary. It might be that keeping under this size assures reasonable performance for the OST file. If so, that’s a good thing because you don’t want the OST to become so large that it impacts PC performance.

Assigning Archives Based on Mailbox Size

The outline of the solution is:

  1. Find mailboxes that are not archive-enabled.
  2. Check the mailbox size.
  3. If the mailbox size exceeds the threshold, enable the archive mailbox, and assign an Exchange Online mailbox retention policy to instruct the Mailbox Folder Assistant to move items from the primary to the archive mailbox after they reach a certain age.

Exchange Online mailbox retention policies are the only way to move items into an archive mailbox. Microsoft Purview retention policies can keep or remove items, but they cannot move mailbox items.

To prepare, I created an Exchange Online mailbox retention policy with a single default move to archive tag (Figure 1). The policy can contain other retention tags to handle processing of default folders like the Inbox and Sent Items, or to allow users to mark items for retention. However, all that we need is the default move to archive tag. In this instance, the tag instructs the MFA to move items from the primary to the archive mailbox once they reach 730 days (2 years) old.

Configuring an Exchange Online mailbox retention policy to move items into archive mailboxes
Figure 1: Configuring an Exchange Online mailbox retention policy to move items into archive mailboxes

Now we need some PowerShell to check for and process mailboxes. Here’s the script that I came up with:

# Define archive threshold
$ArchiveThreshold = 40GB

# Find mailboxes without an archive
Write-Host "Looking for mailboxes that are not archive-enabled..."
[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox -Filter {ArchiveState -ne "Local"} -ResultSize Unlimited
If (!($Mbx)) { Write-Host "No mailboxes found without archives - exiting!" ; break }

Write-Host ("Checking {0} mailboxes" -f $Mbx.count); $MbxUpdated = 0
ForEach ($M in $Mbx) {
   
   $Stats = Get-ExoMailboxstatistics -Identity $M.ExternalDirectoryObjectId
   If ($Stats.TotalItemSize.Value -gt $ArchiveThreshold) { # Mailbox size is larger than the threshold
      Write-Host ("Enabling archive for mailbox {0}..." -f $M.UserPrincipalName)
      Enable-Mailbox -Archive -Identity $M.ExternalDirectoryObjectId
      Set-Mailbox -Identity $M.ExternalDirectoryObjectId -RetentionPolicy "Mailbox Two-Year Archive Policy"
      $MbxUpdated++
   } #End if
} #End ForEach Mbx

Write-Host ("All done. {0} mailboxes were processed and {1} were archive-enabled" -f $Mbx.Count, $MbxUpdated)

Wrapping Things Up

To complete the solution, we should arrange for the script to be run periodically to be sure that mailboxes receive archives once they exceed the threshold. The scheduler in Azure Automation is a great way to run scripts like this and the cost to execute scripts is very reasonable. V3.0 of the Exchange Online management module introduced support for Azure Automation managed identities so there’s no danger of compromise due to leaked credentials. Which is exactly how it should be.


Learn about exploiting Exchange Online and the rest of Office 365 by subscribing to the Office 365 for IT Pros eBook. Use our experience to understand what’s important and how best to protect your tenant.

❌