PowerShell – Office 365 for IT Pros https://office365itpros.com Mastering Office 365 and Microsoft 365 Wed, 25 Jun 2025 15:38:51 +0000 en-US hourly 1 https://i0.wp.com/office365itpros.com/wp-content/uploads/2024/06/cropped-Office-365-for-IT-Pros-2025-Edition-500-px.jpg?fit=32%2C32&ssl=1 PowerShell – Office 365 for IT Pros https://office365itpros.com 32 32 150103932 Token Protection Extends to Microsoft Graph PowerShell SDK Sessions https://office365itpros.com/2025/06/26/token-protection-graph-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=token-protection-graph-sdk https://office365itpros.com/2025/06/26/token-protection-graph-sdk/#respond Thu, 26 Jun 2025 07:00:00 +0000 https://office365itpros.com/?p=69782

Token Protection, PRTs, Device Binding, and Session Keys

Last year, I discussed how to use a conditional access policy to apply a new session control called token protection. The idea is to protect against token theft by requiring connections to have a token (the Primary Refresh Token, or PRT) that has a “cryptographically secure tie” with the device that the connection originates from. The PRT is “bound” to a device key that’s securely stored in the device’s Trusted Platform Module (TPM). PRTs are supported on Windows 10 or later devices.

The PRT is an “opaque blob” that’s specific to a user account and device. The Entra ID authentication service issues a PRT following a successful connection by a user when the device is registered, joined, or hybrid joined. Entra ID also issues a session key, an encrypted symmetric key to serve as proof of possession when a PRT attempts to obtain tokens for applications. If an attacker attempts to hijack a connection with an access token they’ve stolen, they’ll fail because they don’t have access to the device key.

Why Does This Matter?

As noted in my article last year, it’s possible to create a conditional access policy with a session control requiring token protection. In other words, when a connection attempts to satisfy the conditions of the policy, it must be able to prove that its PRT is bound to the device where the connection originates and the user making the request. This process is managed by a component called Web Account Manager (WAM).

But conditional access policies can only work if everything involved in the connection understand what’s going on. At the time I wrote the last article, limited support existed for token protection. The reason for this article is that interactive Microsoft Graph PowerShell SDK sessions now support token protection (see details about support for token protection by other applications here). This opens the possibility of extending additional protection for administrators and developers who might work on sensitive data through the Graph SDK.

The reason why you might want to do this is revealed in a recent Entra ID change that shows the resources a user can access when they satisfy a conditional access policy to connect. In this case, the connection is to an interactive Graph PowerShell SDK session, and the resources available in that session depends on the delegated permissions held by the Microsoft Graph Command Line Tools service principal. The set of permissions tends to swell over time as administrators grant consent to permissions needed to work with different cmdlets, but as Figure 1 shows, a Graph PowerShell SDK session can have access to many different resources.

Conditional access policy signin reveals the Resources accessible to the Microsoft Graph PowerShell SDK.
Figure 1: Resources accessible to the Microsoft Graph PowerShell SDK

Enabling Token Protection for Graph Interactive Sessions

Normally, interactive Graph PowerShell SDK sessions don’t use WAM. To enable WAM for Graph sessions, run the Set-MgGraphOption cmdlet before running Connect-MgGraph. As the documentation says, the cmdlet sets global configuration options, so the configuration setting stays in force for all Microsoft Graph interactive sessions on the workstation until it is reversed.

Set-MgGraphOption –EnableLoginByWAM $true
Connect-MgGraph

If the device isn’t registered or joined, the conditional access policy condition for token protection isn’t satisfied and the sign-in attempt is rejected with a 530084 error code. The cause is obvious if you examine the policy details captured in the sign-in event (Figure 2).

The token protection session  control for a conditional access policy rejects a connection attempt.
Figure 2: The token protection session control rejects a connection attempt

WAM doesn’t affect app-only authentication for the Graph SDK, including Azure Automation runbooks that use modules and cmdlets from the Graph PowerShell SDK.

Token Protection and Elevated PowerShell Sessions

The Web Account Manager option doesn’t work in elevated PowerShell sessions (run as administrator). Attempts to connect fail with the error “InteractiveBrowserCredential authentication failed: User canceled authentication.

The solution is two-fold. First, revert to normal authentication on the workstation by running the Set-MgGraphOption cmdlet to set EnableLoginByWAM to $false. If you don’t, authentication fails because a protected token isn’t available (Figure 3). The second step is to remove users who need to run Graph cmdlets in elevated PowerShell sessions from the scope of the conditional access policy. This avoids the user running into problems on other workstations.

Failure to connect because a conditional access policy condition requires a protected token that isn’t available.
Figure 3: Failure to connect because a conditional access policy condition requires a protected token that isn’t available

Token Protection and Microsoft Graph PowerShell SDK Versions

The WAM option also doesn’t work with the latest versions of the Microsoft Graph PowerShell SDK. This is likely due to Microsoft’s decision to remove support for .NET6 from V2.25 on. In V2.28 of the SDK, the error when running Connect-MgGraph is:

InteractiveBrowserCredential authentication failed: Could not load type 'Microsoft.Identity.Client.AuthScheme.TokenType' from assembly 'Microsoft.Identity.Client, Version=4.67.2.0, Culture=neutral, PublicKeyToken=0a613f4dd989e8ae'.

While Microsoft gets their act together and decides how to fix the issue, the only option is to remain using V2.25. PCs that have upgraded to the current V2.28 release must downgrade to V2.25.

Token Protection is Just Another Tool

Token protection is not for everyone. Its linkup with conditional access policies is another tool for administrators to consider when figuring out how to secure their tenant. My recommendation is that you test the feature and make a measured decision whether it has any value for your organization. Remember that this is an evolving space and other applications are likely to support token protection over time. Maybe one of those applications will be exactly the one you want to secure.


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.

]]>
https://office365itpros.com/2025/06/26/token-protection-graph-sdk/feed/ 0 69782
Microsoft 365 PowerShell Modules Need Better Testing https://office365itpros.com/2025/06/25/microsoft-365-powershell-azure/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-powershell-azure https://office365itpros.com/2025/06/25/microsoft-365-powershell-azure/#respond Wed, 25 Jun 2025 07:00:00 +0000 https://office365itpros.com/?p=69757

Problems with Azure Automation Afflict Microsoft 365 PowerShell Modules

The recent problems with the Microsoft Graph PowerShell SDK are well documented. Suffice to say that the Graph PowerShell SDK hasn’t been very stable since V2.25. V2.26 and V2.27 just didn’t work, and although Microsoft delivered a much-improved update in V2.28 in May 2025, the Graph PowerShell SDK still has problems with Azure Automation.

In the Azure Automation environment, runbooks are configured to use a runtime version of PowerShell. When a runbook starts, Azure Automation loads the dependent modules (which must be a version that matches the runtime version) on the target server where the runbook executes. Currently, Azure Automation supports runtime versions for PowerShell V5.1, V7.1, and V7.2.

A Question of .NET

PowerShell V5.1 is the “classic” version. V7-based PowerShell is “PowerShell Core.” The V7.1 and V7.2 runtimes support .NET 6 while the latest versions of PowerShell use .NET 8. Software engineering groups don’t like supporting what they consider to be outdated software, so a decision was taken to drop support for .NET 6. The net effect was that V7.1 and V7.2 runbooks couldn’t use the Graph PowerShell SDK. The workaround was to use the PowerShell V5.1 runtime or revert to V2.25 of the Graph PowerShell SDK, which still supports .NET6.

Microsoft says that the solution will come when Azure Automation supports the PowerShell V7.4 runtime. That update was supposed to arrive by June 15, 2025. It’s late, so I cannot confirm or deny if Graph PowerShell SDK V2.28 code supports PowerShell V7.4 runbooks.

The .NET Versioning Problem Strikes Exchange

A week or so ago, a reader complained that the latest version of the Exchange Online management module (now V3.8.0) didn’t run with PowerShell V7.2 runbooks. A previous comment for the article where the issue was raised said that V3.5 was required to support PowerShell V7.2 runbooks as long ago as February 13, 2025. At the time, apart from finding a relevant Stack Overflow discussion, I didn’t pay too much attention to the problem. I guess I became accustomed to the Exchange module just working while the Graph PowerShell SDK was the problem child of the Microsoft 365 PowerShell modules.

As it turns out, the Exchange Online management module shares the same problem as the Microsoft Graph PowerShell SDK. Engineering decided to remove support for .NET 6 in V3.5.1 of the Exchange module and screwed up Azure Automation V7 runbooks. The release notes for V3.5.1 are brief and concise:

Version 3.5.1

  • Bug fixes in Get-EXOMailboxPermission and Get-EXOMailbox.
  • The module has been upgraded to run on .NET 8, replacing the previous version based on .NET 6.
  • Enhancements in Add-VivaModuleFeaturePolicy.

There’s nothing to raise awareness for tenant administrators that the change in supported .NET version will stop runbooks dead in the water. It’s easy to glance over the release notes and conclude that not much has changed and it’s therefore safe to upgrade to the new version. The problem becomes very evident when the Connect-ExchangeOnline cmdlet can’t run and as a result, every other Exchange cmdlet cannot be found (Figure 1).

An Exchange Online management runbook barfs when run by Azure Automation.

Microsoft 365 PowerShell.
Figure 1: An Exchange Online management runbook barfs when run by Azure Automation

The Need for Solid Azure Automation Support

No one denies that Microsoft must prune old software from their cloud services. It’s hard enough to keep a service running smoothly when it carries unnecessary baggage in the form of old code. But in the cases of both the Microsoft Graph PowerShell SDK and the Exchange Online Management module, it seems like the engineering groups never stopped to ask if the change might impact the ability of scripts to run. Running scripts interactively revealed no issues, but running code in an interactive session on a Windows PC (or even a Mac) is not the same as Azure Automation firing up a headless Linux server and configuring it with the software necessary to execute a runbook.

Ensuring that shipped modules support Azure Automation is a problem that can be solved by incorporating Azure Automation runbooks in the test procedures that must succeed before a new version of a module can be released. What’s more upsetting is the lack of awareness within Microsoft about why customers pay for Azure Automation to run scripts.

When a script moves from running interactively on an administrator workstation to become an Azure Automation runbook, it’s probably because the script is deemed to be important enough to run on a stable, robust, and secure environment, often on a schedule (the Windows Task Schedule should not be relied upon to run important scripts). In other words, Azure Automation is an important platform that deserves the respect and solid support of the Microsoft engineers that build PowerShell modules that can run within Azure Automation. That doesn’t seem to be the case today.

Too Much Disruption

Microsoft 365 tenants have suffered far too much disruption with PowerShell modules over the last few years. The retirement of the old Azure AD and MSOL modules was a necessary evil, but Microsoft didn’t handle the situation as well as they should. Many sins might be forgiven if the Microsoft 365 PowerShell modules were rock solid. They’re not currently. Let’s hope that Microsoft does a better job in their testing and pre-release verification processes for PowerShell modules in the future.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/06/25/microsoft-365-powershell-azure/feed/ 0 69757
Updating the Entra ID Custom Banned Password List with PowerShell https://office365itpros.com/2025/06/19/custom-banned-password-list/?utm_source=rss&utm_medium=rss&utm_campaign=custom-banned-password-list https://office365itpros.com/2025/06/19/custom-banned-password-list/#respond Thu, 19 Jun 2025 07:00:00 +0000 https://office365itpros.com/?p=69687

Use Microsoft Graph PowerShell SDK Cmdlets to Maintain the Entra ID Custom Banned Password List

Vasil Michev is busy these days. Apart from his day job, he’s doing the technical reviews for the Office 365 for IT Pros (2026 edition) and Automating Microsoft 365 with PowerShell (2nd edition) eBooks, both due for release on July 1, 2025. Technical editing is an important part of our publication process because it’s an annual end-to-end review of all content to help authors refine their chapters, eliminate old and unnecessary text, and consider what they should be covering.

And still Vasil finds time for his own writing, such as a recent article about using the Microsoft Graph PowerShell SDK to update the banned password list for Entra ID accounts. Given that the Graph PowerShell SDK is a major topic for Automating Microsoft with PowerShell, my attention was immediately drawn to the article to understand what it described and consider it for inclusion in the book. It is now, along with 350+ pages of other PowerShell content about automating different aspects of Microsoft 365 activities.

Global Banned Password List

The Entra ID password protection feature maintains a global list of banned passwords. Microsoft maintains the list and updates it on an ongoing basis from telemetry for Entra ID authentication. All attempts to change account passwords are checked against the global banned list to make sure that the new password is reasonably strong. In other words, it’s not something like “Mypassword” or “Cats.” Tenant administrators cannot affect how Entra ID uses the global list of banned passwords, nor can they add or remove values from the list. It’s just part of how Entra ID works, and this part of password protection is included in the version of Entra ID included with all Microsoft 365 tenants.

Custom Banned Password List

If a tenant has Entra P1 or P2 licenses, they can implement a custom banned password list. The custom list supplements the global banned password list. The custom list is limited to 1,000 entries, but those entries are “key base terms” of between 4 and 16 characters. In other words, Entra ID blocks variations and combinations of the terms in the custom banned password list.

When a custom banned password list is available, Entra ID combines its entries with the global banned password list. The idea is that tenants might want to stop people using organization-specific terms like the names of locations or buildings in passwords because these terms might be easy for attackers to guess in a spray attack. Of course, you shouldn’t be depending on passwords and should deploy multifactor authentication to protect accounts, but it’s worthwhile protecting passwords as much as possible.

Blocking Passwords

Figure 1 shows some of the entries in the custom banned password list as viewed through the Entra admin center. You can see that the last entry is for “VictorMeldrew.” This is a key base term for password checking.

The custom banned password list in the Entra admin center.
Figure 1: The custom banned password list in the Entra admin center

In Figure 2, an administrator has attempted to change an account password through the Microsoft 365 admin center. The password looks strong, but Entra ID rejects it because it includes a key base term. Telling the administrator that the password is easily guessable is just the way Microsoft chose to say: “can’t use that password.”

The custom banned password list stops a password based on a key base term.
Figure 2: : The custom banned password list stops a password based on a key base term

Updating the Custom Banned Password List with a Script

Vasil’s article covers the basics of creating a directory settings object to hold password protection settings, including the custom banned password list. I used that information to create a script that’s more like something you might use as production code, which you can download from GitHub.

The code:

  • Checks if the correct permission (Directory.ReadWrite.All) is available to read, create, and update directory settings. This is a very high-level permission that should be restricted as tightly as possible. You should also monitor the apps that hold this permission to make sure that they are used correctly.
  • Import a list of key base terms from a CSV file and checks that each term is at least 4 and no more than 16 characters long.
  • Uses the Get-MgBetaDirectorySetting cmdlet to check if a directory setting object for password protection is defined in the tenant. If not, the script runs the New-MgBetaDirectorySetting cmdlet to create and populate a new directory setting object with the list of key base terms (and other default values). The directory setting object is derived from the directory settings template for password rules. The template always has an identifier of 5cf42378-d67d-4f36-ba46-e8b86229381d.
  • If a directory setting object for password protection is available, fetch the list of current key base terms and combine it with the new list to generate a combined list. The Update-MgBetaDirectorySetting cmdlet then updates the directory setting object with the combined list.
  • Export the newly-updated list to a CSV file.

If you prefer to use the input CSV file as the definitive set of key base terms and not combine the input set with the current set, it’s easy to comment out the two lines that create a combined list.

The only semi-weird thing about the list of key base terms is that it uses tabs for delimitation (which is why the code splits the list using [char]9).

Hopefully the script is of some use. If not, I won’t be offended. Check out the 320-plus scripts in the Office365Itpros GitHub repository. You might find something more useful there!


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/06/19/custom-banned-password-list/feed/ 0 69687
When the Invoke-MgGraphRequest Cmdlet Needs Help to Fetch Responses https://office365itpros.com/2025/06/12/invoke-mggraphrequest-responses/?utm_source=rss&utm_medium=rss&utm_campaign=invoke-mggraphrequest-responses https://office365itpros.com/2025/06/12/invoke-mggraphrequest-responses/#comments Thu, 12 Jun 2025 07:00:00 +0000 https://office365itpros.com/?p=69514

Running Graph API Requests and Checking the Response

Whenever I need to run a Graph API request where a Microsoft Graph PowerShell SDK cmdlet isn’t available (or doesn’t work as expected), my normal go-to solution is the Invoke-MgGraphRequest cmdlet. The cmdlet works well and is extremely useful when testing a new API because it uses the authenticated connection established by the Connect-MgGraph cmdlet. In other words, you don’t need to obtain an access token to run requests because the cmdlet uses the token held by the session, including the scopes (permissions) detailed in the token.

The Graph Explorer App and Its Permissions

However, sometimes the Invoke-MgGraphRequest cmdlet comes up short and a different tool is needed for testing. That’s where the Graph Explorer can help. Like the Microsoft Graph PowerShell SDK, the Graph Explorer is implemented as an enterprise Entra ID app (appid de8bc8b5-d9f9-48b1-a8ad-b748da725064). And like the Microsoft Graph PowerShell Command Line tools app, the Graph Explorer app accumulates a set of delegated permissions over time as consent for permissions is granted to allow requests to run (that’s why the Graph Explorer UI includes a prominent Modify Permissions button).

The permissions allow signed-in users to run Graph API requests and access data available to them. Sometimes too many permissions can get in the way of testing, so it’s a good idea to review the permissions and remove any that seem not to be necessary.

Running an eDiscovery Purge Job

In this case, I was experimenting with the eDiscovery method to purge mailbox data based on a search. This is not the compliance search purge action. It’s an action to purge data found by eDiscovery premium searches. The Clear-MgSecurityCaseEdiscoveryCaseSearchData SDK cmdlet runs purge requests, using a command this:

Clear-MgSecurityCaseEdiscoveryCaseSearchData -EdiscoveryCaseId $Case.Id -EdiscoverySearchId $Search.Id -BodyParameter $PurgeParameters

The problem is that the cmdlet only reports failures (like a malformed payload). It doesn’t report the successful submission of the background purge job it creates, nor does it report the progress and eventual result of the purge job. Submission is like lobbing a stone into a deep black pit.

At first glance, using Invoke-MgGraphRequest doesn’t do any better. Once again, nothing happens, and no insight is available into the progress of the purge job.

$Uri = ("https://graph.microsoft.com/v1.0/security/cases/ediscoveryCases/{0}/searches/{1}/purgeData" -f $Case.Id, $Search.Id)
Invoke-MgGraphRequest -Uri $Uri -Body $PurgeParameters -Method POST

The documentation for the API says that a successful submission returns a 202 Accepted response code, and a response header containing the location of the Purge data operation created to commit the purge. The question is how to see that information.

Graph Explorer Reveals Responses

The Graph Explorer is designed to be a training and debugging tool. As you can see in Figure 1, it displays the 202 Accepted response, and it shows the response header. To see what’s happening with the purge job, copy the location URL and run a GET request against it (in Graph Explorer or using Invoke-MgGraphRequest) and you’ll see details such as the current progress of the purge job.

Graph Explorer reveals response headers for a Graph API request.

Invoke-MgGraphRequest
Figure 1: Graph Explorer reveals response headers for a Graph API request

Invoke-MgGraphRequest Comes Through in the End

You can’t run Graph Explorer in a script. Although the app is great for testing, it can’t work in a production environment. All of which brought me back to the Invoke-MgGraphRequest documentation, where I discovered the ResponseHeadersVariable parameter, which outputs response headers to a variable if generated by an API request. We can’t see the 202 response for a job submission, but we can fetch details of the purge job by extracting the URI from the variable and using it to query the job status. Apparently, the purge succeeded!

Invoke-MgGraphRequest -Uri $Uri -Body $PurgeParameters -Method POST -ResponseHeadersVariable Response

[string]$ResponseLocationURI = $Response.Location
$ResponseURI = [system.uri]$ResponseLocationURI

$ResponseData = Invoke-MgGraphRequest -Uri $ResponseURI -Method Get

Name                           Value
----                           -----
createdDateTime                05/06/2025 13:44:58
id                             f580a3b0c72b4c849912520e04bc39e7
percentProgress                100
@odata.context                 https://graph.microsoft.com/v1.0/$metadata#security/cases/ediscoveryCases('7fc26cf0-bc8d-421c-8ad1-bea9782f564c')/operations/$entity
action                         purgeData
status                         succeeded
@odata.type                    #microsoft.graph.security.ediscoveryPurgeDataOperation
completedDateTime              05/06/2025 13:47:23
createdBy                      {[user, System.Collections.Hashtable], [application, ]}

The learnings here are that the Graph Explorer is a very useful debugging tool and that you should check every cmdlet parameter, even for cmdlets that have become second nature.

]]>
https://office365itpros.com/2025/06/12/invoke-mggraphrequest-responses/feed/ 2 69514
How to List Hidden Group Memberships with the Graph https://office365itpros.com/2025/05/29/hidden-group-memberships/?utm_source=rss&utm_medium=rss&utm_campaign=hidden-group-memberships https://office365itpros.com/2025/05/29/hidden-group-memberships/#comments Thu, 29 May 2025 07:00:00 +0000 https://office365itpros.com/?p=69343

Administrative Interfaces Can List Hidden Group Memberships, but Graph-Based Apps Need Extra Permission

A user of the Microsoft 365 Groups and Teams activity report script (which I should do some work on to upgrade some really old code) pointed out that they weren’t getting details of groups with hidden membership. I’ve written about groups with hidden membership before and observed that administrative interfaces like the Microsoft 365 admin center or Entra admin center have access to hidden membership (Figure 1) where user-facing clients like Outlook block access to hidden group memberships.

The Entra admin center reveals the hidden membership of a Microsoft 365 group .

Hidden group memberships.
Figure 1: The Entra admin center reveals the hidden membership of a Microsoft 365 group

PowerShell modules built for administrative use also count is administrative interfaces, so cmdlets like Get-UnifiedGroupLinks from the Exchange Online management module report hidden memberships as happily as they list open memberships. Modules like Exchange Online assume that anyone running their cmdlets is an administrator, so they extend the same access to data that the administrator enjoys through an admin portal.

Listing Hidden Group Memberships is Different with the Graph

The Graph API is different. Working on a least permission model, the Graph makes no assumptions about permissions when a session starts and the only access to data available during the session is via granted permissions. The permissions can be delegated (access to data available to the signed-in user) or application (available to tenant data). Delegated permissions are used for interactive sessions. Application permissions are used by apps (which can run in interactive sessions).

When the problem was first reported, I did a quick check but couldn’t find anything wrong. But I screwed up by running commands in an interactive session signed in with an account that held the Exchange administrator role. Interactive sessions use delegated permissions, but they also respect any of the administrative roles assigned to the account, so while the Get-MgGroupMember cmdlet would normally stop me seeing the members of a group with hidden membership, the Exchange administrator role removed the block and made the membership visible.

This experience proves once again that any testing for a Graph access scenario should be done with application permissions. In other words:

  • Create an app (registration).
  • Assign the app the lowest possible level of permissions you think are needed to get the job done.
  • Test.
  • Refine as necessary using different permissions until the test is successful.

Fetching Hidden Group Membership

In this scenario, I started off with an app with consent to use the Group.Read.All and User.Read.All permissions. The former is needed to read group details; the latter to retrieve member information (user accounts). I then disconnected my current interactive Microsoft Graph PowerShell SDK session and signed in with the app, using the thumbprint of an X.509 certificate uploaded to the app to authenticate. Running Get-MgContext confirmed the available permissions (scopes):

Connect-MgGraph -Tenantid $TenantId -AppId $AppId -CertificateThumbprint $Thumbprint
(Get-MgContext).Scopes | Format-List
User.Read.All
Group.Read.All

Now attempt to read the membership of a group with hidden membership. The Get-MgGroupMember cmdlet returns nothing, but we know why because the visibility property of the group is set to HiddenMembership. A group might have no members, but if its visibility property is set to HiddenMembership, there might be data to retrieve,

Get-MgGroupMember -GroupId $GroupId

Get-MgGroup -GroupId $GroupId | Select-Object Visibility

Visibility
----------
HiddenMembership

The Visibility property is most often used to note whether a group has private or public membership. Unfortunately, it’s not a filterable property for the Graph, so to find the groups with hidden membership, you do something like this:

[array]$Groups = Get-MgGroup -All -PageSize 500
$Groups | Where-Object {$_.Visibility -eq 'HiddenMembership' } | Format-Table DisplayName, Id, Visibility

To find details of the hidden membership, grant consent for the app to use the Member.Read.Hidden permission. Disconnect and reconnect using the app and make sure that Member.Read.Hidden is available. Now run Get-MgGroupMember again:

[array]$Members = Get-MgGroupMember -GroupId $GroupId

Id                                   DeletedDateTime
--                                   ---------------
eff4cd58-1bb8-4899-94de-795f656b4a18
cad05ccf-a359-4ac7-89e0-1e33bf37579e
08dda855-5dc3-4fdc-8458-cbc494a5a774
75ba0efb-aed5-4c0b-a5de-be5b65187c08
4daa5f06-55eb-4d79-9a24-1be369919fec
59e09287-ac1b-4ff7-80a3-08d0d1eed939

Or to see the display names of the members:

$Members.additionalProperties.displayName
Tony Redmond
James Ryan
Sean Landy
Terry Hegarty
Otto Flick
Hans Geering (Project Management)

If an app has a higher-level permission, such as Directory.Read.All, it can also read hidden membership. The same permission governs access to hidden memberships of Entra ID groups and administrative units.

The Takeaway about Graph Permissions

The takeaway here is not to assume anything about Graph permissions. The ability of the Microsoft Graph Command Line Tools app (used for Microsoft Graph PowerShell SDK interactive sessions) to accrue delegated permissions over time is both a benefit and a problem. When you can, use app-only mode to validate exactly what permissions are required to run your code.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/05/29/hidden-group-memberships/feed/ 2 69343
June 2025 Update for the Automating Microsoft 365 with PowerShell eBook https://office365itpros.com/2025/05/23/microsoft-365-powershell-12/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-powershell-12 https://office365itpros.com/2025/05/23/microsoft-365-powershell-12/#respond Fri, 23 May 2025 07:00:00 +0000 https://office365itpros.com/?p=69337

Update #12 Available to Help People Figure Out Microsoft 365 PowerShell

Automating Microsoft 365 with PowerShell.

Microsoft 365 PowerShell

As is our norm, we have released the monthly update for the Automating Microsoft 365 with PowerShell eBook some days before the end of the month to allow us to concentrate on working on the Office 365 for IT Pros eBook. The current version number is 12.2 and the updated PDF and EPUB files are available for subscribers to download from Gumroad.com. Please use the link in your receipt (which always fetches the latest files) or go to your Gumroad account, See our FAQ for more information about downloading book updates.

The Automating Microsoft 365 with PowerShell eBook is available separately and as part of the Office 365 for IT Pros eBook bundle. The same update is available to all subscribers.

We also have a paperback version of the book available from Amazon.com. This version is proving to be more popular than we anticipated. I guess some people still like the tactile experience of reading a real book, and we are happy to oblige. Regretfully, we cannot provide monthly updates to the paperback edition as there’s no way to paste (literally) updated text into paper copies.

Focus Areas for Update #12

Most of the work in Update #12 focused on adding extra detail to the sections covering retrieving calendar information, messages, group-based license assignments, and sensitivity labels. Like always, a bunch of other changes were made to clarify thoughts or correct possible misinterpretations.

It’s the nature of a book like this that developments in Microsoft’s tools affect our content, so some Graph API requests that were used because of problems with Microsoft Graph PowerShell SDK cmdlets are now replaced by cmdlets following the release of V2.28 of the SDK on May 10, 2025.

Should I Upgrade to V2.28 of the Graph PowerShell SDK?

So far, the experience with V2.28 is positive. However, this isn’t a massive endorsement because the previous versions were so buggy and poorly tested prior to release. I think it’s safe to say that V2.28 is at least as good as V2.25, which was the last good release.

This does not mean that V2.28 is bug free. I think it would be impossible to release even a 99% bug-free Graph PowerShell SDK. The number of dependencies on many different product groups, the complex interactions with other PowerShell modules and products like Azure Automation, and the errors and omissions in the Open API documents that describe the different Graph APIs all create the potential for problems like missing parameters or failure to process parameters properly. Throw in some Entra ID authentication problems, like the current bug that sometimes requires double authentication after running the Connect-MgGraph cmdlet to create an interactive session, and it’s easy to understand why there’s over 160 reported issues for the SDK.

Bugs are a fact of IT life, and the presence of some known bugs is no reason to avoid using the Graph PowerShell SDK. In fact, the SDK is more popular now than ever before because of the retirement of the AzureAD and MSOL modules (some people still ask why they can’t run Connect-MSOLService or Connect-AzureAD like they used to…). It does mean that you should:

  • Pay attention to the known bugs reported to Microsoft.
  • Report any bugs that you find that aren’t on the known issues list.
  • Be prepared to use the underlying Graph API if a Graph PowerShell SDK cmdlet doesn’t work as expected (alternatively, if a parameter doesn’t work, try passing values in a hash table using the BodyParameter parameter).

Overall, I think it’s safe to upgrade to V2.28. Remember to upgrade modules used as resources by Azure Automation accounts too.

On to Update #13

Work has now started on update #13, which is planned for July 1. This version of the book will be part of Office 365 for IT Pros (2026 edition), which we plan to release on the same day. Happy coding!

]]>
https://office365itpros.com/2025/05/23/microsoft-365-powershell-12/feed/ 0 69337
Microsoft Graph PowerShell SDK V2.28 Attempts to Restore Stability https://office365itpros.com/2025/05/15/microsoft-graph-powershell-sdk-228/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-228 https://office365itpros.com/2025/05/15/microsoft-graph-powershell-sdk-228/#comments Thu, 15 May 2025 07:00:00 +0000 https://office365itpros.com/?p=69204

One Step Forward, Six Steps Back for Flawed Releases

Literally millions of people download and use the Microsoft Graph PowerShell SDK. With the retirement of the older Azure AD and MSOL modules, an obvious spike in the number of downloads occurred, all of which meant that the SDK is now a critical automation component for many Microsoft 365 tenants.

On May 10, 2025, Microsoft released V2.28 of the Microsoft Graph PowerShell SDK to the PowerShell Gallery (Figure 1). This release follows a catalog of woe since the release of V2.26 of the Graph PowerShell SDK on February 25, 2025. In an attempt to stem a cascade of bugs, Microsoft followed up by releasing V2.26.1, and V2.27 in April. It was all to no avail. In a case of one step forward, six steps back, V2.27 addressed a problem with Azure Automation but introduced the disappearing payload issue.

Microsoft Graph PowerShell SDK V2.28 in the PowerShell Gallery.
Figure 1: Version 2.28 of the Microsoft Graph PowerShell SDK in the PowerShell gallery

Disappearing Payloads

Graph API requests to create or update objects like users, groups, and policies usually include a JSON-formatted payload containing parameter values or instructions. Graph SDK cmdlets also use payloads, usually formatted as hash tables, that are passed to the underlying Graph API requests when the cmdlets run. You can see the Graph API request and payload used by an SDK cmdlet by including the Debug parameter.

Soon after the release of V2.27, developers complained that cmdlets did not pass the provided payload. An example of the problem is the inability to pass parameters when assigning licenses to user accounts with the Set-MgUserLicense cmdlet. Because license management is such an important task, this problem easily fell into the “must fix quick” category. Another example is when the payload disappears when updating an application with the Update-MgApplication cmdlet, or when creating a new calendar event with New-MgUserEvent ignores the start and end times.

Running what appears to be perfectly good code (often copied from Microsoft documentation) only to run into inexplicable failures is frustrating and annoying. A problem like this happening after a succession of flawed releases is especially worrisome because you’d expect Microsoft to have upped their game and improved software release processes.

Cautious Optimism

At this point, just a few days since the release of V2.28, I am cautiously optimistic. Microsoft is closing SDK issues in GitHub as people test the problems reported with previous releases. I have not experienced any new problems, scripts run without problems (aside from my own bugs), and everything works with PowerShell 5.1 runbooks in Azure Automation, as far as I can see (or rather, test). PowerShell V7 runbooks are still problematic and will remain so until Azure Automation supports PowerShell V7.4 in mid-June 2025.

I guess the takeaway is that V2.28 of the Microsoft Graph PowerShell SDK seems to be as stable as V2.25. Given that Microsoft has fixed some bugs, V2.28 is likely a little better. That’s as far as I would go at this point. V2.28 is definitely worth testing in a development environment to make sure that production scripts run with.

Each installation of the Microsoft Graph PowerShell SDK leaves a bunch of modules on your PC. When you install, make sure that you clean out old files and reboot, just to make sure that the new modules are used. To make things a little easier, I have a script to install and clean up modules on a local PC and another to update the Graph PowerShell modules used with Azure Automation.

Next Steps

I doubt that V2.28 will be perfect. New bugs will emerge, and we already know that some reported bugs are not fixed. One issue that I am tracking is where interactive sessions fail to recognize URIs when running cmdlets (including Invoke-MgGraphRequest) and respond with an “Invalid URI: The format of the URI could not be determined.” error. Running Connect-MgGraph to reconnect the session restores everything to good health, but suddenly losing the ability to run cmdlets is a disturbing problem that Microsoft needs to fix.

Overall, I’m not all that worried about seeing a few new bugs or having to wait a little longer for Microsoft to fix known issues. If you do find a bug, please take the time to report it by filing a report in GitHub. Don’t complain if things are not fixed if you don’t report the problem.

All I want is to see V2.28 resort relative stability to the Microsoft Graph PowerShell SDK in such a way that Microsoft 365 tenants can depend on it for day-to-day management of users, groups, licenses, devices, and other objects. That’s not too much to ask.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/05/15/microsoft-graph-powershell-sdk-228/feed/ 2 69204
How to Enhance Copilot Usage Data https://office365itpros.com/2025/05/09/copilot-usage-data-accounts/?utm_source=rss&utm_medium=rss&utm_campaign=copilot-usage-data-accounts https://office365itpros.com/2025/05/09/copilot-usage-data-accounts/#comments Fri, 09 May 2025 07:00:00 +0000 https://office365itpros.com/?p=69127

Combine Copilot Usage Data with User Account Details to Gain Better Insight for Deployments

Discussing the usage data that’s available for Microsoft 365 Copilot (in the Microsoft 365 admin center and via a Graph API), a colleague remarked that it would be much easier to leverage the usage data if it contained the department and job title for each user. The usage data available for any workload is sparse and needs to be enhanced to be more useful.

Knowing what data sources exist within Microsoft 365 and how to combine sources with PowerShell or whatever other method you choose is becoming a valuable skill for tenant administrators. I’ve been down this path before to discuss combining usage data with audit data to figure out user accounts who aren’t using expensive Copilot licenses. Another example is combining Entra ID account information with MFA registration methods to generate a comprehensive view of user authentication settings.

Scripting a Solution

In this instance, the solution is very straightforward. Use a Graph API call (complete with pagination) to download the latest Copilot usage data, Find the set of user accounts with a Microsoft 365 Copilot license and loop through the set to match the user account with usage data. Report what’s found (Figure 1).

Copilot usage datacombined with user account details.
Figure 1: Copilot usage data combined with user account details

Obfuscated Data and Graph Reports

The thing that most people trip over is matching usage data with user accounts. This is impossible if your tenant obfuscates (anonymizes) usage data. This facility has been available since late 2020 and if the obfuscation setting is on in the Microsoft 365 admin center, all usage data, including the data used by the admin center and Graph API requests is “de-identified” by replacing information like user principal names and display names with a system-generated string.

It’s therefore important to check the settings and reverse it if necessary for the duration of the script to make sure that you can download “real” user information. If you don’t, there’s no way of matching a value like FE7CC8C15246EDCCA289C9A4022762F7 with a user principal name like Lotte.Vetler@office365itpros.com.

Fortunately, I had a lot of code to repurpose, so the script wasn’t difficult to write. You can download the complete script from the Office 365 for IT Pros GitHub repository.

Finding Areas for Focus

Getting back to the original question, I assume the idea of including job titles and departments with Copilot usage data is to figure out where to deploy assistance to help people understand how to use Copilot in different apps. You could do something like this to find the departments with Copilot users who have no activity in the report period (90 days).

    Group-Object -Property Department | ForEach-Object {
        [PSCustomObject]@{
            Department = $_.Name
            UserCount  = $_.Group.Count
        }
    }

$GroupedReport | Sort-Object -Property Department | Format-Table -AutoSize

Department               UserCount
----------               ---------
Analysis and Strategy            3
Business Development             1
Core Operations                 57
Editorial                        1
Group HQ                         1
Information Technology           3
Marketing                       22
Planning & Action                1
Project Management               1
Research and Development         1

With this kind of output, the team driving Copilot adoption and use for the organization would be wise to spend some time with the Core Operations and Marketing departments to ask why so many of their users don’t appear to be using Copilot.

As noted above, understanding how to use PowerShell to mix and match data sources to answer questions is a valuable skill. There’s lots of data available in a Microsoft 365 tenant. That data is there to be used!


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/05/09/copilot-usage-data-accounts/feed/ 1 69127
How to Permanently Remove Mailbox Items with the Graph API https://office365itpros.com/2025/05/07/permanent-deletion-mailbox-item/?utm_source=rss&utm_medium=rss&utm_campaign=permanent-deletion-mailbox-item https://office365itpros.com/2025/05/07/permanent-deletion-mailbox-item/#respond Wed, 07 May 2025 07:00:00 +0000 https://office365itpros.com/?p=69106

Permanent Deletion for Message and Other Types of Items from User Mailboxes

On April 1, 2025, Microsoft announced the availability of APIs to permanently delete mailbox items. This news might well have passed you by because the post appeared in the developer blog rather than anything a Microsoft 365 tenant administrator might see.

The APIs are intended to fill in some gaps in Graph API coverage for mailbox items compared to Exchange Web Services (EWS). It’s part of the campaign to remove EWS from Exchange Online by October 2026. An example of where permanent removal of mailbox items is needed is when migrating mailboxes from one tenant to another. After a successful move, the migration utility might clean up by removing items from the source mailbox.

In any case, APIs are now available to permanently delete mail message, mail folder, event, calendar, contact, and contact folder objects.

What Permanent Removal Means

In this context, permanent removal means that no client interface exists to allow the user to recover the message. For example, users can’t use Outlook’s Recover Deleted Items facility to retrieve the deleted items and administrators can’t use the Get-RecoverableItems cmdlet to do likewise (or appear in a report of recoverable items).

The reason why this is so is that when Outlook deletes items in the Deleted Items folder, the items move to the Deletions folder within Recoverable Items. When the API deletes an item, the item moves to the Purges folder. If the item is not subject to a hold, the Managed Folder Assistant will remove it the next item the mailbox is processed. If it is subject to a hold, the item remains in the Purges folder until the hold lapses.

Permanent Removal with the Microsoft Graph API

Two pieces of information are needed to permanently remove a message item using the Graph API: the object identifier for the account that owns the mailbox and the message identifier. Let’s assume that you have a variable containing details of a message:

$Message | Format-List Subject, CreatedDateTime, Id

Subject         : Thank You for Subscribing
CreatedDateTime : 06/05/2022 06:47:28
Id              : AAMkADAzNzBmMzU0LTI3NTItNDQzNy04NzhkLWNmMGU1MzEwYThkNABGAAAAAAB_7ILpFNx8TrktaK8VYWerBwDcIrNcmtpBSZUJ1fXZjZ5iAB_wAYDdAAA3tTkMTDKYRI6zB9VW59QNAAQnaACXAAA=

To delete the item, construct a URI pointing to the message and post the request to the messages endpoint. This example shows where the variables for the user identifier and message identifier are in the URI:

$Uri = ("https://graph.microsoft.com/v1.0/users/{0}/messages/{1}/permanentDelete" -f $UserId, $Message.Id)

$Uri
https://graph.microsoft.com/v1.0/users/eff4cd58-1bb8-4899-94de-795f656b4a18/messages/AAMkADAzNzBmMzU0LTI3NTItNDQzNy04NzhkLWNmMGU1MzEwYThkNABGAAAAAAB_7ILpFNx8TrktaK8VYWerBwDcIrNcmtpBSZUJ1fXZjZ5iAB_wAYDdAAA3tTkMTDKYRI6zB9VW59QNAAQnaACXAAA=/permanentDelete

Invoke-MgGraphRequest -Uri $Uri -Method Post

The Graph API doesn’t ask for confirmation before proceeding to remove the item and it doesn’t provide a status to show that the deletion was successful. The only indication that something happened is found by using the Get-MailboxFolderStatistics cmdlet to see if the items in the Purges folder increase:

Get-MailboxFolderStatistics -FolderScope RecoverableItems -Identity Tony.Redmond | Format-Table Name, ItemsInFolder

Name                                    ItemsInFolder
----                                    -------------
Recoverable Items                                   0
Deletions                                        2135
DiscoveryHolds                                   2543
Purges                                             16
SubstrateHolds                                     12
Versions                                           79

Alternatively, use the MFCMAPI utility to examine the items in the Purges folder. Figure 1 shows that the “Thank you for subscribing” message is in the Purges folder.

MFCMAPI shows the permanently deleted item in the Purges folder.

Permanent deletion with Graph APIs
Figure 1: MFCMAPI shows the permanently deleted item in the Purges folder

Permanent Removal with the Microsoft Graph PowerShell SDK

The Remove-MgUserMessagePermanent cmdlet does the same job as the Graph API request:

Remove-MgUserMessagePermanent -UserId $UserId -MessageId $Message.Id

Once again, there’s no status or confirmation required for the deletion to proceed. The other Microsoft Graph PowerShell SDK cmdlets to permanently remove objects are:

All the cmdlets work in the same way. Deletion is immediate and permanent.

Adding new automation capabilities by extending APIs is always welcome. I just need to find a suitable use case for the new cmdlets.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/05/07/permanent-deletion-mailbox-item/feed/ 0 69106
Microsoft Attempts to Fix Microsoft Graph PowerShell SDK Problem with Azure Automation https://office365itpros.com/2025/04/14/microsoft-graph-powershell-sdk-2261/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-2261 https://office365itpros.com/2025/04/14/microsoft-graph-powershell-sdk-2261/#respond Mon, 14 Apr 2025 07:00:00 +0000 https://office365itpros.com/?p=68847

.NET Dependencies Stop Microsoft Graph PowerShell SDK Authentication in Runbooks

As anyone who keeps tabs on the Microsoft Graph PowerShell SDK, the V2.26 release was a disaster. Poor testing and other failures let obvious problems escape into customer environments. To be fair to Microsoft, the development group fixed some of the more grievous problems and issued version 2.26.1 a week or so after V2.26 appeared.

Alas, V2.26.1 came with its own set of flaws, notably breaking Azure Automation runbooks that use PowerShell V7.1 and V7.2 because the SDK developers decided to remove support for .NET 6. Cue the infamous “invalid JWT access token” issue (Figure 1).

Authentication fails for V2.26.1 of the Microsoft Graph PowerShell SDK.
Figure 1: Authentication fails for V2.26.1 of the Microsoft Graph PowerShell SDK

All in all, the Microsoft Graph PowerShell SDK descended into grand farce, and no one knew what would happen next. The problem only happens for runbooks based on PowerShell V7.1 and V7.2. It doesn’t arise when runbooks use PowerShell V5.1.

Microsoft’s Solution – Azure Automation Support for PowerShell V7.4

On April 10, 2025, Microsoft laid out their plans to clean up the mess. Explaining that the root cause of the problem in V2.26.1 is a component conflict between the Exchange Online PowerShell module and the Microsoft Graph PowerShell SDK that prevents the Connect-MgGraph cmdlet working, Microsoft says that the issue is resolved when Azure Automation is upgraded to support PowerShell V7.4 (based on .NET 8). A preview of PowerShell V7.4 support is available today.

Microsoft doesn’t say when Azure Automation will fully support PowerShell V7.4 support in a generally available version.

Update 18 April: According to notes from a community call, Microsoft expects to release PowerShell 7.4 support for Azure Automation on June 15, 2025.

However, they do say that the next release of the Microsoft Graph PowerShell SDK is “expected later this month.

Update: Microsoft released V2.27 of the Microsoft Graph PowerShell SDK on April 19, 2025. The new version fixes some of the problems seen in V2.26.1. However, it still has problems with V7.1 and V7.2 Azure Automation runbooks.

Until Microsoft releases PowerShell V7.4 support for Azure Automation, if you have Azure Automation runbooks, stay with Microsoft Graph PowerShell SDK V2.25 or use V2.27 with PowerShell 5.1 runbooks.

Work Remains to be Done

Assuming that Microsoft delivers a new version of the Microsoft Graph PowerShell SDK that delivers “enhanced stability” (couldn’t be worse than the last two versions), “compatibility and performance” and address the many issues reported in the SDK GitHub repository (163 open at present), is that the end of this saga?

I don’t think so. The history of the Microsoft Graph PowerShell SDK is littered with poor quality and buggy releases. The clash with the Exchange Online PowerShell module speaks of a failure within Microsoft to coordinate updates to critical PowerShell modules used by Microsoft 365 customers. Given the closely-connected nature of Microsoft 365, it’s unacceptable for engineering groups to make changes to PowerShell modules without understanding if their updates will impact modules like Teams, SharePoint, and Exchange.

Quality instead of Fast-Paced Releases

Customers need a sustained run of high-quality Microsoft Graph PowerShell SDK releases to rebuild faith. In the past, Microsoft issued new SDK versions on a monthly cadence in an attempt to keep up with changes in Graph APIs. That cadence is too rapid. Stability should be the name of the game from here on with focus on delivering a high-quality quarterly SDK. Lessening the pace will permit the SDK engineers to coordinate better with their peers and burn down the swelling bug list. If people need to use a new Graph API, there’s no need to wait for Microsoft to build an SDK cmdlet because they can always use the API via the Invoke-MgGraphRequest cmdlet.

Over three million downloads now occur for new SDK versions. It’s time that Microsoft treats the Microsoft Graph PowerShell SDK as what it is: a serious piece of the PowerShell framework for Microsoft 365 automation.

]]>
https://office365itpros.com/2025/04/14/microsoft-graph-powershell-sdk-2261/feed/ 0 68847
Reporting the Creation of SharePoint Agents https://office365itpros.com/2025/04/10/report-sharepoint-agent-creation/?utm_source=rss&utm_medium=rss&utm_campaign=report-sharepoint-agent-creation https://office365itpros.com/2025/04/10/report-sharepoint-agent-creation/#respond Thu, 10 Apr 2025 07:00:00 +0000 https://office365itpros.com/?p=68805

Use Audit Records to Find Who Creates SharePoint Agents

Another day, another event on the TEC 2025 European Roadshow. This time we were in Paris and just like in London’s discussion about how to protect old confidential files from Copilot access, attendees posed many good questions. Following the discussion about how to manage agents, I was asked how an organization could discover if SharePoint agents are in use. It’s a good question that isn’t answered in Microsoft’s documentation about how to manage agents in SharePoint. Microsoft gives some details about planned administrative features for agents are in an April 8, 2025 blog post, but there’s nothing available today.

I hadn’t thought about the problem up to now. SharePoint agents are limited in scope and don’t seem to pose too many administrative challenges. Each agent exists as a file in a document library with an .agent extension (originally, agents had a .copilot extension). Except for sites marked for Restricted Content Discovery, SharePoint Online sites have a default agent that reasons over the entire site. Site members can create other agents that focus on specific parts of the site. Agents created by site members are available to all site members and can be amended by them.

Approved Agents

To mark agents as being particularly useful, site owners can approve agents to highlight the agents in the agent picker. The files for approved agents are moved to the Site Assets library where they’re stored in the Approved sub-folder of the Copilots folder. Only site owners can edit approved agents. Figure 1 shows the details of an approved agent.

Details of an approved SharePoint agent.
Figure 1: Details of an approved SharePoint agent

Auditing Agent Creation

The creation and updating of custom SharePoint agents is evidence that people are using agents. Because the agent files are treated like other SharePoint files, audit records are captured in the Microsoft 365 audit log when these actions occur. By interrogating the audit log, we can discover who is creating agents and the sites where agents are used.

Here’s some PowerShell to use the Search-UnifiedAuditLog cmdlet to find SharePoint FileUploaded audit records for files with an .agent extension:

[array]$Records = Search-UnifiedAuditLog -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -Formatted -ObjectIds "*.agent" -Operations FileUploaded -ResultSize 5000 -SessionCommand ReturnLargeset
If ($Records) {
    $Records = $records | Sort-Object Identity -Unique
    Write-Host ("{0} audit records found" -f $Records.Count)
} Else {
    Write-Host "No audit records found"
    Break
}

$AgentReport = [System.Collections.Generic.List[Object]]::new()
ForEach ($Rec in $Records) {
    $AuditData = $Rec.AuditData | ConvertFrom-Json
 
    $ReportLine = [PSCustomObject][Ordered]@{
        TimeStamp       = Get-Date ($AuditData.CreationTime) -format 'dd-MMM-yyyy HH:mm'
        User            = $AuditData.UserId
        Action          = $AuditData.Operation
        SiteURL         = $AuditData.SiteURL
        Agent           = $AuditData.SourceFileName

    }
    $AgentReport.Add($ReportLine)
}
$AgentReport = $AgentReport | Sort-Object {$_.TimeStamp -as [datetime]} -Descending
$AgentReport | Out-GridView -Title "Custom SharePoint Agent Creation"

Write-Host ""
Write-Host "Custom agents created in these SharePoint Online sites"
$AgentReport | Group-Object SiteURL -NoElement | Sort-Object Count -Descending | Format-Table Name, Count
Write-Host ""
Write-Host "Custom agents created by these users"
$AgentReport | Group-Object User -NoElement | Sort-Object Count -Descending | Format-Table Name, Count

Figure 2 shows some sample output seen through the Out-GridView cmdlet.

Reporting audit events captured when users create SharePoint agents.
Figure 2: Reporting audit events captured when users create SharePoint agents

Some basic statistics are also produced about the sites where custom agents were created and the user accounts which create agents. To track agent usage, you can use the same technique to fetch and analyze FileAccessed audit events.

Microsoft Reports to Come?

Once again, the Microsoft 365 audit log is the source to answer questions. I’m sure that Microsoft will eventually get around to generating better-looking reports about agent creation and activity in the future. The usual course of events is that these kinds of gaps are filled sometime after functionality becomes available. Given that SharePoint agents reached general availability in November 2024, we’re still only finding out what reporting is needed for operational purposes, so it might take a while yet before we see any Microsoft reports.


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.

]]>
https://office365itpros.com/2025/04/10/report-sharepoint-agent-creation/feed/ 0 68805
Transferring Meeting Ownership From an Ex-Employee Can Be Hard Work https://office365itpros.com/2025/04/03/transfer-meeting-ownership/?utm_source=rss&utm_medium=rss&utm_campaign=transfer-meeting-ownership https://office365itpros.com/2025/04/03/transfer-meeting-ownership/#comments Thu, 03 Apr 2025 07:00:00 +0000 https://office365itpros.com/?p=68727

No Out-of-the-box Answer for Transfer Meeting Ownership

A problem that’s often faced when tidying up the affairs of ex-employees is what to do about the meetings they organize. Sometimes, no issue arises because the ex-employee doesn’t organize meetings or they have just a few meetings that can be easily canceled. In other instances, the departing individual is the organizer of a many meetings, including recurring meetings, and the meetings have artifacts like Loop-based meeting notes, attendance reports, and so on.

The core issue is that no way exists to transfer the ownership of meetings from one user to another. If this facility existed, it would be easy for someone like an ex-employee’s manager to take over responsibility for future and past meetings. To avoid the problem happening with important company events, some organizations use designated shared mailboxes to schedule and manage these events. It doesn’t matter when someone leaves the organization because the meeting organizer always remains.

The One Calendar

Outlook and Teams share the same calendar. Teams allows meetings to have co-organizers. This feature helps keep scheduled meetings running and preserves past events, but no transfer of ownership occurs. The Outlook equivalent is a delegate with full control over a calendar, but delegation is not ownership.

On the surface, it seems like the software engineering involved in transferring meeting ownership is just a matter of moving calendar events from the old organizer’s calendar to the new organizer’s calendar. However, that simple move hides a lot of complexity when issues like delegation and recurring events are considered. Transferring meeting ownership without affecting access to meeting resources is likely a good chunk of work, which is probably why it hasn’t happened.

A New Take on the Classic Answer to the Transfer Meeting Ownership Question

The classic answer is to cancel all future meetings owned by the ex-employee and have another person reschedule the meetings. You can automate meeting cancellation by running the Remove-CalendarEvents cmdlet, which can cancel events for up to 1,825 days in advance. Meeting participants receive cancellation notifications as normal. It’s an effective way of cleaning up events owned by an ex-employee, provided their mailbox is still online.

And while the mailbox remains online, it’s a good idea to create a report detailing meetings that might need to be rescheduled. The data is easily fetched with the Graph list calendar view API, which fetches the set of calendar events for a mailbox for a specified period.

The Office 365 for IT Pros GitHub repository contains many scripts covering different parts of Microsoft 36. It’s my scripting toolbox when I need some code to solve a problem. In this case, I used code from the room mailboxes statistics report. The original version uses Graph requests. To simplify matters, I modified the code to use Microsoft Graph PowerShell SDK cmdlets. The basic flow is:

  • Run the Connect-MgGraph cmdlet to connect an interactive session to the Graph with the Calendars.ReadBasic and User.ReadBasic.All scopes. The script available from GitHub uses delegated permissions with the signed-in account. If you want a script that can read any mailbox, use an app to hold the permissions and authenticate with a certificate so that you can run in app-only mode (see an example here).
  • Run the Get-MgUserCalendarView cmdlet to fetch data for the last 180 days (an arbitrary value that can be set to whatever number of days you want).
  • Find the set of meetings organized by the user from the data returned in the calendar view.
  • Report details of the meeting and generate an Excel worksheet or CSV file as output (depending on if the ImportExcel module is available).

Figure 1 shows selected details for some reported events through the Out-GridView cmdlet.

Calendar events report.

Transfer meeting ownership.
Figure 1: Calendar events report

You can download the complete script from GitHub. The script as written doesn’t report details like meeting body (notes) or attachments. It’s possible to fetch and reuse this data (the script will need the Calendars.Read rather than the Calendars.ReadBasic.All scope to access the meeting body and attachments).

Reschedule to Transfer Meeting Ownership

The remaining work is to review the set of meetings found in the ex-employee’s calendar and decide which meetings need to be rescheduled and who should be the new owner. The rescheduling process is probably going to be manual, but it would be possible to read in event details from the output XLSX or CSV file using the New-MgUserCalendarEvent cmdlet. It’s not worth doing the work if only a few meetings are involved but it might be if a large volume of meetings need to be rescheduled. I’ll leave that work to the reader.

One final point: cleaning up future meetings and possibly rescheduling meetings are points that should be part of a departing employee checklist. It’s best to be proactive.


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.

]]>
https://office365itpros.com/2025/04/03/transfer-meeting-ownership/feed/ 6 68727
Artificial Intelligence, PowerShell, and Microsoft 365 Administration https://office365itpros.com/2025/03/27/artificial-intelligence-and-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=artificial-intelligence-and-powershell https://office365itpros.com/2025/03/27/artificial-intelligence-and-powershell/#comments Thu, 27 Mar 2025 07:00:00 +0000 https://office365itpros.com/?p=68601

Artificial Intelligence and PowerShell for Tenant Administration – An Unlikely Couple?

I’ve been asked by a few people to comment about Lokka, the new creation of Merill Fernando, a program manager in the Microsoft Entra ID group. Lokka is a proof of concept exploring how the combination of AI Large Language Models (LLMs) and the Model Context Protocol (MCP) can bring value to Microsoft 365 administration. In this case, by generating Graph API queries in response to administrator prompts. For example, “How many user accounts belong to the marketing or sales departments.”

Merill’s a very inventive individual whose capacity to invent extends to his eye-catching tweet asking the question if Lokka is the end of PowerShell for Microsoft 365 administrators (Figure 1).

 Is Lokka the end of PowerShell for Microsoft 365 administrators?

Artificial intelligence and powershell.
Figure 1: Will Lokka meld Artificial Intelligence and PowerShell into a tool for Microsoft 365 administrators?

Helping Administrators with Simple Queries and Examples

Of course, the advent of a proof of concept like Lokka doesn’t mean that Microsoft 365 administrators suddenly need to lose all interest in PowerShell. AI tools can certainly be helpful in responding to queries that aren’t covered by the standard admin center GUI. They can also educate administrators by showing them how to use PowerShell to run Graph AI queries.

The Exchange Server 2007 product was the first Microsoft server to embrace PowerShell. One of the brainwaves in that product was how the Exchange Management Center (EMC) console displayed the PowerShell code it executed when it performed actions. Figure 2 shows how the EMC in Exchange Server 2007 displayed the code used to create a new mailbox.

Exchange Server 2007 EMC shows the PowerShell to create a new mailbox.
Figure 2; Exchange Server 2007 EMC shows the PowerShell to create a new mailbox

Seeing the PowerShell code in action and being able to copy the commands for reuse helped administrators master basic PowerShell command for managing Exchange servers. Another example is how Merill’s Graph X-Ray tool gives administrators a glimpse into the Graph API requests run to perform some actions in the console.

Artificial Intelligence and PowerShell in the Microsoft 365 Admin Center

The Microsoft 365 admin center already has Copilot assistance that’s added automatically when a tenant buys some Copilot for Microsoft 365 licenses (Figure 3). The implementation is much like a Copilot Chat session where an administrator prompts Copilot for some information and receives a response containing instructions and possibly some PowerShell code. I imagine that the content used by Copilot is a restricted set of documentation, just like you can restrict a Copilot agent to reasoning over certain SharePoint and external web sites when it composes its responses.

Copilot in the Microsoft 365 admin center.
Figure 3: Artificial Intelligence and PowerShell from Copilot in the Microsoft 365 admin center

The Importance of Training Material

There’s no doubt that we will see increasing use of AI to assist administrators with tasks as time progresses. The assistance will become more comprehensive, intelligent, and useful. However, the usefulness of any generative AI tool is bounded by the material used to create its LLMs. This means that the answers that an administrative agent can give, whether how-to instructions or PowerShell code snippets, depend on text scanned to build the LLM. If an answer exists to a question, the AI can respond. This includes incorrect answers because the LLM doesn’t know if content contained in source material is accurate. And if an answer isn’t available, the AI cannot respond without hallucinating. For example, Copilot has been known to include the names of PowerShell cmdlets that don’t exist in its responses.

The current set of AI tools we have don’t include insight or creativity. They can respond to known problems, but even so, responses are often based on whatever the most common answer is found in its source material. Those answers might be inefficient. Take the code suggested in Copilot’s response in Figure 3.

Get-MgUser | Where Department eq "Sales"

Several problems exist with the answer. First, the syntax is incorrect and won’t work because the piping to the Where-Object cmdlet is wrong (probably because Copilot absorbed an incorrect answer from some source). Second, the Department property is not retrieved by the Get-MgUser cmdlet unless explicitly requested.

Get-MgUser -All -Property Id, Displayname, Department | Where-Object {$_.Department -eq "Sales"}

Third, it’s always better to use a server-side filter to retrieve PowerShell objects. And in the case of user accounts, it’s also a good idea to filter out guest accounts.

Get-MgUser -All -Filter "Department eq 'Sales' and userType eq 'member'"

And even with member accounts selected, you might get some accounts that are used for room or shared mailboxes that you don’t want to process.

The takeaway is that generative AI can only be as good as the material used for its training. The current state of the art is such that AI can’t recognize when its output is incorrect.

PowerShell Still an Essential Tenant Management Skill

Even with the prospect of better, more complete, and more comprehensive AI tooling on the horizon, I still believe that Microsoft 365 administrators should take the time to acquire a working knowledge of PowerShell. For the foreseeable future, AI might well offer help to those who don’t even know how to start using PowerShell to manage a tenant.

Experience to date demonstrates that AI is unlikely to master the creativity that’s often needed to create something like a full-blown tenant licensing report, complete with costs anytime soon. Combining data from multiple sources to deliver a solution requires more ingenuity than running straightforward Graph requests. I await to be proven wrong that artificial intelligence and PowerShell can do more than perform straighforward, mundane tasks. In the interim, using GitHub Copilot to accelerate the development of PowerShell scripts might be the most productive way to use AI to improve Microsoft 365 automation.


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.

]]>
https://office365itpros.com/2025/03/27/artificial-intelligence-and-powershell/feed/ 2 68601
SharePoint Online PowerShell Module Gets Modern Authentication https://office365itpros.com/2025/03/14/sharepoint-online-powershell-oauth/?utm_source=rss&utm_medium=rss&utm_campaign=sharepoint-online-powershell-oauth https://office365itpros.com/2025/03/14/sharepoint-online-powershell-oauth/#respond Fri, 14 Mar 2025 07:00:00 +0000 https://office365itpros.com/?p=68427

Old-Fashioned Identity Client Jettisoned for OAuth

Message center notification MC1028318 (March 11, 2025) says that the SharePoint Online PowerShell module will replace the IDCRL authentication protocol with OAuth (modern authentication). Microsoft says that the replacement is “part of our ongoing efforts to enhance security and adopt modern authentication practices.”

Some might ask why it’s taken so long for Microsoft to make the decision to switch the module to OAuth. Microsoft has not given the SharePoint Online PowerShell module much tender loving care over the last few years. For instance, the module hasn’t been upgraded to PowerShell 7 and remains an outlier in this respect within the set of PowerShell modules used within Microsoft 365.

It’s not as if an adequate Graph-based replacement exists. The SharePoint Settings Graph API appeared in mid-2022 and hasn’t made much progress since. It’s just one of the reasons why the SharePoint PnP module is so popular.

The Identity Client Run Time Library

IDCRL is the Identity Client Run Time Library. It’s a very old authentication protocol that was used by products like Lync 2010 Server to authenticate with Exchange Online and Lync Online. IDCRL was also used by the Office desktop apps. Microsoft replaced IDCRL in the Microsoft 365 Apps for enterprise in September 2020 (MC222132).

More pertinently, SharePoint Online used IDCRL for authentication until recently, including with CSOM-based applications.

Upgrade in Modules Released after March 28, 2025

Microsoft issues new versions of the Microsoft.Online.SharePoint.PowerShell module regularly, mostly to add cmdlets or parameters needed to manage features like intelligent versioning. In this case, the change to OAuth is effective for modules released after March 28, 2025 (versions higher than 16.0.25814.12000).

You can download the latest version of the SharePoint Online management module from the PowerShell gallery (Figure 1). Once installed, the Connect-SPOService cmdlet automatically uses modern authentication (also called “modern TLS protocols”) instead of IDCRL. Although the implementation is designed not to affect how scripts work, you might see warning messages because Microsoft will deprecate the ModernAuth parameter in the future (the parameter is now obsolete).

SharePoint Online management module in the PowerShell gallery..

Microsoft.Online.SharePoint.PowerShell
Figure 1: SharePoint Online PowerShell management module in the PowerShell gallery

Although I accept Microsoft’s statement that the upgrade to OAuth-based authentication should not affect scripts, it’s always wise to test and verify in case the specific use of the module in a tenant is an edge case that Microsoft doesn’t test. Given some of the recent problems with other PowerShell modules, testing an updated module before putting it into production is always wise.

One Small Step Forward

Given Microsoft’s focus on removing outdated authentication protocols from across Microsoft 365 workloads, it’s surprising that the SharePoint Online management PowerShell module is only now being updated. It’s well behind the modules to manage other major workloads like Exchange and Teams. But then again, as I keep on saying, the signs over the last few years is that Microsoft really doesn’t devote too much attention to the SharePoint Online management module, and that’s a real pity.


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2025/03/14/sharepoint-online-powershell-oauth/feed/ 0 68427
Microsoft Graph PowerShell SDK V2.26.1 Remains Flawed https://office365itpros.com/2025/03/04/powershell-sdk-problems/?utm_source=rss&utm_medium=rss&utm_campaign=powershell-sdk-problems https://office365itpros.com/2025/03/04/powershell-sdk-problems/#comments Tue, 04 Mar 2025 07:00:00 +0000 https://office365itpros.com/?p=68280

Microsoft Graph PowerShell SDK Problems Means that Reputation Won’t be Easily Fixed

On February 25, 2025, I described how bugs in V2.26 of the Microsoft Graph PowerShell SDK made the software unusable. Late the same day, Microsoft pushed out V2.26.1. After a week’s testing, the signs are that the new version solved some of the problems seen in V2.26. However, bugs are still present in V2.26.1, including licensing failires and a nasty “invalid JWT access token” issue encountered when running the Connect-MgGraph cmdlet in an Azure Automation runbook. This error means that Connect-MgGraph failed to authenticate with Entra ID to secure an access token to allow the runbook to access data. It’s a pretty fundamental problem that’s accompanied by other issues reported on the Microsoft Graph PowerShell SDK GitHub issues page.

 The Connect-MgGraph cmdlet suffers from an invalid JWT access token problem.

Microsoft Graph PowerShell SDK problem.
Figure 1: The Connect-MgGraph cmdlet suffers from an invalid JWT access token problem

Given the current state, my advice is to stay on V2.25 until we know that a solid newer version of the Microsoft Graph PowerShell SDK is available.

On the surface, progress in squashing bugs is happening, and we can carry on using the SDK to generate automation solutions for Entra ID and Microsoft 365 as normal. Alas, that’s not a great plan At least, if Microsoft continues to develop the Microsoft Graph PowerShell SDK as before, exactly the same problems will appear in future versions. Lack of testing and poor communication will lead to more bugs and heightened customer dissatisfaction as Microsoft replays what happened with V2.14, V2.17/18, and now V2.26.

The Question of Testing to find Microsoft Graph PowerShell SDK Problems

Customers who experienced problems after installing V2.26 can justifiably ask what testing happens during the release cycle for a new version of the Microsoft Graph PowerShell SDK. I’m sure that some testing happens. The problem is that the testing has proven ineffective at picking up problems with heavily-used cmdlets like New-MgGroupMember and Send-MgUserMail, both of which were obviously flawed in V2.26.

It’s hard to test PowerShell cmdlets. Your use of a cmdlet might not be the way I use a cmdlet (the flexibility of PowerShell can be its own worst enemy at times). One method might be to test each cmdlet using the examples in the documentation. This sounds feasible, but it’s not. First, not all cmdlets have documented examples. Second, when examples exist, the code invariably reflects the simplest use of the cmdlet. For instance, the Send-MgUserMail cmdlet would have passed a test in V2.26 in terms of being able to send a simple message; its problems were revealed with moderately complex HTML body parts and attachments.

Third, there are just too many cmdlets to test. For V2.26.1, the number is 39,878 cmdlets (11,445 production (V1.0) and 28,433 beta). The SDK spans 38 sub-modules for the production cmdlets and 43 for beta cmdlets. These numbers grow over time as new Graph APIs appear.

Remember that the Microsoft Graph PowerShell SDK cmdlets are created by the Autorest process, which reads the Graph API schema and metadata to discover the resources and methods used to access data. The result is a cmdlet for every API. Some cmdlets or their documentation are flawed due to errors in the Graph metadata.

The challenge is to improve the quality of SDK cmdlets and documentation by making sure that the foundation (metadata) is right, and the cmdlets work the way that they should for every release. Developers don’t like surprises, and they especially don’t like when code that works with one version fails in another.

The Need for Crystal Clear Communication about Microsoft Graph PowerShell SDK Problems

The engineering group for the Microsoft Graph PowerShell SDK needs to improve its communication dramatically. Apart from some brief release notes (probably not read by many people), the bomb that lay within V2.26 was not discussed. The bomb was the impact on Azure Automation runbook due to the retirement of support for .NET6 and .NET7.

The release notes for V2.26.1 are equally terse. It’s possible to understand that the SDK rolled back to support .NET6 to address the Azure Automation issue, but not even the full changelog adds much value for anyone who’s not a professional developer, familiar with the SDK structure, and knows how to manage GitHub repositories.

The thing that seems to be forgotten is that many Microsoft 365 tenant administrators use the Microsoft Graph PowerShell SDK. Usage of the SDK is going up due to the imminent retirement of the MSOL and Azure AD modules.

V2.25 of the Microsoft Graph PowerShell SDK clocked up 3.6 million downloads compared to 1.18 million for V2.24. These are higher download numbers than for the Exchange Online management, Teams, SharePoint Online, or Entra modules and should be enough to prove the popularity of the SDK.

Tenant administrators are not developers. Most will have a passing knowledge of GitHub, and few will be able to trace the development of a new version through a GitHub changelog. What’s needed is clear, concise, and explicit explanations of what has changed in a new version, what impact this might have on existing code (if any), and any steps a tenant administrator must take to ensure that their code will continue to work.

It’s not too much to ask for people who create code that’s used by millions of people to communicate clearly with their audience. The temptation to use GitHub to generate release notes from change logs might be overpowering, but it’s simply not good enough. Context is everything, and bald statements that such and such a component was updated to fix bug number 3133 is never going to be an example of good communication.

Learning from Coping with Many Microsoft Graph PowerShell SDK Problems

I hope Microsoft learns from the V2.26 fiasco. It was a debacle created from their own making due to perfectly avoidable circumstances. To their credit, the SDK developers scrambled quickly to fix problems and get V2.26.1 out the door, but that’s still no reason for inflicting so much heartache on what should be their most fervent admirers.

There’s no doubt that the Microsoft Graph PowerShell SDK is a great tool for Microsoft 365 automation, like creating SharePoint pages from an RSS feed (just one example). Being able to interact with multiple workloads through a single SDK makes the pain somewhat bearable, except when it happens frequently. Trust in the PowerShell SDK was degraded by the V2.26 experience. I hope we see progress to allow SDK fans to build that trust back again.


Need some assistance to write great PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/03/04/powershell-sdk-problems/feed/ 2 68280
Processing Multiple Message Attachments with the Microsoft Graph PowerShell SDK https://office365itpros.com/2025/02/19/add-attachments-email/?utm_source=rss&utm_medium=rss&utm_campaign=add-attachments-email https://office365itpros.com/2025/02/19/add-attachments-email/#respond Wed, 19 Feb 2025 07:00:00 +0000 https://office365itpros.com/?p=68115

When You Want to Add Attachments to Email

Many examples exist to show how to send a single attachment with an email using the Send-MgUserMail cmdlet from the Microsoft Graph PowerShell SDK. Microsoft’s documentation covers how to send a single attachment but is mute on how to process a batch of attachments. This is understandable because the need to send multiple attachments for a single message from PowerShell isn’t probably of huge importance when it comes to programmatically creating and sending email.

With that thought in mind, I set out to create some sample code to illustrate the principle behind adding multiple attachments to a message sent with the Send-MgUserMail cmdlet.

An Array of Attachments

The essential points to remember are:

  • To include one or more attachments in a message, the attachment key must be present in the hash table that describes the message. The associated value is an array of attachments.
  • Each attachment is represented by a hash table in the attachments array.
  • The hash table for an attachment describes its odata type, file name, content type, and the base64-encoded content for the file.

Thus, the hash table for an attachment looks like this:

$AttachmentDetails = @{
        "@odata.type" = "#microsoft.graph.fileAttachment"
        Name = $File
        ContentType = $ContentType
        ContentBytes = $ConvertedContent
}

Adding Multiple Attachments

The first step is to find some files to attach. This code looks for files in a specified folder and checks the total file size to make sure that adding all the files as attachments won’t exceed 140 MB. The documented maximum message size for Exchange Online is 150 MB, but there’s always some overhead incurred from encoding, the message body, message properties, and so on.

$AttachmentsFolder = "c:\Temp\Attachments"
[array]$InputAttachments = Get-ChildItem -Path $AttachmentsFolder
If (!($InputAttachments)) {
    Write-Host "No attachments found in $AttachmentsFolder"
    Break
}   
$FileSizeThreshold = 146800640 # 140 MB in bytes
$TotalFileSize = ($InputAttachments | Measure-Object -Sum Length).Sum
$FoundSizeMB = [math]::Round($TotalFileSize / 1MB, 2)
If ($TotalFileSize -gt $FileSizeThreshold) {
    Write-Host ("Total size of attachments is {1} MB. Maximum size for an Outlook message is 140 MB. Please remove some attachments and try again." -f $TotalFileSize, $FoundSizeMB)
    Break
}

To prevent problems, the code won’t process the attachments if their total size is more than 140 MB and will report an error like this:

Total size of attachments is 182.14 MB. Maximum size for an Outlook message is 140 MB. Please remove some attachments and try again.

This avoids the problem when an attempt is made to send a message with oversized attachments, the Send-MgUserMail cmdlet will report:

Error sending message: [ErrorMessageSizeExceeded] : The message exceeds the maximum supported size., Cannot save changes made to an item to store.

The failure could occur because the mailbox that’s sending the message isn’t capable of handling such a large email. By default, Exchange Online enterprise mailboxes can send messages of up to 150 MB and receive messages of up to 125 MB (why the two values are different is debatable). To change these values for a mailbox, run the Set-Mailbox cmdlet:

Set-Mailbox -Identity Jane.Smith@office365itpros.com -MaxReceiveSize 150MB -MaxSendSize 150MB

Populating the Attachments Array

To populate the attachments array, the code creates a base64-encoded form of the file content and attempts to figure out the most appropriate content type. This is an optional property and Microsoft 365 can decide which format is best if you omit the property.

$FullFileName = $AttachmentsFolder + "\" + $File
$ConvertedContent = [Convert]::ToBase64String([IO.File]::ReadAllBytes($FullFileName))
$FileExtension = [System.IO.Path]::GetExtension($FullFileName) 
Switch ($FileExtension) {
    ".pdf" {
        $ContentType = "application/pdf"
    }
    ".docx" {
        $ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    }
    ".xlsx" {
        $ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    }   
    ".pptx" {
        $ContentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
    }   
    ".jpg" {
        $ContentType = "image/jpeg"
    }   
      ".png" {
       $ContentType = "image/png"
    }   
       default {
       $ContentType = "application/octet-stream"
    }
}

After processing an attachment, the code creates the hash table referred to earlier and adds it to the attachment array:

$MsgAttachments += $AttachmentDetails

The attachment array then becomes part of the message structure:

$Message = @{}
$Message.Add('subject', $MsgSubject)
$Message.Add('toRecipients', $MsgTo)
$Message.Add('body', $MsgBody)
$Message.Add('attachments', $MsgAttachments)

The final step is to call Send-MgUserMail to send the message. If everything works, it will arrive at its destination complete with a set of attachments (Figure 1).

A message sent by the Send-MgUserMail cmdlet with 10 attachments.

Add attachments to Exchange email.
Figure 1: A message sent by the Send-MgUserMail cmdlet with 10 attachments

Get the Structure Right and Everything Flows

Dealing with attachments through the Microsoft Graph PowerShell SDK is straightforward once you understand the structures used and how they are populated. It would be nice if the SDK cmdlet documentation covered this kind of thing better, but they don’t, so here we are.

You can download the script I used from the Office 365 for IT Pros GitHub repository. This article describes another example of using the Send-MgUserMail cmdlet.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/02/19/add-attachments-email/feed/ 0 68115
Microsoft Graph PowerShell SDK Needs to Fix Its Password Problem https://office365itpros.com/2025/02/14/graph-sdk-plain-text-passwords/?utm_source=rss&utm_medium=rss&utm_campaign=graph-sdk-plain-text-passwords https://office365itpros.com/2025/02/14/graph-sdk-plain-text-passwords/#comments Fri, 14 Feb 2025 07:00:00 +0000 https://office365itpros.com/?p=68078

Graph SDK Plain Text Passwords are Unacceptable in Today’s Threat Climate

Graph SDK plain text passwords problem

Many PowerShell developers are all too aware that time is running out for the Azure AD and MSOL modules. Microsoft will retire the MSOL module in April 2025 and the Azure AD module in Q3 2025. The result is that a lot of work is going on to upgrade scripts to replace MSOL and Azure AD cmdlets with equivalents from the Microsoft Graph PowerShell SDK or Entra module.

Microsoft launched the Entra module in June 2024 and made its V1.0 release generally available in January 2025. The Entra module is built on top of the Microsoft Graph PowerShell SDK. The major difference is that the Entra module comes with a set of hand-crafted cmdlets intended to mimic how Azure AD cmdlets work. I say hand-crafted because Microsoft engineers upgrade the automatically-generated versions created for the SDK to add support for features like piping.

The AutoRest process, which generates the SDK cmdlets, uses the metadata of the underlying Graph APIs to guide what it generates. That metadata is not constructed with PowerShell in mind, which is why the SDK cmdlets can be difficult to work with at times. For instance, the SDK cmdlets don’t support piping, which is a fundamental PowerShell feature. Output from SDK cmdlets is often a set of identifiers rather than human-understandable objects, and so on.

You could ask why Microsoft doesn’t intervene to add support for piping, make cmdlet output more useful, and address the other SDK foibles. One reason is the sheer number of Graph APIs that end up as SDK cmdlets.

[array]$Command = Get-Command -Module Microsoft.Graph*
$Command.count
42474

The 42,474 cmdlets are broken down into 15,259 V1.0 and 27,215 beta cmdlets. Updating all the cmdlets in V2.25 of the Microsoft Graph PowerShell SDK would take enormous effort. The number of cmdlets grows with each version of the SDK to reflect newly-added Graph APIs. The automatic generation process would need to change (and testing of the generated cmdlets). Whether the world’s largest software company should do this is an argument that’s been going on for years.

All of which brings me to issue 3119 reported in the SDK’s GitHub repository reported by MVP Aleksandar Nikolić, a well-known PowerShell expert. The problem report is terse and accurate:

The Update-MgUserPassword command’s parameters, -CurrentPassword and -NewPassword, expect a string value instead of a SecureString value.

Using Graph SDK Plain Text Passwords to Update User Accounts

The Update-MgUserPassword cmdlet is designed to allow users to change their own password. For instance, this code updates the user who’s signed into an SDK interactive session.

$User = Get-MgUser -UserId (Get-MgContext).Account

Update-MgUserPassword -UserId $User.Id -NewPassword "P@sswOrD" -CurrentPassword "Galway2020!!!"

If an administrator is changing someone’s password, they should use the Update-MgUser cmdlet.

$NewPasswordProfile = @{}
$NewPasswordProfile["Password"]= "$!FDGmso13@"

Update-MgUser -UserId $User.Id -PasswordProfile $NewPassword

Notice that in both cases, the password is in clear text. I don’t know how many tenants have coded solutions to allow users to change their own passwords and use the Update-MgUserPassword cmdlet, but I know that many have processes to change user passwords with Update-MgUser, so that’s the more serious problem.

By comparison, the equivalent cmdlets from the now-deprecated modules both take SecureString values when changing passwords.

$NewPassword | ConvertTo-SecureString -AsPlainText -Force
Set-MsolUserPassword -UserPrincipalName $userPrincipalName -NewPassword $NewPassword -ForceChangePassword $true
Set-AzureADUserPassword -ObjectId $User.Id" -Password $NewPassword

Using Secure Strings for Password Changes

Microsoft defines a SecureString as “text that should be kept confidential.” In terms of security, Microsoft says that A SecureString “provides more data protection than a string.” It seems like the right kind of protection passwords should have.

You might consider that there’s nothing much wrong here because the passwords are available to those who set them. However, it’s possible to have a script generate a password for an account and store it as a SecureString in a repository like Azure Key Vault, and have a different script read the password and use it to update an account. This method means that no password ever appears in plain text.

The Solution to Graph SDK Plain Text Passwords

The initial response from the SDK development team was that they follow the documentation for the underlying user: ChangePassword Graph API, which defines the input values in the request body strings. Referring back to the Graph API seems like a dereliction of duty. If everyone did that, we’d never see any improvements in software. Switching to support SecureString input for the SDK cmdlets is the right thing to do, even if it might break some existing scripts. That’s an acceptable cost to pay for better security.


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.

]]>
https://office365itpros.com/2025/02/14/graph-sdk-plain-text-passwords/feed/ 1 68078
Primer: Using Exchange Online PowerShell in Azure Automation Runbooks https://office365itpros.com/2025/02/10/azure-automation-exchange-primer/?utm_source=rss&utm_medium=rss&utm_campaign=azure-automation-exchange-primer https://office365itpros.com/2025/02/10/azure-automation-exchange-primer/#comments Mon, 10 Feb 2025 07:00:00 +0000 https://office365itpros.com/?p=67992

Exchange Online PowerShell Assumes Administrators Run Its Cmdlets

My last primer article in the Azure Automation series covered how to send email using the Exchange Online High-Volume Email (HVE) facility. HVE is still in preview (Microsoft is targeting September 2025 for general availability) but it still does a nice job of sending email from scheduled automation jobs.

This article discusses how to create and execute Azure Automation Exchange runbooks using PowerShell cmdlets from the Exchange Online management module. Unlike HVE, which doesn’t require any Exchange cmdlets, Automation accounts that use the Exchange module in their jobs need some special configuration. This is because the Exchange module assumes that anyone running its cmdlets is an Exchange administrator. There’s no concept of least privilege implemented in the module: once a process loads the module, it can act like a human administrator.

Loading Exchange Online PowerShell into an Automation Account

At least, an app can be all-powerful for Exchange if it meets three conditions. First, it can load the Exchange Online management module. For Azure automation accounts, this means that module is loaded as a resource into the account (Figure 1).

Selecting the Exchange Online management module to load into an Azure Automation account.
Figure 1: Selecting the Exchange Online management module to load into an Azure Automation account

At the time of writing, Exchange Online PowerShell only supports PowerShell V5.1 for automation runbooks, so be sure to install that version of the module. Due to module dependencies, you must install the PackageManagement and PowerShellGet modules (loaded jn that order) before installing the Exchange Online module.

Assigning Exchange Online Permissions and Roles for the Automation Account

Second, the service principal for the app must be assigned the Exchange administrator RBAC role. For Azure Automation, this means the service principal for the automation account. The assignment can be done through the Entra admin center (Figure 2) or with PowerShell. Make sure that you select the correct automation account from the set of enterprise applications listed in the picker.

Selecting the service principal for an automation account to assign the Exchange administrator role.
Figure 2: Selecting the service principal for an automation account to assign the Exchange administrator role

Third, the app must be assigned the Exchange.ManageAsApp permission. This is not a Microsoft Graph permission. It is an Office 365 Exchange Online permission designed to allow apps to act as administrators. The assignment can only be made through PowerShell. Here’s how to do the job with the Microsoft Graph PowerShell SDK:

$ExoApp = Get-MgServicePrincipal -Filter "AppId eq '00000002-0000-0ff1-ce00-000000000000'"
$TargetSP = Get-MgServicePrincipal -filter "displayname eq 'M365Automation'"
$Role = $ExoApp.AppRoles | Where-Object {$_.DisplayName -eq "Manage Exchange As Application"}
$AppRoleAssignment = @{}
$AppRoleAssignment.Add("PrincipalId",$TargetSP.Id)
$AppRoleAssignment.Add("ResourceId",$ExoApp.Id)
$AppRoleAssignment.Add("AppRoleId",$Role.Id)

$RoleAssignment = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $TargetSP.Id -BodyParameter $AppRoleAssignment
If ($RoleAssignment.AppRoleId) {
  Write-Host ("{0} permission granted to {1}" -f $Role.Value, $TargetSP.DisplayName)
}

Creating a Runbook to use Exchange Online Cmdlets

With the three prerequisites in place, you can create a runbook. To test that everything works as expected, create a V5.1 PowerShell runbook with the following code (replace the organization name with your tenant):

Connect-ExchangeOnline -ManagedIdentity -Organization Office365itpros.com
(Get-OrganizationConfig).DisplayName

Save the runbook and use the test pane to execute it. The output should be the display name for your organization. If that’s all you see, you can go ahead and build out the runbook with code to do more useful work.

As a demonstration, I took the script to report missing properties for user mailboxes and copied it into the runbook. The only changes that I made were:

  • Remove the code that checks for an active connection to Exchange Online at the start of the script and replace it with the Connect-ExchangeOnline -ManagedIdentity command.
  • Remove the Clear-Host cmdlet (Azure Automation doesn’t have a host to clear).
  • Replace the Write-Host cmdlet with Write-Output (Azure Automation outputs everything together (a stream) at the end of a job).
  • Remove the code to output the results as an CSV file at the end of the script.

Figure 3 shows the output of the runbook in the test pane. Everything works and we know that there are some mailboxes with missing properties that should be addressed.

Output from an Exchange Online script run by Azure Automation.

Azure automation Exchange Online.
Figure 3: Output from an Exchange Online script run by Azure Automation

Azure Automation can create an output file on the headless server where the runbook executes, but the question is then how to copy the file to somewhere more accessible later. The easy answer is to use HVE to send the file as an email attachment or to include the data in the body of a message. Something more complicated, like creating a file in a SharePoint Online site, will require more effort.

Not So Difficult

Running Exchange Online scripts in Azure Automation isn’t difficult once the initial setup for the automation account is in place. Some tweaking of the script code is probably necessary, but it’s not difficult to make the changes and will become second nature after a while. If you need to run jobs that process large numbers of Exchange objects (like mailboxes), Azure Automation is an excellent platform choice.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/02/10/azure-automation-exchange-primer/feed/ 21 67992
Primer: How to Schedule Azure Automation Runbooks to Process Microsoft 365 Data https://office365itpros.com/2025/01/23/azure-automation-runbook-schedule/?utm_source=rss&utm_medium=rss&utm_campaign=azure-automation-runbook-schedule https://office365itpros.com/2025/01/23/azure-automation-runbook-schedule/#comments Thu, 23 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67789

Let Azure Automation Execute Runbooks Using an Automation Schedule

After covering the basics of using Azure Automation runbooks to access Microsoft 365 data, the second part of the primer covers how to write data generated by a runbook to a SharePoint Online list. Populating data on an on-demand basis is valuable, but consistent gathering of data for comparison purposes can be so much more valuable. For that to happen, we need Azure Automation to execute the runbook on a schedule.

Runbook Details

The runbook that I have been using is called GetRecentAccounts. Figure 1 shows the runbook properties as viewed through the Azure portal. Essential details here are the name of the automation account associated with the runbook (M365Automation) and the resource group it uses (ExoAutomation).

Properties of an Azure Automation runbook.
Figure 1: Properties of an Azure Automation runbook

Resource groups are containers that Azure uses to hold resources. Resource groups are also used to track consumption, such as the processing performed by services such as Microsoft 365 backup or SharePoint document translation. An automation schedule is a resource, so it’s managed in a resource group. You might be familiar with the Windows Task Scheduler. The automation schedule is a more secure and better solution for running PowerShell scripts.

Creating an Automation Schedule

Two methods exist to create an automation schedule: through the GUI of the Azure portal or PowerShell using cmdlets from the Az.Accounts and Az.Automation modules. Because this discussion is all about PowerShell, we’ll take the latter option. Besides, using PowerShell to manage object often exposes details that are otherwise easily overlooked.

After installing the latest version of the modules from the PowerShell gallery, connect to the Azure account like this:

Connect-AzAccount -TenantId $TenantId -SubscriptionId $SubscriptionId -AccountId Tony.Redmond@office365itpros.com

You’ll need to know identifier for the tenant and Azure subscription and the account you sign in with must be entitled to manage Azure resources. The subscription identifier is listed in the runbook details (Figure 1) and the tenant identifier is easily found by running the Get-MgOrganization cmdlet:

(Get-MgOrganization).Id

After establishing a connection, you can manage the Azure automation resources available to the account. This code shows how to create a new automation schedule to run at 17:30 every weekday. The names of the automation account and resource group are required. This automation schedule doesn’t have an end date. If you want to add an end date, pass a valid date value in the EndTime parameter. More details about creating automation schedules are in the documentation.

# Set up when the new schedule will start (today at 17:30)
$StartTime = (Get-Date "17:30:00")
$ResourceGroup = "ExoAutomation"
$ScheduleName = "WeekDaySchedule"
$AutomationAccountName = "M365Automation"
# Define what days of the week the schedule will run
[System.DayOfWeek[]]$WeekDays = @([System.DayOfWeek]::Monday..[System.DayOfWeek]::Friday)

# Create the new schedule
New-AzAutomationSchedule -AutomationAccountName $AutomationAccountName -Name $ScheduleName -StartTime $StartTime -WeekInterval 1 -DaysOfWeek $WeekDays -ResourceGroupName $ResourceGroup -Description 'Schedule to execute runbooks at 17:30 UTC on weekdays'

# Check its details
Get-AzAutomationSchedule -ResourceGroupName $ResourceGroup -AutomationAccountName $AutomationAccountName

StartTime              : 22/01/2025 17:30:00 +00:00
ExpiryTime             : 31/12/9999 23:59:00 +00:00
IsEnabled              : True
NextRun                : 22/01/2025 17:30:00 +00:00
Interval               : 1
Frequency              : Week
MonthlyScheduleOptions :
WeeklyScheduleOptions  : Microsoft.Azure.Commands.Automation.Model.WeeklyScheduleOptions
TimeZone               : Etc/UTC
ResourceGroupName      : ExoAutomation
AutomationAccountName  : M365Automation
Name                   : WeekDaySchedule
CreationTime           : 22/01/2025 17:12:50 +00:00
LastModifiedTime       : 22/01/2025 17:16:02 +00:00
Description            : Schedule to execute runbooks at 17:30 UTC on weekdays

Register the Runbook with an Automation Schedule

Before Azure Automation can execute a runbook without human intervention, the runbook must be registered with an automation schedule. This code does the job, as does the Link to schedule option shown in Figure 1.

# Name of runbook to schedule
$RunbookName = "GetRecentAccounts"

Register-AzAutomationScheduledRunbook -AutomationAccountName $AutomationAccountName -Name $RunbookName -ScheduleName $ScheduleName -ResourceGroupName $ResourceGroup
Register-AzAutomationScheduledRunbook: The runbook has no published version. Runbook name GetRecentAccounts.

Or rather, it would if the runbook was published. Up to now, the runbook has been under active development, and we’ve used the test pane feature to make sure that the runbook code runs as expected. Eventually, a runbook reaches the point where the code works and is stable. At that point, you should publish the runbook to create a version that Azure Automation can schedule. Development can still continue, but Azure Automation will only run the published version.

To create a published version, edit the runbook and choose the Publish option (Figure 2).

Azure portal option to publish a runbook.

Azure automation runbook.
Figure 2: Azure portal option to publish a runbook

Alternatively, use the Publish-AzAutomationRunbook cmdlet to publish the runbook:

$Params = @{}
$Params.Add("Name", $RunbookName)
$Params.Add("ResourceGroupName", $ResourceGroup)
$Params.Add("AutomationAccountName", $AutomationAccountName)
Publish-AzAutomationRunbook @Params

Location              : westeurope
Tags                  : {}
JobCount              : 0
RunbookType           : PowerShell72
Parameters            : {}
LogVerbose            : False
LogProgress           : False
LastModifiedBy        :
State                 : Published
ResourceGroupName     : ExoAutomation
AutomationAccountName : M365Automation
Name                  : GetRecentAccounts
CreationTime          : 20/01/2025 15:03:41 +00:00
LastModifiedTime      : 22/01/2025 18:06:47 +00:00
Description           : Find recently created accounts

Either way, the runbook can now be registered with an automation schedule in the Azure portal or by running the Register-AzAutomationScheduledRunbook cmdlet. Confirmation of the scheduling can be obtained by examining the details of the schedule in the Azure portal (Figure 3) or by running the Get-AzAutomationScheduledRunbook cmdlet:

Get-AzAutomationScheduledRunbook -RunbookName $RunbookName -ScheduleName $ScheduleName -ResourceGroupName $ResourceGroup -AutomationAccountName $AutomationAccountName

ResourceGroupName     : ExoAutomation
RunOn                 :
AutomationAccountName : M365Automation
JobScheduleId         : 9a2aadd0-c3ac-4baa-8cfb-855b12a1a277
RunbookName           : GetRecentAccounts
ScheduleName          : WeekDaySchedule
Confirming that a runbook is registered with an automation schedule.
Figure 3: Confirming that a runbook is registered with an automation schedule

To confirm that everything’s running as expected, wait until after the job should run and check the output location for new data or use the Get-AzAutomationJob cmdlet:

Get-AzAutomationJob -ResourceGroupName $ResourceGroup -RunbookName $RunbookName -AutomationAccountName $AutomationAccountName | Where-Object {$_.Status -eq 'Completed'} | Format-Table StartTime, EndTime, RunbookName, Status

StartTime                  EndTime                    RunbookName       Status
---------                  -------                    -----------       ------
22/01/2025 17:30:59 +00:00 22/01/2025 17:31:53 +00:00 GetRecentAccounts Completed

The Value of Scheduled Runbooks

There’s nothing like an automated schedule to make sure that jobs get run at the right time. The combination of runbooks and schedules means that you can be sure that important Microsoft 365 automation processes are executed. That’s a nice feeling.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/01/23/azure-automation-runbook-schedule/feed/ 1 67789
Primer: Output Data Generated with an Azure Automation Runbook to a SharePoint List https://office365itpros.com/2025/01/22/azure-automation-runbook-list/?utm_source=rss&utm_medium=rss&utm_campaign=azure-automation-runbook-list https://office365itpros.com/2025/01/22/azure-automation-runbook-list/#comments Wed, 22 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67771

Execute an Azure Automation Runbook and Store its Results in a SharePoint Online List Item

Yesterday, I explained the basics of how to use Azure Automation to run a script using Microsoft Graph PowerShell SDK cmdlets. Today, I want to extend the knowledge outlined in that article to demonstrate another important aspect: How to output information from an Azure Automation runbook.

Azure Automation runbooks execute on headless servers that you don’t control. Runbooks can create output data but need to get that information back to whoever needs it. Available Microsoft 365 methods to share information include:

  • Creating a file in a SharePoint Online document library.
  • Posting a message to a Teams chat or channel.
  • Sending email.
  • Creating items in a list in a SharePoint Online site.

This article covers how to use the last method because SharePoint lists are a good way to capture the information generated by background processes. The script used yesterday reports user accounts created in the last 30 days. We’ll extend it to find some additional information and create a list item containing the data.

The Basics – Resources and Permissions

The first version of the script uses two modules of the Microsoft Graph PowerShell SDK for authentication and to find user accounts. To interact with SharePoint sites, we must add the Microsoft.Graph.Sites module and because the script generates some information about Microsoft 365 Groups, add the Microsoft.Graph.Groups module too.

The automation account already has the User.Read.All Graph permission. To read details of groups, it needs the Group.Read.All permission. The interaction with sites is both read (to access the site and find the target list) and write (to create items in the target list), so the automation account needs the Sites.ReadWrite.All permission. We’ll add the two permissions using PowerShell as follows:

Connect-MgGraph -Scopes AppRoleAssignment.ReadWrite.All
# Add Graph permissions to the service principal for the automation account
$GraphApp = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
$TargetSP = Get-MgServicePrincipal -filter "displayname eq 'M365Automation'"

[array]$Permissions = "Group.Read.All", "Sites.ReadWrite.All"

ForEach ($Permission in $Permissions){
    $Role = $GraphApp.AppRoles | Where-Object {$_.Value -eq $Permission}

    # Create the parameters for the new assignment
    $AppRoleAssignment = @{}
    $AppRoleAssignment.Add("PrincipalId",$TargetSP.Id)
    $AppRoleAssignment.Add("ResourceId",$GraphApp.Id)
    $AppRoleAssignment.Add("AppRoleId",$Role.Id)

    $RoleAssignment = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $TargetSP.Id -BodyParameter $AppRoleAssignment
    If ($RoleAssignment.AppRoleId) {
        Write-Host ("{0} permission granted to {1}" -f $Role.Value, $TargetSP.DisplayName)
    }
}

After these commands execute, Figure 1 shows what you should see when viewing the permissions for the automation account in the enterprise apps section of the Entra admin center.

 Graph permissions needed to interact with users, groups, and sites.
Figure 1: Graph permissions needed to interact with users, groups, and sites

Prepare the Target List

Although you can create a list in a SharePoint Online site with PowerShell (here’s how), it’s easier to do this work through the SharePoint browser interface. Select a target site and create a list there. I used a minimal set of fields to capture details like the number of user accounts, the number of Microsoft 365 groups, the names of recently added user accounts, and a timestamp. Don’t get too worried about what data is output: we’re just exploring principles here instead of creating a fully-fledged solution. Remember to note the name of the fields you add to the list because they’ll need to be stated in the script (field names are case sensitive).

Amend the Runbook Code

Yesterday’s script is simple and spans just a few commands to find and list recently added user accounts. Today’s script needs more code. Let’s investigate.

First, we need to connect to the SharePoint site that holds the target list. This code takes a URL for a site and converts it to a SharePoint site identifier that can be used to find a site. We can then look for the target list and fetch its details.

$Uri = "https://office365itpros.sharepoint.com/sites/Office365Adoption"
$SiteId = $Uri.Split('//')[1].split("/")[0] + ":/sites/" + $Uri.Split('//')[1].split("/")[2]
$Site = Get-MgSite -SiteId $SiteId
If (!$Site) {
    Write-Output ("Unable to connect to site {0} with id {1}" -f $Uri, $SiteId) 
    Exit
}
$List = Get-MgSiteList -SiteId $Site.Id -Filter "displayName eq 'Tenant Statistics'"
If (!$List) {
    Write-Output ("Unable to find list 'Tenant Statistics' in site {0}" -f $Site.DisplayName)
    Exit
}

The script includes some simple code to find user accounts and Microsoft 365 groups:

[array]$UserAccounts = Get-MgUser -All -PageSize 500 -Filter "userType eq 'Member'"
[array]$M365Groups = Get-MgGroup -Filter "groupTypes/any(c:c eq 'unified')" -All -PageSize 500

Finally, the runbook has the code to create an item in the target list. This is accomplished by creating a hash table to hold details of the fields (inside a separate hash table). What seems to be an odd structure is because PowerShell is mimicking a JSON structure for a payload body submitted to the Graph request to add the item. In any case, here’s the code:

$NewItemParameters = @{
   fields = @{
     Title               = Get-Date ($Date) -format s
     Rundate             = $RunDate
     NumberM365Groups    = $M365Groups.Count
     NumberUserAccounts  = $UserAccounts.Count
     RecentUserAccounts  = $RecentUserAccounts
   }
}

$NewItem = New-MgSiteListItem -SiteId $Site.Id -ListId $List.Id -BodyParameter $NewItemParameters
If ($NewItem) {
    Write-Output ("Added item to list {0}" -f $List.DisplayName)
} Else {
    Write-Output "Failed to add item to list"

I can’t emphasize too much the importance of testing code interactively before submitting it to Azure Automation. When that happens, after running in test mode, the runbook should report that it created a new item in the list (Figure 2).

Testing that the Azure Automation runbook works and creates an item in the target SharePoint list.
Figure 2: Testing that the Azure Automation runbook works and creates an item in the target SharePoint list

To verify that the runbook succeeded, go to the SharePoint site and open the target list. The item(s) created by the runbook should be present.

Items created by the runbook in a SharePoint Online list.
Figure 3: Items created by the Azure Automation runbook in a SharePoint Online list

Running Azure Automation Runbooks Aren’t So Hard After All

I hope that by now you’ll understand that running PowerShell scripts with Azure Automation is not particularly difficult. Once a runbook can output data, that data can be processed further. Lists are particularly adaptable in this way because there are many ways to reuse the data through channels like Power Apps or Power BI.

There are many code examples available that can help to solve automation problems. But the most important thing is to get the basics right. When that happens, everything clicks into place.

The script I used for the runbook can be downloaded from the Office 365 IT Pros repository on GitHub.


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.

]]>
https://office365itpros.com/2025/01/22/azure-automation-runbook-list/feed/ 2 67771
Primer: How to Use Azure Automation to Run Microsoft Graph PowerShell SDK Scripts https://office365itpros.com/2025/01/21/azure-automation-runbook-primer/?utm_source=rss&utm_medium=rss&utm_campaign=azure-automation-runbook-primer https://office365itpros.com/2025/01/21/azure-automation-runbook-primer/#respond Tue, 21 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67742

Running PowerShell in Azure Automation Runbooks Seems Complex – But Is it?

Over this past weekend, I was quizzed about why many people recommend using Azure Automation runbooks to run PowerShell scripts when setting everything up is so complex. I guess that I’ve been using this stuff for so long that I just accept how it works and parse out some of the issues that people struggle with. In an attempt to help, I thought that I’d create a really simple example as a starting point. Let’s see how I can do.

The Nature of Azure Automation

Azure Automation is a cloud-based service that supports running PowerShell scripts on headless servers. The scripts are called runbooks and the code that runs in Azure Automation is similar to the scripts that you run interactively.

The big difference is that Azure Automation is a non-interactive environment. Prompts don’t exist and any output is only seen when scripts finish. That being said, it is not difficult to take code written for interactive use and move it to Azure Automation. In fact, because debugging code in a non-interactive environment is difficult, it’s always best to make sure that a script runs without problems in an interactive environment before attempting to move it to Azure Automation.

Starting Off with Azure Automation

To begin, you’ll need an Azure subscription with an associated credit card to pay for the resources used to run code. Microsoft has Azure free account and pay-as-you-go options.

With an Azure account, you can create a resource group (to hold the resources needed by Azure Automation) and an automation account (Figure 1). The automation account holds the permissions and roles needed to access Microsoft 365 data.

Creating a new Azure Automation account.
Figure 1: Creating a new Azure Automation account

Resources for the Automation Account

Before writing any code to access Microsoft 365 via Azure Automation, you’ll need to add some resources to the automation account. The resources are the PowerShell modules containing the cmdlets needed by your scripts. When you execute a runbook, Azure Automation loads the modules into the session created on the headless server.

To add a PowerShell module, access the automation account and go to Modules under Shared Resources. Click Browse gallery and input the name of the module to add (Figure 2).This example features a script to list recently created Entra ID user accounts, so I added the Microsoft.Graph.Authentication (needed for any Graph SDK script) and the Microsoft.Graph.Users modules.

Browsing PowerShell modules to add as resources to an automation account.
Figure 2: Browsing PowerShell modules to add as resources to an automation account

How do you know what modules to add? In some cases, like Exchange Online, a single module (ExchangeOnlineManagement) is needed. The Microsoft Graph PowerShell SDK is more complex because it’s composed of multiple sub-modules.

If you have the Microsoft Graph PowerShell SDK installed on a workstation, an easy way to find out which sub-module is needed for a specific cmdlet is to use Get-Command in an interactive session. For instance, Get-Command reports that the source for the Get-MgUser cmdlet is the Microsoft.Graph.Users module:

Get-Command Get-MgUser | Format-Table Name, Source

Name       Source
----       ------
Get-MgUser Microsoft.Graph.Users

If you don’t have the Microsoft Graph PowerShell SDK installed, the name of the module that a cmdlet is in is mentioned in the cmdlet documentation.

Permissions for the Automation Account

As you’re probably aware, any access to data via the Microsoft Graph is governed by permissions. Automation accounts use application permissions and therefore have access to any data in the tenant allowed by the assigned permissions.

The permissions also include Entra ID roles needed to access the data you want to process. For instance, cmdlets from the Exchange Online module assume that they’re run by administrators, so the automation account must be added to the Entra ID Exchange administrator role and have consent for the Exchange ManageAsApp permission.

The permissions granted to automation accounts are held by the service principal for each account. You can see details of the service principal for an automation account in the enterprise apps section of the Entra admin center. Figure 3 shows that the automation account called M365Automation has a single assigned Graph permission (User.Read.All).

Permissions assigned to the service principal for an automation account.
Figure 3: Permissions assigned to the service principal for an automation account

The Entra admin center allows you to see assigned permissions but not assign other permissions. You can only assign permissions with PowerShell. This is a little messy, but once you know how, it will make sense. After connecting a Graph SDK interactive session with the AppRoleAssignment.ReadWrite.All permission, find the details of the Graph application (which always has the same identifier) and the service principal for the automation account.

Connect-MgGraph -Scopes AppRoleAssignment.ReadWrite.All
$GraphApp = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
$TargetSP = Get-MgServicePrincipal -filter "displayname eq 'M365Automation'"

Next, find the identifier for the app role (permissions) we want to assign.

$Role = $GraphApp.AppRoles | Where-Object {$_.Value -eq "User.Read.All"}

Now build a hash table containing the parameters for the new role assignment. As you can see, the parameters are the identifiers for the service principal, resource (Microsoft Graph), and the role.

$AppRoleAssignment = @{}
$AppRoleAssignment.Add("PrincipalId",$TargetSP.Id)
$AppRoleAssignment.Add("ResourceId",$GraphApp.Id)
$AppRoleAssignment.Add("AppRoleId",$Role.Id)

Finally, run the New-MgServicePrincipalAppRoleAssignment cmdlet to make the assignment and report success if an application role assignment identifier is returned.

$RoleAssignment = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $TargetId -BodyParameter $AppRoleAssignment
If ($RoleAssignment.AppRoleId) {
  Write-Host ("{0} permission granted to {1}" -f $Role.Value, $TargetSP.DisplayName)
}

Write Some Code for an Azure Automation Runbook

All the steps above have created the environment to write and run some PowerShell code. My example is to return the names of Entra ID accounts created in the last month. In an interactive session, the code is:

$Date = (Get-Date).ToUniversalTime().AddDays(-30).ToString("yyyy-MM-ddTHH:mm:ssZ")
[array]$Users = Get-MgUser -Filter "createdDateTime ge $Date" -Property Id, displayName, UserType, CreatedDateTime |Sort-Object UserType
If ($Users) {
   $Users | Format-Table DisplayName, UserType
}

The same code works in Azure Automation. Go to the automation account and create a PowerShell V7.2 runbook. Copy the same code into the runbook and add a line to authenticate using a managed identity:

Connect-MgGraph -Identity -NoWelcome

A managed identity is a system-managed highly secure identity. All the major Microsoft 365 PowerShell modules support system-assigned managed identities. Using a managed identity for authentication means that you don’t need to worry about passwords, secrets, or X.509 certifications.

After copying the code into the runbook and adding the connection via a managed identity, the runbook should look like Figure 4.

Viewing PowerShell code in an Azure Automation runbook.
Figure 4: Viewing PowerShell code in an Azure Automation runbook

The test pane allows you to test the code under Azure Automation. When the test pane loads, click Start. Azure Automation goes through its process to allocate a server, provision the server with the necessary resources, and then run the code. When the code finishes, you’ll see the output (Figure 5). It’s always nice to see the expected result when an Azure automation runbook stops.

The output for the runbook.
Figure 5: The output for the runbook

Lots More Possible with Azure Automation Runbooks

We’ve been through a basic example to explore the principles involved in creating an Azure Automation account, adding resources and permissions, and running some code. There’s lots more to do from this point: code will be more complex and probably create some output like email, SharePoint Online documents, or Teams messages, more resources and permissions will be needed, and you’ll probably want to explore how to schedule jobs so that they run on a regular basis. For instance, checking audit events weekly for signs of any problems with tenant security. In the next article in this series, I cover how some of this ground by showing to output the results of an Azure Automation runbook to a SharePoint list.

Azure Automation isn’t overly complex. Like all of us, it just needs to be appreciated in its own way.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/01/21/azure-automation-runbook-primer/feed/ 0 67742
How to Replace Group Owners When They Leave the Organization https://office365itpros.com/2025/01/20/replace-group-owners-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=replace-group-owners-powershell https://office365itpros.com/2025/01/20/replace-group-owners-powershell/#respond Mon, 20 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67726

Replace Group Owners to Avoid Ownerless Groups

An ownerless group is not a thing of beauty, but it can happen when someone leaves an organization and the remobal of their Entra ID user account results in some groups becoming ownerless. The Microsoft 365 group ownership policy helps, but it’s better to avoid the problem in the first place by proactively replacing the to-be-deleted account with a new group owner.

Writing PowerShell to Replace Group Owners

Which brings me to a PowerShell snippet posted in LinkedIn to address the problem. Here’s the code:

$OldOwnerUPN = "user@domain.com"
$NewOwnerUPN = "user1@domain.com"
$Groups = Get-UnifiedGroup -ResultSize Unlimited
foreach ($Group in $Groups) {
  $Owners = Get-UnifiedGroupLinks -Identity $Group.Identity -LinkType Owners
  if ($Owners.PrimarySmtpAddress -contains $OldOwnerUPN) {
     Remove-UnifiedGroupLinks -Identity $Group.Identity -LinkType Owners -Links $OldOwnerUPN -Confirm:$false
    Add-UnifiedGroupLinks -Identity $Group.Identity -LinkType Members -Links $NewOwnerUPN -Confirm:$false
    Add-UnifiedGroupLinks -Identity $Group.Identity -LinkType Owners -Links $NewOwnerUPN -Confirm:$false
    Write-Output "$($Group.DisplayName): Replaced $OldOwnerUPN with $NewOwnerUPN as owner"
  }
}#

This is a great example of proof-of-concept code that runs superbly in a small tenant where there are fewer than a hundred or so groups but rapidly runs out of steam rapidly as the number of objects to process escalates. The big performance sink is running the Get-UnifiedGroup cmdlet to fetch every Microsoft 365 group in the tenant. Because of the number of properties it retrieves, Get-UnifiedGroup is not a fast cmdlet. It’s a cmdlet that should be used judiciously rather than being the default method to fetch details of Microsoft 365 groups.

Use the Graph for Best Performance

The Get-MgUserOwnedObject cmdlet from the Microsoft Graph PowerShell SDK is faster and more scalable. Get-MgUserOwnedObject is based on the Graph list OwnedObjects API. Its purpose is to find the set of Entra ID directory objects owned by a user account and requires consent for the Directory.Read.All permission to find those objects.

This example fetches the set of Microsoft 365 Groups owned by a user account. Unfortunately, it’s not possible to use a server-side filter to extract the Microsoft 365 groups from the set of directory objects fetched by the cmdlet. The set of owned objects can include other group types such security groups and distribution lists and other objects like applications and service principals. Using a client-side filter isn’t a huge issue here because most of the fetched objects are likely to be Microsoft 365 groups.

[array]$Groups = Get-MgUserOwnedObject -UserId Kim.Akers@office365itpros.com -All | Where-Object {$_.additionalProperties.groupTypes -eq "unified"}

Like many Graph SDK cmdlets, the output for a group is determined by the underlying Graph request. If you look at the information returned for an object in the output array, you’ll see something like this:

$Groups[0] | Format-List

DeletedDateTime      :
Id                   : f9b6dcb7-609d-48ca-83c1-5afbfe888fe0
AdditionalProperties : {[@odata.type, #microsoft.graph.group], [createdDateTime, 2020-06-08T16:59:06Z], [creationOptions, System.Object[]], [description, Sunny Days]…}

If you examine the details of the additionalProperties property for a group, you’ll see the “normal” properties that a cmdlet like Get-MgGroup returns for a group.

$Groups[0].additionalProperties

@odata.type                  #microsoft.graph.group
createdDateTime              2020-06-08T16:59:06Z
creationOptions              {SPSiteLanguage:1033, HubSiteId:00000000-0000-0000-0000-000000000000, SensitivityLabel:00000000-0000-0000-0000-000000000000, ProvisionGro…
description                  Sunny Days
displayName                  Sunny Days
groupTypes                   {Unified}
mail                         SunnyDays@office365itpros.com
mailEnabled                  True
mailNickname                 SunnyDays
proxyAddresses               {SMTP:SunnyDays@office365itpros.com, SPO:SPO_c5365af4-7636-4bca-a9d2-e1eb9bbfe9f6@SPO_b…
renewedDateTime              2020-06-08T16:59:06Z
resourceBehaviorOptions      {}
resourceProvisioningOptions  {}
securityEnabled              False
securityIdentifier           S-1-12-1-4189510839-1221222557-4217028995-3767503102
visibility                   Private
onPremisesProvisioningErrors {}
serviceProvisioningErrors    {}

The most important property is the group identifier because it’s needed to update group membership. The other group properties can be accessed by prefixing them with additionalProperties (which is case sensitive). For example:

$Groups[0].additionalProperties.displayName
Office 365 Planner Tips

Rewriting the Original Code to Replace Group Owners

The original code can be adjusted to replace Get-UnifiedGroup with Get-MgUserOwnedObject. Apart from the performance boost gained by only finding the set of Microsoft 365 groups owned by the user instead of all groups, eliminating the need to run the Get-UnifiedGroupLinks cmdlet to check the ownership of each group improves code execution further:

[array]$Groups = Get-MgUserOwnedObject -UserId $OldOwnerUPN -All | Where-Object {$_.additionalProperties.groupTypes -eq "unified
ForEach ($Group in $Groups) {
  Remove-UnifiedGroupLinks -Identity $Group.Id -LinkType Owners -Links $OldOwnerUPN -Confirm:$false
  Add-UnifiedGroupLinks -Identity $Group.Id -LinkType Members -Links $NewOwnerUPN -Confirm:$false
  Add-UnifiedGroupLinks -Identity $Group.Id -LinkType Owners -Links $NewOwnerUPN -Confirm:$false
  Write-Output "$($Group.additionalPropertiesDisplayName): Replaced $OldOwnerUPN with $NewOwnerUPN as owner"
}

The lesson here is that you can mix and match cmdlets from different Microsoft 365 PowerShell modules to solve problems. In this case, Get-MgUserOwnedObject finds groups to process before the group memberships are updated using the Remove-UnifiedGroupLinks and Add-UnifiedGroupLinks cmdlets.

Running a Pure Microsoft Graph PowerShell SDK Version

You might want to write a script based solely on the Microsoft Graph PowerShell SDK instead of combining Exchange Online and Graph SDK cmdlets. To do this, we replace the Exchange cmdlets with the following SDK cmdlets:

  • New-MgGroupMember: Add the new owner as a member of the group.
  • New-MgGroupOwnerByRef: Add the new owner as a group owner. This should be done before removing the original owner to avoid the risk of failure because Entra ID won’t allow cmdlets to remove the last owner of a group
  • Remove-MgGroupOwnerDirectoryObjectByRef: Remove the old owner as a group owner. This also removes the account from group membership.

Running the Microsoft Graph PowerShell SDK script to replace group owners
Figure 1: : Running the Graph SDK script to replace group owners

To see how to use these cmdlets, check out this script, available from the Office 365 for IT Pros GitHub Repository. You’ll notice that I include calls to Get-MgGroupMember and Get-MgGroupOwner to avoid attempting to add the new owner as a group member and owner if they already hold these roles. The best thing of all is that the script (Figure 1) is extremely fast.

To complete the job, you should update any security groups, distribution lists, and dynamic distribution lists owned by the soon-to-depart account. The required code isn’t difficult, so I shall leave it to the reader to write.


Need some assistance to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/01/20/replace-group-owners-powershell/feed/ 0 67726
Final Days for the MSOnline and AzureAD PowerShell Modules https://office365itpros.com/2025/01/15/msonline-module-retirement/?utm_source=rss&utm_medium=rss&utm_campaign=msonline-module-retirement https://office365itpros.com/2025/01/15/msonline-module-retirement/#respond Wed, 15 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67685

Time Ebbing Away Before AzureAD and MSOnline Module Retirement

On January 13, 2025 Microsoft posted what I am sure they hope will be the last notification about retirement details for the MSOnline and AzureAD PowerShell modules. This has been a long-running saga that’s taken almost as long as the effort to eradicate basic authentication for Exchange Online connection protocols.

The original August 2021 announcement that Microsoft intended to retire the two modules set a date of June 30, 2022. Following customer feedback that the date was too aggressive, Microsoft pushed the date out by a year and then by another nine months. The transition to a new Microsoft 365 licensing platform in mid-2024 forced people to take notice when PowerShell cmdlets stopped being able to assign or update licenses. Microsoft has now set what they say is the final schedule for the retirement (Figure 1).

The final schedule to retire the MSOnline and AzureAD modules (source: Microsoft).

MSOnline module retirement
Figure 1: The final schedule to retire the MSOnline and AzureAD modules (source: Microsoft)

Final Red Flags for MSOnline Module Retirement

The end of support for both modules is March 30, 2025, just eleven weeks away. But the really interesting note here is the temporary outage tests Microsoft plans for the MSOnline module cmdlets starting on January 20, 2025. What this means is that the MSOnline cmdlets will stop working at least twice between January 20 and February 28. The outages will last between three and eight hours and happen at different times during the day.

The two short outages will be followed in March 2025 by a longer outage. Microsoft hasn’t said how long the longer outage will last. What they have said is that the outages are “To ensure that customers are ready for this retirement of MSOnline PowerShell.”

Customers might view the outages in a different light, especially if the outages stop production scripts running. But to be fair to Microsoft, they have been up front and patient as the process for the retirement of the MSOnline and AzureAD modules unfolded since August 2021. The outages are no more than a final red flag warning to tenants. If you ignore the warnings, be prepared for disruption when the MSOnline module retirement finally completes sometime in April 2025. At that point, all the MSOnline cmdlets will stop working permanently.

AzureAD Module Retirement Follows in Third Quarter

To allow customers to focus on upgrading scripts from the older MSOnline module, Microsoft is targeting the third quarter of 2025 for the final retirement of the AzureAD module. Remember, its cmdlets have already lost their license management capability, so scripts used for other purposes such as reporting accounts and groups need to be upgraded now.

Upgrade to Entra PowerShell or the Microsoft Graph PowerShell SDK

Two upgrade options are available:

Microsoft built the Entra module from Graph SDK components wrapped up with some tweaks to make it slightly easier to migrate from MSOnline or AzureAD. Although I respect the opinion of those who advocate for the Entra module as the best migration target, I think this approach is a very short-term tactical step. You might end up being able to migrate scripts, but in doing so you’ll miss the opportunity to master the Graph SDK (and also be able to migrate your scripts).

Missing out on the Graph SDK might not sound like such a big deal, especially when the Entra module is available to handle the immediate need to migrate scripts before the old modules stop working. However, mastering the Graph SDK opens up the opportunity to use PowerShell to interact with many other forms of Microsoft 365 data instead of “just Entra ID.” The same techniques learned to interact with users, groups, and devices can be applied to teams, SharePoint Online sites, OneDrive for Business accounts, Exchange mailboxes, Planner plans and tasks, and so on. Understanding how the Microsoft Graph works is the better strategic choice for the longer term.

Whatever choice you make, time is ebbing away. If you need help to migrate, consider investing in a copy of the Office 365 for IT Pros eBook, which includes the Automating Microsoft 365 with PowerShell eBook (also available separately as an eBook or paperback). The hundreds of practical examples contained in these eBooks include many worked-out solutions for applying the Microsoft Graph PowerShell SDK to solve problems.

]]>
https://office365itpros.com/2025/01/15/msonline-module-retirement/feed/ 0 67685
Using the SharePoint Pages Graph API https://office365itpros.com/2025/01/14/sharepoint-pages-api/?utm_source=rss&utm_medium=rss&utm_campaign=sharepoint-pages-api https://office365itpros.com/2025/01/14/sharepoint-pages-api/#respond Tue, 14 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67659

Create and Publish SharePoint Pages API with the Microsoft Graph PowerShell SDK

In April 2024, Microsoft announced the General availability for the Graph API for SharePoint Pages (also in message center notification MC789609 and Microsoft 365 roadmap item 101166). Despite Microsoft proclaiming that they were thrilled with the new API, I never got around to looking at it, largely because other work got in the way.

Given the period since general availability, it is no surprise that cmdlets for the SharePoint Pages API are available in the Microsoft Graph PowerShell SDK. However, some functionality is missing, and the Get- cmdlet to fetch pages for a site doesn’t work very well and some cmdlets are missing.

Get SharePoint Pages for a Site

Using the API requires the Sites.ReadWrite.All permission to read details of site pages and to create new pages, so the first step is to run the Connect-MgGraph cmdlet with Sites.ReadWrite.All as the selected scope.

The Get-MgSitePageAsSitePage cmdlet retrieves details of the pages for a site. You’ll need to fetch the site identifier for the target site first. The site identifier is not the site URL. A full site identifier looks something like this:

office365itpros.sharepoint.com,8e0a5589-b91d-496e-a5be-3473a75f2fe2,22d7a59d-d93c-498e-a806-6c9475717c88

If you know the URL for a site, you can compute a form of the site identifier that SharePoint will accept to lookup a site like this:

$Uri = "https://office365itpros.sharepoint.com/sites/BlogsAndProjects"
$SiteId = $Uri.Split('//')[1].split("/")[0] + ":/sites/" + $Uri.Split('//')[1].split("/")[2]

$Site = Get-MgSite -SiteId $SiteId

With the site identifier, you can run Get-MgSitePageAsSitePage. Here’s how to return the set of site pages sorted in date created order:

[array]$Posts = Get-MgSitePageAsSitePage -SiteId $Site.Id -All | Sort-Object {$_.CreatedDateTime -as [datetime]} -Descending

Unfortunately, the cmdlet doesn’t return values for many interesting properties, such as createdByUser. Better results are obtained by using the Graph API request:

$Uri = ("https://graph.microsoft.com/V1.0/sites/{0}/pages/microsoft.graph.sitepage" -f $Site.Id)
$Data = Invoke-MgGraphRequest -Uri $Uri -Method Get
$Pages = $Data.Value

Create a Page (a News Post) with the SharePoint Pages API

The example of creating a SharePoint page features see a large JSON structure composed of many properties. I wanted to simplify things to create a simple News Post page by running the New-MgSitePage cmdlet.

In PowerShell terms, the JSON structure is represented by a set of hash tables and arrays. It’s usually easier to manipulate the contents of hash tables and arrays programmatically, so that’s what I do here to create a page with a news item about a recent Office 365 for IT Pros article featuring the top five SharePoint features shipped in 2024.

$PostTitle = 'Microsoft Describes Top Five SharePoint Features Shipped in 2024'
$PostName = ("News Post {0}.aspx" -f (Get-Date -format 'MMddyyy-HHmm'))
$PostImage = "https://i0.wp.com/office365itpros.com/wp-content/uploads/2025/01/Top-Five-SharePoint-Features.png"
$PostContent = '<p> An interesting article by Mark Kashman, a Microsoft marketing manager, lists his top five SharePoint features shipped in 2024. Four of the five features involve extra cost. Is the trend of Microsoft charging extra for most new features likely to continue in 2025? The need to generate additional revenues from the Microsoft 365 installed base probably means that this is the new normal.</p><a href="https://office365itpros.com/2025/01/07/top-five-sharepoint-features-2024" target="_blank">Read full article</a>'

# The title area
$TitleArea = @{}
$TitleArea.Add("enableGradientEffect", $true)
$TitleArea.Add("imageWebUrl", $PostImage)
$TitleArea.Add("layout", "imageAndTitle")
$TitleArea.Add("showAuthor",$true)
$TitleArea.Add("showPublishedDate", $true)
$TitleArea.Add("showTextBlockAboveTitle", $true)
$TitleArea.Add("textAboveTitle", $PostTitle)
$TitleArea.Add("textAlignment", "center")
$TitleArea.Add("imageSourceType", $null)
$TitleArea.Add.("title", "News Post")

# A news item only needs one web part to publish the content
$WebPart1 = @{}
$WebPart1.Add("id", "6f9230af-2a98-4952-b205-9ede4f9ef548")
$WebPart1.Add("innerHtml", $PostContent)
$WebParts = @($WebPart1)

# The webpart is in a single column
$Column1 = @{}
$Column1.Add("id", "1")
$Column1.Add("width", "12")
$Column1.Add("webparts", $webparts)

$Columns = @($Column1)
$Section1 = @{}
$Section1.Add("layout", "oneColumn") 
$Section1.Add("id", "1")
$Section1.Add("emphasis", "none")
$Section1.Add("columns", $Columns)

$HorizontalSections = @($Section1)
$CanvasLayout = @{}
$CanvasLayout.Add("horizontalSections", $HorizontalSections)

# Bringing all the creation parameters together
$Params = @{}
$Params.Add("@odata.type", "#microsoft.graph.sitePage")
$Params.Add("name", $PostName)
$Params.Add("title", $PostTitle)
$Params.Add("pagelayout", "article")
$Params.Add("showComments", $true)
$Params.Add("showRecommendedPages", $false)
$Params.Add("titlearea", $TitleArea)
$Params.Add("canvasLayout", $CanvasLayout)

$Post = New-MgSitePage -SiteId $site.Id -BodyParameter $Params
If ($Post) { Write-Host ("Post {0} successful" -f $PostTitle) }

Update (Promote) a SharePoint Page to be a News Post

After creating a page, we might need to update it. In this case, I update the page to promote it to be a news post so that it will appear in the News section of the site. I also add a description to appear under the title in the card shown for the item in the News section.

The Update-MgSitePage cmdlet reported an “API not found” error, so I used the Graph API request:

$UpdateBody = ‘{
  "@odata.type": "#microsoft.graph.sitePage",
  "promotionKind": "newsPost",
  "description": "Microsoft Lists Top Five SharePoint Online features shipped in 2024"
}’
$Uri = ("https://graph.microsoft.com/V1.0/sites/{0}/pages/{1}/microsoft.graph.sitePage" -f $Site.Id, $Post.Id)
$Status = Invoke-MgGraphRequest -Uri $Uri -Method Patch -Body $UpdateBody
If ($Status) { Write-Host 'Post Updated'}

Publish the News with the SharePoint Pages API

The news item that’s created is in a draft state. It must be published to make it visible to other site members. I couldn’t find a cmdlet to publish a news item, so I used the Graph API request:

$Uri = ("https://graph.microsoft.com/V1.0/sites/{0}/pages/{1}/microsoft.graph.sitePage/publish" -f $Site.Id, $Post.Id)
Invoke-MgGraphRequest -Uri $Uri -Method Post

If an error isn’t reported, we can assume that SharePoint has published the page. Figure 1 shows how the page appears as a news item. I still have some bugs to figure out because the image I selected isn’t visible. There’s always something to do!

A news item created and published with the SharePoint Pages Graph API.
Figure 1: A news item created and published with the SharePoint Pages Graph API

Acceptable SharePoint Pages API but Problematic Cmdlets

As far as I can tell, the SharePoint Pages Graph API works pretty well but the Microsoft Graph PowerShell SDK cmdlets generated from the API isn’t in great shape. I admit that some of the issues might be due to my lack of experience with SharePoint pages, but you do expect to be successful when you follow the documentation. I expect things to improve over time. At least, I hope improvement comes…


Need more help to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/01/14/sharepoint-pages-api/feed/ 0 67659
All About the Office 365 for IT Pros GitHub Repository https://office365itpros.com/2025/01/10/office365itpros-github/?utm_source=rss&utm_medium=rss&utm_campaign=office365itpros-github https://office365itpros.com/2025/01/10/office365itpros-github/#respond Fri, 10 Jan 2025 07:00:00 +0000 https://office365itpros.com/?p=67527

A Store of PowerShell Scripts for Microsoft 365 Tenant Management in the Office365ITPros GitHub Repository

I’m on record as saying that knowing how to access and interact with Microsoft 365 data with PowerShell is essential knowledge for tenant administrators. Many options are enabled through settings that are only accessible through PowerShell, and it’s possible to extract more data from workloads with PowerShell (including use of Graph API requests) than is exposed through the different Microsoft 365 administrative interfaces.

Microsoft helps by including PowerShell examples in its documentation. The examples are basic and tend to concentrate on performing a single step in what is often a more complex sequence of commands necessary to fully complete a task. Nevertheless, all examples are welcome, and the Microsoft examples receive lots of attention because of their source.

What’s in the Office365ITPros Repository

As part of investigating Microsoft 365 technology to report how things work, we write a lot of PowerShell code. Until 2019, we published code in articles. At an Experts Live event in Oslo, Norway in 2019, Ståle Hansen (who wrote the Teams devices chapter for the book at that time) suggested that we establish a GitHub repository and use it to distribute script samples instead. Simple web links allow us to reference scripts in the Office365ITPros GitHub repository as needed in presentations, articles, and the Office 365 for IT Pros and Automating Microsoft 365 with PowerShell eBooks.

The suggestion made a ton of sense. Instead of updating script code in WordPress pages, we could update the script code in GitHub to keep it current, eliminate annoying bugs, and smoothen out problems caused by Microsoft changing the way that cmdlets work. For instance, scripts that call the Search-UnifiedAuditLog cmdlet have needed updates several times since 2018. Looking forward into 2025, Microsoft proposes to make another fundamental (and horrible) change to Search-UnifiedAuditLog that will cause many problems.

Another important change happened when the Microsoft Graph PowerShell SDK went from V1 to V2 and changed the structure of the modules and naming scheme for the beta cmdlets. Looking back, this was a good change, even if it caused disruption at the time by forcing developers to remove the cmdlet that selected beta or production cmdlets together with renaming any beta cmdlets called in scripts.

https://office365itpros.com/2021/01/21/introducing-office-365-for-it-pros-github-repository/The Office 365 for IT Pros GitHub repository (Figure 1) currently contains 304 PowerShell scripts covering different aspects of Microsoft 365 and Entra ID tenant management (the repository held 80 scripts when we first launched it in 2021). We update scripts when we discover issues or when people let us know about bugs or features they would like to see implemented.

The Office365ITPros GitHub Repository.
Figure 1: The Office365ITPros GitHub Repository

The quality of the code has gradually improved over the years. Several reasons exist why this should be so:

The repository could be better organized into folders for different topics and the naming convention isn’t great at times. We know that things could be better and improving the structure is on our to-do list.

It’s important to understand that Office 365 for IT Pros does not create fully-fledged solutions. The scripts are to explore principles of interacting with Microsoft 365 workloads, to extract and refine data, create objects, manage settings, and so on. Error handling is enough to make sure that everything works, but not sufficient for deployment in a production environment. We take this approach deliberately because every organization has its own coding standards. Our intention is that developers can take the code we create and meld it in their own fashion to solve automation problems within a tenant.

Forking the Office365ITPros GitHub Repository

You can share the scripts in the Office365ITPros repository by forking to create your own copy of the repository. As you can see from Figure 1, 583 forks exist for the Office365ITPros repository, all created by people who want to have their own copy of the scripts to work with. Those with forks can change code to make scripts work better by fixing bugs or adding features and then push the changes for inclusion in our repository. Twenty people have made contributions in this manner since the creation of the repository. It’s a great example of community in action.

Browse the Office365ITPros GitHub Repository – And Maybe Become a Contributor

The Office365ITPros repository exists to share PowerShell code so that we can all learn how to write quality scripts for Microsoft 365. Take the time to browse the scripts to see what might be useful to you. If you find something that could be done better, fork the repository and make the change and push the amended code to us. We’ll have a look at the changes and decide whether to accept them. We always welcome a new contributor!


Need more help to write and manage PowerShell scripts for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2025/01/10/office365itpros-github/feed/ 0 67527
Processing Microsoft 365 Retention Labels with the Microsoft Graph PowerShell SDK https://office365itpros.com/2024/12/18/microsoft-365-retention-labels-ps/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-retention-labels-ps https://office365itpros.com/2024/12/18/microsoft-365-retention-labels-ps/#comments Wed, 18 Dec 2024 07:00:00 +0000 https://office365itpros.com/?p=67421

A Simple Question About Managing Microsoft 365 Retention Labels with the Microsoft Graph

A reader asked if it is possible to have scripts apply retention labels to files and email using cmdlets from the Microsoft Graph PowerShell SDK. This is an example of a question that seems like it should be easy to answer but turns out not to be the case. Let’s plunge in and explain why.

Two Types of Retention Inside Microsoft 365

Microsoft 365 supports two forms of retention labels. The first type is MRM (Messaging Records Management) retention tags, the second is Microsoft 365 retention labels. Microsoft would very much like to get rid of retention tags, and have been saying so for years. However, retiring MRM retention tags is impracticable because Microsoft 365 retention labels don’t deliver some of the features available through retention tags, such as folder-specific processing and the move to archive action.

MRM retention tags are only available for email while Microsoft 365 retention labels cover both files and email. The complexity is invisible to users because Outlook clients combine tags and labels into a common set. Figure 1 shows a list of retention tags and labels as seen in the new Outlook for Windows. Why the list isn’t sorted alphabetically as it is in Outlook classic is beyond me. Hopefully, Microsoft will sort this annoying aspect out before they discontinue support for Outlook classic in 2029.

The new Outlook for Windows lists Microsoft 365 retention labels and MRM retention tags
Figure 1: The new Outlook for Windows lists Microsoft 365 retention labels and MRM retention tags

The Exchange Managed Folder Assistant also combines tags and labels into a common set when it applies retention settings to mailboxes.

The Programmatic Issue

Microsoft has sorted the problem of different retention labels in its clients and background processing, but dealing with the two types of labels is problematic for programmatic interfaces. MRM retention tags can be applied to emails through Exchange Web Services (EWS) but not through the Microsoft Graph APIs because they deal exclusively with files.

Microsoft wants to retire EWS in October 2026 and suggest that developers should migrate their code to the Graph APIs. However, gaps exist in Graph coverage that make such a movement currently impossible, and retention labels are one such gap that Microsoft must close before it can retire EWS.

The alternative is that Microsoft enhances Microsoft 365 retention labels to add features like support for moving items to archive mailboxes. I don’t see any prospect of this happening in the short term.

An Example Script

An example script is always a great way to demonstrate how to use a Graph API. The OneDrive for Business file report script contains several examples of SDK cmdlets being used to access retention labels. Access to retention label information requires the RecordsManagement.Read.All permission, which is only available in a delegated form. The lack of an application permission might seem odd, but Microsoft 365 applications only show users the set of retention labels made available to them through label publishing policies.

One of the first steps in the script is to run the Get-MgSecurityLabelRetentionLabel cmdlet to retrieve the set of Microsoft 365 retention labels (but not MRM tags) available to the signed-in user.

[array]$RetentionLabels = Get-MgSecurityLabelRetentionLabel

The script fetches details of every file in every folder in the OneDrive account. To check if a file has a retention label, the script runs the Get-MgDriveItemRetentionLabel cmdlet and passes the drive identifier and the file identifier. The drive identifier points to a library in a SharePoint Online site or a OneDrive for Business account. Each file in the library has a unique identifier. Together, a file can always be found.

This modified form of the script code fetches the identifier for the default library in the OneDrive account of the user signed into a Graph SDK interactive session and finds all the files in the root folder. It then checks the first item to see if the file has a retention label. No values are reported in the set of retention label properties output by the cmdlet, so we know that the file is not labeled.

$User = Get-MgUser -UserId (Get-MgContext).Account
$OneDrive = Get-MgUserDrive -UserId $User.Id | Where-Object {$_.Name -like "*OneDrive*"}
[array]$Data = Get-MgDriveItemChild -DriveId $OneDrive.Id -DriveItemId "root" -All
[array]$Files = $Data | Where-Object {$null -ne $_.file.mimetype}
Get-MgDriveItemRetentionLabel -DriveId $OneDrive.Id -DriveItemId $Files[0].Id

Id IsLabelAppliedExplicitly LabelAppliedDateTime Name
-- ------------------------ -------------------- ----

Taking the concept a little further, here’s how to report the set of labeled files in the root folder of the OneDrive account.

$LabeledFiles = [System.Collections.Generic.List[Object]]::new()
ForEach ($File in $Files) {
   $FileInfo =  Get-MgDriveItemRetentionLabel -DriveId $OneDrive.Id -DriveItemId $File.Id
   If ($FileInfo.Name) {
      $ReportLine = [PSCustomObject]@{
         Label = $FileInfo.Name
         File  = $File.Name
       }
       $LabeledFiles.Add($ReportLine)
   }
}

Applying and Removing Retention Labels

The script doesn’t apply retention labels to items. If it did, it would run the Update-MgDriveItemRetentionLabel cmdlet. The cmdlet takes a hash table containing the name of the retention label to apply as its input:

$RetentionLabel = @{}
$RetentionLabel.Add("Name","Approved")
$Status = Update-MgDriveItemRetentionLabel -DriveId $OneDrive.Id -DriveItemId $File.Id -BodyParameter $RetentionLabel
If ($Status.Name) { Write-Host "Retention label assigned"}

To remove a retention label from a file, run the Remove-MgDriveItemRetentionLabel cmdlet:

Remove-MgDriveItemRetentionLabel -DriveId $OneDrive.Id -DriveItemId $File.Id

The Graph Covers SharePoint and OneDrive but not Exchange

What we’ve learned is that Microsoft Graph PowerShell SDK cmdlets are available to get, apply, and remove Microsoft 365 retention labels from items stored in SharePoint Online and OneDrive for Business sites. If you want to apply MRM retention tags to email, you’ll need to use EWS. That is, until Microsoft retires EWS in 2026…


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.

]]>
https://office365itpros.com/2024/12/18/microsoft-365-retention-labels-ps/feed/ 1 67421
Using the Audit Log to Generate a Daily Action Summary for a User https://office365itpros.com/2024/12/03/audit-events-for-a-user/?utm_source=rss&utm_medium=rss&utm_campaign=audit-events-for-a-user https://office365itpros.com/2024/12/03/audit-events-for-a-user/#respond Tue, 03 Dec 2024 07:00:00 +0000 https://office365itpros.com/?p=67270

Analyze the Audit Events for a User Over a Single Day

I’m speaking about decoding the Microsoft 365 audit log at the ESPC conference in Stockholm, Sweden today. As part of the preparation for the session, I wanted to create a demo to highlight some of the challenges of interpreting audit records to attendees. I have many examples of PowerShell scripts that perform different tasks, like finding the last accessed date for documents stored in SharePoint Online. These scripts work well and solve problems, but they don’t shine a light onto the biggest single issue with audit log data. That issue is the maddening inconsistency found in the audit data payload contained in audit records.

The Two Parts of Audit Log Records

Audit records have two parts. The first is a consistent set of properties that must be respected by workloads when they generate audit records. These properties include the unique identity (GUID) for the record, a timestamp, the name of the user or process who performed an action, and the name of the action. The second part is the audit payload, a JSON structure contained in the AuditData property. Microsoft 365 workloads like Exchange Online and SharePoint Online control what they insert into the audit payload for their events, and no consistency and sometimes no reason governs what turns up in audit payloads.

The lack of consistency means that anyone attempting to interpret audit data must figure out what the audit payload contains and put it into context with what you know about the action captured by the event. The content differs from Exchange Online to SharePoint Online to Teams to Planner to Entra ID. The lack of consistency and the obvious errors in audit data points to poor control and attention to detail by engineering groups, both the team responsible for the audit log and the teams responsible for generating workload events.

Investigating User Actions for a Day

To illustrate the problem, I decided to create a script to report details of all actions taken by an individual user over a single day. I developed the script by fetching the audit records (about 2,200) logged for me on 27 November 2024 and reporting what I found. I stripped UserLoggedIn events from the set because of the number (946) of sign-ins to different applications from multiple devices. Most of the sign-ins are silent and result from the renewal of an access token. Figure 1 shows what the output report looks like.

Audit records logged for a user over a day

Audit events for a user
Figure 1: Audit records logged for a user over a day

The set of actions spanned interactions with multiple workloads for user activities like creating and updating documents, sending messages via Exchange and Teams, and reading a Planner task list. It also included some administrative actions like conducting an eDiscovery search, running some Exchange PowerShell cmdlets, and so on. No set of audit events for any single user will be 100% representative of what you’ll find across Microsoft 365, but I am confident that the results found in this set of audit records demonstrates the problem.

The script unpacks the audit payload for each event to extract a small set of properties for the report. A large Switch statement is used to interpret each type of event. It would be practically impossible to include every possible event, so I concentrated on common events and some that illustrate the problem.

In some cases, some of the properties contained in compliance audit records are obscured through Base64 encoding. Unfortunately, the encoding is resistant to PowerShell decoding unless you remove spurious characters at the end of the string. For example, here’s how the script handles events for the Get-ComplianceSearch action (retrieve details of a content search):

"Get-ComplianceSearch" {
   $Action = 'Compliance search retrieved'
   If ($AuditData.Parameters -eq '-ResultSize "Unlimited"' ) {
      $Object = "All results"
    } Else {
      $Object = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String( $AuditData.Parameters.Split('"')[1].SubString(0,32)))
    }
      $Location = $AuditData.Workload
      $Workload = 'eDiscovery'
   }

Some audit events contain details for multiple events. This is done for very good reason as the actions being captured are very common and would otherwise flood the audit log with data. The MailItemsAccessed event is a good example. This event is now available to Purview Audit Standard (Office 365 E3) customers and captures details of email items accessed by a user. The audit payload for a MailItemsAccessed event can contain details of 20 or 30 messages. MailItemsAccessed events can also contain details of sync actions (see this article and the associated script for details).

You can download the script to generate a report of user audit events for a day from GitHub. It’s easy to add processing for other events if you wish.

Consistency Would Make Administration Easier

The bottom line is that interpreting audit events takes a lot of knowledge and persistence. Like anything else in technology, the combination brings you a long way. It’s regrettable that Microsoft has allowed a situation to develop where nearly 2,000-odd audit events might need different processing to extract real value. Life would be so much easier if audit data was more consistent.


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.

]]>
https://office365itpros.com/2024/12/03/audit-events-for-a-user/feed/ 0 67270
Use the Microsoft Graph to Report Service Principal Sign-In Activity https://office365itpros.com/2024/11/21/service-principal-sign-in-activity/?utm_source=rss&utm_medium=rss&utm_campaign=service-principal-sign-in-activity https://office365itpros.com/2024/11/21/service-principal-sign-in-activity/#respond Thu, 21 Nov 2024 07:00:00 +0000 https://office365itpros.com/?p=67161

Gain Insight from Service Principal Sign-in Activity

Before an app can be used in an Entra ID tenant, it must be registered and have a unique identifier. Apps can be owned by the tenant or created by third parties. In both cases, a service principal for the app is required to access tenant resources. The service principal is the security principal for the app and defines who can access the app and what resources the app can access. Managed identities also have service principals to allow them to access resources.

All Microsoft 365 tenants have many service principals created for apps, including many created for Microsoft first-party apps. To find out how many Microsoft apps are known within your tenant, you can run this code to find the service principals belonging to the tenant used by Microsoft to host its services.

[array]$ServicePrincipals = Get-MgServicePrincipal -All -PageSize 500 | Sort-Object AppId
$MicrosoftApps = $ServicePrincipals | Where-Object {$_.AppOwnerOrganizationId -eq 'f8cdef31-a31e-4b4a-93e4-5f571e91255a'}

$MicrosoftApps.count
563

This isn’t the full picture because Microsoft uses other tenants to host its apps, like 9188040d-6c67-4c5b-b112-36a304b66dad (Microsoft accounts). In any case, many apps owned by Microsoft show up in Microsoft 365 tenants. The more Microsoft services you consume, the more apps you’ll find.

The Entra Admin Preview Feature for Service Principal Sign-in Activity

A recent discussion on BlueSky (my account is @office365itpros.bsky.social) alerted me to an Entra ID preview Usage & insights feature (Figure 1) to give administrators a view into service principal sign-in activity. This is important because if an attacker can compromise a privileged account in a tenant, they can create an app, give it permissions, and use the app to exfiltrate data. Keeping a wary eye on app activity is a good idea, as is reviewing the set of permissions held by apps (here’s a PowerShell script to report app permissions).

Service principal sign-in activity in the Entra admin center
Figure 1: Service principal sign-in activity in the Entra admin center

Whenever a feature turns up in the Entra admin center, there’s usually a Graph API (listServicePrincipalSignInActivities), and wherever there’s a Graph API, there might be a Microsoft Graph PowerShell SDK cmdlet (Get-MgBetaReportServicePrincipalSignInActivity), and with a cmdlet, we can retrieve and analyze data.

Writing a Script to Report Service Principals Sign-in Activity

The script I wrote (downloadable from GitHub) does the following:

  • Runs Get-MgServicePrincipal to retrieve the set of service principals known in the tenant.
  • Build a hash table of application identifiers and display names (sign-in records for service principals don’t include the app name).
  • Runs Get-MgBetaReportServicePrincipalSignInActivity to find sign-in activity for service principals when the last sign-in date is more than a year old.
  • Creates a report about the service principals and exports the data to a CSV file.
  • Generates some statistics such as the tenants that own apps, total service principals, etc.

Here’s what I found in my tenant:

Some notes about service principals for the Office 365 for IT Pros tenant
-------------------------------------------------------------------------

Service Principals by owning tenant

Tenant Name                        Tenant ID                            Number of Apps
-----------                        ---------                            --------------
Microsoft Services                 f8cdef31-a31e-4b4a-93e4-5f571e91255a            563
Office 365 for IT Pros             a662313f-14fc-43a2-9a7a-d2e27f4f3478             58
Microsoft                          72f988bf-86f1-41af-91ab-2d7cd011db47             19
Microsoft Accounts                 9188040d-6c67-4c5b-b112-36a304b66dad              2
PRDTRS01                           cdc5aeea-15c5-4db6-b079-fcadd2505dc2              2
trustportal                        7579c9b7-9fa5-4860-b7ac-742d42053c54              2
Adobe Inc                          f889b897-fa4a-4d20-b6dd-182555a5b308              1
Apple Inc.                         e0fad04c-a04c-41ab-b35e-dc523af755a1              1
Office 365 Customer Success Center d25014ba-ff6e-4f21-a7a7-698d6e524490              1
Microsoft Community & Event Tenant b4c9f32e-da17-4ded-9c95-ce9da38f25d9              1
Microsoft                          0d2db716-b331-4d7b-aa37-7f1ac9d35dae              1
PnP                                73da091f-a58d-405f-9015-9bd386425255              1
LinkedIn Production                658728e7-1632-412a-9815-fe53f53ec58b              1
AdobeExternal                      55aa7ab7-a04b-4623-ba3b-04cda52e667f              1
Credly                             54e44946-b280-4ccf-b102-2224d7008f17              1
Merill                             10407d69-1ba5-4bec-8ebe-9af2f0b9e06a              1
eventpoint                         0e45e1a3-686e-44ec-8f47-5daa29692074              1
mspmecloud                         975f013f-7f24-47e8-a7d3-abc4752bf346              1
Adobe                              fa7b1b5a-7b34-4387-94ae-d2c178decee1              1

Total Service Principals 668
Service Principals with no sign-ins in the last year 90
Service Principals with sign-ins in the last year 578
Number of apps with no service principal 46

The tenant names include Apple (used to reset authentication methods for Apple devices during the Exchange basic authentication retirement project) and several for Adobe (one of which is likely to connect SharePoint Online to the Adobe Cloud). The LinkedIn tenant likely hosts the app to connect LinkedIn data with the Microsoft 365 profile card. The PnP tenant is for the app used by the PnP PowerShell module, and the Merill tenant is home of many tools authored by Merill Fernando. This entry might be used to document conditional access policies in PowerPoint.

A total of 46 sign-in activity records for service principals could not be associated with a current service principal. This might be due to a bug in the preview feature, but it could also be due to the removal of apps by developers.

A list of the identifiers for Microsoft apps is available online. From the list I found a number of apps that are no longer in the set of service principals, including Office Online Client Microsoft Entra ID- Augmentation Loop (2abdc806-e091-4495-9b10-b04d93c3f040), OfficeShredderWacClient (4d5c2d63-cf83-4365-853c-925fd1a64357), Office Online Client Microsoft Entra ID- Loki (b23dd4db-9142-4734-867f-3577f640ad0c), and Microsoft Authentication Broker (29d9ed98-a469-4536-ade2-f981bc1d605e).

New Tools, New Insights

The nice thing about new tools is that they open up new opportunities to use data to gain additional insights into what happens in a tenant. Now that I can monitor and analyze service principal sign-in activity with PowerShell, I’ll be doing it regularly.


Need more help to write PowerShell for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2024/11/21/service-principal-sign-in-activity/feed/ 0 67161
Use the Audit Log to Find the Last Accessed Date for Documents https://office365itpros.com/2024/11/15/file-operations-audit-events/?utm_source=rss&utm_medium=rss&utm_campaign=file-operations-audit-events https://office365itpros.com/2024/11/15/file-operations-audit-events/#comments Fri, 15 Nov 2024 07:00:00 +0000 https://office365itpros.com/?p=67077

Exploit File Operations Audit Events to Find Who Accessed a Document Last

I’m speaking about how to master the unified (Microsoft 365) audit log at the European SharePoint Conference (ESPC) event in Stockholm in early December. At this point in the proceedings, the normal panic about putting together a presentation is in full swing, and I’ve been busy creating slides and examples.

In May 2024, I published an article about how to use the Microsoft Graph PowerShell SDK to create a report of files in a SharePoint Online document library. The idea is that it’s hard to understand everything that’s in a document library by scrolling through file details in the SharePoint browser app. Sometimes it’s just easier to see things in a report, and it’s definitely easier to figure out which files can be removed to clean up the document library. The temptation to leave well alone is deep in us all, but cleaning out old files from SharePoint has two benefits: it returns some storage quota, and it eliminates some of the potential for digital rot that can affect AI results.

A reader asked if the SharePoint files report could include the last accessed date for documents. The Graph API to List children of a drive item (folder) or the equivalent SDK Get-MgDriveItemChild cmdlet doesn’t return a last accessed date as far as I can see, so some other method must be used.

Analyzing SharePoint Online File Operations Audit Events

The unified audit log is a feature available to all tenants with Office 365 E3 or higher licenses. SharePoint Online creates a profusion of audit events that the audit log ingests on an ongoing basis. In this case, we’re interested in the FileAccessed event, which is logged when someone opens a file. Other events are logged for creation (FileUploaded), modification (FileModified), downloaded (FileDownload), and so on. You might be surprised at how many file operation events are logged for a busy SharePoint Online site. Figure 1 shows the count of file operations for some of documents used to generate the Office 365 for IT Pros eBook over the last six months.

Count of file operations audit events logged per document for a SharePoint Online site
Figure 1: Count of file operations audit events logged per document for a SharePoint Online site

Scripting a Solution Based on File Operations Audit Events

The outline of the PowerShell script to answer the request is:

  • Connect to Exchange Online with an administrator account.
  • Run the Search-UnifiedAuditLog to find SharePoint file operations audit events for the target site over whatever period is required. Office 365 E3 tenants store audit events for 180 days. E5 tenants store events for 365 days. Remove any duplicates that might have been fetched from the audit log. You could also interrogate the audit log with the Graph AuditLog Query API, but richer information is fetched by Search-UnifiedAuditLog.
  • Filter out file events logged by human users. SharePoint Online has many background processes to do things like clean out the recycle bin, preserve files for retention, and so on. We’re not interested in system events.
  • The full set of file operation events can be used to generate statistics, such as the count of user activity over the period, or the number of operations for individual files. We’re interested in file access events and use FileModified and FileAccessed events to generate this information, so the script populates a separate array with those events.
  • By grouping the file access events by file name and sorting the events by date, we can easily extract the last accessed date for each file. The result is something like this:

File                                                    User                                 Timestamp
----                                                    ----                                 ---------
01 Introduction and Overview.docx                       paul.robichaux@office365itpros.com   31-Oct-2024 12:34:06
02 Managing Identities.docx                             tony.redmond@office365itpros.com     31-Oct-2024 14:12:54
03 Tenant Management.docx                               paul.robichaux@office365itpros.com   31-Oct-2024 20:21:47
04 User Management.docx                                 paul.robichaux@office365itpros.com   31-Oct-2024 20:21:48
05 Managing Exchange Online.docx                        Andy.Ruth@office365itpros.com        29-Oct-2024 20:45:03
06 Managing Mail Flow.docx                              James.ryan@office365itpros.com       29-Sep-2024 15:07:31
07 Managing SharePoint Online.docx                      tony.redmond@office365itpros.com     14-Oct-2024 13:00:56
08 Managing Tasks.docx                                  paul.robichaux@office365itpros.com   29-Oct-2024 19:40:47
09 Managing Video.docx                                  paul.robichaux@office365itpros.com   29-Oct-2024 19:40:47
10 Managing Microsoft 365 Groups.docx                   brian.weakliamoffice365itpros.com    20-Oct-2024 17:49:23
11 Teams Architecture and Structure.docx                tony.redmond@office365itpros.com     16-Oct-2024 15:02:20
12 Managing Teams.docx                                  Lotte.Vetler@office365itpros.com     04-Nov-2024 19:01:57

Two odd user identifiers for bdc6105c-4e11-4050-82e6-6549f9b99b89 and eba15bfd-c28e-4433-a20e-0278888c5825 can appear in file operation events. I assume these identifiers belong to background SharePoint Online processes, so the script filters these events from the set.

You can download the complete script from GitHub.

Good Example of the Power of the Audit Log

Finding who last accessed SharePoint Online documents and when that access occurred is a good example of why the unified audit log is a great repository of information for tenant administrators and forensic investigators alike. If you’re at ESPC 24 in Stockholm, come along to my session on Decoding the Microsoft 365 Audit Log on Tuesday, December 3 at 10:30am. I’ll share more useful tips about exploiting the audit log there.


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.

]]>
https://office365itpros.com/2024/11/15/file-operations-audit-events/feed/ 9 67077
Manage PIM Role Assignments with the Microsoft Graph PowerShell SDK https://office365itpros.com/2024/11/14/pim-role-assignment-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=pim-role-assignment-powershell https://office365itpros.com/2024/11/14/pim-role-assignment-powershell/#comments Thu, 14 Nov 2024 07:00:00 +0000 https://office365itpros.com/?p=67043

Add Eligible and Active PIM Role Assignment Requests

I recently wrote about Microsoft’s recommendation to use the UnifiedRoleDefinition Graph API instead of the older DirectoryRole API. In that article, I show how to use the Microsoft Graph PowerShell SDK to make role assignments to user accounts. Assignments made in this manner are effective immediately. The assignments are permanent and last until an administrator removes them from accounts.

In many Microsoft 365 tenants where a limited set of administrators run operations, permanent role assignments work well. However, in larger tenants, some additional control is often desirable. Microsoft’s answer is Entra ID Privileged Identity Management (PIM), designed to enable administrators “manage, control, and monitor access to important resources in your organization.” PIM assignments can be permanent, but more commonly the assignments are time-limited to allow administrators to perform tasks on a just-in-time basis without their account needing elevated permissions on an ongoing basis. PIM is not part of the basic Entra ID license granted with Microsoft 365 and administrators need a license like Entra ID P2 to use PIM. See this page for more licensing information.

Microsoft’s Recommendation to use Entra Admin Center to Manage PIM Role Assignments

The PIM overview contains the interesting recommendation that tenants should use “PIM to manage active role assignments over using the unifiedRoleAssignment or the directoryRole resource types to manage them directly.” In other words, Microsoft thinks it better to use the GUI built into the Entra admin center to create and manage PIM role assignments. The reason for this might be that the GUI includes guardrails to stop administrators from making mistakes, which is something to avoid when assigning privileged roles.

In any case, PIM organizes role assignments into two categories:

  • Eligible assignments are roles granted to users, groups, or service principals (apps) that are not active. These assignments must be activated by the holder (principal) before they can perform the privileged tasks enabled by the role. By default, eligible assignments are activated for a maximum of 8 hours, after which the activation can be extended or renewed.
  • Active assignments are roles that are currently available for use. An active assignment can be permanent, but more often in PIM it is time-limited.

Both categories have a schedule, and Graph APIs and SDK cmdlets are available to add requests to add, update, and remove assignments from the schedules.

Creating an Eligible PIM Role Assignment

Here’s the PowerShell code to create a new eligible assignment schedule request to add a user account to the User administrator role. Before the New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest cmdlet can run, a certain amount of setup is necessary to fetch the identifiers for the account and role and define the period during which the assignment is eligible. You also need to decide whether the assignment is for the entire directory or an administrative unit.

$User = Get-MgUser -UserId Lotte.Vetler@office365itpros.com
[array]$DirectoryRoles = Get-MgRoleManagementDirectoryRoleDefinition | Sort-Object DisplayName
$UserAdminRoleId = $DirectoryRoles | Where-Object {$_.DisplayName -eq "User administrator"} | Select-Object -ExpandProperty Id
[string]$StartAssignmentDate = Get-Date -format "yyyy-MM-ddTHH:mm:ssZ"
[string]$EndAssignmentDate = (Get-Date).AddDays(30).ToString("yyyy-MM-ddTHH:mm:ssZ")

$ScheduleInfo = @{}
$ScheduleInfo.Add("startDateTime", $StartAssignmentDate)

$ExpirationInfo = @{}
$ExpirationInfo.Add("type", "afterDateTime")
$ExpirationInfo.Add("endDateTime", $EndAssignmentDate)

$ScheduleInfo.Add("expiration", $ExpirationInfo)

$AssignmentParameters = @{}
$AssignmentParameters.Add("action", "adminAssign")
$AssignmentParameters.Add("justification", "Assign User administrator role to user")
$AssignmentParameters.Add("roleDefinitionId", $UserAdminRoleId)
$AssignmentParameters.Add("directoryScopeId", "/")
$AssignmentParameters.Add("principalId", $User.Id)
$AssignmentParameters.Add("scheduleInfo", $ScheduleInfo)

$Status = New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -BodyParameter $AssignmentParameters
If ($Status.Id) {
   Write-Host ("Assignment for user administrator role for {0} added to eligibility schedule" -f $User.displayName)
}

The values in the hash table holding the parameters for the new assignment looks like this:

$AssignmentParameters

Name                           Value
----                           -----
justification                  Assign User administrator role to user
scheduleInfo                   {[startDateTime, 2024-11-12T17:51:03Z], [expiration, System.Collections.Hashtable]}
directoryScopeId               /
roleDefinitionId               fe930be7-5e62-47db-91af-98c3a49a38b1
principalId                    ce0e26f8-da88-4efa-90ad-d16df1d9500d
action                         adminAssign

The result of a successful assignment as seen in the Entra admin center looks like the example shown in Figure 1.

The eligible role assignment created by PowerShell

PIM Role assignment
Figure 1: The eligible role assignment created by PowerShell

The assigned user receives email about the assignment and can use the link in the message to activate their assignment (Figure 2). See this article about approval workflows that you might like to use to control activations.

Email informing user that they've received a PIM role assignment
Figure 2: Email informing user that they’ve received a PIM role assignment

Accounts holding the Privileged Role Administrator or Global Administrator role also receive email to inform them about the new assignment.

Creating an Active PIM Role Assignment

The code to create a PIM active role assignment request is like that used for the PIM eligible role assignment request. In this example, we create an active role assignment schedule request for the Groups administrator role and limit the assignment to a six hour period from now. The duration is expressed in ISO8601 duration format, so PT6H means six hours.

$GroupsAdminRoleId = $DirectoryRoles | Where-Object {$_.DisplayName -eq "Groups administrator"} | Select-Object -ExpandProperty Id
[string]$StartAssignmentDate = Get-Date -format "yyyy-MM-ddTHH:mm:ssZ"
$ScheduleInfo = @{}
$ScheduleInfo.Add("startDateTime", $StartAssignmentDate)

$ExpirationInfo = @{}
$ExpirationInfo.Add("type", "afterDuration")
$ExpirationInfo.Add("duration","PT6H")

$ScheduleInfo.Add("expiration", $ExpirationInfo)

$AssignmentParameters = @{}
$AssignmentParameters.Add("action", "adminAssign")
$AssignmentParameters.Add("justification", "Assign Groups administrator role to user")
$AssignmentParameters.Add("roleDefinitionId", $GroupsAdminRoleId)
$AssignmentParameters.Add("directoryScopeId", "/")
$AssignmentParameters.Add("principalId", $User.Id)
$AssignmentParameters.Add("scheduleInfo", $ScheduleInfo)
$Status = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $AssignmentParameters
If ($Status.Id) {
   Write-Host ("Assignment for Groups administrator role for {0} added to active schedule" -f $User.displayName)
}

To remove a role assignment from a schedule, create another role assignment schedule request and state the action to be “adminRemove” rather than “adminAssign.” For example, the request to remove the assignment request created above is:

$AssignmentParameters = @{}
$AssignmentParameters.Add("action", "adminRemove")
$AssignmentParameters.Add("justification", "Remove Groups administrator role to user")
$AssignmentParameters.Add("roleDefinitionId", $GroupsAdminRoleId)
$AssignmentParameters.Add("directoryScopeId", "/")
$AssignmentParameters.Add("principalId", $User.Id)
$Status = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $AssignmentParameters#
If ($Status.Status -eq "Revoked") { Write-Host "Active assignment revoked" }

Required Permissions for PIM

Adding role assignments requires the RoleManagement.ReadWrite.Directory permission. If you’re only reading role information, the RoleManagement.Read.Directory permission is sufficient. In addition, when using delegated permissions, read operations are only possible when the signed-in account holds one of the Global Reader, Security Operator, Security Reader, Security Administrator, or Privileged Role Administrator roles. Write operations, like adding a new role assignment to a schedule, require the signed-in account to hold the Privileged Role Administrator (or Global administrator) role.

Most Will Use the Entra Admin Center

Although it’s straightforward to create and manage PIM role assignment schedule requests with PowerShell, it’s easier to use the Entra admin center. Microsoft has done the work to create and refine the GUI and create the necessary checks to make sure that administrators don’t do something silly. I suspect that most administrators will interact with PIM through the Entra admin center, but it’s nice to know that the option to automate with PowerShell exists too.


Need more advice about how to write PowerShell for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2024/11/14/pim-role-assignment-powershell/feed/ 4 67043
How to Use the Graph SDK to Manage Group-Based Licensing https://office365itpros.com/2024/11/04/group-based-licensing-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=group-based-licensing-sdk https://office365itpros.com/2024/11/04/group-based-licensing-sdk/#respond Mon, 04 Nov 2024 07:00:00 +0000 https://office365itpros.com/?p=66847

Make License Assignment Easier with Group-Based Licensing and PowerShell

Group-based licensing is a mechanism by which user accounts receive product licenses through membership of a group. In September 2024, Microsoft moved the UI to control group-based licensing from the Entra admin center to the Microsoft 365 admin center. The move didn’t meet with universal approval, but that’s how things work now.

Group-based licensing works by creating a group, adding members to the group, and associating the group with one or more licenses. A security group is the preferred target (but group-based licensing works with Microsoft 365 groups) and each group member must have an Entra P1 license. Figure 1 shows that I am using a group called Teams EEA license holders to assign the Teams-only licenses created after Microsoft split Teams out from other products like Office 365 E3 and Microsoft 365 E5.

Assigning a product license to a group

Group-based licensing
Figure 1: Assigning a product license to a group

One of the known issues with group-based licensing is that the license administrator role is needed to assign licenses via the Microsoft 365 admin center but accounts with the group administrator role can assign licenses with PowerShell. With this in mind, it’s best to make sure that any account that needs to work with licenses holds the license administrator role.

Graph SDK Cmdlets for Group-Based Licensing

Behind the scenes, the Microsoft 365 admin center calls Graph APIs to assign and control licenses. In the case of group-based licensing, the Microsoft Graph PowerShell SDK cmdlet to use is Set-MgGroupLicense. The cmdlet works very much like the Set-MgUserLicense cmdlet, which assigns licenses directly to user accounts (here’s an example of how to switch licenses with Set-MgUserLicense).

For instance, to assign another license to the group (and therefore to the group members), run the Set-MgGroupLicense cmdlet after finding the identifier for the group to assign the licenses:

Get-MgGroup -Filter "displayName eq 'Teams EEA License Holders'" | Format-Table DisplayName, Id, Description

DisplayName               Id                                   Description
-----------               --                                   -----------
Teams EEA License Holders 90bbfa24-4413-4ffe-8e0a-2c7f10cea873 People with Teams EEA licenses

Set-MgGroupLicense -GroupId  90bbfa24-4413-4ffe-8e0a-2c7f10cea873 -AddLicenses @{SkuId = '8c4ce438-32a7-4ac5-91a6-e22ae08d9c8b'} -RemoveLicenses  @()

Just like direct assignments, group-based licensing supports disabling any of the service plans included in a product license.

Adding a license to a group like this is a cumulative update, and the new license joins any already assigned by the group. To confirm that this is the case, run the Get-MgGroup cmdlet and examine the AssignedLicenses property:

Get-MgGroup -GroupId 90bbfa24-4413-4ffe-8e0a-2c7f10cea873 -Property "AssignedLicenses" | Select-Object -ExpandProperty AssignedLicenses

DisabledPlans SkuId
------------- -----
{}            8c4ce438-32a7-4ac5-91a6-e22ae08d9c8b
{}            7e74bd05-2c47-404e-829a-ba95c66fe8e5

To remove a license from the group members, run Set-MgGroupLicense and include the SKU identifier to remove in the RemoveLicenses array:

Set-MgGroupLicense -GroupId  90bbfa24-4413-4ffe-8e0a-2c7f10cea873 -AddLicenses @{} -RemoveLicenses @('8c4ce438-32a7-4ac5-91a6-e22ae08d9c8b')

Background jobs process the actions required to assign or remove licenses from group members. Depending on the number of assignments to process, the license assignments should be complete in a matter of minutes rather than hours.

Checking License Assignments

To check license assignments, run the Get-MgUser cmdlet and check the contents of the licenseAssignmentStates property. Direct-assigned licenses don’t have a value in the AssignedByGroup property, while licenses assigned through a group have the group identifier listed. The output below shows that the user has one license assigned through a group and two assigned directly:

Get-MgUser -Userid Kim.Akers@Office365itpros.com -Property Id, displayname, assignedLicenses, licenseAssignmentstates | Select-Object -ExpandProperty licenseAssignmentstates | Where-Object {$_.Error -eq 'None' -and $_.State -eq 'Active'} | Format-Table AssignedByGroup, @{expression={$SkuHashTable[$_.SkuId]};label="Product"}, LastUpdatedDateTime

AssignedByGroup                      Product                       LastUpdatedDateTime
---------------                      -------                       -------------------
90bbfa24-4413-4ffe-8e0a-2c7f10cea873 Microsoft Teams EEA           28/10/2024 19:41:39
                                     Microsoft Power Automate Free 28/10/2024 19:38:38
                                     Office 365 E5 EEA (No Teams)  28/10/2024 19:38:38

Microsoft has a page of PowerShell examples for group-based licensing. Here’s a modified version of a script to report all the groups used for licensing assignments in a tenant.

To make the output easier to understand, the code looks up the product SKU identifier for each license against a hash table built from the Get-MgSubscribedSku cmdlet. You could also build the hash table from the CSV file available from Microsoft’s product names and service plan identifiers page, which is how product identifiers are translated to names by the Microsoft 365 licensing report script. The code to create a hash table from the downloaded CSV file is available from GitHub.

[array]$Skus = Get-MgSubscribedSku
$SkuHashTable = @{}
ForEach ($Sku in $Skus) { $SkuHashTable.Add($Sku.SkuId, $Sku.SkuPartNumber) }

[array]$Groups = Get-MgGroup -All -PageSize 500 -Filter "assignedLicenses/`$count ne 0" `
    -ConsistencyLevel Eventual -CountVariable Count `
    -Property LicenseProcessingState, DisplayName, Id, AssignedLicenses | `
    Select-Object DisplayName, Id, AssignedLicenses -ExpandProperty LicenseProcessingState
If (!($Groups)) { 
    Write-Host "No groups used for license assignment found... exiting!"; 
    break 
}

$Report = [System.Collections.Generic.List[Object]]::new()
ForEach ($Group in $Groups) {
    # Report SKU names rather than identifiers
    $ProductLicenseNames = [System.Collections.Generic.List[Object]]::new()
    ForEach ($License in $Group.AssignedLicenses) {
        $ProductLicenseNames.Add($SkuHashTable[$License.SkuId])
    }
        [array]$GroupMembers = Get-MgGroupMember -GroupId $Group.Id -All -Property Id, displayName, LicenseProcessingState
    $ReportLine = [PSCustomObject] @{ 
        'Group name'        = $Group.DisplayName
        GroupId             = $Group.Id
        'Assigned licenses' = $ProductLicenseNames -join ", "
        'Total Users'       = $GroupMembers.count
        'Processing state'  = $Group.State
    }
    $Report.Add($ReportLine)
}

$Report | Format-Table 'Group Name', 'Total Users', 'Processing state', 'Assigned Licenses' -Wrap

Group name                                Total Users Processing state   Assigned licenses
----------                                ----------- ----------------   -----------------
Teams EEA License Holders                           5 ProcessingComplete Microsoft Teams EEA
Microsoft Fabric License Assignment Group           9 ProcessingComplete Microsoft Stream, Microsoft Power Automate Free, Microsoft Power BI (free)

As Easy as User Direct Assignments

Group-based licensing is no more difficult to master than direct license assignments. The major difference is that you’re working with a group rather than a user account. Once you get that fact straight, everything flows naturally.


Need more advice about how to write PowerShell for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2024/11/04/group-based-licensing-sdk/feed/ 0 66847
How to Restore the Service Plan for a Microsoft 365 Product License https://office365itpros.com/2024/10/29/enable-service-plan/?utm_source=rss&utm_medium=rss&utm_campaign=enable-service-plan https://office365itpros.com/2024/10/29/enable-service-plan/#respond Tue, 29 Oct 2024 07:00:00 +0000 https://office365itpros.com/?p=66823

Reasons Exist to Disable Service Plans and Enable Service Plans

Plenty of articles are available on the internet to explain how to disable a service plan from a Microsoft 365 license. In this respect, a service plan controls a component within a Microsoft 365 product (SKU), such as Exchange Online, Sway, Microsoft Bookings, or Viva Engage. Some Microsoft products include many service plans. For instance, Office 365 E3 spans 38 service plans, which are referred to as apps when viewing user account details in the Microsoft 365 admin center. Some apps, like OneDrive for Business, are practically impossible to remove because of dependencies that exist across different Microsoft 365 components.

It’s common for organizations to disable service plans when they decide that there’s no need for an app. After Microsoft renamed Yammer to be Viva Engage, they introduced a new Viva Engage core service plan, and quite a few organizations promptly disabled the new plan because they never used Yammer and had no plans to use Yammer. Another example is Clipchamp, a very competent video editing application that’s part of Office 365 and higher licenses. Although Clipchamp offers a more comprehensive array of options to edit videos than is found in the Stream app, I’ve heard companies say that they have no interest in users creating and editing videos. Clipchamp is useful to the corporate marketing team, but no one else needs it, so the organization disables the Clipchamp service plan for most accounts.

Disable a Service Plan

To disable a service plan, uncheck the app from the set listed for the user account in the Microsoft 365 admin center. In Figure 1, the Microsoft Bookings service plan is disabled. To disable Clipchamp, all that’s required is to uncheck the entry for the Microsoft Clipchamp service plan.

Disabling a service plan in the Microsoft 365 admin centre

Enable service plan
Figure 1: Where to enable service plans in the Microsoft 365 admin center

Using the admin center is fine to disable or enable a service plan for just a few accounts. Automating the process with PowerShell is better when dealing with more accounts.

To disable a service plan, we need to know the identifier for the product SKU assigned to the user account and the identifier for the service plan. The easiest way to find this information is to consult Microsoft’s product names and identifiers page. Open the page and download the CSV file (frequently used by scripts such as the Microsoft 365 licensing report). Then search the file for the product name (like Office 365 E3) to find the SKU identifier (6fd2c87f-b296-42f0-b197-1e91e994b900). The service plans included in the SKU are also listed, and you should also be able to find the service plan identifier for the app you want to remove.

However, the August 19, 2024, version of the file doesn’t list Clipchamp for either Office 365 E3 or E5. Omissions like this sometimes happen, but it’s easy to find the service plan identifier for Clipchamp in other product SKUs. The identifier is a1ace008-72f3-4ea0-8dac-33b3a23a2472.

To confirm that these identifiers are correct, run the Get-MgUserLicenseDetail cmdlet against an account that you know has an Office 365 E3 license and check for a Clipchamp service plan:

Get-MgUserLicenseDetail -UserId Ben.James@office365itpros.com | Where-Object {$_.SkuId -eq '6fd2c87f-b296-42f0-b197-1e91e994b900'} | Select-Object -ExpandProperty ServicePlans | Where-Object {$_.ServicePlanName -like "*ClipChamp*"}

AppliesTo ProvisioningStatus  ServicePlanId                        ServicePlanName
--------- ------------------  -------------                        ---------------
User      PendingProvisioning a1ace008-72f3-4ea0-8dac-33b3a23a2472 CLIPCHAMP

The Set-MgUserLicense cmdlet manages license details and takes two arrays as input parameters. The first array (AddLicenses) hold details of licenses (SKUs) to add or modify. The second (RemoveLicenses) holds details of licenses to remove. In this case, we want to modify a license that the user account already has, so we’ll use the AddLicenses array to specify the SKU to update along with the service plan that we wish to disable. An account might already have some disabled service plans for the target SKU, so the first step is to find if any exist and store the identifiers for any previously-disabled service plans in an array. It’s critical to check for previously-disabled service plans as otherwise Entra ID will reenable these plans if you run Set-MgUserLicense to disable other plans.

[array]$DisabledServicePlans = Get-MgUserLicenseDetail -UserId Ben.James@office365itpros.com | Where-Object {$_.SkuId -eq '6fd2c87f-b296-42f0-b197-1e91e994b900'} | Select-Object -ExpandProperty ServicePlans | Where-Object {$_.ProvisioningStatus -eq "Disabled"} | Select-Object -ExpandProperty ServicePlanId

Now add the service plan for Clipchamp to the array:

$DisabledServicePlans += "a1ace008-72f3-4ea0-8dac-33b3a23a2472"

Finally, run the Set-MgUserLicense cmdlet to update the Office 365 E3 SKU:

Set-MgUserLicense -UserId Ben.James@Office365itpros.com -AddLicenses @{SkuId = '6fd2c87f-b296-42f0-b197-1e91e994b900'; DisabledPlans = $DisabledServicePlans} -RemoveLicenses @()

Enable Service Plans for Microsoft 365 Licenses

To restore a service plan, use the same code to find the set of disabled service plans, remove the service plan identifier from the array, and run Set-MgUserLicense. This code restores Clipchamp to the set of service plans available to the user:

$DisabledServicePlans = $DisabledServicePlans -ne "a1ace008-72f3-4ea0-8dac-33b3a23a2472"
Set-MgUserLicense -UserId Ben.James@Office365itpros.com -AddLicenses @{SkuId = '6fd2c87f-b296-42f0-b197-1e91e994b900'; DisabledPlans = $DisabledServicePlans } -RemoveLicenses @()

If you don’t fetch details of disabled service plans and pass an empty array, Entra ID will restore all disabled service plans. This might or might not be what you intended.

Understand the Identifiers

Disabling and restoring service plans for Microsoft 365 licenses through PowerShell might seem complicated. In reality, it’s simple, once you understand how product (SKU) identifiers and service plans work.


Need more advice about how to write PowerShell for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2024/10/29/enable-service-plan/feed/ 0 66823
How I Write PowerShell Scripts for Microsoft 365 https://office365itpros.com/2024/10/11/write-powershell-for-microsoft-365/?utm_source=rss&utm_medium=rss&utm_campaign=write-powershell-for-microsoft-365 https://office365itpros.com/2024/10/11/write-powershell-for-microsoft-365/#comments Fri, 11 Oct 2024 07:00:00 +0000 https://office365itpros.com/?p=66606

Selecting Effective Tools to Write PowerShell for Microsoft 365

Last week, I attended a PowerShell workshop at the TEC 2024 event in Dallas. Michel de Rooij and Jaap Wesselius, two well-known MVPs, ran the show. I was there to provide color commentary, which is the polite way of saying that I asked many annoying questions.

In any case, after the workshop one of the attendees asked me what tools and setup that I use to write PowerShell code for Microsoft 365. No one has the perfect answer as to how you should write PowerShell for Microsoft 365. To start the ball rolling, here’s what I do.

Use Visual Studio Code (VSC) to Write PowerShell for Microsoft 365

I use VSC for all my PowerShell work. It took me quite a while to get my head around using VSC because I didn’t like the PowerShell ISE and used to write with Notepad. I guess using a simple text editor is a throwback to the days when I wrote DCL scripts and ALL-IN-1 scripts at Digital Equipment Corporation. Old habits do die hard.

In any case, after learning and experiencing the advantages of VSC, I wouldn’t go back, even if Notepad (the most undervalued utility program in Windows) now supports autosave. VSC is updated monthly and keeps on improving, so it’s absolutely recommended.

Use GitHub to Store the Scripts Written for Microsoft 365

VSC is connected to the Office365ItPros GitHub repository. I used to simply publish bits of PowerShell or complete scripts in articles. That’s all very well until you need to update code to enhance its value or fix bugs (fixing bugs is a form of value enhancement). At an event in Oslo in 2018, Ståle Hansen, who used to write for the Office 365 for IT Pros eBook, suggested that I create a GitHub repository to store scripts. I’m glad that I did because GitHub is a great way to store and share code.

The Office365ITPros repository is not the best-organized store for PowerShell code that you’ll find on the internet, or even just in GitHub. It’s a bit scatty, like me. But I update the code as people suggest improvements or report bugs and add any new script that I write there, like the recent script to report sharing of files stored in OneDrive for Business accounts.

VSC makes the process easy by supporting connections to GitHub repositories. Once you link VSC to GitHub, it’s easy to synchronize (commit) any change made to a script in VSC or any new code that you write to update the repository (Figure 1).

Commiting a change to GitHub with Visual Studio Code

Write PowerShell for Microsoft 365
Figure 1: Commiting a change to GitHub with Visual Studio Code

At the TEC 2024 PowerShell script-off, it was noticed that some of the competitors really struggled to find sample scripts or code snippets that could help them attack a new coding problem. Keeping everything in GitHub and having a local copy of your repository on your PC means that you can stay productive even when disconnected from the network.

Speaking of snippets, sometimes I need to write a small piece of code to illustrate a point that’s not quite a full script. I do this in VSC with a file that’s not synchronized with GitHub. Instead, it’s a simple file called “Working Stuff” that’s stored on my PC and full of PowerShell snippets that I’ve played with.

Use GitHub Copilot to Improve How You Write PowerShell for Microsoft 365

I’ve often asked if the $10/month charge for a GitHub Copilot license is worth the money: it absolutely is, especially for Copilot’s ability to generate functions to process and reformat data.

GitHub Copilot isn’t perfect (my experience using GitHub Copilot is described here). It can hallucinate and create some code that just won’t work, including calls to cmdlets that don’t exist. But that’s the nature of generative AI. GitHub Copilot integrates nicely with VSC and if you keep a careful eye on what Copilot produces, it can save you time, improve your code, and even write comments.

Keeping Modules Updated

I test and run everything using the latest version of PowerShell 7. The outlier here is the SharePoint Online management module, which runs on PowerShell 5. I also use Azure Automation for various tasks.

Keeping modules up to date is important. In the last week or so, there’s been updates for the Teams (to 6.6) and Exchange Online modules (to 3.6), and the Microsoft Graph PowerShell SDK updates monthly or thereabouts. Add in SharePoint Online and other modules that you might use, and it takes some effort to keep everything updated. I use two PowerShell scripts to update:

  • My PC (never install PowerShell modules into your OneDrive for Business accounts. I mean, why would you?)
  • Azure Automation (PowerShell resources in Azure automation accounts).

Naturally, the scripts are available from the Office365ITPros repository. Happy coding!


Need more advice about how to write PowerShell for Microsoft 365? Get a copy of the Automating Microsoft 365 with PowerShell eBook, available standalone or as part of the Office 365 for IT Pros eBook bundle.

]]>
https://office365itpros.com/2024/10/11/write-powershell-for-microsoft-365/feed/ 4 66606
Microsoft Retires the Revoke-SPOUserSession Cmdlet https://office365itpros.com/2024/10/04/revoke-spousersession-deprecation/?utm_source=rss&utm_medium=rss&utm_campaign=revoke-spousersession-deprecation https://office365itpros.com/2024/10/04/revoke-spousersession-deprecation/#respond Fri, 04 Oct 2024 07:00:00 +0000 https://office365itpros.com/?p=66581

Revoke-SPOUserSession is No Longer Fit for Purpose

Microsoft’s announcement in message center notification MC903785 (3 October 2024) that they will retire the Revoke-SPOUserSession cmdlet (in the SharePoint Online PowerShell module) in early November 2024 was expected. There’s no purpose served by having a workload-specific cmdlet to revoke user access to an app when the job can be done across all workloads with a single cmdlet built for the job. That cmdlet is Revoke-MgUserSignInSession, which I discuss in an article about the right way to revoke access from Entra ID accounts.

The Roots of Revoke-SPOUserSession

Microsoft introduced the Revoke SPOUserSession cmdlet in January 2016. That’s an aeon in cloud terms. Teams hadn’t yet appeared, Azure AD delivered a much simpler directory and authentication service, with no notion of features like continual access evaluation (CAE), and SharePoint Online wasn’t trying to deal with nearly 4 billion files created daily.

At the time, the primary access to SharePoint Online was through the browser (now I suspect primary access is via Teams), and I’m sure that it made perfect sense to create a cmdlet to force the sign-out of a user from SharePoint Online across all devices.

Retiring Revoke-SPOUserSession

Microsoft says that their telemetry indicates that only a few organizations are active users of Revoke-SPOUserSession. I’m surprised that even a few tenants exist that might still use the cmdlet because better options have existed for some time, cumulating with the Revoke-MgUserSignInSession cmdlet the Microsoft Graph PowerShell SDK.

The critical difference is that the SDK cmdlet forces a sign-out from all Microsoft 365 sessions, not just SharePoint Online. It’s an essential part of any administrator action to secure an account because of suspected compromise or because an employee is leaving the organization. If you’re in the category of those who have scripts that use Revoke-SPOUserSession, it’s time tio change before the curtain comes down.

Securing an Employee Account

All of which brings me to the second annual PowerShell script-off at TEC 2024 (in Dallas). It’s quite a challenge to strut your PowerShell skills in front of a sometimes-boisterous crowd, and I admire the folks (Figure 1) who stepped up to take part.

Intense coding at the TEC 2024 PowerShell script-off (and yes, the glass of wine helps)

Revoke-SPOUserSession
Figure 1: Intense coding at the TEC 2024 PowerShell script-off (and yes, the glass of wine helps)

The first challenge was to write a script to automate the securing of my account (I make a great victim) after my forced ejection from the organization at 9AM on Monday. You’d imagine that this is a well-trodden path with many sample scripts available on the internet, so it was surprising the difficulty some had with the challenge. Competitors couldn’t use ChatGPT and Microsoft 365 Copilot to avoid any hint of generative AI spoiling the responses, and it was interesting to see how people approached the issue without that kind of help.

Most immediately focused on disabling the Microsoft 365 account. This is undoubtedly an important step, but there’s more to be done, like:

Forcing a sign out with Revoke-MgUserSignInSession is a great next step, but only after changing the account password. You don’t want to have someone be prompted to reauthenticate because their access tokens are invalid only to be able to sign in again because their account password is changed. Yes, disabling the account should stop the sign-in, but let’s be sure.

Securing devices is another step. It all depends on what device management software a tenant uses, but it should be possible to wipe corporate data from devices to prevent ex-employees having continued access to local copies. Sensitivity labels help here by making sure that even if an ex-employee takes copies of sensitive files, they won’t be able to authenticate and gain the right to access the content. Sensitivity labels put a stop to the tactic often seen when people just about to leave exfiltrate large amounts of confidential documents and email (in PSTs) to removeable devices. Exfiltration might work, but once the ex-employee can no longer authenticate, the confidential material becomes no more than an interesting collection of bytes.

It’s Hard to Revoke Access

No one quite delivered a script to totally secure an ex-employee’s account in the 20 minutes allotted for the task (one solution was delivered that removed access from every account in the tenant). Even with access to the internet, it takes time to find, assess, and decide what code to base a solution on. The difficulty is compounded when people are looking over your shoulder to criticize every move, or even when you find a great cmdlet to revoke access that Microsoft’s just about to deprecate…


Learn more about how the Microsoft 365 applications really work on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. Our monthly updates keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2024/10/04/revoke-spousersession-deprecation/feed/ 0 66581
Installing the Entire Microsoft Graph PowerShell SDK Seems Like the Right Idea https://office365itpros.com/2024/09/24/microsoft-graph-powershell-sdk-code/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-code https://office365itpros.com/2024/09/24/microsoft-graph-powershell-sdk-code/#respond Tue, 24 Sep 2024 07:00:00 +0000 https://office365itpros.com/?p=66452

Some Say It’s a Bad Idea to Install Every Microsoft Graph PowerShell SDK Sub-Module

An interesting article by Sam Ende made the case for not installing every one of the 38 sub-modules in the Microsoft Graph PowerShell SDK, not to mention the 171 sub-modules used by Azure PowerShell. The idea is that you review the full set of modules and only install the modules needed top run scripts. This is not a new notion. Azure Automation requires explicit installation of modules as resources for automation accounts before the cmdlets in the modules are used.

Advantages of Not Installing Every Sub-Module

Proponents of the idea cite several advantages. Taking the Graph SDK as an example, let’s discuss them:

  • Faster Auto-Import and Tab-Completion: Auto-import is what happens when a script references a cmdlet that isn’t already loaded into the session. If the module containing the cmdlet is installed on the workstation, PowerShell loads it. Tab completion is what happens if you enter a partial cmdlet name and press tab. PowerShell cycles through the set of matching cmdlets for you to choose from. It makes sense that auto-import and tab completion will function faster when less processing is needed and I do see a slight delay when an SDK cmdlet is first referenced, but overall, I think the gain is marginal.
  • Reduced Memory Usage: Every module that’s loaded into a PowerShell session consumes some memory. On a theoretical level, if a session loads every SDK module, it might consume a lot of memory. However, auto-import means that PowerShell only loads modules when necessary, so I’m not worried about memory consumption on a modern PC.
  • Avoiding Conflicts: Because a cmdlet name most only be unique within a module, the possibility for cmdlet name clashes increases as the number of installed modules mounts. However, Microsoft takes care of ensuring that SDK cmdlet names are unique (but very long at times), so this should not be an issue, even when beta modules and the Microsoft Entra module are considered. A bigger issue is Microsoft’s inability to coordinate module dependencies across products, something that still happens.
  • Improved Security: The fear is that unused modules might not be regularly updated. I assume the lack of updating is the fault of the user rather than the developer because Microsoft updates every module in the Microsoft Graph PowerShell SDK monthly using a process called AutoRest. If you automate module updating and make sure that this is done regularly, the fact that a few unused modules are on a drive shouldn’t be a concern.
  • Simplified Management: It’s true that the fewer components that must be maintained, the less chance that problems will sneak in to compromise an environment. However, if you perform periodic maintenance, all should be well. I use a script to maintain the full set of Microsoft 365-related modules (including Exchange Online, SharePoint Online, Teams, SharePoint PnP, DSC, etc.). Using PowerShell to manage PowerShell seems like the right thing to do.

From V2.0 on, the Microsoft Graph PowerShell SDK is divided into separate production and beta modules. I install the complete set of production and beta modules. I never know when a script will require a cmdlet that’s in a relatively underused module and prefer to have everything to hand at the expense of a little extra disk space (my automated script removes old versions of the SDK modules). I also install the Microsoft Entra module to keep an eye on its progress.

Determining Modules to Install

My preference is to install the complete Microsoft Graph PowerShell SDK. But if you want to reduce the set of modules, you must decide which to install. You can check each module to find the cmdlets it contains with the Get-Command cmdlet, like this:

Get-Command -Module Microsoft.Graph.Authentication | Sort-Object Name | Format-table Name, CommandType

Name                   CommandType
----                   -----------
Add-MgEnvironment           Cmdlet
Connect-Graph                Alias
Connect-MgGraph             Cmdlet
Disconnect-Graph             Alias
Disconnect-MgGraph          Cmdlet

An analysis of all the Graph SDK and Entra module cmdlets can be accomplished by extracting details of the cmdlets from all the modules. Here’s code to do the job:

$Report = [System.Collections.Generic.List[Object]]::new()
[array]$Modules = Get-InstalledModule -Name Microsoft.Graph.*  | Select-Object Name, Version | Sort-Object Name
Write-Host ("{0} Microsoft Graph modules found" -f $Modules.Count)
ForEach ($Module in $Modules) {
    Write-Host ("Processing module {0} version {1}" -f $Module.Name, $Module.Version)
    If ($Module.Name -ne "Microsoft.Graph.Beta") {
        [array]$Cmdlets = Get-Command -Module $Module.Name
        ForEach ($Cmdlet in $Cmdlets) {
            $ReportLine = [PSCustomObject] @{ 
                ModuleName = $Module.Name
                ModuleVersion = $Module.Version
                CmdletName = $Cmdlet.Name
                CmdletType = $Cmdlet.CommandType
            }
            $Report.Add($ReportLine)
        }
    }
}
$Report | Sort-Object CmdletName | Out-GridView -Title 'Microsoft Graph Cmdlets'

The script will take some time to run. On my PC, it reported 25,130 cmdlets in 38 Graph (production) modules, 44 Graph (beta) modules, and 2 Entra modules (Figure 1). Your numbers might differ depending on the version of the SDK (I used 2.23.0).

Listing cmdlets from the Microsoft Graph PowerShell SDK.
Figure 1: Listing cmdlets from the Microsoft Graph PowerShell SDK

Another method is to check the modules loaded in an interactive session after running some representative scripts. Here’s what’s reported for my current interactive session.

Get-Module | Where-Object {$_.Name -like "*Graph*"} | Format-Table Name, Version
Name                                              Version
----                                              -------
Microsoft.Graph.Authentication                    2.23.0
microsoft.graph.beta.calendar                     2.23.0
microsoft.graph.beta.identity.directorymanagement 2.23.0
microsoft.graph.beta.people                       2.23.0
microsoft.graph.beta.users                        2.23.0
microsoft.graph.beta.users.actions                2.23.0
microsoft.graph.beta.users.functions              2.23.0
Microsoft.Graph.Calendar                          2.23.0
Microsoft.Graph.DirectoryObjects                  2.23.0
Microsoft.Graph.Groups                            2.23.0
Microsoft.Graph.Identity.DirectoryManagement      2.23.0
Microsoft.Graph.Mail                              2.23.0
Microsoft.Graph.PersonalContacts                  2.23.0
Microsoft.Graph.Reports                           2.23.0
Microsoft.Graph.Users                             2.23.0

I can’t remember how long the session lasted (at least a few days) or what scripts were run (several), but this is a good indication of the modules that you actually use. The Microsoft.Graph.Authentication module is always required as without it you cannot authenticate.

A Personal Choice

Deciding what Microsoft Graph PowerShell SDK modules to install is a personal call. It is highly dependent on the objects that you focus on (like users, groups, and devices) and the actions taken in scripts. It might be worthwhile to check what modules you use as described above. You might then decide to trim what’s install or, if you’re like me, leave well enough alone and install everything.


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.

]]>
https://office365itpros.com/2024/09/24/microsoft-graph-powershell-sdk-code/feed/ 0 66452
How to Add Contacts to User Mailboxes From a CSV File https://office365itpros.com/2024/09/23/import-contacts/?utm_source=rss&utm_medium=rss&utm_campaign=import-contacts https://office365itpros.com/2024/09/23/import-contacts/#respond Mon, 23 Sep 2024 07:00:00 +0000 https://office365itpros.com/?p=66441

Amend a PowerShell Script to Import Contacts from a CSV File

Being a creature of habit, my practice is to write about shorter topics here and keep long-form articles that address complex topics for Practical365.com. Those topics often include discussion about using PowerShell to automate operations and involve a script that I publish in the Office365ITPros GitHub repository. The idea in writing scripts is to illustrate the principles of the topic under discussion, not to deliver a complete solution. I expect people to take the code and change it to meet their needs.

All of which brings me to an article where I cover how to read data from a list in a SharePoint Online site and use the information to create personal contacts in user mailboxes. I like the idea because I think a list is a good way to maintain information. Others obviously disagree, because soon after publication, I received a note saying that keeping stuff in a list is too complex (from the scripting perspective) and could they have a version that reads the input from a CSV file?

The Joy of Publishing PowerShell Scripts

Authors who write about PowerShell and include code snippets or complete scripts in their text often find that people reach out to ask for changes to be made. It’s as if you’re an online script generation service. For instance, after writing about how to create a licensing report for Microsoft 365 accounts, I received multiple requests for enhancements. Most of the ideas were very good and I was happy to incorporate the changes, which is why the script is now at version 1.94.

In some respects, generative AI has taken over as the go-to place to get advice about writing PowerShell. In any case, because generative AI depends on knowledge captured in its LLM from previous scripts, articles, and blog posts, it has a nasty habit of getting things wrong. Copilot seems prone to creating cmdlets that don’t exist and recommending their use to solve a problem. However, people do like the fact that it’s often easier to ask AI about a script than to track down an author.

Getting back to the original point, when an author receives a request to change code that they’ve published, they can ignore the email, tweet, or whatever platform was used to reach out to them or respond. If you want an author to help, you can prepare the ground by attempting to make the change yourself and explaining exactly why you think the change will be valuable. The desired outcome is more likely if you demonstrate that you’ve tried to understand and amend the code, and that good logic underpins the request.

Script Change to Import Contacts from a CSV File

In this instance, lines 20-46 of the script are where the input data is fetched from the SharePoint Online list. If you want to use a CSV file instead, you can throw away those lines and add something like this:

$ImportFile = 'c:\temp\Contacts.csv'
[array]$Itemdata = Import-Csv $ImportFile

These lines import data from a CSV file to the array used to populate contacts in user mailboxes. If the input CSV has the correct columns, then that’s all you need to do. The script will run and add the contacts to the target mailboxes.

Figure 1 shows an example of a CSV file in Excel. The column names are those expected by the script. If you don’t include the column headings or use different names, the script won’t know how to map properties from the CSV file to the contact records and it won’t be possible to import contacts.

CSV file containing comtacts to import.

Import Contacts from a CSV file
Figure 1: CSV file containing comtacts to import

A Quick Change to Switch Source of Import Contacts from SharePoint Site to a CSV File

Making the change took me about five minutes, three of which were to fix a bug where the hash table used by the script to detect if a contact already exists didn’t handle duplicate contacts with the same name. I’d created the duplicates to test how well the new Outlook suppresses duplicate contacts and forgot to remove them. It just shows how developing PowerShell scripts can be an iterative process.


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. The Office 365 book package now includes the Automating Microsoft 365 with PowerShell eBook.

]]>
https://office365itpros.com/2024/09/23/import-contacts/feed/ 0 66441
Transferring Reusable PowerShell Objects Between Microsoft 365 Tenants https://office365itpros.com/2024/09/03/tojsonstring-method/?utm_source=rss&utm_medium=rss&utm_campaign=tojsonstring-method https://office365itpros.com/2024/09/03/tojsonstring-method/#comments Tue, 03 Sep 2024 07:00:00 +0000 https://office365itpros.com/?p=66220

The Graph SDK’s ToJsonString Method Proves Its Worth

ToJsonString Method is valuable

One of the frustrations about using the internet is when you find some code that seems useful, copy the code to try it out in your tenant, and discover that some formatting issue prevents the code from running. Many reasons cause this to happen. Sometimes it’s as simple as an error when copying code into a web editor, and sometimes errors creep in after copying the code, perhaps when formatting it for display. I guess fixing the problems is an opportunity to learn what the code really does.

Answers created by generative AI solutions like ChatGPT, Copilot for Microsoft 365, and GitHub Copilot compound the problem by faithfully reproducing errors in its responses. This is no fault of the technology, which works by creating answers from what’s gone before. If published code includes a formatting error, generative AI is unlikely to find and fix the problem.

Dealing with JSON Payloads

All of which brings me to a variation on the problem. The documentation for Graph APIs used to create or update objects usually include an example of a JSON-formatted payload containing the parameter values for the request. The Graph API interpret the JSON content in the payload to extract the parameters to run a request. By comparison, Microsoft Graph PowerShell SDK cmdlets use hash tables and arrays to pass parameters. The hash tables and arrays mimic the elements of the JSON structure used by the underlying Graph APIs.

Composing a JSON payload is no challenge If you can write perfect JSON. Like any other rules for programming or formatting, it takes time to become fluent with JSON, and who can afford that time when other work exists to be done? Here’s a way to make things easier.

Every object generated by a Graph SDK cmdlet has a ToJsonString method to create a JSON-formatted version of the object. For example:

$User = Get-MgUser -UserId Kim.Akers@office365itpros.com
$UserJson = $User.ToJsonString()

$UserJson
{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity",
  "id": "d36b323a-32c3-4ca5-a4a5-2f7b4fbef31c",
  "businessPhones": [ "+1 713 633-5141" ],
  "displayName": "Kim Akers (She/Her)",
  "givenName": "Kim",
  "jobTitle": "VP Marketing",
  "mail": "Kim.Akers@office365itpros.com",
  "mobilePhone": "+1 761 504-0011",
  "officeLocation": "NYC",
  "preferredLanguage": "en-US",
  "surname": "Akers",
  "userPrincipalName": Kim.Akers@office365itpros.com
}

The advantages of using the ToJsonString method instead of PowerShell’s ConvertTo-JSON cmdlet is that the method doesn’t output properties with empty values. This makes the resulting output easier to review and manage. For instance, the JSON content shown above is a lot easier to use as a template for adding new user accounts than the equivalent generated by ConvertTo-JSON.

Transferring a Conditional Access Policy Using ToJsonString

The output generated by ToJsonString becomes very interesting when you want to move objects between tenants. For example, let’s assume that you use a test tenant to create and fine tune a conditional access policy. The next piece of work is to transfer the conditional access policy from the test tenant to the production environment. Here’s how I make the transfer:

  • Run the Get-MgIdentityConditionalAccessPolicy cmdlet to find the target policy and export its settings to JSON. Then save the JSON content in a text file.
$Policy = Get-MgIdentityConditionalAccessPolicy -ConditionalAccessPolicyId '1d4063cb-5ebf-4676-bfca-3775d7160b65'
$PolicyJson = $Policy.toJsonString()
$PolicyJson > PolicyExport.txt
  • Edit the text file to replace any tenant-specific items with equivalent values for the target tenant. For instance, conditional access policies usually include an exclusion for break glass accounts, which are listed in the policy using the account identifiers. In this case, you need to replace the account identifiers for the source tenant in the exported text file with the account identifiers for the break glass account for the target tenant.
  • Disconnect from the source tenant.
  • Connect to the target tenant with the Policy.ReadWrite.ConditionalAccess scope.
  • Create a variable ($Body in this example) containing the conditional policy settings.
  • Run the Invoke-MgGraph-Request cmdlet to import the policy definition into the target tenant.
$Uri = "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies"
Invoke-MgGraphRequest -uri $uri -method Post -Body $Body

The Other Way

Another way to create a conditional access policy with PowerShell is to run the New-MgIdentityConditionalAccessPolicy cmdlet, which takes a hash table as its payload. It’s easy to translate the JSON into the format used for parameter values stored in the hash table, but it’s even easier to run Invoke-MgGraphRequest and pass the edited version of the JSON exported from the source tenant. Why make things hard for yourself?


This tip is just one of the hundreds included the Automating Microsoft 365 with PowerShell eBook (available separately, as part of the Office 365 for IT Pros (2025 edition) bundle, or as a paperback from Amazon.com).

]]>
https://office365itpros.com/2024/09/03/tojsonstring-method/feed/ 1 66220
PnP PowerShell Changes Its Entra ID App https://office365itpros.com/2024/08/29/pnp-powershell-changes-app/?utm_source=rss&utm_medium=rss&utm_campaign=pnp-powershell-changes-app https://office365itpros.com/2024/08/29/pnp-powershell-changes-app/#comments Thu, 29 Aug 2024 05:00:00 +0000 https://office365itpros.com/?p=66182

Critical Need to Update Scripts Using PnP PowerShell Before September 9 2024

On August 21, 2024, the Pattern and Practices (PnP) team announced a major change for the PnP PowerShell module. To improve security by encouraging the use apps configured with only the permissions needed to process data within the tenant, the PnP PowerShell module is moving away from the multi-tenant Entra app (the PnP Management Shell, application identifier 31359c7f-bd7e-475c-86db-fdb8c937548e) used up to this point to require tenants to register a unique tenant-specific app for PnP.

Reading between the lines, the fear is that attackers will target the current PnP multi-tenant app and attempt to use it to compromise tenants. The multi-tenant app holds many Graph API permissions (Figure 1) together with a mixture of permissions for Entra ID, SharePoint Online, and the Office 365 service management API. Being able to gain control over such an app would be a rich prize for an attacker.

Some of the many permissions held by the multi-tenant PnP PowerShell app
Figure 1: Some of the many permissions held by the multi-tenant PnP PowerShell app

Swapping out one type of Entra app for another might sound innocuous, but it means that the sign-in command for PnP in every script must be updated. The PnP team will remove the current multi-tenant app on September 9, 2024, so any script that isn’t updated will promptly fail because it cannot authenticate. That’s quite a change.

The Usefulness of PnP PowerShell

I don’t use PnP PowerShell very often because I prefer to use Graph APIs or the Microsoft Graph PowerShell SDK whenever possible. However, sometimes PnP just works better or can perform a task that isn’t possible with the Graph. For instance, creating and populating Microsoft Lists is possible with the Graph, but it’s easier with PnP. SharePoint’s support for Graph APIs is weak and PnP is generally a better option for SharePoint Online automation, such as updating site property bags with custom properties (required to allow adaptive scopes to identify SharePoint Online sites). Finally, I use PnP to create files in SharePoint Online document libraries generated as the output from Azure Automation runbooks.

Creating a PnP Tenant Application

The first thing to do is to download the latest version of the PnP PowerShell module (which only runs on PowerShell 7) from the PowerShell Gallery. The maintainers update the module regularly. I used version 2.9.0 for this article.

The easiest way to create a tenant-specific application for PnP PowerShell is to run the Register-PnPEntraIDApp cmdlet:

Register-PnPEntraIDApp -ApplicationName "PnP PowerShell App" -Tenant office365itpros.onmicrosoft.com -Interactive

Make sure that you sign in with an account that has global administrator access. The cmdlet creates an Entra ID app and populates the app with some default properties, including a default set of Graph API permissions and a self-signed certificate for authentication. It doesn’t matter what name you give the app because authentication will use the unique application identifier (client id) Entra ID creates for the new app. The user who runs the cmdlet must be able to consent for the permissions requested for the app (Figure 2).

Consent sought for the default set of Graph permissions used by the PnP PowerShell app
Figure 2: Consent sought for the default set of Graph permissions used by the PnP PowerShell app

The Graph permissions allow read-write access to users, groups, and sites. Other permissions will be necessary to use PnP PowerShell with other workloads, such as Teams. Consent for these permissions is granted in the same way as for any other Entra ID app. Don’t rush to grant consent for other permissions until the need is evident and justified.

Using the Tenant App to Connect to PnP PowerShell

PnP PowerShell supports several ways to authenticate, including in Azure Automation runbooks. Most of the examples found on the internet show how to connect using the multi-tenant application. To make sure that scripts continue to work after September 9, every script that uses PnP PowerShell must be reviewed to ensure that its code works with the tenant-specific application. For instance, a simple interactive connection looks like this:

Connect-PnPOnline -Url https://office365itpros.sharepoint.com -ClientId cb5f363f-fbc0-46cb-bcfd-0933584a8c57 -Interactive

The value passed in the ClientId parameter is the application identifier for the PnP PowerShell application.

Azure Automation requires a little finesse. In many situations, it’s sufficient to use a managed identity. However, if a runbook needs to add content to a SharePoint site, like uploading a document, an account belonging to a site member must be used for authentication. This example uses credentials stored as a resource in the automation account executing the runbook.

$SiteURL = "https://office365itpros.sharepoint.com/sites/Office365Adoption"
# Insert the credential you want to use here... it should be the username and password for a site member
$SiteMemberCredential = Get-AutomationPSCredential -Name "ChannelMemberCredential"
$SiteMemberCredential
# Connect to the SharePoint Online site with PnP
$PnpConnection = Connect-PnPOnline $SiteURL -Credentials $SiteMemberCredential -ReturnConnection -ClientId cb5f363f-fbc0-46cb-bcfd-0933584a8c57

[array]$DocumentLibraries = Get-PnPList -Connection $PnpConnection | Where-Object {$_.BaseType -eq "DocumentLibrary"}
 
# Display the name, Default URL and Number of Items for each library
$DocumentLibraries | Select Title, DefaultViewURL, ItemCount

Ready, Steady, Go…

September 9 is not too far away, so the work to review, update, and test PnP PowerShell scripts needs to start very soon (if not yesterday). Announcing a change like this 19 days before it happens seems odd and isn’t in line with the general practice where Microsoft gives at least a month’s notice for a major change. I imagine that some folks coming back from their vacations have an unpleasant surprise lurking in their inboxes…

]]>
https://office365itpros.com/2024/08/29/pnp-powershell-changes-app/feed/ 12 66182
Handling the Too Many Retries Error and Dealing with Odd Numbers of Audit Events https://office365itpros.com/2024/08/14/auditlog-query-oddities/?utm_source=rss&utm_medium=rss&utm_campaign=auditlog-query-oddities https://office365itpros.com/2024/08/14/auditlog-query-oddities/#comments Wed, 14 Aug 2024 07:00:00 +0000 https://office365itpros.com/?p=65970

AuditLog Query API Cmdlets Now Available in the Microsoft Graph PowerShell SDK

In April 2024, I wrote about the new AuditLog Query Graph API. At the time, the API exhibited the normal rough edges found in any beta API, but I managed to use it to retrieve records from the Microsoft 365 unified audit log.

Roll forward some months and cmdlets are available for the AuditLog Query Graph API in the beta version of the Microsoft Graph PowerShell SDK (I used version 2.21 to test). Microsoft uses a process called AutoRest to automatically generate SDK cmdlets from Graph API metadata and cmdlets usually turn up a month or so after an API appears. The relevant cmdlets are:

  • New-MgBetaSecurityAuditLogQuery: create and submit an audit log query. Purview processes audit log queries in the background, just like the way audit searches work in the Purview compliance portal.
  • Get-MgBetaSecurityAuditLogQuery: check the processing status of an audit log query. Because background jobs handle the queries, they take much longer to complete than searches performed with the Search-UnifiedAuditLog cmdlet do. One job took 35 minutes to complete when Search-UnifiedAuditLog required three minutes.
  • Get-MgBetaSecurityAuditLogQueryRecord: retrieve the audit records found by the query.

Running a query is a matter of constructing a hash table containing the parameters such as the start and end time and the operations to search for, checking for completion of the job, and downloading the results. You can check out the test script I used from GitHub.

The Too Many Retries Problem

Two oddities occurred during testing. First, “Too many retries performed” errors appeared when running the New-MgBetaSecurityAuditLogQuery cmdlet. A search against the SDK issues revealed that I wasn’t the only one to encounter the problem. Adding the Set-MgRequestContext cmdlet to the script seems to have solved the problem. At least, it hasn’t reappeared.

According to its documentation, the Set-MgRequestContext cmdlet “Sets request context for Microsoft Graph invocations.” This is a delightfully obscure description that means little to most people. The important point is that you can increase the retry delay (in seconds) and maximum retries to get around then “too many retries problem” that seems to afflict some Graph APIs (those dealing with devices and Intune seem to be most affected). The default for these values are 3 (retries) and 3 (seconds delay). The maximums are 10 (retries) and 180 (delay seconds). For example:

Set-MgRequestContext -MaxRetry 10 -RetryDelay 15

Some trial and error is likely required to determine the optimum values for a script.

The Incorrect Audit Record Counts

The second issue was a complete disconnect between the number of audit records returned by the audit log query (10,878) and Search-UnifiedAuditLog (10,879), and the number reported by the Purview compliance portal (2,538).

Audit search results shown in the Purview compliance portal.

AuditLog Query Graph API
Figure 1: Audit search results shown in the Purview compliance portal

The compliance portal loads pages of 150 audit records at a time. If you scroll to the bottom of the list, it loads the next page, and so on. If you’re persistent, it’s possible to advance page by page until the full set of retrieved records is exhausted (Figure 2).

 Paging through the results gets to the end of the audit events
Figure 2: Paging through the results gets to the end of the audit events

I don’t know why the Purview compliance portal shows an incorrect count of audit records found by a search. The reason might be that the actual number of audit records found by a search is not returned by the API. Instead, you must fetch the records to find out how many are found.

Microsoft might be relying on the fact that audit searches are often quite precise (for instance, focusing on Copilot interactions for a single user). These searches don’t return thousands of records. If only 100 audit records are found, it’s easy for the portal to display an accurate count.

AuditLog Query API Still Needs Work

It’s nice to see the AuditLog Query API appear in SDK cmdlets. However, the API is still in beta status and the audit records it returns are less complete than those found by the Search-UnifiedAuditLog cmdlet. I guess everything needs time to mature.


Learn more about how the Microsoft 365 applications really work on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. Our monthly updates keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2024/08/14/auditlog-query-oddities/feed/ 4 65970
Finding Managers of Users with the Microsoft Graph PowerShell SDK https://office365itpros.com/2024/07/29/find-manager-for-entra-id-account/?utm_source=rss&utm_medium=rss&utm_campaign=find-manager-for-entra-id-account https://office365itpros.com/2024/07/29/find-manager-for-entra-id-account/#comments Mon, 29 Jul 2024 07:00:00 +0000 https://office365itpros.com/?p=65761

Find Manager for Entra ID Accounts is Easy at the Individual Level

Following Friday’s discussion about needing to update the script to create the Managers and Direct Reports report, I was asked what’s the best way to find managers assigned to Entra ID user accounts (Figure 1).

The manager listed in the properties of an Entra ID account.

Find manager for Entra ID account.
Figure 1: Find manager for Entra ID account in the Entra admin center

It is simple to find and report the manager for an individual user account with PowerShell. For instance, to find Sean Landy’s manager, run the Get-MgUserManager cmdlet. The return value is the object identifier for the manager’s account, so to find details of the manager, we must fetch it from the data stored in the additionalProperties property.

Get-MgUserManager -UserId Sean.Landy@office365itpros.com | Select-Object -ExpandProperty additionalproperties

Key               Value
---               -----
@odata.context    https://graph.microsoft.com/v1.0/$metadata#directoryObjects/$entity
@odata.type       #microsoft.graph.user
businessPhones    {+353 1 8816644}
displayName       James Ryan
givenName         James
jobTitle          Chief Story Teller
mail              James.Ryan@office365itpros.com

The Manager property is in the set available to Get-MgUser, but it must be fetched to be available for processing. The property is a reference to another account, so it must be resolved by using the ExpandProperty parameter. Again, the manager’s display name is retrieved from the additionalProperties property.

$UserData = Get-MgUser -UserId Sean.Landy@office365itpros.com -Property displayname, manager -ExpandProperty Manager

$UserData | Format-Table @{n='Employee'; e={$_.displayname}}, @{n='Manager'; e={$data.manager.additionalproperties['displayName']}}

Employee   Manager
--------   -------
Sean Landy James Ryan

Find the Managers for Multiple Users

Challenges emerge when dealing with multiple user accounts. For example, it’s common to retrieve the set of licensed user accounts in a tenant with a complex query that checks for the presence of at least one license. However, adding the ExpandProperty parameter to this command stops it working:

[array]$users = Get-MgUser -Filter "userType eq 'Member' and assignedLicenses/`$count ne 0" -ConsistencyLevel eventual -CountVariable UsersFound -All -PageSize 999 -Property Id, userPrincipalName, displayName, Manager, Department, JobTitle, EmployeeId -ExpandProperty Manager

The error is not terribly helpful:

Expect simple name=value query, but observe property 'assignedLicenses' of complex type 'AssignedLicense'.

Removing the ExpandProperty parameter from the command makes it work, but the Manager property is not populated.

Any filter to find user accounts that needs to populate the Manager property is restricted to a simple query. Here’s an example of a query to find all member accounts and populate the Manager property. A client-side filter then reduces the set to accounts with an assigned manager:

[array]$EmployeesWithManager = Get-MgUser -All -PageSize 999 -Property Id, DisplayName, JobTitle, Department, City, Country, Manager -ExpandProperty Manager -Filter "UserType eq 'Member'"| Where-Object {$_.Manager.id -ne $null}

$EmployeesWithManager | Format-Table id, displayname, @{Name='Manager';expression={$_.Manager.additionalProperties.displayName}} -Wrap

Id                                   DisplayName                             Manager
--                                   -----------                             -------
a3eeaea5-409f-4b89-b039-1bb68276e97d Ben Owens                               James Ryan
d446f6d7-5728-44f8-9eac-71adb354fc89 James Abrahams                          Kim Akers 
cad05ccf-a359-4ac7-89e0-1e33bf37579e James Ryan                              René Artois

The results generated by this code are acceptable because a user account with an assigned manager is probably one used by a human. The account probably has licenses too. Obviously, any account that hasn’t got an assigned manager will be left out of the report.

Looking for User Accounts without Managers

Things get a little more difficult if we reverse the client-side filter and look for member accounts that don’t have an assigned manager:

[array]$EmployeesWithoutManager = Get-MgUser -All -PageSize 999 -Property Id, DisplayName, JobTitle, Department, City, Country, Manager, UserPrincipalName -ExpandProperty Manager -Filter "UserType eq 'Member'"| Where-Object {$_.Manager.id -eq $null}

In addition to user accounts lacking managers, the set of resulting accounts will include utility accounts created by Exchange Online, including:

  • Room and equipment accounts.
  • Shared mailbox accounts.
  • Accounts used for Microsoft Bookings.
  • Accounts synchronized from other tenants in a multi-tenant organization (MTO).
  • Accounts created for submission of messages to the Exchange Online High Volume Email (HVE) solution.
  • Accounts created for Teams meeting rooms.
  • Service accounts created by the tenant for background processing and other reasons.

In a medium to large tenant, there might be thousands of these kinds of accounts cluttering up the view. To remove the utility accounts, create an array containing the object identifiers of the owning accounts:

[array]$CheckList = Get-ExoMailbox -RecipientTypeDetails RoomMailbox, EquipmentMailbox, SharedMailbox, SchedulingMailbox -ResultSize Unlimited | Select-Object -ExpandProperty ExternalDirectoryObjectId

If the tenant uses HVE, add the account identifiers for the HVE accounts to the array.

Get-MailUser -LOBAppAccount | ForEach { $Checklist += $_.ExternalDirectoryObjectId }

Now filter the account list to find those that don’t appear in the list of utility mailboxes:

$EmployeesWithoutManager = $EmployeesWithoutManager | Where-Object {($_.Id -notin $Checklist)}

If the tenant is part of a multi-tenant organization, this filter removes the accounts synchronized from the other tenants:

$EmployeesWithOutManager = $EmployeesWithoutManager | Where-Object {$_.UserPrincipalName -notlike "*#EXT#@*"}

Eventually, you’ll end up with hopefully a very small list of employees without assigned managers and can take the necessary action to rectify the situation.

Entra ID Should Mark Utility Accounts

The problem of dealing with utility accounts that end up in Entra ID with the same status as “human” user accounts is growing. Applications create new member accounts without thinking about the consequences. No problem is apparent because no licenses are consumed, but the steps needed to cleanse the set of accounts returned by Entra ID with cmdlets like Get-MgUser are another trap waiting for the unwary administrator. Microsoft really should do better in this area, like creating a new “utility” value for the UserType property. Would that be so bad?


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.

]]>
https://office365itpros.com/2024/07/29/find-manager-for-entra-id-account/feed/ 9 65761
The Maddening Side of the Microsoft Graph PowerShell SDK https://office365itpros.com/2024/07/26/microsoft-graph-powershell-sdk-odd/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-odd https://office365itpros.com/2024/07/26/microsoft-graph-powershell-sdk-odd/#comments Fri, 26 Jul 2024 07:00:00 +0000 https://office365itpros.com/?p=65740

Counting Fetched Objects is a Hard Computer Problem

All software has its own foibles, something clearly evident in the Microsoft Graph PowerShell SDK. You become accustomed to the little oddities and workaround known issues and all is well. But when the underlying foundation of software causes problems, it can cause a different level of confusion.

Take the question posed by MVP Aleksandar Nikolić on Twitter about how many accounts a Get-MgUser command will return (Figure 1).

An apparently simple question for the Get-MgUser cmdlet

Microsoft Graph PowerShell SDK
Figure 1: An apparently simple question for the Get-MgUser cmdlet

The answer is 4 even though the command explicitly requests the return of the top 3 matching objects. Why does this happen? It’s all about Graph pagination and the way it’s implemented in Microsoft Graph PowerShell SDK cmdlets.

Pagination and Page Size

To understand what occurs, run the command with the debug switch to see the actual Graph requests posted by Get-MgUser. The first request is against the Users endpoint and requests the top 2 matching objects.

https://graph.microsoft.com/v1.0/users?$top=2

Note that the Graph request only includes a $top query parameter. This sets the page size of the query and matches the PageSize parameter in the Get-MgUser command. The Top parameter used with Get-MgUser has no significance for the Graph query because it’s purely used to tell PowerShell how many objects to show when the command completes. The use of Top in different contexts is confusing, but few people look behind the scenes to see how the cake is made.

The Graph request respects the page size and fetches 2 objects. However, we asked for 3 objects so some more work is needed to fetch the outstanding item. Microsoft’s Graph documentation says “When more than one query request is required to retrieve all the results, Microsoft Graph returns an @odata.nextLink property in the response that contains a URL to the next page of results.” The skiptoken or nextlink lets the command know that further data remains to be fetched, so it continues to fetch the next page with a request that includes the skiptoken:

https://graph.microsoft.com/v1.0/users?$top=2&$skiptoken=RFNwdAIAAQAAAFI6NWUyZWI1YWIub2ZmaWNlMzY1aXRwcm9zLmNvbV9lbWVhLnRlYW1zLm1zI0VYVCNAUmVkbW9uZEFzc29jaWF0ZXMub25taWNyb3NvZnQuY29tKVVzZXJfYmNmYTZlY2ItY2M1Yi00MzU3LTkwOWMtMWUwZTQ1MDg2NGUzuQAAAAAAAAAAAAA

The follow up request fetches the remaining page containing 2 more objects and completes processing. The person running the command sees 4 objects from the two pages. In effect, the Get-MgUser cmdlet ignores the instruction passes in the Top parameter to only show 3 objects.





The same processing happens with different combinations of page size and objects requested, and it’s the same for other cmdlets too. For instance, the command:

Get-MgGroup-Top 7 -PageSize 3

Returns 9 group objects because 3 page fetches are necessary. It seems odd, and it’s odder still that running Get-MgGroup -Top 7 without specifying a page size will return exactly what we asked for (7 objects), while using a larger page size returns all the objects that can be packed into the page:

Get-MgUser -Top 3 -PageSize 8 | Format-Table Id, DisplayName

Id                                   DisplayName
--                                   -----------
44c0ca9c-d18c-4466-9492-c60c3eb78423 12 Knocksinna (Gmail)
bcfa6ecb-cc5b-4357-909c-1e0e450864e3 Email Channel for Exchange GOMs
da1288d5-e63c-4118-af62-3280823e04e1 GOM Email List for Teams
de67dc4a-4a51-4d86-9ee5-a3400d2c12ff Guest member - Project Condor
3e5a8c92-b9b6-4a45-a174-84ee97e5693f Arthur Smith
63699f2f-a46a-4e99-a068-47a773f9af11 Annie Jopes
7a611306-17d0-4ea0-922e-2924616d54d8 Andy David 
d8afc094-9c9b-4f32-86ee-fadd63b112b2 Aaron Jakes

Frustrating Paging and Display

The typical page size for a Graph request is 100 objects (but this can differ across resources), so it’s unusual to use the Top parameter to request a limited set of objects that’s larger than the default page size. Usually, I bump the page size up to 999 (the maximum) to reduce the number of requests made to fetch large quantities of user or group objects. Using a large page size can significantly affect the performance of queries retrieving large numbers of objects.

The conclusion is that changing the default page size for a Microsoft Graph PowerShell SDK cmdlet overrides the Top parameter. This kind of thing is commonly known as a bug and it’s very frustrating. The Graph requests work perfectly but then something gets in the way of restricting the output to the required number of objects.

Selecting Properties to Use

The same kind of problem arises when Microsoft changes the way Graph requests respond. For instance, this week I was asked why a script I included in an article about reporting Entra ID Managers and their Direct Reports didn’t work. The article dates from April 2023, so neither the text nor the script code is ancient.

Sometime in the intervening period, Microsoft made a change that affected the set of default properties returned by the Get-MgUser cmdlet (probably in the transition to V2.0 of the Microsoft Graph PowerShell SDK). The result meant that some of the properties returned when the script was written are not returned today. The fix is simple: use the Property parameter to specify the properties you expect to use in the script:

[array]$Users = Get-MgUser -Filter "assignedLicenses/`$count ne 0 and userType eq 'Member'" -ConsistencyLevel eventual -CountVariable Records -All -PageSize 999 -Property Id, displayName, userprincipalName, department, city, country

I believe Microsoft made the change to reduce the strain on Graph resources. It’s annoying to be forced to update scripts because of external factors, especially when cmdlets appear to run smoothly and generate unexpected output.

More Handcrafting Required for the Microsoft Graph PowerShell SDK

The issues discussed here make me think that Microsoft should dedicate more engineering resources to perfecting the Microsoft Graph PowerShell SDK instead of creating a new Entra PowerShell module that duplicates Microsoft Graph PowerShell SDK cmdlets. The statement’s been made that the Entra cmdlets are better because they’re “handcrafted,” which I understand means that humans write the code for cmdlets. T

It’s nice that the Entra module gets such attention, but it would be nicer if the Graph PowerShell SDK received more human handcrafting and love to make it more predictable and understandable. Even Entra ID would benefit from that work.


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.

]]>
https://office365itpros.com/2024/07/26/microsoft-graph-powershell-sdk-odd/feed/ 3 65740
Adding Cost Center Reporting to the Microsoft 365 Licensing Report https://office365itpros.com/2024/07/23/microsoft-365-licensing-report-192/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-licensing-report-192 https://office365itpros.com/2024/07/23/microsoft-365-licensing-report-192/#comments Tue, 23 Jul 2024 07:00:00 +0000 https://office365itpros.com/?p=65683

Different Forms of Cost Centers

On June 20, I announced version 1.9 of the Microsoft 365 Licensing Report. A month later, version 1.92 is available for download from GitHub. This version adds support for reporting licensing costs by cost center. Here’s how it works.

Ever since Exchange Server added a set of 15 custom attributes to mailboxes, organizations have used the attributes to hold all kinds of information. Cost center numbers come in different formats. In Digital Equipment Corporation, the numbers (or rather, designation) were values like 8ZW and 9HPE. In Compaq and HP, the values were more like 1001910. In any case, organizations often store cost center values in custom attributes to allow a more precise assignment of costs than is possible using standard Entra ID account properties like city, department, and country.

For cost center reporting to work, it’s obvious that accurate cost center numbers must be present in Exchange mailbox properties. Sometimes cost centers are added when users join an organization and receive a mailbox and are never updated afterwards. In other instances, organizations have synchronization mechanisms in place to ensure that if a change is made to an employee’s cost center (usually in a HR database), that change also happens for mailbox properties.

It might also be possible to implement cost center reporting based on managers (if managers manage cost centers). To do this, the script would have to find all the managers and assume that any direct reports are in the same cost center as the manager. I discounted this method and chose the simpler approach of using cost centers stored in a custom attribute, but it wouldn’t be difficult to code because Entra ID links stores details of the manager for each user account. Storing a manager for an account is not mandatory, so the same problem of data accuracy and availability might be present.

Microsoft 365 Licensing Report Script Changes to Support Cost Centers

The script supports cost center reporting through a variable called $CostCenterAttribute, which holds the name of the custom attribute to use. The name stored in the variable is the Entra ID property name rather than the Exchange name, so it’s a value like extensionAttribute1. If $CostCenterAttribute is not defined, the report doesn’t attempt to generate any information about licensing cost per cost center.

Exchange Online synchronizes the values of the mailbox custom attributes to the Entra ID user accounts of the mailbox owners. The custom attributes are stored in a property called OnPremisesExtensionAttributes. The Get-MgUser command to fetch user account details is amended to include OnPremisesExtensionAttributes in the set of retrieved properties. A set of cost centers found in user accounts is derived from the information retrieved by Get-MgUser.

When scanning user accounts for license information, the script extracts the cost center for each account and stores it along with other licensing data in a PowerShell list. This allows the report to later loop through the set of cost centers found in user accounts and calculate the licensing spend for each cost center, much like the licensing spend analysis done for departments and countries.

Reporting Licensing Spend by Cost Center

The script then outputs the cost center licensing spend analysis along with the other spending data in the summary part of the report (Figure 1).

Cost center analysis in the Microsoft 365 licensing report
Figure 1: Cost center analysis in the Microsoft 365 licensing report

Custom Attributes Open Up Lots of Opportunity

In this instance, the Microsoft 365 licensing report uses a custom attribute to store a cost center value. It is easy to see how custom attributes could be used for other analysis. For example, if a custom attribute held details of major projects, you could report the licensing spend for each project. All of this is basic PowerShell, so feel free to experiment!


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.

]]>
https://office365itpros.com/2024/07/23/microsoft-365-licensing-report-192/feed/ 2 65683
Upgrading the Teams and Groups Activity Report to 6.0 https://office365itpros.com/2024/07/15/teams-and-groups-activity-6/?utm_source=rss&utm_medium=rss&utm_campaign=teams-and-groups-activity-6 https://office365itpros.com/2024/07/15/teams-and-groups-activity-6/#comments Mon, 15 Jul 2024 06:00:00 +0000 https://office365itpros.com/?p=65597

Updating Old Code to Use the Microsoft Graph PowerShell SDK

Teams and Groups activity report

The Teams and Groups Activity Report is a reasonably popular script which attempts to measure whether teams and groups are in active use based on criteria like the number of messages sent in a team. Processes like this are important because it’s all too easy for a Microsoft 365 tenant to fall into a state of digital rot where unused teams and groups mask where useful work is done.

But like many scripts, the code has evolved over years (since 2016 in this case). The current version uses many Graph API calls and some Exchange Online cmdlets to fetch and analyze statistics. Microsoft recently released the Entra PowerShell module, which is built on top of the Microsoft Graph PowerShell SDK. I think this is a mistake because there are many issues that Microsoft should address in the PowerShell SDK. Dividing their engineering resources and focus across two modules seems like a recipe for inadequacy instead of excellence.

To prove the usefulness of the Microsoft Graph PowerShell SDK, it seemed like a good idea to rewrite the Teams and Groups activity report and replace Graph API requests with PowerShell SDK cmdlets wherever possible. The new Entra PowerShell module is incapable of the task because it deals exclusively with Entra objects, and the script needs to access elements like usage reports to determine if a group or team is active.

Microsoft Graph PowerShell SDK Advantages

By converting to the Microsoft Graph PowerShell SDK, I wanted to take advantages of two specific features offered by the SDK cmdlets. First, you don’t need to worry about pagination. Second, you don’t need to deal with access token acquisition and renewal. Many SDK cmdlets like Get-MgGroup have an All parameter, which instructs a cmdlet to perform automatic pagination to fetch all available items. Token acquisition and renewal is handled automatically for Graph SDK interactive or app-only sessions.

The old version of the script handles pagination and token renewal, but scripts require code to handle these tasks. Extra code means extra places where things can go wrong, and that’s always a concern.

The value passed to the PageSize parameter is another important factor for performance. Cranking its value up to 999 (or whatever the maximum supported value is for a resource like groups) reduces the number of Graph requests required to fetch data, a factor that can be very important when dealing with thousands of groups and teams.

Upgrading Script Code

Like all PowerShell scripts that use Graph API requests, the previous version uses an Entra ID application (or rather, the application’s service principal) to hold the Graph permissions used by the script.

The same technique can be used with the Microsoft Graph PowerShell SDK. In fact, it’s the right way to confine apps to the limited set of permissions necessary to do whatever processing they perform. Using an Entra ID registered app to connect to the Graph means that application permissions are used rather than delegated permissions and therefore the script has access to all data consented through permissions rather than just the data available to the signed-in account, which is the case with an interactive Graph session.

Here’s the code to connect a Graph session in app-only mode. The code specifies the tenant identifier, application identifier, and a certificate thumbprint. After connection, the script can use any permission consented to for the application.

$TenantId = "a662313f-14fc-43a2-9a7a-d2e27f4f3478"
$AppId = "a28e1143-88e3-492b-bf82-24c4a47ada63"
$CertificateThumbprint = "F79286DB88C21491110109A0222348FACF694CBD"
# Connect to the Microsoft Graph
Connect-MgGraph -NoWelcome -AppId $AppId -CertificateThumbprint $CertificateThumbprint -TenantId $TenantId

In the case of the script, the application must hold consent for the Group.Read.All, Reports.Read.All, User.Read.All, GroupMember.Read.All, Sites.Read.All, Organization.Read.All, and Teams.ReadBasic.All application permissions.

Some Hiccups

Like all coding projects, some hiccups occurred.

First, the cmdlets to fetch usage report data don’t seem to be capable of saving the data to a PSObject. Instead, the data must be saved to a temporary CSV file and then imported into an array. Also in this area, the annoying bug that prevents SharePoint usage data returning site URLs persists. It’s only been present since September 2023!

Second, the Get-MgSite cmdlet returned a 423 “site locked” error for some sites when retrieving site information. As it turned out, the sites were archived by Microsoft 365 Archive. Unfortunately, the Get-MgSite cmdlet doesn’t have an IsArchived property to filter against.

Third, it’s always better for performance to have the Graph return sorted information instead of fetching data and then sorting it with the Sort-Object cmdlet. When fetching groups, the original script used Sort-Object to sort the objects by display name. I converted this code to:

[array]$Groups = Get-MgGroup -Filter "groupTypes/any(a:a eq 'unified')" -PageSize 999 -All `
-Property id, displayname, visibility, assignedlabels, description, createdDateTime, renewedDateTime, drive -Sort "displayname DESC"

Get-MgGroup_List: Sorting not supported for current query.

The command didn’t work and the error isn’t as helpful as it could be. The reason for the failure is that adding a sort converts the query from a standard to an advanced query, which means that you need to add the ConsistencyLevel and CountVar parameters. Here’s a working version of the command:

[array]$Groups = Get-MgGroup -Filter "groupTypes/any(a:a eq 'unified')" -PageSize 999 -All `
-Property id, displayname, visibility, assignedlabels, description, createdDateTime, renewedDateTime, drive -Sort "displayname DESC" -ConsistencyLevel eventual -CountVar GroupCount

Oddly, the Get-MgTeam cmdlet doesn’t support the ConsistencyLevel parameter so you cannot sort a list of teams except by sorting the objects fetched by Get-MgTeam with the Sort-Object cmdlet.

A Successful Conversion

I am happy with the migration. There are about 10% fewer lines of code in the Graph SDK version of the script, and everything works as expected. Or so I think. If you want to see the converted script, you can download it from GitHub.


Learn more about how the Office 365 applications really work on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. Our monthly updates keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2024/07/15/teams-and-groups-activity-6/feed/ 1 65597
The Right Way to Replace the Remove-SPOExternalUser Cmdlet https://office365itpros.com/2024/07/11/remove-spoexternaluser-cmdlet/?utm_source=rss&utm_medium=rss&utm_campaign=remove-spoexternaluser-cmdlet https://office365itpros.com/2024/07/11/remove-spoexternaluser-cmdlet/#respond Thu, 11 Jul 2024 04:00:00 +0000 https://office365itpros.com/?p=65499

Microsoft Will Remove-SPOExternalUser Between July 29 and August 9

Message center notification MC806103 (27 June 2024) reports the deprecation of the Remove-SPOExternalUser cmdlet from the SharePoint Online management PowerShell module. Microsoft suggests that administrators replace the cmdlet with the Remove-AzureADUser cmdlet, which is a perfectly reasonable strategy if only the cmdlet isn’t part of the retired and soon-to-be deprecated AzureAD module.

Between July 29. 2024 and August 9, 2024, Microsoft will disable the Remove-SPOExternalUser cmdlet. When the block arrives in a tenant, attempts to run the cmdlet will be greeted with:

To streamline scope and permissions for external users, enhance access management, and strengthen our security posture, this cmdlet has been deprecated. Alternatively, please use the Remove-AzureADUser cmdlet in Microsoft Entra ID for user management.”

Microsoft 365 is so Large that No One Understands Everything

MC806103 is a classic example of Microsoft being such a large organization that no one knows what’s happening across the board, or even what’s happening within Microsoft 365. In this case, the SharePoint Online people want to deprecate the Remove-SPOExternalUser cmdlet. That’s a good idea because the cmdlet has low usage (I don’t think I have ever used it) and doesn’t really make sense inside the Microsoft 365 ecosystem where external access for applications like SharePoint Online is now governed using guest accounts. It makes perfect sense to remove overlapping or conflicting features and replace them with what you’d consider a component that’s closer to the core.

Entra ID is the directory of record for Microsoft 365. Individual workloads like SharePoint Online have their own directory, but everything flows back to Entra ID. Replacing the SharePoint Online cmdlet with an Entra ID cmdlet is the right thing to do. The problem is that the program manager in charge of making the transition obviously doesn’t know that the Entra ID team has been trying to deprecate the AzureAD and AzureADPreview modules since 2020. For the last few years, Microsoft has conducted an ongoing campaign to move tenants off these modules to use the Microsoft Graph PowerShell SDK.

What makes this laughable is that Microsoft launched the Entra PowerShell module in preview on June 27 in the hope that a dedicated Entra module (built on top of the Microsoft Graph PowerShell SDK) would help the remaining customers who have scripts that use the AzureAD and AzureADPreview modules to move to a modern platform. Obviously, whoever wrote MC806103 had no idea that this development was in train.

The Right Way to Replace Remove-ExternalSPOUser

The Get-SPOExternalUser cmdlet reports the external users registered for a SharePoint Online tenant. The last time I discussed its use, I observed that the Get-SPOExternalUser cmdlet is an odd cmdlet in some ways, but it does generate a list of external users from the SharePoint directory.

An external user record looks like:

RunspaceId    : 9630573b-c675-4697-a029-72d535e48613
Email         : charu.someone@microsoft.com
DisplayName   : Charu Someone
UniqueId      : 100320009C9C6789
AcceptedAs    : charsomeone@microsoft.com
WhenCreated   : 20/02/2020 19:45:02
InvitedBy     :
LoginName     :
IsCrossTenant : False

Remove-SPOExternalUser works like this:

Remove-SPOExternalUser -UniqueIDs ($User).UniqueId -Confirm:$false
Successfully removed the following external users
100320009C9C6789

The cmdlet removes the external user entry from SharePoint Online. It also removes the matching guest account, if one exists, from Entra ID. In my tenant there are quite a few lingering external accounts that don’t have matching Entra ID guest accounts. These accounts are just another form of digital debris that needs to be cleaned up.

The right way to remove an external account is to use the Remove-MgUser cmdlet from the Microsoft Graph PowerShell SDK:

$User = Get-MgUser -filter "mail eq 'andrew@proton.me"
Remove-MgUser -UserId $User.Id

Or, if you decide to use the preview Entra module:

$User = Get-EntraUser -SearchString 'AdamP@contoso.com'
Remove-EntraUser -ObjectId $User.Id

Either cmdlet has a much longer future ahead of it than the Remove-AzureADUser cmdlet has. In both cases, SharePoint Online synchronizes with Entra ID and removes the matching external user record.

It’s Just Hard to Keep Up

I don’t blame the individual program manager responsible for MC806103. It’s hard to keep up with everything that goes on within Microsoft 365 and all too easy to assume that a solution that works (for now) is the right long-term recommendation. Perhaps Microsoft needs a clearing house to cross-check dependencies outside the control of an individual development group before they publish information to customers?


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2024/07/11/remove-spoexternaluser-cmdlet/feed/ 0 65499
Adding Details of Authentication Methods to the Tenant Passwords and MFA Report https://office365itpros.com/2024/06/25/authentication-methods-v13/?utm_source=rss&utm_medium=rss&utm_campaign=authentication-methods-v13 https://office365itpros.com/2024/06/25/authentication-methods-v13/#comments Tue, 25 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65312

Revealing Full Details of Authentication Methods and Why This Might Be a Privacy Issue

Soon after releasing V1.2 of the Tenant Passwords and MFA Report (to add details about per-user MFA states), I was asked if it was possible to add more information for authentication methods, like the phone number used for SMS responses. My response was that I had covered the topic of reporting the details of authentication methods in a previous article and it was simply a matter of using the code from that article, updating it slightly to deal with the device-based passkeys recently introduced for Entra ID.

Not everyone likes cracking open a PowerShell script to insert code that they didn’t write. I don’t like messing with other peoples’ code either and will usually write my own version when necessary. In any case, I found some time and upgraded the script to include the expanded details, available in V1.3 of the script in GitHub.

Reporting Authentication Methods

Figure 1 shows the information about authentication methods registered for a user account in V1.2 of the report. The information given use the names from the MethodsRegistered property returned by the Get-MgBetaReportAuthenticationMethodUserRegistrationDetail cmdlet from the Microsoft Graph PowerShell SDK.

 Reporting the authentication methods registered for a user account.
Figure 1: Reporting the authentication methods registered for a user account

The problem is that the names aren’t very user-friendly. If you’re used to working with authentication methods, you probably recognize the values and understand what they mean. If not, this information might be useless.

More detail about the methods is available by running the Get-MgUserAuthenticationMethod cmdlet. Even so, some manipulation is necessary to generate human-friendly output. I’d done most of the work before, so it was easy to generate more information for each method. For instance, in Figure 2 you can see the mobile phone number used for SMS challenges and the version of the Authenticator app used for push notifications.

Expanded details of a user account's registered authentication methods.
Figure 2: Expanded details of a user account’s registered authentication methods

Because the script captures details in a PowerShell list, it’s also possible to query the list to find information like who uses a YubiKey FIDO2 key with a command like:

$Report | Where-Object {$_.'Authentication Methods' -like "*Yubikey*"}

The Privacy Issue

All was going well when I realized that the information generated about authentication methods might include some PII data, like the mobile phone number used for SMS responses. In most instances, I don’t think this will be a problem because details like mobile phone numbers are often included in the properties of Entra ID user accounts. The email addresses used to recover passwords via the Self-Service Password Reset (SSPR) feature are often personal accounts, so they might be more of an issue.

However, the regulations covering access to PII differs from country to country and it’s a good idea to cover all bases. The script now has a PrivacyFlag parameter. It’s a switch parameter, so the value is false by default. If set to true by including the parameter when running the script or by setting the flag explicitly, the script generates the names of the authentication methods without any details.

$PrivacyFlag = $true

On to The Next Version

I am sure that many other good ideas about how to add value to a report like this exist within the community. If you do, suggest the change through the Office 365 for IT Pros GitHub repository (for this script or any of our other scripts). Many people create a fork of our repository and work on updates that way. Whatever’s easier for you…


Learn more about how Microsoft 365 applications and Entra ID work on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. Our monthly updates keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2024/06/25/authentication-methods-v13/feed/ 1 65312
Planner User Policy Stops Task and Plan Deletions https://office365itpros.com/2024/06/21/set-planneruserpolicy-effects/?utm_source=rss&utm_medium=rss&utm_campaign=set-planneruserpolicy-effects https://office365itpros.com/2024/06/21/set-planneruserpolicy-effects/#respond Fri, 21 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65202

Running the Set-PlannerUserPolicy Cmdlet Has an Unexpected Effect

Although Planner supports a Graph API, the API focuses on management of plans, tasks, buckets, categories, and other objects used in the application rather than plan settings like notifications or backgrounds. It’s good at reporting plans and tasks or populating tasks in a plan, but the API also doesn’t include any support for tenant-wide application settings. In most cases, these gaps don’t matter. The Planner UI has the necessary elements to deal with notification and background settings, neither of which are likely changed all that often. But tenant-wide settings are a dirty secret of Planner. Let me explain why.

The Planner Tenant Admin PowerShell Module

In 2018, Microsoft produced the Planner Tenant Admin PowerShell module. With such a name, you’d expect this module to manage important settings for Planner. That is, until you read the instructions about how to use the module, which document the odd method chosen by the Planner development group distribute and install the software.

Even the Microsoft Commerce team, who probably have the reputation for the worst PowerShell module in Microsoft 365, manage to publish their module through the PowerShell Gallery. But Planner forces tenant administrators to download a ZIP file, “unblock” two files, and manually load the module. The experience is enough to turn off many administrators from interacting with Planner PowerShell.

But buried in this unusual module is the ability to block users from being able to delete tasks created by other people. Remember that most plans are associated with Microsoft 365 Groups. The membership model for groups allows members to have the same level of access to group resources, including tasks in a plan. Anyone can delete tasks in a plan, and that’s not good when Planner doesn’t support a recycle bin or another recovery mechanism.

What the Set-PlannerUserPolicy Cmdlet Does

The Set-PlannerUserPolicy cmdlet from the Planner Tenant Admin PowerShell module allows tenant administrators to block users from deleting tasks created by other people. It’s the type of function that you’d imagine should be in plan settings where a block might apply to plan members. Or it might be a setting associated with a sensitivity label that applied to all plans in groups assigned the label. Alternatively, a setting in the Microsoft 365 admin center could impose a tenant-wide block.

In any case, none of those implementations are available. Instead, tenant administrators must run the Set-PlannerUserPolicy cmdlet to block individual users with a command like:

Set-PlannerUserPolicy -UserAadIdOrPrincipalName Kim.Akers@office365itpros.com -BlockDeleteTasksNotCreatedBySelf $True

The Downside of the Set-PlannerUserPolicy Cmdlet

The point of this story is that assigning the policy to a user account also blocks the ability of the account to delete plans, even if the account is a group owner. This important fact is not mentioned in any Microsoft documentation.

I discovered the problem when investigating how to delete a plan using PowerShell. It seemed a simple process. The Remove-MgPlannerPlan cmdlet from the Microsoft Graph PowerShell SDK requires the planner identifier and its “etag” to delete a plan. This example deletes the second plan in a set returned by the Get-MgPlannerPlan cmdlet:

[array]$Plans = Get-MgPlannerPlan -GroupId $GroupId
$Plan = $Plans[1]
$Tag = $Plan.additionalProperties.'@odata.etag' 
Remove-MgPlannerPlan -PlannerPlanId $Plan.Id -IfMatch $Tag

The same problem occurred when running the equivalent Graph API request:

$Headers = @{}
$Headers.Add("If-Match", $plan.additionalproperties['@odata.etag'])
$Uri = ("https://graph.microsoft.com/v1.0/planner/plans/{0}" -f $Plan.Id)
Invoke-MgGraphRequest -uri $Uri -Method Delete -Headers $Headers

In both cases, the error was 403 forbidden with explanatory text like:

{"error":{"code":"","message":"You do not have the required permissions to access this item, or the item may not exist.","innerError":{"date":"2024-06-13T17:10:10","request-id":"d5bf922c-ea9b-48c6-9629-d9749ab7ec51","client-request-id":"6a533cf8-4396-4743-acf1-a40c32dd11bc"}}}

Even more bafflingly, the Planner browser client refused to let me delete a plan too. At least, the client accepted the request but then failed with a very odd error (Figure 1). After dismissing the error, my access to the undeleted plan continued without an issue.

The Planner browser app declines to delete a plan because of the effect of the Set-PlannerUserPolicycmdlet.
Figure 1: The Planner browser app declines to delete a plan

A Mystery Solved

Fortunately, I have some contacts inside Microsoft that were able to check why my attempts to delete plans failed and report back that the deletion policy set on my account blocked the removal of both tasks created by other users and plans. The first block was expected, the second was not. I’m glad that the mystery is solved but underimpressed that Microsoft does not document this behavior. They might now…

The moral of the story is not to run PowerShell cmdlets unless you know what their effect would be. I wish someone told me that a long time ago.

]]>
https://office365itpros.com/2024/06/21/set-planneruserpolicy-effects/feed/ 0 65202
Version 1.9 of the Microsoft 365 Licensing Report https://office365itpros.com/2024/06/20/microsoft-365-licensing-report-19/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-licensing-report-19 https://office365itpros.com/2024/06/20/microsoft-365-licensing-report-19/#comments Thu, 20 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65235

Highlighting License Costs for Disabled and Inactive Users with Color

The Microsoft 365 Licensing report is one of the more popular scripts I’ve written. The last set of updates added analysis of licensing costs by department and country. I maintain a list of things that people have asked me to add to the script. Last week, I wanted to take a break from the work to prepare the new edition of the Office 365 for IT Pros eBook, so I fired up Visual Studio Code and got to work.

On my to-list were the following:

  • Highlight disabled counts better and report the cost of licenses assigned to disabled accounts.
  • Highlight the cost of licenses assigned to user accounts that haven’t signed in for 90 days or more.
  • Add Excel worksheet output using the ImportExcel module.
  • Categorize the license spend for individual user accounts to be under, average, or high based on the average cost for the tenant.
  • Use color to highlight important points in the HTML report (Figure 1). I’m color blind, so the colors I selected to highlight different values might not be to your taste. If so, feel free to select different colors and modify the script by inserting the hex code values of those colors into the style sheet for the report.
  • Fix some small bugs. There’s always a couple to clean up.

Microsoft 365 Licensing Report (HTML file)
Figure 1: Microsoft 365 Licensing Report (HTML file)

Summarizing Licensing Costs

Figure 2 shows the updated summary of costs generated at the end of the HTML report. The cost analyses by country and department were in the last update, but I fixed a bug where the report didn’t deal as well as it should do when no licenses are assigned to accounts without a department or country.

Summary information for the Microsoft 365 Licensing Report.
Figure 2: Summary information for the Microsoft 365 Licensing Report

The new information is in the section for inactive user accounts and disabled user accounts. Each category lists the set of user accounts that match the criteria together with the total cost of licenses assigned. I used 90 days since the last sign-in to decide if an account is inactive. It’s easy to modify the script to use a higher or lower value, depending on how long it takes before your organization considers an account to be inactive.

Generating an Excel Worksheet for the Licensing Data

Many PowerShell scripts generate CSV files for their output. It’s natural that this should be the case. The Export-CSV cmdlet is part of base PowerShell, and the CSV file format is easy to work with and the data is easy to import back into a PowerShell array.

Some of the CSV files end up as Excel worksheets. It’s easy to do this by opening the CSV file with Excel and saving the file as a worksheet. The ImportExcel module supports the generation of worksheet in many different styles with data inserted into a table ready to be analyzed (Figure 3).

Microsoft 365 Licensing Report in an Excel worksheet.
Figure 3: Microsoft 365 Licensing Report in an Excel worksheet

The script checks if the ImportExcel module is available. If it is, the script generates an Excel worksheet. If not, the licensing data is exported to a CSV file.

Important Note and How to Get the Script

If you haven’t run the script before, make sure to read these Practical365.com articles to understand how the script works, how to generate the two (SKU and service plan) CSV files used by the script, and how to add cost data for Microsoft 365 subscriptions. Basically, some up-front work is necessary to prepare reference data for the script to use in its analysis. The code can extract details of user accounts and their assigned licenses from Entra ID, but turning GUIDs into human-friendly product names requires some help. The cost of Microsoft 365 subscriptions differs from country to country too.

You can download V1.9 of the script from GitHub.

Microsoft 365 tenants can have large quantities of licenses to manage. This script might help as written, or inspire you to create your own version tailored to the needs of your organization


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.

]]>
https://office365itpros.com/2024/06/20/microsoft-365-licensing-report-19/feed/ 2 65235
Working with Calendar Permissions using the Microsoft Graph PowerShell SDK https://office365itpros.com/2024/06/18/set-default-calendar-permission/?utm_source=rss&utm_medium=rss&utm_campaign=set-default-calendar-permission https://office365itpros.com/2024/06/18/set-default-calendar-permission/#comments Tue, 18 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65222

Set Calendar Permission to Allow Organization Users to See Limited Details

In September 2021, I wrote about how to set the calendar permission for mailboxes to allow users within the organization to view event titles and locations. In the article, I discuss how to use the Set-MailboxFolderPermission cmdlet to update the access rights assigned to the “default user” from availability only to limited details. The permission assigned to the default user is the one used if a more specific permission is unavailable. By allowing more access to a user calendar for the default user, it means that anyone in the organization can see more information from that user’s calendar. In OWA and the new Outlook for Windows (Monarch) client, the sharing permission is called “can view titles and locations” (Figure 1).

Can view titles and locations means that users who check someone else’s calendar to see event subjects and locations. The default shows only that slots in a calendar are blocked or free.

Using OWA to set the default user calendar permission
Figure 1: Using OWA to set the default user calendar permission

Calendar Permissions and the Graph

Time passes on and today an alternative solution is available in the form of the Graph calendar permission resource and its methods, plus the associated Microsoft Graph PowerShell SDK cmdlets like Get-MgUserCalendarPermission and Update- MgUserCalendarPermission.

The Get-MailboxFolderPermission and Set-MailboxFolderPermission cmdlets have never been quick, so the question is whether the Graph-based cmdlets are faster at checking and setting calendar permissions.

Testing Performance

I decided to test by writing two scripts. Both scripts fetch user and room mailboxes which use the limited availability permission and update the mailboxes to allow access to limited details.

Both scripts use the Get-ExoMailbox cmdlet to fetch mailbox details. There isn’t a good Graph-based method to fetch mailbox-enabled accounts. Get-MgUser can apply a filter to fetch licensed accounts, but that set won’t include room mailboxes. Get-MgUser can fetch all member accounts, but this set will probably include a bunch of accounts that don’t have mailboxes. In addition, because the script loads the Exchange Online management module to use Get-ExoMailbox, it can also use Set-Mailbox to update a custom attribute with an indicator after processing a mailbox.

Maintaining an indicator in a custom attribute is important because the Get-ExoMailbox command can filter out mailboxes that have the permission set. For instance, if you run the script monthly, it will only process mailboxes created since the last run.

Here’s the Exchange Online script. The Set-MailboxFolderPermission cmdlet requires passing the name of the calendar folder, so there’s some code to figure out the value in different languages.

# Exchange Online version 
[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox, RoomMailbox -Filter {CustomAttribute10 -ne "OpenCalendar"} -ResultSize Unlimited -Properties Languages | Sort-Object DisplayName
Write-Host ("{0} mailboxes found" -f $Mbx.Count)
[int]$Updates = 0
ForEach ($M in $Mbx) {
  # Figure out the name of the Calendar folder in the user's preferred language
  [array]$Languages = $M.Languages
  Switch ($Languages[0]) {
      "en-US" { $CalendarName = "Calendar" }
      "fr-FR" { $CalendarName = "Calendrier" }
      "de-DE" { $CalendarName = "Kalender" }
      "es-ES" { $CalendarName = "Calendario" }
      "it-IT" { $CalendarName = "Calendario" }
      "nl-NL" { $CalendarName = "Agenda" }   
      Default { $CalendarName = "Calendar" }
  }
  # Build the path to the Calendar folder
  $CalendarFolder = ("{0}:\{1}" -f $M.UserPrincipalName, $CalendarName)
  [array]$Data = Get-MailboxFolderPermission -Identity $CalendarFolder | Where-Object {$_.User.usertype.value -eq "Default"} | Select-Object -ExpandProperty AccessRights
  If ([string]$Data -ne "LimitedDetails") {
      Write-Host ("Setting LimitedDetails permission for {0}" -f $M.displayName) -ForegroundColor Yellow
      Set-MailboxFolderPermission -Identity $CalendarFolder -User Default -AccessRights LimitedDetails
      Set-Mailbox -Identity $M.UserPrincipalName -CustomAttribute10 "OpenCalendar"
      $Updates++
  } Else {
      # for some reason the custom attribute is not set to reflect the calendar permission, so update it
      Write-Host "Setting custom attribute for" $M.UserPrincipalName
      Set-Mailbox -Identity $M.UserPrincipalName -CustomAttribute10 "OpenCalendar"
  }
}
Write-Host ("Calendar permission updated for {0} mailboxes" -f $Updates)

Here’s the version using a mixture of Exchange Online and Microsoft Graph PowerShell SDK cmdlet. This code doesn’t need to know anything about language values for folder names because the Graph uses different identifiers.

# Graph version
[int]$Updates = 0
[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox, RoomMailbox -Filter {CustomAttribute10 -ne "OpenCalendar"} -ResultSize Unlimited -Properties Languages | Sort-Object DisplayName
Write-Host ("{0} mailboxes found" -f $Mbx.Count)
ForEach ($M in $Mbx){
    [array]$CalendarPermissions = Get-MgUserCalendarPermission -UserId $M.ExternalDirectoryObjectId
  If ($CalendarPermissions) {  
     $OrgDefault = $null
     [array]$OrgDefault = $CalendarPermissions | Where-Object {$_.EmailAddress.Name -eq "My Organization"}  
     If ($Permission -notin $OrgDefault.Role) {
        Write-Host ("Setting Limited Read permission for {1}" -f $M.DisplayName) -ForegroundColor Yellow
        Try {
           Update-MgUserCalendarPermission -UserId $M.ExternalDirectoryObjectId `
             -Role "LimitedRead" -CalendarPermissionId $OrgDefault.id | Out-Null
           $Updates++
        } Catch {
            Write-Host ("Failed to update calendar permission for {0}" -f $M.DisplayName) -ForegroundColor Red
        }
        Set-Mailbox -Identity $M.ExternalDirectoryObjectId -CustomAttribute10 "OpenCalendar"
        } Else {
          Write-Host ("{0} already has the Limited Read permission" -f $M.DisplayName)
        }
  } 
}
Write-Host ("Calendar permission updated for {0} mailboxes" -f $Updates)

Here’s the version using a mixture of Exchange Online and Microsoft Graph PowerShell SDK cmdlet. This code doesn’t need to know anything about language values for folder names because the Graph uses different identifiers. I can’t account for why Microsoft decided to call the permission LimitedDetails in Exchange and LimitedRead in the Graph. The different roles available for the Graph are documented online.

# Graph version
[int]$Updates = 0
[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox, RoomMailbox -Filter {CustomAttribute10 -ne "OpenCalendar"} -ResultSize Unlimited -Properties Languages | Sort-Object DisplayName
Write-Host ("{0} mailboxes found" -f $Mbx.Count)
ForEach ($M in $Mbx){
    [array]$CalendarPermissions = Get-MgUserCalendarPermission -UserId $M.ExternalDirectoryObjectId
  If ($CalendarPermissions) {  
     $OrgDefault = $null
     [array]$OrgDefault = $CalendarPermissions | Where-Object {$_.EmailAddress.Name -eq "My Organization"}  
    If ("LimitedRead" -notin $OrgDefault.Role) {
       Write-Host ("Setting Limited Read permission for {0}" -f $M.DisplayName) -ForegroundColor Yellow
       Try {
          Update-MgUserCalendarPermission -UserId $M.ExternalDirectoryObjectId `
            -Role "LimitedRead" -CalendarPermissionId $OrgDefault.id | Out-Null
          $Updates++
       } Catch {
           Write-Host ("Failed to update calendar permission for {0}" -f $M.DisplayName) -ForegroundColor Red
       }
       Set-Mailbox -Identity $M.ExternalDirectoryObjectId -CustomAttribute10 "OpenCalendar"
       } Else {
         Write-Host ("{0} already has the Limited Read permission" -f $M.DisplayName)
       }
  } 
}
Write-Host ("Calendar permission updated for {0} mailboxes" -f $Updates)

The Measure-Command cmdlet generated the test results, which showed that the Exchange script required 2.84 seconds per mailbox to run. The Graph version was nearly a second faster per mailbox (1.96 seconds). Your mileage might vary.

No Need to Change Unless You Must

Using the Graph SDK cmdlets saves almost a second per mailbox. That doesn’t mean that you should update scripts to rip out and replace the Set-MailboxFolderPermission cmdlet. While it’s important to use code that runs quickly, this kind of script is not something you’re going to run daily. It’s more likely to run on a scheduled basis, such as an Azure Automation runbook, and you won’t notice the extra time.

Besides, the most important contribution to performance in this example is reducing the number of mailboxes to process by maintaining the indicator and using the indicator to filter mailboxes. One cmdlet might be faster than another, but it’s how you use cmdlets in a script that dictates overall performance.


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2024/06/18/set-default-calendar-permission/feed/ 2 65222
To Splat or Not to Splat, That’s the Question https://office365itpros.com/2024/06/12/splatting-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=splatting-powershell https://office365itpros.com/2024/06/12/splatting-powershell/#comments Wed, 12 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65119

Splatting Helps the Readability of PowerShell Code

Splatting is a way to define and use values for parameters dent to PowerShell cmdlets and functions. Instead of specifying a value for each parameter when running a cmdlet, you create a hash table and add the parameters and their values to the hash table. Then you specify the hash table when running the cmdlet. The idea is to avoid long command lines that might or might not be broken up with backticks. Long command lines can sometimes be difficult to scan to understand exactly what the intent of a command is.

Those who endorse splatting say that it’s easy to forget a parameter or a backtick when composing long command lines. In addition, the parameters and values passed in long command lines can be harder to read than a nicely formatted hash table.

Using a development tool like Visual Studio Code will help to make sure that commands are properly formed. After that, using splatting to pass parameters is down to personal choice. And if you pay for a GitHub Copilot license, you’ll discover that Copilot does an excellent job of filling parameter values.

Example of Splatting

Here’s an example of how splatting works. The Set-User command updates many properties of a user account. Note the use of backticks to break the command over several lines:

Set-User -Identity Ben.James -Office 'Dublin Center' -City 'Dublin' `
-CountryOrRegion 'Ireland' -Department 'Sales and Marketing' `
 -DisplayName 'Ben James (Sales)' -Initials 'BJ' -Title 'Senior Lead Manager' ` 
-StateOrProvince 'Leinster' -StreetAddress '1, Liffey Walk' -PostalCode 'D01YYX1' `
-Confirm:$False

Splatting allows you to do this instead:

$Parameters = @{}
$Parameters.Add("Office", "Galway")
$Parameters.Add("Department", "Business Development")
$Parameters.Add("DisplayName", "Ben James (BusDev)")
$Parameters.Add("StateOrProvince", "Connacht")
$Parameters.Add("PostalCode", "GY1H1842")
$Parameters.Add("StreetAddress", "Kennedy Center")
$Parameters.Add("City", "Galway")
$Parameters.Add('Title', "Senior Development Manager")
$Parameters.Add("Identity", "Ben.James@office365itpros.com")
$Parameters.Add("Confirm", $false)

Set-User @Parameters

Adding or changing a parameter is a matter of updating the hash table.

An advantage of using splatting is that it is easy to update objects with common parameters. For instance, to update another user who shares the same office and location values, we can do this:

Set-User -Identity Jane.Sixsmith@office365itpros.com -DisplayName 'Jane Sixsmith' 
-Title 'Promotions Manager' @parameters

PowerShell applies the values for the parameters in the hash table except where a parameter value is explicitly passed.

Microsoft Graph PowerShell SDK Cmdlets And Splatting

In script examples used by articles on this site, we spell out parameters because of personal preference. In addition, the cmdlets in the Microsoft Graph PowerShell SDK can use a construct like splatting when updating or creating objects, meaning that splatting is less of an issue. Because the SDK cmdlets are based on Graph APIs, cmdlets that implement POST and PATCH requests require commands to pass a request body in the Body parameter.

Here’s an example of using the Update-MgUser cmdlet to update a set of properties for a user account with a request body created as a hash table:

$UserId = (Get-MgUser -UserId 'Michelle.duBois@office365itpros.com').id
$Body = @{}
$Body.Add("Office", "Galway")
$Body.Add("Department", "Business Development")
$Body.Add("DisplayName", "Ben James (BusDev)")
$Body.Add("State", "Connacht")
$Body.Add("PostalCode", "GY1H1842")
$Body.Add("StreetAddress", "Kennedy Center")
$Body.Add("City", "Galway")
$Body.Add('JobTitle', "Senior Development Manager")

Update-MgUser -UserId $UserId -BodyParameter $Body

The same hash table can be used with splatting:

Update-MgUser -UserId $UserId @Body

Even complex Graph SDK commands can be converted to splatting. Take the example of using the Get-MgUser cmdlet shown in Figure 1. This is an advanced Graph query because it’s checking the service plans held in (a multivalued property) assigned to user accounts to find accounts with a specific plan. As written, the command is spread over three lines using backticks.

A complex Get-MgUser command.

Splatting
Figure 1: A complex Get-MgUser command

Graph SDK cmdlets like Get-MgUser are based on GET Graph queries so there’s no need to pass a request body. However, the cmdlet parameters can be put into a hash table and passed to cmdlets. Here’s an example using an advanced query with Get-MgUser:

[guid]$SPOPlanId = "5dbe027f-2339-4123-9542-606e4d348a72"
$Body = @{}
$Body.Add("Filter", "assignedPlans/any(s:s/serviceplanid eq $SPOPlanId and capabilityStatus eq 'Enabled')")
$Body.Add("ConsistencyLevel", "eventual")
$Body.Add("Countvariable", "Test")
$Body.Add("Pagesize", "999")
$Body.Add("Property", "Id, displayName, userprincipalName, assignedLicenses, assignedPlans, department, country")
$Body.Add("Sort", "DisplayName")
$Body.Add("All", $true)

[array]$Users = Get-MgUser @Body

Note that switch parameters (like -All in this example) that don’t take a value when run in a command need $true as the value for their entry in the hash table.

Splatting is A Personal Choice

One of the nice things about PowerShell is the variety of styles supported for writing code. Some favor dense masses of commands including some pretty hard-to-understand pipelined code. Some eschew functions and others like to lay out commands with plenty of white space in between. Splatting does a job of defining and passing parameters. It’s up to you to decide how to use it.


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

]]>
https://office365itpros.com/2024/06/12/splatting-powershell/feed/ 4 65119
Interpreting Audit Records for Teams Meeting Recordings (Again) https://office365itpros.com/2024/06/07/teams-meeting-recordings-june24/?utm_source=rss&utm_medium=rss&utm_campaign=teams-meeting-recordings-june24 https://office365itpros.com/2024/06/07/teams-meeting-recordings-june24/#comments Fri, 07 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65081

Change in Audit Records for Teams Meeting Recordings Since 2021

Three years ago, I wrote about how to use audit records to track the creation of Teams meeting recordings. The idea was to find the audit records created when a Teams meeting recording was uploaded to OneDrive for Business or SharePoint Online.

Time marches on and old blogs rot, as do old PowerShell scripts. Three years ago, Microsoft hadn’t completed the transition from Stream classic to Stream on SharePoint. The migration finished recently and Microsoft has moved to standardize how Teams meeting recordings and transcripts are stored in OneDrive for Business. Of course, OneDrive only holds recordings for personal meetings. Recordings for channel meetings, including Meet Now in the channel, end up in the SharePoint Online site belonging to the host team.

Closing a Compliance Gap

While some might think that I spend endless hours examining audit records, this is a fallacy. I check on an as required basis, which means that I didn’t notice that my script wasn’t working quite so well because the format of the audit records changed. One important change is that the user noted in all the audit records is app@sharepoint, the ubiquitous SharePoint utility account. No trace exists in the audit records about the user who recorded the meeting, as had happened before.

From a compliance perspective, this is a big deal. Audit records exist to track the actions taken by individuals and system processes, and in this case, it seems important to know who initiated a recording.

Unfortunately, there’s nothing in the audit record to indicate who initiated the recording of a channel message, so we’re left with the SharePoint app. Recordings for personal meetings used to end up in the OneDrive account of the user who started the recording (the organizer or a presenter). Some time ago, Microsoft changed this to a more logical arrangement where recordings always go into the meeting organizer’s OneDrive account. The URL of a OneDrive account contains the site URL, like:

https://office365itpros-my.sharepoint.com/personal/jane_ryan_office365itpros_com

Figuring Out the OneDrive Site Owner

It’s easy for a human to read the URL and know that the OneDrive account belongs to Jane.Ryan@office365itpros.com. With time, I could parse the URL to extract the email address, but I went for a simpler (faster) approach. I used the Get-SPOSite cmdlet from the SharePoint Online PowerShell module to fetch the set of OneDrive accounts in the tenant and created a hash table from the site URL and site owner. It’s fast to check the hash table with the site URL taken from an audit record to return the user principal name of the site owner:

$User = $OneDriveHashTable[$AuditData.SiteURL]
If ($null -eq $User) {
   $User = "SharePoint app"
}

Changes in Search-UnifiedAuditLog Too!

Another influence on the output was the change made by Microsoft in summer 2023 to how the Search-UnifiedAuditLog cmdlet works. Microsoft have denied to me that they did anything, but the evidence shows that:

  • The SessionCommand parameter must now be set to ReturnLargeSet to force the cmdlet to return more than 120 records.
  • Many more duplicate records are returned than before. This necessitates sorting by the unique audit event identifier to remove the duplicates.
  • Search-UnifiedAuditLog returns unsorted data. If a sorted set is important to you, make sure that you sort the audit records by creation date.
$Records = $Records | Sort-Object Identity -Unique | Sort-Object {$_.CreationDate -as [datetime]} -Descending

Of course, you can try to run high completeness searches with Search-UnifiedAuditLog, but I have not had good luck with this preview feature.

Figure 1 shows the output from the updated script, which is available from GitHub. Normal service is resumed.

Audit records for Teams Meeting Recordings.
Figure 1: Audit records for Teams Meeting Recordings

A Reminder to Check Audit Log Analysis Scripts

It would be nice if a script lasted a little longer, but the ongoing change within Microsoft 365 means that PowerShell developers need to keep a wary eye on updates that might affect production scripts. In this instance, the confluence of the Stream migration and the change to the Search-UnifiedAuditLog cmdlet made a mess of a perfectly good script. I guess life is like that sometimes. Maybe now is a good time to check your scripts that use the Search-UnifiedAuditLog cmdlet.


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.

]]>
https://office365itpros.com/2024/06/07/teams-meeting-recordings-june24/feed/ 2 65081
Report Delegated Permission Assignments for Users and Apps https://office365itpros.com/2024/06/06/delegated-permissions-report/?utm_source=rss&utm_medium=rss&utm_campaign=delegated-permissions-report https://office365itpros.com/2024/06/06/delegated-permissions-report/#comments Thu, 06 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65048

Extract and Report Delegated Permission Assignments with the Microsoft Graph PowerShell SDK

When discussing permissions used to retrieve data with Graph API requests (including cmdlets from the Microsoft Graph PowerShell SDK), most of the time we refer to application permissions rather than delegate permissions. The reason is simple: when automating operations with PowerShell, tenant administrators usually process data drawn from multiple sources, like all user mailboxes or all groups. This level of processing requires application permissions.

Delegated permissions (also called scopes) allow apps to access information on behalf of the signed in user. Anything that user can access, the app can too. Usually, the user is the owner of a resource (like their mailbox), but they can gain access to information through an RBAC role, such as Teams administrator.

Delegated permissions are granted by a specific resource (like a Graph API) and represent the operations that an app can perform for the user. For instance, the Mail.Read scope allows an app to read messages in the user’s mailbox. The grant of consent for a delegated permission usually happens when a user signs into an app and the app discovers that consent for the required permission is not granted. At this point, Entra ID displays the consent prompt window to allow the user to give consent for the app to use the permission and proceed.

Reporting Permissions

Application permissions assigned to apps can be checked by examining the app role assignments for service principals. It’s a good idea to inventory app permissions periodically to ensure that apps don’t have high-profile permissions without good reason.

To report delegated permissions, we need to check delegated permission grants (otherwise called OAuth2 permission grants). These are delegated permissions granted for a client application to access an API on behalf of a signed-in user. The Microsoft Graph PowerShell SDK cmdlet used for this purpose is Get-MgOauth2PermissionGrant. The Directory.Read.All permission is required to read details of delegated permissions and user accounts.

Interpreting a Delegated Permission for Users

After connecting, run the Get-MgUser cmdlet to create an array of user accounts to query. Usually, I apply a filter to find licensed accounts. Once you have a set of accounts, it’s a matter of looping through the set to find the delegated permissions for each account:

[array]$Permissions = Get-MgUserOauth2PermissionGrant -UserId $User.Id -All

An individual permission assignment looks like this:

$Permission | Format-List

ClientId             : 5482d706-b547-4b9d-b159-b91a5776e0e9
ConsentType          : Principal
Id                   : BteCVEe1nUuxWbkaV3bg6YnEoxRs7QVAltG-nFdw96NYzfTvuBuZSJTeeV9la0oY
PrincipalId          : eff4cd58-1bb8-4899-94de-795f656b4a18
ResourceId           : 14a3c489-ed6c-4005-96d1-be9c5770f7a3
Scope                :  openid profile User.ReadWrite User.ReadBasic.All Sites.ReadWrite.All Contacts.ReadWrite People.Read Notes.ReadWrite.All Tasks.ReadWrite Mail.ReadWrite Files.ReadWrite.All Calendars.ReadWrite Group.Read.All Group.ReadWrite.All Directory.AccessAsUser.All Directory.ReadWrite.All User.ReadWrite.All IdentityRiskEvent.Read.All Reports.Read.All AuditLog.Read.All User.Read SecurityEvents.ReadWrite.All offline_access TeamSettings.Read.All TeamSettings.ReadWrite.All Mail.ReadBasic Chat.Read Chat.ReadBasic Analytics.Read
AdditionalProperties : {}
  • The client identifier points to the service principal for the client app. In this case, it is the Graph Explorer.
  • The principal identifier points to the identifier for the user account. Because we’re listing delegated permissions by user, the consent type for the permission is always Principal, meaning that the app is limited to impersonating the specific user. If the consent type is AllPrincipals, meaning that the app can use the consent to impersonate all users, the principal identifier would be empty.
  • The resource identifier points to the service principal for the resource. In this example, the resource identifier points to “Microsoft Graph” (the Graph API). The set of permissions (Scope) confirm this because they are Graph permissions. As you can see, the Graph Explorer has consent for many permissions. This is a normal situation if developers use the Graph Explorer to test different Graph APIs.

Processing Delegated Permissions for AllPrincipals

After processing the delegated permission assignments for user accounts, we process those for all principals (any user). The set of assignments is found with:

[array]$AppGrants = Get-MgOauth2PermissionGrant -filter "consentType eq 'AllPrincipals'" -All

Steps in the Script

The steps in the script are as follows:

  • Find the set of user accounts.
  • For each account, check if any delegated permissions exist.
  • For each permission, check the client app and resource.
  • Find the set of delegated permissions for all principals.
  • Do much the same as for individual assignments.
  • Report what’s been found.

Figure 1 shows the output generated.

Delegated permissions report.
Figure 1: Delegated permissions report

You can download the full script from GitHub.

Interpreting the Results

It’s inevitable that delegated permissions will accumulate over time. Looking at the results from my tenant, I see evidence of the iOS account migration to modern authentication from 2021, apps from conference organizers like Sessionize and Community Days, the app used to register for the Microsoft Technical Community, and so on. All these assignments are understandable. The question is whether the assignments are needed any longer and if not, should they be removed. That’s up to you…


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.

]]>
https://office365itpros.com/2024/06/06/delegated-permissions-report/feed/ 2 65048
Choosing Between Graph API Requests or Graph SDK Cmdlets https://office365itpros.com/2024/06/05/microsoft-graph-powershell-sdk-api/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-api https://office365itpros.com/2024/06/05/microsoft-graph-powershell-sdk-api/#comments Wed, 05 Jun 2024 07:00:00 +0000 https://office365itpros.com/?p=65033
Microsoft Graph PowerShell SDK.

Which to Choose for PowerShell Development?

I’m sometimes asked why people should bother using the Microsoft Graph PowerShell SDK to develop PowerShell scripts. The arguments against the SDK are that it’s buggy, doesn’t have great documentation, and adds an extra layer on top of Graph API requests. I can’t deny that the SDK has had recent quality problems that shook developer confidence.

I cannot advance a case that Microsoft’s documentation for the Graph PowerShell SDK cmdlets is good because it’s not. Some improvements have been made over the last year, but the examples given (copied mostly from the Graph documentation) are too simple, if they exist at all. There’s also the small fact that the Graph PowerShell SDK cmdlets share some foibles that make them less useful than they should be.

Given the problems, why continue to persist with the Graph PowerShell SDK? I guess the reason is that the SDK cmdlets are easier to work with for anyone who’s used to PowerShell development. For instance, the Graph SDK automatically performs housekeeping operations like retrieving an access token, renewing the token (only needed for long-running scripts), and pagination. None of these operations are complex. Once mastered, the same code can be copied into scripts to take care of these points.

Call me mad, I therefore persist in writing scripts using Graph PowerShell SDK cmdlets. However, times exist when it’s necessary to use a Graph API request, including when:

  • Microsoft’s AutoRest process has not processed a new API to create a cmdlet.
  • The data returned by a cmdlet is not as complete as the underlying Graph API request. This shouldn’t happen, but it does.
  • It’s necessary to retrieve properties that a cmdlet doesn’t support.

Let’s look at examples of the last two points.

Fetching Attendee Data with Microsoft Graph PowerShell SDK Cmdlets and API Requests

I’ve used the List CalendarView API in situations like reporting usage statistics for room mailboxes. Here’s an example of retrieving calendar events between two dates.

$Uri = ("https://graph.microsoft.com/V1.0/users/{0}/calendar/calendarView?startDateTime={1}&endDateTime={2}" -f $Organizer.Id, $StartDateSearch, $EndDateSearch)

The resulting URI fed to the Invoke-MgGraphRequest cmdlet looks like this:

$Uri
https://graph.microsoft.com/V1.0/users/4adf6057-95da-430a-8757-6a58c85e13d4/calendar/calendarView?startDateTime=2024-03-28T12:56:37&endDateTime=2024-05-29T12:56:37

$Items = Invoke-MgGraphRequest -Method Get -Uri $Uri | Select-Object -ExpandProperty Value

You might ask why I use Invoke-MgGraphRequest (a cmdlet from the Microsoft Graph PowerShell SDK) rather than the general-purpose Invoke-RestMethod cmdlet. It’s because I start scripts off with the Graph PowerShell SDK and only go to standard Graph API requests when necessary.

In any case, the attendees of a meeting are returned like this:

attendees                      {System.Collections.Hashtable, System.Collections.Hashtable, System.Collections.Hashtable, System.Collections.Hashtable}

The attendee data are available in individual hash tables and are easy to access:

$Items[0].attendees

Name                           Value
----                           -----
emailAddress                   {[address, Sean.Landy@office365itpros.com], [name, Sean Landy]}
status                         {[response, none], [time, 01/01/0001 00:00:00]}
type                           required
emailAddress                   {[address, Lotte.Vetler@office365itpros.com], [name, Lotte Vetler (Paris)]}
status                         {[response, none], [time, 01/01/0001 00:00:00]}

Get-MgUserCalendarView is the equivalent cmdlet in the Microsoft Graph PowerShell SDK. This command does the same job as the List CalendarView API request above.

[array]$CalendarItems = Get-MgUserCalendarView -UserId $Organizer.id -Startdatetime $StartDateSearch -Enddatetime $EndDateSearch -All

Attendees                     : {Microsoft.Graph.PowerShell.Models.MicrosoftGraphAttendee, Microsoft.Graph.PowerShell.Models.MicrosoftGraphAttendee}

$calendarItems[0].Attendees

Type
----
required
required

The attendee data is incomplete. No information is available about the attendees’ email addresses and display names. That’s why my scripts use the API rather than the cmdlet.

How the Microsoft Graph PowerShell SDK Cmdlets Return Data

When you run a Graph PowerShell SDK cmdlet, the returned data ends up in an array, which is convenient for further PowerShell processing. You’ll note that I use the -All parameter to fetch all available objects.

$AllUsers = Get-MgUser -All

$AllUsers.gettype()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array

Things are a little more complicated with Graph API requests. We get an array back, but the array contains a hashtable. The actual data that we might want to process is in the record with the Value key. We also see an @odata.nextlink to use to fetch the next page of available data:

$Uri = "https://graph.microsoft.com/v1.0/users"
$Data = Invoke-MgGraphRequest -Method Get -Uri $Uri
$Data.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array

$Data

Name                           Value
----                           -----
@odata.context                 https://graph.microsoft.com/v1.0/$metadata#users
value                          {44c0ca9c-d18c-4466-9492-c60c3eb78423, bcfa6ecb-cc5b-4357-909c-1e0e450864e3, da1288d5-e63c-4118-af62-3280823e04e1, de67dc4a-4a51-4d86-…
@odata.nextLink                https://graph.microsoft.com/v1.0/users?$skiptoken=RFNwdAIAAQAAABc6Z3NjYWxlc0BkYXRhcnVtYmxlLmNvbSlVc2VyX2UwNjIzZjE0LTUzM2QtNDhmYS1hODRl…

In most cases, I simply create an array of the data and then go ahead and process the information as normal for an array:

[array]$Data = $Data.Value

The Invoke-MgGraphRequest cmdlet supports output to a PowerShell object.

$Data = Invoke-MgGraphRequest -Method Get -Uri $Uri -OutputType PsObject

The output data is the same but it’s in the form of an array rather than a hash table:

$Data | Format-List

@odata.context  : https://graph.microsoft.com/v1.0/$metadata#users
@odata.nextLink : https://graph.microsoft.com/v1.0/users?$skiptoken=RFNwdAIAAQAAABc6Z3NjYWxlc0BkYXRhcnVtYmxlLmNvbSlVc2VyX2UwNjIzZjE0LTUzM2QtNDhmYS1hODRlLTljOTg0MDhkNDgxYbkAAAAAAAAAAAAA
value           : {@{businessPhones=System.Object[]; displayName=12 Knocksinna (Gmail); givenName=12; jobTitle=; mail=12knocksinna@gmail.com; mobilePhone=;
officeLocation=; preferredLanguage=; surname=Knocksinna; userPrincipalName=12knocksinna_gmail.com#EXT#@RedmondAssociates.onmicrosoft.com;
id=44c0ca9c-d18c-4466-9492-c60c3eb78423}, @{businessPhones=System.Object[]; displayName=Email Channel for Exchange GOMs; givenName=Teams;
jobTitle=; mail=5e2eb5ab.office365itpros.com@emea.teams.ms; mobilePhone=; officeLocation=; preferredLanguage=; surname=Email Channel for GOM List;

Once again, the data to process is in the Value record.

I usually don’t bother outputting to a PowerShell object, perhaps because I’m used to dealing with the hash table.

Mix and Match

The important thing to remember is that a PowerShell script can mix and match Graph API requests and Graph PowerShell cmdlets. My usual approach is to start with cmdlets and only use Graph requests when absolutely necessary. I know others will disagree with this approach, but it’s one that works for me.


Make sure that you’re not surprised about changes that appear inside Microsoft 365 applications by subscribing to the Office 365 for IT Pros eBook. Our monthly updates make sure that our subscribers stay informed.

]]>
https://office365itpros.com/2024/06/05/microsoft-graph-powershell-sdk-api/feed/ 10 65033
Reporting Mailbox Audit Configurations https://office365itpros.com/2024/05/28/mailbox-audit-configuration-report/?utm_source=rss&utm_medium=rss&utm_campaign=mailbox-audit-configuration-report https://office365itpros.com/2024/05/28/mailbox-audit-configuration-report/#comments Tue, 28 May 2024 07:00:00 +0000 https://office365itpros.com/?p=64892

Make Sure that Mailbox Audit Configurations Capture Important Events

Following Microsoft’s announcement about the availability of the promised additional audit events for Purview Audit (standard) customers, some folks got in touch to ask if I had a script to report current mailbox audit configurations. As it happens, I didn’t, but cracking open Visual Studio Code and GitHub Copilot soon put that right.

How Not to Find Accounts with Purview Audit (Advanced) Licenses

My original plan was to find and report mailboxes owned by licensed user accounts. I wanted to know which accounts use Purview Audit standard and which use the advanced variant. This is more difficult than it seems because, as far as I can tell, there’s no Purview Audit standard service plan. At least, I can’t find one on the Microsoft page listing all the license and service plan identifiers.

There is a service plan called M365_ADVANCED_AUDITING (2f442157-a11c-46b9-ae5b-6e39ff4e5849), which seemed like a good candidate for Purview Audit (advanced). However, if you use the Get-MgUser cmdlet from the Microsoft Graph PowerShell SDK to find accounts with this service plan identifier in the assignedPlans property (see below), the service plan name returned for the identifier is “exchange.”

[guid]$PurviewAuditAdvancedPlanId = "f6de4823-28fa-440b-b886-4783fa86ddba"

[array]$Users = Get-MgUser -filter "assignedPlans/any(x:x/serviceplanid eq $PurviewAuditAdvancedPlanId)" -ConsistencyLevel eventual -CountVariable Test -Property Id, displayName, userprincipalName, assignedLicenses, assignedPlans

The service plan identifier appears in accounts that don’t have Office 365 E5 or Microsoft 365 E5 licenses, which are the products that include Purview Audit (advanced). This is because the service plan identifier has a disabled status in those accounts. To solve that problem, amend the filter to check for enabled service plans:

[array]$Users = Get-MgUser -filter "assignedPlans/any(x:x/serviceplanid eq $PurviewAuditAdvancedPlanId and capabilityStatus eq 'Enabled')" -ConsistencyLevel eventual -CountVariable Test -Property Id, displayName, userprincipalName, assignedLicenses, assignedPlans

But then I found that the resulting set of accounts only included those with Microsoft 365 E5 licenses. No trace existed of the Office 365 E5 accounts, even though Microsoft includes the Office 365 E5 license in the set with access to Purview Audit (advanced) in this useful comparison chart.

Microsoft documentation assures me that there is an app for Purview Audit (advanced). Usually, an app equates to a service plan. When I checked the Microsoft 365 admin center as directed, the app shows up under the moniker Microsoft 365 advanced auditing (Figure 1).

Microsoft 365 advanced auditing app listed for an account in the Microsoft 365 admin center.

Mailbox audit configuration
Figure 1: Microsoft 365 advanced auditing app listed for an account in the Microsoft 365 admin center

Disabling and enabling the app in the Microsoft 365 admin center disables and enables the 2f442157-a11c-46b9-ae5b-6e39ff4e5849 service plan behind the scenes. After all that, we know that a service plan called exchange controls an app called Microsoft 365 advanced auditing (aka the Microsoft Purview Audit (advanced) product) that only shows up in accounts with Microsoft 365 E5 licenses. It’s all very confusing, so I lost interest at this point.

Back to Scripting Mailbox Audit Configurations

After wasting too much time discovering the mess of service plans, product names, and SKUs, I went back to scripting and wrote some straightforward code to:

  • Connect to Exchange Online.
  • Run Get-ExoMailbox to find user and shared mailboxes.
  • Define some critical audit events to check for in the owner and delegate audit sets.
  • Check each mailbox to see if it uses the default audit configuration (maintained by Microsoft). Report the audit set defined in the configuration.
  • Check that the critical audit events are present in the owner and delegate audit sets and flag any critical audit events (like MailItemsAccessed) found missing.
  • Report what’s been found.
  • If the ImportExcel PowerShell module is available, generate an Excel worksheet containing the results (Figure 2). If not, generate a CSV file.

Reporting mailbox audit configurations with Excel
Figure 2: Reporting mailbox audit configurations with Excel

You can download the full script from GitHub.

A Note About Enabling Audit with Set-Mailbox

The script checks if auditing is enabled for a mailbox, and if it is, the script runs Set-Mailbox to set AuditEnabled to true. Microsoft documentation says that if mailbox auditing is turned on by default for an organization, mailbox auditing ignores the AuditEnabled mailbox property.

But their May 20 announcement about the new audit events says that “Every standard user mailbox should have AuditEnabled set to true to ensure all audit records are uploaded to Purview Audit” and “Please note that this Set-Mailbox command must be run for every Standard license user regardless of its current value to correctly enable their mailbox to upload the new standard logs to Purview Audit.” Microsoft documentation is confusing on this point. I think the situation is that Microsoft manages mailbox auditing for accounts with Purview Audit advanced licenses while manual intervention is needed for mailboxes with Purview Audit standard, Whatever the reason, it’s always better to be safe than sorry when dealing with audit events, the script runs Set-Mailbox. You can certainly eliminate this section of the script to speed things up if you want to.

Feel free to improve and embellish the script to meet your needs. In the meantime, I need a headache tablet to recover from the trials of audit licensing.


Stay updated with developments like new events for mailbox audit configurations 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.

]]>
https://office365itpros.com/2024/05/28/mailbox-audit-configuration-report/feed/ 1 64892
More Microsoft Graph PowerShell SDK Problems https://office365itpros.com/2024/05/06/microsoft-graph-powershell-sdk-217/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-217 https://office365itpros.com/2024/05/06/microsoft-graph-powershell-sdk-217/#comments Mon, 06 May 2024 04:00:00 +0000 https://office365itpros.com/?p=64674

Odd Replacement Cmdlets appear in Microsoft Graph PowerShell SDK V2.17 and Azure Automation Issues in V2.18

Updated 15 May 2024

Microsoft Graph PowerShell SDK V2.18

Last week was odd for me. I headed to Orlando for the M365 Community Conference on Sunday and arrived at the Dolphin hotel feeling somewhat odd. A few hours later, I was flat on my back unable to move with a combination of a horrible cough and the effects of a norovirus. Enough to say that it wasn’t pretty.

The conference swung into full action on Tuesday, and I spoke (between coughs) about the Microsoft Graph PowerShell SDK. I think it’s fair to say that I have a love-hate relationship with the software. I like the access to all sorts of Microsoft 365 data enabled through the SDK cmdlets, but I dislike some of its foibles.

I also hate when Microsoft makes changes that seem to be firmly in the category of shooting itself in the foot, like the spurious output generated for cmdlets introduced in V2.13.1 and worsened in V2.14. Stuff like this shouldn’t get through basic testing.

Update: Microsoft has released V2.19 of the SDK to fix the reported problems. They describe the affected cmdlets in a May 15 blog.

The Case of the DirectoryObjectByRefs Cmdlets

Which brings me to a problem that seems to have surfaced in V2.17. Until this time, the Remove-MgGroupMemberByRef cmdlet worked to remove a member from a group by passing the user account identifier for the member and the group identifier. With V2.17, the following happens:

Remove-MgGroupMemberByRef -DirectoryObjectId $UserId -GroupId $GroupId
Remove-MgGroupMemberByRef: A parameter cannot be found that matches parameter name 'DirectoryObjectId'

The same happens with the Remove-MgGroupOwnerByRef cmdlet to remove a group member (but not if the action would leave the group ownerless).

Microsoft’s response is documented here and it is a calamity. Not only does it appear that other cmdlets are involved (like Remove-MgApplicationOwnerByRef – I have asked Microsoft for a definitive list), but the fix is terrible. No experienced PowerShell person would think that it is a good idea to fix a problem in a cmdlet by introducing a brand-new cmdlet, but that’s what Microsoft did by including cmdlets like Remove-MgGroupMemberDirectoryObjectByRef and Remove-MgGroupOwnerDirectoryObjectByRef in V2.17.

The SDK developers might be pleased that V2.17 contains functional cmdlets to remove group members and owners, but anyone who wrote scripts prior to V2.17 based on the old cmdlets is left high and dry.

I hadn’t noticed the problem because I haven’t run the affected cmdlets for a while. But Ryan Mitchell of Northwestern University had, and he brought the matter to my attention after the session at the Microsoft 365 Community Conference. Suffice to say that the necessary protests have been made in the right quarters. I had the opportunity in Orlando to chat with some senior members of the Graph development team who acknowledged that this is not the way that cmdlet problems should be addressed and that overall Graph SDK quality must be improved. Specifically for the group cmdlets, Microsoft is investigating how the situation developed. It could be that this is a side effect of the famous AutoRest process that generates SDK cmdlets from Graph APIs. We’ll see in time.

Update May 9: Microsoft has published V2.19 of the SDK to address the problem with cmdlet renaming. They’ve introduced aliases to make sure that scripts continue to work with the old cmdlets.

Microsoft Graph PowerShell SDK V2.18 and Azure Automation

Microsoft released V2.18 of the Microsoft Graph PowerShell SDK last week. After installing the new module and running some tests, everything checked out and I duly tweeted that the new module was available.

But problems lurked for Azure Automation runbooks configured for PowerShell 5.1 because people noted that they couldn’t use the Groups module after connecting with a user-provided access token obtained using the Get-AzAccessToken cmdlet. Everything works with PowerShell 7, but not with the earlier release. It seems like a clash occurs between the version of the Azure identity assembly loaded by the AzAccounts module. In any case, Microsoft is investigating (here are the full details) and the advice is to stay with V2.17 if you use Azure Automation until Azure updates their assembly.

Time for a Checkup

It’s disappointing to see issues like these continue to appear in new versions of the Microsoft Graph PowerShell SDK. Basic testing and some knowledge about how people use PowerShell in practice should have caught these issues. Their existence lessens faith in the SDK. After all, who wants to chase new bugs in a module that’s refreshed monthly?

Chapter 23 of the Office 365 for IT Pros eBook referenced examples of the cmdlets affected by the V2.17 issue. We’ve issued update 107.1 with amended text. The nature of an eBook means that it’s much easier to address problems in text than with printed books and we do try and fix known issues as quickly as we can. For everyone else who uses the Microsoft Graph PowerShell SDK for group management or Azure Automation, it’s time to check that everything’s working as expected.


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2024/05/06/microsoft-graph-powershell-sdk-217/feed/ 4 64674
Sending Urgent Teams Chats with PowerShell https://office365itpros.com/2024/04/24/teams-urgent-message-ps/?utm_source=rss&utm_medium=rss&utm_campaign=teams-urgent-message-ps https://office365itpros.com/2024/04/24/teams-urgent-message-ps/#respond Wed, 24 Apr 2024 01:00:00 +0000 https://office365itpros.com/?p=64540

Scripting Teams Urgent Messages for a Set of Users

A reader asked if it was possible to write a PowerShell script to send chats to a set of people when something important happened, like a failure in an important piece of plant or a medical emergency. They explained that they have the facility to broadcast this kind of information via email, but a lot of their internal communications have moved to Teams and they’d like to move this kind of scripted communication too.

Teams supports urgent messages for one-to-one chats. Originally, these messages were called priority notifications and Microsoft planned to charge for their use. That idea disappeared in the mists of the Covid pandemic, and anyone can send urgent messages today. The nice thing about urgent messages is that Teams pings the recipient every two minutes until they read the message or twenty minutes elapses.

Compose and Send Teams Urgent Messages with PowerShell

The Teams PowerShell module is designed for administrative activities and doesn’t support access to user data like chats. To compose and send chats, you must use Graph API requests or cmdlets from the Microsoft Graph PowerShell SDK, which is what I chose to do.

The outline of the script is as follows:

  • Run the Connect-MgGraph cmdlet to connect to the Graph. Delegated permissions must be used, and I specified the Chat.ReadWrite and User.Read.All permissions.
  • Because the script works with delegated permissions, the chats are sent by the signed-in user. The script runs the Get-MgContext cmdlet to find out what that account is.
  • The script sends chats to a set of users. Any Entra ID group will do.
  • The New-MgChatMessage cmdlet eventually sends the chat message. Because I want to include an inline image and a mention in the message, work must be done to construct the payload needed to tell Teams what content to post.
  • In Graph requests, this information is transmitted in JSON format. PowerShell cmdlets don’t accept parameters in the same way. Three different parameters are involved – the body, the mention, and the hosted content (image uploaded to Teams). Each parameter is passed as a hash table or array, and if the parameter takes an array, it’s likely to include some hash tables. Internally, Teams converts these structures to JSON and submits them to the Graph request. You don’t need to care about that, but constructing the various arrays and hash tables takes some trial and error to get right. The examples included in Microsoft documentation are helpful but are static examples of JSON that are hard to work with programmatically. I use a different approach. Here’s an example of creating the hash table to hold details of the inline image:

# Create a hash table to hold the image content that's used with the HostedContents parameter
$ContentDataDetails = @{}
$ContentDataDetails.Add("@microsoft.graph.temporaryId", "1")
$ContentDataDetails.Add("contentBytes", [System.IO.File]::ReadAllBytes("$ContentFile"))
$ContentDataDetails.Add("contentType", "image/jpeg")
[array]$ContentData = $ContentDataDetails
  • After populating the hash tables and arrays, the script runs the New-MgChat cmdlet. If an existing one-on-one chat exists for the two users, Teams returns the identifier of that chat thread. If not, Teams creates a new chat thread and returns that identifier.
  • The script runs the New-MgChatMessage cmdlet to post the prepared message to the target chat thread. Setting the importance parameter to “urgent” marks this as a Teams urgent message.

$ChatMessage = New-MgChatMessage -ChatId $NewChat.Id -Body $Body -Mentions $MentionIds -HostedContents $ContentData -Importance Urgent

The Urgent Teams Message

Figure 1 shows an example of the chat message posted to threads. You can see the inline image and that an @mention exists for James Ryan. If the recipient hovers over the mention, Teams displays the profile card for James Ryan to reveal details like contact information.

Teams urgent message created with PowerShell.
Figure 1: Teams urgent message created with PowerShell

You can download the script from GitHub.

Plain Sailing After Understanding Parameter Formatting

There’s no doubt that it’s more complicated to create and send one-to-one chats than it is to send email to a group of recipients, especially if you stray away from a very simple message body. However, much of the complexity is getting your head around the formatting of parameter input. Once you understand that, it’s reasonably easy to master the rest of the code.


Learn about using the Microsoft Graph PowerShell SDK and the rest of Microsoft 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.

]]>
https://office365itpros.com/2024/04/24/teams-urgent-message-ps/feed/ 0 64540
How to Remove a Single Service Plan from User Accounts with PowerShell https://office365itpros.com/2024/04/23/remove-service-plan-powershell-2/?utm_source=rss&utm_medium=rss&utm_campaign=remove-service-plan-powershell-2 https://office365itpros.com/2024/04/23/remove-service-plan-powershell-2/#comments Tue, 23 Apr 2024 07:00:00 +0000 https://office365itpros.com/?p=64426

Remove Service Plans with the Microsoft Graph PowerShell SDK

In 2021, I wrote about how to remove a single service plan from multiple Entra ID user accounts with PowerShell. The original script used cmdlets from the Microsoft Online Services (MSOL) module. To cover all bases, I updated the post with versions of the script using cmdlets from the AzureAD and the Microsoft Graph PowerShell SDK. Microsoft has deprecated the MSOL and AzureAD modules and the final retirement of these modules is due on March 30, 2025.

The problem with updating a script to replace cmdlets is the tendency to keep the same flow and logic. In other words, the script that started off using MSOL cmdlets behaves in much the same way when updated to use Graph SDK cmdlets. It’s natural that things happen in this way because those updating the code want to get the work done as quickly as possible. Who has the time to sit back and ask if code can be improved during script updates, even if new tools like GitHub Copilot are available.

I’ve been using GitHub Copilot integrated into Visual Studio Code for the last month or so. I’m not sure that Copilot has created any great new code in my scripts, but it certainly has an uncanny ability to auto-complete lines of code and comments, just like Word does when I write. I like GitHub Copilot and recommend the combination of it and Visual Studio Code to anyone who writes PowerShell for Microsoft 365.

What the Script Does to Remove Service Plans from Accounts

Which brings us neatly to some upgrades for the version of the script based on the Microsoft Graph PowerShell SDK. The original script:

  • Lists the set of subscriptions (bought products) found in the tenant and asks the administrator to select a product to modify.
  • Lists the set of service plans for the selected product and asks the administrator to select the service plan to disable. For example, a tenant might decide that they don’t wish to use Viva Engage, so they will remove the Viva Engage Core and Viva Engage Seeded service plans from all accounts with the selected product. This is exactly what happens when an administrator edits a user account with the Microsoft 365 admin center and removes access to some of the apps listed for the user (Figure 1). Obviously, it’s much faster to use PowerShell to remove service plans from multiple accounts.
  • Runs a cmdlet to disable the selected service plan for all user accounts that have the selected license.

Removing service plans from an Entra ID account.

How to remove service plans with PowerShell
Figure 1: Removing service plans from an Entra ID account

There’s not much in terms of cmdlets in the script. Get-MgSubscribedSku returns the set of products and service plans. Get-MgUser finds user accounts and Set-MgUserLicense disables the selected service plan for each account. It’s all very straightforward.

Upgrading the Script to Remove Service Plans Faster

Then someone complained that they couldn’t get the script to work in their tenant. Perhaps consent had not been granted for the Directory.ReadWrite.All permission (scope), which is necessary to read the set of subscribed products, read user information, and update user licenses. Or perhaps the person used an interactive session, and the signed-in account didn’t hold one of the necessary administrative roles (remember, delegated permissions are used for Graph SDK interactive sessions). For whatever reason, it was good enough to check the code to see if any improvements were possible.

I found four areas to update:

  1. Some products (like Office 365 E3 or Microsoft 365 E5) are composite licenses that span many service plans. Each service plan has a target. User service plans can be disabled or enabled on a per-user basis. Company service plans are managed at the tenant level. The new code makes sure that the script only lists user service plans for the user to select.
  2. The cmdlets in the MSOL and AzureAD modules didn’t boast good server-side filtering capabilities to find accounts assigned specific licenses, so filtering happens client-side. The complex filters supported by the Graph for user accounts allows the Get-MgUser cmdlet to find the precise set of accounts with the selected license. This change makes the script much more efficient in large tenants.
  3. The previous iteration of the script didn’t check if a service plan was already disabled before attempting to disable a plan. It does now.
  4. The previous iteration didn’t handle errors well and the report generated by the script could include items where the removal of a service plan didn’t work. Better error handling sorted this problem.

You can download the updated script from GitHub.

The Principle is Proved, Now Let Your Imagination Run Wild

The script to remove service plans is intended to demonstrate a principle of license management for Microsoft 365 user accounts. It would be easy to amend the script in different ways. For instance, you could allow the administrator to select multiple service plans to remove or eliminate the need to select a product and find a target service plan in any of the licenses assigned to a user. It’s PowerShell, so let your imagination run wild and improve the script to meet the needs of your tenant.


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.

]]>
https://office365itpros.com/2024/04/23/remove-service-plan-powershell-2/feed/ 5 64426
Modifying the Teams Tenant Federation Configuration with PowerShell https://office365itpros.com/2024/04/09/tenant-federation-configuration/?utm_source=rss&utm_medium=rss&utm_campaign=tenant-federation-configuration https://office365itpros.com/2024/04/09/tenant-federation-configuration/#comments Tue, 09 Apr 2024 07:00:00 +0000 https://office365itpros.com/?p=64400

Blocking Sub-Domains in the Tenant Federation Configuration

The publication of message center notification MC770792 (5 April 2024) describing a new Teams tenant federation setting to block all sub-domains of a blocked domain seems like a very good idea. After all, if you decide to block inbound connections from “malware.com.” it’s likely that you also want to block sub-domains like “marketing.malware.com.”

Microsoft says that the update should be in all tenants by mid-April. From an administrator perspective, the change becomes active with version 6.1 of the Microsoft Teams PowerShell module, which adds support for the BlockAllSubdomains switch for the Set-CsTenantFederationConfiguration cmdlet. For example:

Set-CsTenantFederationConfiguration -BlockAllSubdomains $True -BlockedDomains "malware.com"

The new setting isn’t used by default and won’t affect existing block lists. If you do use it, Microsoft notes that the setting blocks “all new communication to and from subdomains in the Block list… Existing 1:1 chats with users from blocked subdomains will be disabled. In existing group chats with users from blocked subdomains, the users from the blocked subdomains will be removed from the group chat.”

Updating the Allow List

In September 2022, I wrote an article explaining how to update the Teams external federation configuration with PowerShell. The idea was to create an allow list for federated chat based on the home domains for guest accounts known in the tenant directory. The article was a response to the theoretical “GIFShell” attack against Teams by a security researcher. Having an allow list of known domains means that users can only communicate with users belonging to domains in the allow list using one-to-one federated chat. It’s still the most effect way of blocking potential malware arriving in a tenant via Teams chat with an attacker.

I looked over the code to remind myself about how to manipulate the tenant federation configuration and realized that a nice update would be to check the domains for guest accounts to make sure that they are Microsoft 365 tenants before adding them to the tenant federation configuration. For instance, guest accounts might belong to domains like gmail.com, yahoo.com, and outlook.com, but there’s no need to have these large consumer domains in the configuration.

The technique explained in the article about tenant identifiers provided the foundation for the solution. I created a function to check if a domain is a Microsoft 365 tenant and call the function to check a domain before including it in the list to update the tenant federation configuration with. Here’s the function:

function Get-DomainByCheck {
# Check a domain name to make sure that it's active
  param (
      [parameter(Mandatory = $true)]
      $Domain
  )

  $Uri = ("https://graph.microsoft.com/v1.0/tenantRelationships/findTenantInformationByDomainName(domainName='{0}')" -f $Domain) 
  Try {	
    [array]$Global:DomainData = Invoke-MgGraphRequest -Uri $Uri -Method Get -ErrorAction Stop
    If ($DomainData.displayname -in $UnwantedRealms) {
      Return $false
    } Else {
      Return $true
    }
  } Catch {
    Return $false
  }
}

Domains that pass the test are added to the tenant federation configuration, which is also available through the Settings & Policies section of the Teams admin center (Figure 1).

Tenant federation configuration in the Teams admin center.
Figure 1: Tenant federation configuration in the Teams admin center

Dealing with Unwanted Domains

You’ll notice that the function checks against an array called $UnwantedRealms. If a domain is found in the array, the function returns false to indicate that the domain shouldn’t be added to the tenant federation configuration. The script defines the array as follows:

$Global:UnwantedRealms = "MSA Realms", "Test_Test_Microsoft"

If the Graph findTenantInformationByDomainName API matches a Microsoft 365 tenant, its display name is returned in the domain information fetched by the request. For instance, if the function checks Microsoft.com, the display name is Microsoft. But if it checks a domain which is federated for identity purposes with Entra ID, like gmail.com, the display name is “MSA Realms.” And the display name returned for the domains used by Teams to deliver email to channels (like amer.teams.ms) is “Test_Test_Microsoft.” Perhaps the engineers never thought that the display name they selected for these domains would ever see the light of day…

Why would guest accounts have email addresses belong to Teams channels? The SMTP addresses generated by Teams for channels can be given to guest accounts to allow the account to be a member of a Microsoft 365 group. Any email sent to the group will automatically end up as a channel conversation and serve as a record of that email interaction. Another method to bring email into Teams is to create mail contacts with Teams channel addresses and include them in distribution lists. In any case, we don’t need to include the Teams email domains in the tenant federation configuration, which is why the script excludes them.

Scripting Makes Processing Multiple Domains Easier

The Teams tenant federation configuration is easy to maintain through the Teams admin center. PowerShell makes it easier when large numbers of domains are involved. If you want to see the code I used, download the script from GitHub.


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.

]]>
https://office365itpros.com/2024/04/09/tenant-federation-configuration/feed/ 1 64400
How to Retrieve Loop Workspaces Data with PowerShell https://office365itpros.com/2024/04/08/loop-workspaces-report-ps/?utm_source=rss&utm_medium=rss&utm_campaign=loop-workspaces-report-ps https://office365itpros.com/2024/04/08/loop-workspaces-report-ps/#comments Mon, 08 Apr 2024 08:00:00 +0000 https://office365itpros.com/?p=64322

Report More than 200 Loop Workspaces Requires Fetching Pages of Data

In November 2023, I wrote about a PowerShell script I developed to report the storage consumed by Loop workspaces. The script worked. That is, it worked until a tenant had more than 200 workspaces at which point the script ceased to report details for any more workspaces. This point was recently made to me by a reader after they discovered that the script didn’t produce the desired results in their tenant. Obviously, I didn’t do enough testing to encounter the limit.

Investigation (reading the documentation for the Get-SPOContainer cmdlet) revealed that the cmdlet implements a primitive form of pagination. The default mode is to fetch the first 200 workspaces, but if you know that more workspaces exist, you can add the Paged parameter to the cmdlet.

Odd Pagination to Fetch More Loop Workspaces

APIs implement pagination when they want to limit the amount of data that an app can fetch in one operation. The Graph APIs use pagination for this reason (some of the cmdlets in the Microsoft Graph PowerShell SDK can perform automatic pagination). The idea is that an app fetches the first page, checks to see if a token (pointer) to the next page is present, and if so, the app uses the token to fetch that page. The process continues until the app has fetched all available pages.

In the case of the Get-SPOContainer cmdlet, if more workspace data are available, the 201st record in the set fetched from SharePoint is a pointer to the next page of (up to) 200 workspaces. Oddly, the information is in the form of a string followed by the actual token. Here’s an example:

Retrieve remaining containers with token: UGFnZWQ9VFJVRSZwX0NyZWF0aW9uRGF0ZVRpbWU9MjAyNDAzMzAlMjAwMCUzYTU5JTNhMjUmcF9JRD0yMDA=

To fetch the next page, run the Get-SPOContainer cmdlet and specify both the Paged and PagingToken parameters. The value passed in the PagingToken parameter is the token extracted from the record referred to above. The code must also remove the record from the set that will eventually be used for reporting purposes because it doesn’t contain any information about a workspace. For example:

$Token = $null
If ($LoopWorkspaces[200]) {
    # Extract the token for the next page of workspace information
    $Token = $LoopWorkSpaces[200].split(":")[1].Trim()
    # Remove the last item in the array because it's the one that contains the token
    $LoopWorkspaces = $LoopWorkspaces[0..199]
}

Looping to Fetch All Pages

A While loop can then fetch successive pages until all workspaces are retrieved. The curious thing is that at the end of the data, Loop outputs a record with the text. “End of containers view.” It’s just odd:

While ($Token) {
    # Loop while we can get a token for the next page of workspaces
    [array]$NextSetofWorkSpaces = Get-SPOContainer -OwningApplicationID a187e399-0c36-4b98-8f04-1edc167a0996 `
      -PagingToken $Token -Paged
    If ($NextSetofWorkSpaces[200]) {
        $Token = $NextSetofWorkSpaces[200].split(":")[1].Trim()
        $NextSetofWorkspaces = $NextSetofWorkspaces[0..199]
    } Else {
        $Token = $Null
        If (($NextSetofWorkSpaces[$NextSetofWorkspaces.count -1]) -eq "End of containers view.") {  
            # Remove the last item in the array because it contains the message "End of containers view."
            $NextSetofWorkspaces = $NextSetofWorkspaces[0..($NextSetofWorkspaces.count -2)]
        }             
    }
    $LoopWorkspaces += $NextSetofWorkspaces
}

Eventually, you have an array of all the Loop workspaces and can report it as in the previous script (Figure 1).

Figure 1: Reporting Loop workspaces

The script with the updated code can be downloaded from GitHub.

Another Example of SharePoint PowerShell Strangeness

I have no idea why the Loop developers thought it was a good idea to implement their unique style of PowerShell pagination in the Get-SPOContainer cmdlet. What they should have done is implement the All cmdlet as done elsewhere, like the Get-SPOSite cmdlet. Supporting easy retrieval of all workspaces together with server-side filtering capability would be more than sufficient for most scenarios and would result in simpler code to develop and maintain.

Last month, I wrote wondering if Microsoft cared about SharePoint PowerShell. This is yet another example of strangeness in SharePoint PowerShell that reinforces my feeling that no one in Microsoft does care.


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.

]]>
https://office365itpros.com/2024/04/08/loop-workspaces-report-ps/feed/ 5 64322
Graph and PowerShell Hiccups for the Groups and Teams Report Script https://office365itpros.com/2024/03/22/groups-and-teams-activity-report/?utm_source=rss&utm_medium=rss&utm_campaign=groups-and-teams-activity-report https://office365itpros.com/2024/03/22/groups-and-teams-activity-report/#respond Fri, 22 Mar 2024 08:00:00 +0000 https://office365itpros.com/?p=64226

Fixing Bugs and Applying Workarounds for the Groups and Teams Activity Report

Microsoft 365 Groups and Teams Activity Report.

The Microsoft 365 Groups and Teams activity report is generated by a PowerShell script that I’ve been working on years. The first version is listed as created in July 2016, which is soon after Office 365 Groups made their debut.

Some recent changes caused me to refresh the Groups and Teams Activity Report script (the current version is 5.13 and it is available from GitHub). The major issues are listed below.

Continuing Woes with SharePoint Usage Data

The Graph Usage Reports API continues to have a problem with SharePoint Online usage data. The URL for a SharePoint site is not included in the data, which makes it very difficult to match the usage statistics with a site. As a workaround, the script fetches details of all sites in the tenant using the List Sites API to build a list of site URLs, which is then matched up with the SharePoint usage data. Matching is imperfect but works 99% of the time, which is close enough for a workaround that I hope will become unnecessary soon when Microsoft fixes the Usage Reports API.

Disappearing Group Owner Names

Some people noted that groups were being reported with no owners when they absolutely had some owners. The script calls the Groups API to retrieve owner information using GET requests like this:

https://graph.microsoft.com/v1.0/groups/33b07753-efc6-47f5-90b5-13bef01e25a6/owners?

Weirdly, the information retrieved only included the identifier for group owner accounts. The display name needed by the report was blank. I know that Microsoft encourages developers to include a Select statement in Graph queries to limit the number of properties retrieved for objects. This increases performance and reduces the amount of data that must be transferred from the service to an app. I therefore changed the request to:

https://graph.microsoft.com/v1.0/groups/33b07753-efc6-47f5-90b5-13bef01e25a6/owners?$select=id,displayName,mail

Everything worked, which is good, but when I retested with the original call a few days later, all the expected properties were there.

@odata.type       : #microsoft.graph.user
id                : eff3cd58-1bb8-4899-94de-795f656b4a18
businessPhones    : {+353 1 2080705}
displayName       : Tony Redmond
givenName         : Tony
jobTitle          : Chief Executive Officer
mail              : Tony.Redmond@office365itpros.com
mobilePhone       : +353 86 01629851
officeLocation    : Derrigimlagh
preferredLanguage : en-IE
surname           : Redmond
userPrincipalName : Tony.Redmond@office365itpros.com

My conclusion is that a bug (or perhaps an attempt to introduce a performance enhancement) suppressed the output of the properties for some period in the recent past. If this is the case and Microsoft reverted to previous behavior, it would explain what happened. But it could be something else, and the learning from the experience is that it is better to be explicit when requesting data from the Graph. Use Select to tell the Graph the set of properties needed by an app and all should be well.

Converting Strings to Dates

The data generated by the usage reports API includes the date when the monitored object was last active. This information is important in terms of knowing if a group or team is active. The date is in string format and must be converted to a datetime object to calculate the number of days since the last activity. Normally, casting the date as a datetime object is enough, but then you run into the problem that date format differs across cultures and the script throws the “string was not recognized as a valid datetime” error.

The script hadn’t had the problem before, but then I had a report that the code to convert the last activity date for a team failed. The date seemed OK (21-Sept-2023) and the code worked perfectly on my workstation, but failed elsewhere when the date format defined for PowerShell in the user’s chosen culture didn’t recognize 21-Sep-2023 as a valid date. The solution is to define the expected input string format for the cast. Here’s the current code:

[datetime]$LastItemAddedToTeams = [datetime]::ParseExact($ThisTeamData.LastActivity, "dd-MMM-yyyy", $null)

Hopefully, this fix will resolve the issue no matter what local culture is chosen for PowerShell. The learning here is that Microsoft 365 and Graph APIs output dates in different formats so some care is needed to handle dates properly, especially if you expect code to run in different countries.

Learnings for the Groups and Teams Activity Report

If I was a professional PowerShell developer, I probably would have taken more care with date objects. However, no one can be blamed when their scripts misbehave due to problems introduced by Microsoft. It’s a warning to keep an eye out for changes – or to build better error handling into scripts.

Speaking of which, I might convert the Groups and Teams Activity Report script to use the Microsoft Graph PowerShell SDK. This would simplify matters because the code wouldn’t have to deal with pagination and renewing access tokens (because the script is used to process reports for tens of thousands of groups, it can take hours to run, and the access token must be renewed hourly). Simpler code is easier to maintain… or so the theory goes.


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2024/03/22/groups-and-teams-activity-report/feed/ 0 64226
How to Convert an Entra ID External Account to Internal https://office365itpros.com/2024/03/21/convert-to-internal-user/?utm_source=rss&utm_medium=rss&utm_campaign=convert-to-internal-user https://office365itpros.com/2024/03/21/convert-to-internal-user/#comments Thu, 21 Mar 2024 08:00:00 +0000 https://office365itpros.com/?p=64207

Use the Entra Admin Center or PowerShell to Convert to Internal User Accounts

Many Microsoft 365 tenants support a mixture of internal and external accounts. Internal accounts are member accounts that authenticate with the tenant. External accounts authenticate somewhere else, such as another Microsoft 365 tenant. The most common form of external accounts found in Microsoft 365 tenants are guest accounts created to participate in team or group memberships or for sharing. Other examples are the accounts synchronized into a tenant directory through membership of a Microsoft 365 multi-tenant organization (MTO).

The Convert to Internal User Feature (Preview)

A recent preview feature introduced by the Entra ID team allows organizations to convert accounts from external to internal. In effect, the code takes an external account identity, breaks the link to the original account, and makes the account local. The original account remains intact and is not removed, so some cleanup might be necessary to remove duplicates.

The Entra admin center includes an option in the user account overview to convert the account (Figure 1). The option is only available for external accounts.

The convert to internal user option in the Entra admin center.

Convert to internal user
Figure 1: The convert to internal user option in the Entra admin center

Selecting the option displays a dialog to allow the administrator to specify the user principal name, password, and (optionally) email address for the converted account (Figure 2).

Adding properties to convert an external user to be internal.
Figure 2: Adding properties to convert an external user to be internal

The conversion process preserves the account’s membership in Microsoft 365 groups and teams. However, some background synchronization must happen to make sure that all workloads recognize that the account is now internal. In most cases, signing out of all services should be enough (you can force this by revoking the account’s access token), but you might need to remove the Teams cache to force a rebuild of team rosters.

Convert to Internal User Accounts with PowerShell

Being able to convert an external account to internal through a portal is great for a one-off operation, such as when a contractor joins the organization as a permanent employee. It’s not so good when dealing with large-scale account changes like those that happen during corporate mergers and acquisitions. This is where the automation capabilities of PowerShell are invaluable.

The steps needed to convert an external account to internal with PowerShell are straightforward:

  • Connect to the Microsoft Graph. My example uses an interactive Microsoft Graph PowerShell SDK session.
  • Find the source account and check that it is an external identity. My test is that an account is external if the email address for the account doesn’t belong to any of the tenant’s registered domains.
  • Figure out the new user principal name, email address, and a temporary password. Create a password profile to force the user to create a new password the next time they sign in.
  • Call the convertExternalToInternalMemberUser API to make the change. The API is currently accessed through the beta endpoint. The new User-ConvertToInternal.ReadWrite.All Graph permission allows access to the API.
  • If everything works, update the account’s Mail property and revoke the account’s access token.

Here’s the code that does most of the work:

$PasswordProfile = @{}
$PasswordProfile.Add('password',$NewPassword)
$PasswordProfile.Add('forceChangePasswordNextSignIn', $true)

# Create the parameters to convert the account
$NewAccountParameters = @{}
$NewAccountParameters.Add('userPrincipalName', $NewUserPrincipalName)
$NewAccountParameters.Add('passwordProfile', $PasswordProfile)

Write-Host "Switching the account to be internal..."
# Switch the account to make it internal
$Uri = ("https://graph.microsoft.com/Beta/users/{0}/convertExternalToInternalMemberUser" -f $SourceUser.Id)
$NewAccount = Invoke-MgGraphRequest -Uri $Uri -Body $NewAccountParameters -Method POST -ContentType "application/json"

# If we get back some account details, check to make sure that they're what we expect
If ($NewAccount) {
    $CheckNewAccount = Get-MgUser -UserId $SourceUser.Id -Property id, displayName, userPrincipalName, UserType
    If ($CheckNewAccount.usertype -eq 'Member' -and $CheckNewAccount.UserPrincipalName -eq $NewUserPrincipalName) {
        Update-MgUser -UserId $CheckNewAccount.Id -Mail $NewUserPrincipalName
        $RevokeStatus = Revoke-MgUserSignInSession -UserId $CheckNewAccount.Id
        Write-Host ("{0} is now a {1} account" -f $CheckNewAccount.UserPrincipalName, $CheckNewAccount.userType)
        Write-Host ("The temporary password for the account is {0}" -f $NewPassword)
        Write-Host ("Remember to assign some licenses to the converted account and to remove it from the previous source.")
    }
}

You can download the full script from GitHub.

Some Cleanup Necessary

Being able to switch a user account from external to internal is a useful feature. Remember that some cleanup is necessary to make the newly switched account a full member of the organization. It’s important to assign licenses to the account after its conversion as otherwise the account won’t be able to access Microsoft 365 services. In addition, some adjustments might be necessary to ensure that the account properties are fully populated so that the Microsoft 365 profile card displays correct information and functionality like dynamic groups and dynamic administrative units pick up the new account as appropriate.


Learn more about how the Entra ID and the rest of the Microsoft 365 ecosystem really works on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. Our monthly updates keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2024/03/21/convert-to-internal-user/feed/ 5 64207
Does Microsoft Care about SharePoint Online PowerShell? https://office365itpros.com/2024/03/19/sharepoint-online-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=sharepoint-online-powershell https://office365itpros.com/2024/03/19/sharepoint-online-powershell/#comments Tue, 19 Mar 2024 08:00:00 +0000 https://office365itpros.com/?p=64133

No Evidence that Microsoft Cares as Pnp.PowerShell Fills the Gap

SharePoint Online PowerShell

I last wrote about the state of SharePoint Online PowerShelll in 2020. At the time, I focused on Microsoft’s PowerShell module (Microsoft.Online.SharePoint.PowerShell), which is downloadable from the PowerShell Gallery. Based on the gallery statistics, the module is popular as each version attracts hundreds of thousands of downloads. Microsoft also updates the module monthly. On the surface, everything seems wonderful, and the module is in rude health.

If only this was true, but it’s not. It’s true that Microsoft updates the module to add tenant settings to control new features as they appear (like request files), but there doesn’t seem to be a coordinated plan about how Microsoft plans to support management of SharePoint Online through PowerShell.

Lack of Progress with Graph API

In 2022, Microsoft released the initial (beta) version of a Graph API to access and update SharePoint tenant settings. Apart from supporting the SharePoint settings API through the production (V1.0) endpoint, Microsoft doesn’t seem to have made much progress with the API since 2020. At least, the same set of tenant settings are visible two years on.

On the upside, SharePoint Online tenant settings are accessible using the Microsoft Graph PowerShell SDK. For instance, the Get-MgAdminSharepointSetting cmdlet reports the supported settings:

Connect-MgGraph -NoWelcome -Scopes SharePointTenantSettings.Read.All

Get-MgAdminSharepointSetting | Format-List

AllowedDomainGuidsForSyncApp                    : {}
AvailableManagedPathsForSiteCreation            : {/sites/, /teams/}
DeletedUserPersonalSiteRetentionPeriodInDays    : 60
ExcludedFileExtensionsForSyncApp                : {*.rar, *.zip}
Id                                              :
IdleSessionSignOut                              : Microsoft.Graph.PowerShell.Models.MicrosoftGraphIdleSessionSignOut
ImageTaggingOption                              : enhanced
IsCommentingOnSitePagesEnabled                  : True
IsFileActivityNotificationEnabled               : True
IsLegacyAuthProtocolsEnabled                    : True
IsLoopEnabled                                   : True
IsMacSyncAppEnabled                             : True
IsRequireAcceptingUserToMatchInvitedUserEnabled : True
IsResharingByExternalUsersEnabled               : False
IsSharePointMobileNotificationEnabled           : True
IsSharePointNewsfeedEnabled                     : False
IsSiteCreationEnabled                           : True
IsSiteCreationUiEnabled                         : True
IsSitePagesCreationEnabled                      : True
IsSitesStorageLimitAutomatic                    : True
IsSyncButtonHiddenOnPersonalSite                : False
IsUnmanagedSyncAppForTenantRestricted           : False
PersonalSiteDefaultStorageLimitInMb             : 5242880
SharingAllowedDomainList                        : {Microsoft.com…}
SharingBlockedDomainList                        : {Gmail.com}
SharingCapability                               : externalUserAndGuestSharing
SharingDomainRestrictionMode                    : none
SiteCreationDefaultManagedPath                  : /sites/
SiteCreationDefaultStorageLimitInMb             : 26214400
TenantDefaultTimezone                           : (UTC) Dublin, Edinburgh, Lisbon, London
AdditionalProperties                            : {[@odata.context, https://graph.microsoft.com/v1.0/$metadata#admin/sharepoint/settings/$entity]}

And the Update-MgAdminSharepointSetting cmdlet updates a setting:

$Body = @{}
$Body.Add("IsResharingByExternalUsersEnabled",$true)
Update-MgAdminSharepointSetting -BodyParameter $Body

SharePoint Online PowerShell is Windows PowerShell

Getting back to the PowerShell module, Microsoft has not updated it to support PowerShell 7. This might not be a problem if you always use Windows, but it does limit platform coverage. Attempting to load and use the module with PowerShell 7 usually fails, especially when multifactor authentication is involved.

The Community Approach to SharePoint Online PowerShell

This brings me to the Pnp.PowerShell module, also available from the PowerShell gallery. Based on the download numbers, Pnp.PowerShell seems to be four to five times more popular than the official Microsoft SharePoint Online module. This state is probably due to:

  • Development driven by a committed set of community advocates.
  • Wider coverage of SharePoint commands. The module spans over 650 cmdlets while the Microsoft.Online.SharePoint.PowerShell module has 250. Part of the reason for the dramatic difference in cmdlet count is that Pnp.PowerShell dips into other Microsoft 365 workloads associated with SharePoint Online like Teams, Planner, Flow, and Entra ID. Another is that Pnp.PowerShell includes cmdlets to create objects like files in SharePoint Online document libraries (here’s an example) that aren’t within the scope of the administrator-centric SharePoint module
  • Frequent updates to introduce new features and support for changes within SharePoint Online.
  • Solid documentation.

Because Pnp.PowerShell is a community effort rather than something produced by Microsoft, some organizations are reluctant to use it. They fear that support for bug fixes will be limited or that some catastrophic bug will creep in due to a lack of testing. My experience is that the community developers are very responsive and do better testing than many Microsoft development groups (an example being the recent bugs afflicting the Microsoft Graph PowerShell SDK). There’s no reason to avoid using Pnp.PowerShell, subject to the normal requirements to test new versions and ensure that every cmdlet does what you expect.

Moving Forward with SharePoint Online PowerShell

Pnp.PowerShell wins the contest for popularity and coverage when it comes to PowerShell access to SharePoint Online. The official module appears stuck in time, and I know of no advocate within Microsoft who wants to bring it forward. The Graph tenant settings API started but hasn’t done much since 2022. Perhaps Microsoft should simply take Pnp.PowerShell over? Or maybe not, because then we might have three modules in a static state instead of two.


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.

]]>
https://office365itpros.com/2024/03/19/sharepoint-online-powershell/feed/ 3 64133
Despite the Doubters, Microsoft 365 Administrators Should Continue Using PowerShell https://office365itpros.com/2024/03/08/microsoft-365-powershell/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-powershell https://office365itpros.com/2024/03/08/microsoft-365-powershell/#comments Fri, 08 Mar 2024 01:00:00 +0000 https://office365itpros.com/?p=63995

Microsoft 365 PowerShell Automates Management Operations Quickly, Easily, and Cheaply, No Matter What an MVP Says

Why Microsoft MVPs shouldn't endorse ISV software products.

Microsoft 365 PowerShell

My strong view that it’s often a bad idea for Microsoft MVPs to endorse ISV products (with or without payment) was reinforced by a recent article titled “6 Reasons Why Identity Admins Should Retire Scripting” written by Sander Berkouwer (described as an Extraordinary Identity Architect in his LinkedIn profile).

Update: The original article is no longer available on the ENow Software site. It seems like they pulled it soon after this article appeared.

Update 2 (March 12): ENow Software republished an amended article. It still contains inaccuracies and demonstrates a lack of knowledge and awareness about the role and function of the Microsoft Graph PowerShell SDK.

The article is a thinly disguised pitch for ENow Software’s App Governance Accelerator product. Basically, Berkouwer says that Entra ID administrators (who are often the same people as Microsoft 365 tenant administrators) should eschew PowerShell and leave management automation to ISVs. It’s a ridiculous position that is insulting to the many IT professionals who work with PowerShell daily.

I’m all for strong ISV participation in the market and have worked with ENow Software and other ISVs during my career. Because the cloud is a more closed environment, it’s more difficult for ISVs to find niches to exploit in the Microsoft 365 ecosystem than in on-premises environments. It’s natural for ISVs to respond by seizing every opportunity to publicize their products. In doing so, many ISVs seek the endorsement of “an expert,” like a Microsoft MVP. In my eyes, these endorsements are close to worthless.

How Microsoft 365 PowerShell Helps Administrators

The major theme developed by Berkouwer is to question whether writing PowerShell scripts is a good use of administrator time and lays out six “reasons to retire this practice.” My perspective is that understanding how to use PowerShell is a fundamental skill for Microsoft 365 administrators to acquire. You don’t have to be proficient, but PowerShell helps administrators to understand how Microsoft 365 works. This is especially true of using Graph APIs, including through the Microsoft Graph PowerShell SDK.

Here are the six reasons advanced for why administrators shouldn’t spend time writing scripts.

Microsoft renamed Azure AD: Including this as a reason to stop writing PowerShell scripts is simply silly and undermines the author’s credibility. Product rebranding happens. The important point is what a product does. Should we stop using the Microsoft Purview solutions simply because Microsoft decided to bring them all under the Purview brand? Or perhaps Yammer customers should have fled when Microsoft renamed it as Viva Engage?

Don’t trust random scripts you find on the internet… “written by everyone’s favorite Microsoft Most Valuable Professional.” This has been the advice given about PowerShell scripts since 2006. It is not a blinding insight into new knowledge. Great care is required with any code downloaded from the internet, including any of the 250-odd scripts available from the Office 365 for IT Pros GitHub repository.

Downloaded code, even written by a favorite MVP, should never be run before it is thoroughly checked and verified. But it’s also true that many scripts are written to demonstrate principles of how to do something instead of being fully worked-out solutions. Before people put PowerShell code into production, it must meet the needs and standards of the organization. For instance, developers might tweak a script to add functionality, improve error handling, or log transactions. Michel de Rooij addresses some of these challenges in his Practical365.com column.

Berkouwer’s assertion ignores the enormous value derived from how the community shares knowledge, especially at a time when tenants are upgrading scripts to use the Graph SDK. Without fully worked out examples, how could people learn? I learned from examples when PowerShell first appeared with Exchange Server 2007 in 2006. I still learn from examining PowerShell scripts written by others today. And many maintain the scripts shared through GitHub repositories.

The greater use of GitHub repositories and their inbuilt facilities to report and resolve issues helps people to share and maintain code. In addition, GitHub Copilot helps developers reuse PowerShell code that’s stored in GitHub to develop new solutions. The net is that it is easier than ever before to develop good PowerShell code to automate tenant operation.

Least Priviliged Principle. It’s true that the changeover from older modules like MSOL and AzureAD to the Graph SDK brings a mindset change. Instead of assuming that you can do anything once you connect to a module with an administrator account, some extra care and thought is needed to ensure that you use the right Graph permissions (delegated or application). Right permission means the lowest privileged permission capable of accessing the data a script works with. Yes, this is a change, but finding out what Graph permissions to use is not a difficult skill to master and I utterly fail to see why Berkouwer considers it to be such a big problem. If anything, adopting the least privileged principle drives better security practice, and that’s goodness.

The only constant in life is change. Yes, change is ongoing all the time across the Microsoft 365 ecosystem, but it is untrue that people can’t keep pace with that change. Microsoft publishes change notifications and although they’re not perfect and don’t include everything that changes (like Entra ID updates), a combination of the message center notifications (perhaps leveraging the synchronization of message center information to Planner) and RSS feeds to track important Microsoft blogs is all that’s needed.

There’s no evidence to suggest that ISVs are any better at tracking change within Microsoft 365. If anything, ISV development cycles, the need for testing, and customer desire for supportable products can hinder their ability to react quickly to changes made by Microsoft.

Maintaining and updating scripts. I’m unsure why the European Cyber Resilience Act is introduced into the discussion. It seems like some FUD thrown into the debate. PowerShell scripts are like any other code used in production. They must have a designated owner/maintainer and they should be checked as new knowledge becomes available, just like programs written using C# or .NET must be checked when Microsoft releases updates. ISVs have the same problems of code maintenance, so handing a task over to an ISV might resolve a tenant of some responsibility without being a magic bullet.

Zero trust. “When you run scripts for monitoring and security reporting purposes, they must provide instantaneous, useful information.“ Well, it would be nice if tenants always had instantaneous data to process but the singular fact is that tenants and ISVs share the same access via public APIs to information like usage reports, audit logs, license data, sign-in logs, workload settings, and so on. For instance, the data used to create a licensing report comes from Entra ID user accounts and a Microsoft web page. The data that the ENow App Governance Accelerator product comes from Entra ID and is easily accessed and reported using PowerShell (here’s an example).

ISVs and PowerShell Access the Same Microsoft 365 Data

ISVs don’t have magic back doors to different information that suddenly throws new light onto the inner functioning of Microsoft 365. ISVs might develop innovative ways of using information and use those methods to create new features, but that’s not the instantaneous, useful information that Berkouwer wants.

If Microsoft 365 tenants want to run PowerShell scripts to check what turns up in audit and other logs, a simple solution exists in the shape of Azure Automation runbooks executed on a schedule. It’s not hard to translate a regular PowerShell script to execute in Azure Automation and the support for managed identities in the major Microsoft 365 modules makes authentication for runbooks easy and highly secure. Here’s an example of using Azure Automation to create a daily risk report for Microsoft 365 tenants.

No Reason to Dump Microsoft 365 PowerShell

The solution is emphatically not to dump PowerShell scripts for an ISV product. Well-written PowerShell is as robust and secure as any ISV product. It’s worth noting here that Microsoft uses tons of PowerShell in its operations.

No single off-the-shelf product can cater for the different aspects of Microsoft 365 tenant management. ISV products have bugs, need to be supported, sometimes do a worse job than tenant-developed scripts, and no guarantee exists that the products will keep up with changes within Microsoft 365. Deploying ISV products also involves additional costs to pay for licenses and support.

On the other hand, ISV products are usually developed and maintained by very experienced professionals who are dedicated to that task (and don’t have to worry about day-to-day tenant management), so they have the time and space to think more deeply about what their product does.

ISVs Should Compete on their Merits, Not with False Arguments

I have the height of respect for Microsoft 365 ISVs and the products they create and support. Those of us who have worked in this space understand the challenges of running ISV operations and how difficult it is to succeed in a very competitive market. Product reviews do help, but only when the review focuses on explaining the strengths and weaknesses of a product after the reviewer spends a reasonable amount of time getting to understand the technology and how it fits into the ecosystem it works in.

Many ISV offerings work extremely well and do a good job of filling gaps left by Microsoft. I applaud the innovation I see in many ISV products and how they add real value to the Microsoft 365 ecosystem. ISVs do not need to be supported by artificial arguments, especially laughable advice to avoid using one of the most valuable tools available in tenant management toolboxes. If Sander would like some help understanding the usefulness of the Microsoft Graph PowerShell SDK, I’ll be delighted to help if he attends my session at the Microsoft 365 Conference in Orlando.


So much change, all the time. It’s a challenge to stay abreast of all the updates Microsoft makes across the Microsoft 365 ecosystem. 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.

]]>
https://office365itpros.com/2024/03/08/microsoft-365-powershell/feed/ 3 63995
Report OneDrive for Business Storage Based on Usage Data https://office365itpros.com/2024/02/27/onedrive-storage-report-usage/?utm_source=rss&utm_medium=rss&utm_campaign=onedrive-storage-report-usage https://office365itpros.com/2024/02/27/onedrive-storage-report-usage/#comments Tue, 27 Feb 2024 01:00:00 +0000 https://office365itpros.com/?p=63828

Much Faster to Create OneDrive Storage Report from Usage Data

Nearly five years ago, I wrote an article about a PowerShell script to report OneDrive for Business storage consumption. The script works well but it’s slow because it uses the Get-SPOSite cmdlet from the SharePoint Online management module. Everything is fine when running in a tenant with less than five hundred accounts. Past this point and you might have plenty of time for coffee.

That’s where the Graph usage reports API comes in handy. Despite being two or so days behind in terms of absolute accuracy for storage consumption, the usage reports API is extremely fast because it reads from a data warehouse populated with information by background processes running in the Microsoft datacenters. In this case, we need the OneDrive account detail report, which can cover usage from 7 to 180 days.

Including User Data in the OneDrive Storage Report

As noted previously, an ongoing issue affects usage reports for SharePoint Online data and prevents the population of site URLs in the reports. The same issue exists for OneDrive for Business data. This is a pain, but there’s often a silver lining in a bug. In this case, I decided to incorporate some user data into the report to make it possible for tenant administrators to sort by city, country, or department.

Outline of the Script to Create the OneDrive Storage Report

Here’s what the script does:

  • Runs Connect-MgGraph to connect to the Graph. This report only needs the User.Read.All and Reports.Read.All permissions.
  • Checks if the tenant obscures user data in reports. If this is true, the script updates the setting to allow it to fetch unobscured data.
  • Runs Get-MgUser to fetch details of all licensed member accounts in the tenant.
  • Populates a hash table. The key is the user principal name and the value is an array of user properties. Looking up a hash table to find user details is quicker than running Get-MgUser for each account or reading an array.
  • Use the Invoke-MgGraphRequest cmdlet to fetch the OneDrive account detail data for the last seven days. The data is loaded into an array.
  • Loop through the array to extract storage information for a user’s OneDrive for Business account and report what’s found. Included in the report is the information found by looking up the hash table for user details.
  • Export the report data to a CSV file.
  • Reset the tenant obscured report data setting if necessary.

Figure 1 shows an example of the OneDrive storage report generated by the script. When Microsoft fixes the Site URL problem for usage reports, I’ll update the script to include that property, but for now the script does a nice job of reporting OneDrive storage consumed by user accounts. And the script runs much faster than the older version based on the SharePoint Online management cmdlets.

OneDrive for Business user storage consumption report.
Figure 1: OneDrive for Business user storage consumption report.

Two Things to Learn About Reporting Microsoft 365 Data

This script demonstrates two things about reporting Microsoft 365 data. First, don’t assume that you need 100% up-to-date information about usage. The point is that data in reports might be accurate immediately after the generation of the report but degrades thereafter. There’s no great difference between an account that’s used 91.01% of its storage quota and 91.11%. The information available through the usage reports API gives as accurate a picture about usage in 99% of cases.

Second, don’t assume that the data returned by a cmdlet limit what you can use in a report. Properties like user identifiers (GUIDs) and user principal names enable matches for data drawn from multiple sources. Using hash tables to store information fetched from different sources is an excellent and fast way to create lookup tables for reports.

You can download the script from GitHub. Normal caveats apply. Don’t assume that the script has bulletproof error handling (it doesn’t) nor that a bug isn’t lurking somewhere. Test the script and have some fun chasing bugs if there are any.


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.

]]>
https://office365itpros.com/2024/02/27/onedrive-storage-report-usage/feed/ 2 63828
Why You Should Not Upgrade to Microsoft Graph PowerShell SDK V2.14 https://office365itpros.com/2024/02/20/microsoft-graph-powershell-sdk-bug/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-graph-powershell-sdk-bug https://office365itpros.com/2024/02/20/microsoft-graph-powershell-sdk-bug/#comments Tue, 20 Feb 2024 01:00:00 +0000 https://office365itpros.com/?p=63786

Problem Found in Microsoft Graph PowerShell SDK V2.13.1 Worsens in V2.14 – But Now We Have V2.15 So All is Well

Usually, I recommend that people upgrade their workstations when new versions of the Microsoft Graph PowerShell SDK appear. I cannot do this for Version 2.14, which Microsoft released on February 17, 2024. The reason is that V2.14 makes a problem that appeared in V2.13.1 (Figure 1) even worse. The problem is described here. It only affects SDK cmdlets and does not impact regular Graph queries.

The spurious output generated by Microsoft Graph PowerShell SDK V2.13.1.
Figure 1: The spurious output generated by Microsoft Graph PowerShell SDK V2.13.1

According to Microsoft, they are working on a fix to make response headers optional. From the wording of the GitHub report, it seems like Microsoft introduced the issue by making a change for cmdlets to output response headers without thinking about how this might affect customers. No date is given when the fix might be available.

Update: Microsoft has released V2.14.1 of the Microsoft Graph PowerShell SDK to fix the reported problems. After downloading and installing V2.14.1 for both interactive and background (Azure Automation) use, it appears that the issue with cmdlets returning spurious output is fixed.

According to the PowerShell Gallery statistics, 22,936 people downloaded the flawed V2.14 and 105,907 downloaded the problematic 2.13.1. Let’s hope that their scripts were not affected by the bug.

Microsoft obviously has a problem testing the SDK before release. In addition to 2.14.1, they have issued four other point releases to fix problems discovered soon after releasing a new version (2,13.1, 2.11.1, 2.9.1, and 2.6.1). This is not evidence of high-quality software engineering. The developers need to improve testing and not rush new versions into production without ensuring that new software will impact customers.

Update (22 February 2024): Microsoft has released V2.15 of the SDK. They say that this release includes fixes two specific bugs that they wanted to close. Details are posted here. Although it’s laudable to close bugs, issuing a succession of new releases and point updates doesn’t create the impression of stability and robustness that the SDK should project.

The Case of the Unwanted ResponseHeaders Object

In a nutshell, instead of returning a single object when cmdlets like Get-MgUser and Get-MgGroup use a valid identifier to find an object, the cmdlets return an array containing an unwanted ‘ResponseHeaders’ object. Here’s an example:

Get-MgUser -UserId Terry.Hegarty@office365itpros.com

DisplayName   Id                                   Mail                              UserPrincipalName
-----------   --                                   ----                              -----------------
Terry Hegarty 75ba0efb-aed5-4c0b-a5de-be5b65187c08 Terry.Hegarty@office365itpros.com Terry.Hegarty@office365itpros.c…

ResponseHeaders : {0bde6f40-9291-4457-9da8-59484710f11a}

You might not notice this problem if you format the output, but there is an extra line in the output:

Get-MgUser -UserId Terry.Hegarty@office365itpros.com | Format-Table DisplayName, userPrincipalName

DisplayName   UserPrincipalName
-----------   -----------------
Terry Hegarty Terry.Hegarty@office365itpros.com

Examining what is returned, we see that it is an array with two items:

$User = Get-MgUser -UserId Terry.Hegarty@office365itpros.com
$User.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Object[]                                 System.Array

$User.count
2

The problem doesn’t appear if you use a filter to find objects. Only a lookup against the object identifier is affected:

Get-MgUser -Filter "displayname eq 'Terry Hegarty'"

DisplayName   Id                                   Mail                              UserPrincipalName
-----------   --                                   ----                              -----------------
Terry Hegarty 75ba0efb-aed5-4c0b-a5de-be5b65187c08 Terry.Hegarty@office365itpros.com Terry.Hegarty@office365itpros.c…

The same behavior is observed with other cmdlets. Here’s an example with the Get-MgGroup cmdlet:

Get-MgGroup -GroupId '78b47932-b35f-4b26-94c2-3228cb234b07'

DisplayName   Id                                   MailNickname Description            GroupTypes
-----------   --                                   ------------ -----------            ----------
PL Test Group 78b47932-b35f-4b26-94c2-3228cb234b07 pltestgroup  Preservation Lock Test {Unified}

ResponseHeaders : {5eec6516-3352-4c98-a7de-f4231a2b0c4d}

Get-MgGroup -filter "Displayname eq 'PL Test Group'"

DisplayName   Id                                   MailNickname Description            GroupTypes
-----------   --                                   ------------ -----------            ----------
PL Test Group 78b47932-b35f-4b26-94c2-3228cb234b07 pltestgroup  Preservation Lock Test {Unified}

The problem is significantly worse in V2.14 because the cmdlets now return a hash table containing what appears to be debug information (you see the same information if you run Get-MgUser or Get-MgGroup with the -Debug switch). Here’s an extract of some of the information returned.

Date                           {Mon, 19 Feb 2024 11:37:26 GMT}
Strict-Transport-Security      {max-age=31536000}
OData-Version                  {4.0}
x-ms-ags-diagnostic            {{"ServerInfo":{"DataCenter":"North Europe ","Slice":"E","Ring":"5","ScaleUnit":"002…
Cache-Control                  {no-cache}
Transfer-Encoding              {chunked}

The issue occurs in many other SDK cmdlets. I focus on the user and group cmdlets here because they are possibly the highest-profile cmdlets in the SDK.

Don’t Upgrade for Now

The bottom line is that you should not update to a new version of the Microsoft Graph PowerShell SDK until Microsoft fixes the problem and removes the spurious output from cmdlets. The last good version of the SDK is V2.12. If you do upgrade to a newer version, be prepared to check scripts to make sure that code runs as normal.

In saying this, I note that the problem has not affected any script that I have worked on since installing V2.13.1 about ten days ago. The reason is probably that most of my scripts use filters to fetch user or group objects for processing. However, some scripts do run Get-MgUser or Get-MgGroup to process individual objects and that’s where the problem will arise.

Final Countdown for MSOL and AzureAD Modules

This problem happened at a sensitive time for the Microsoft 365 PowerShell community. On March 30, 2024, Microsoft will finally retire the old AzureAD and MSOL modules. This process has been ongoing for quite some time and Microsoft has already disabled the cmdlets that deal with license management assignments.

With 38 days to go (at the time of publication) before the old modules retire, the Microsoft Graph PowerShell SDK should be in good health and ready to accommodate everyone who needs to upgrade scripts. Introducing a flaw affecting the cmdlets that access user and group objects which are likely targets for conversion from old scripts is not good news. Let’s hope the fix arrives soon and Microsoft is more careful about making changes to the SDK that could break customer scripts in the future.


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.

]]>
https://office365itpros.com/2024/02/20/microsoft-graph-powershell-sdk-bug/feed/ 5 63786
Tracking Licensing Costs for Microsoft 365 Tenants https://office365itpros.com/2024/02/14/microsoft-365-licensing-report/?utm_source=rss&utm_medium=rss&utm_campaign=microsoft-365-licensing-report https://office365itpros.com/2024/02/14/microsoft-365-licensing-report/#comments Wed, 14 Feb 2024 01:00:00 +0000 https://office365itpros.com/?p=63686

Microsoft 365 Licensing Report Details Costs Per User to Find Optimizations

Recently, I released an update to my Microsoft 365 Licensing Report PowerShell script to include the ability to assign costs to user accounts. The idea is to give administrators information about how much the cumulative annual license charges are for each account. Combining cost data with insight about account activity in a tenant (generated with the user activity report script or by reference to the individual workload usage reports in the Microsoft 365 admin center), administrators can figure out if users have the right licenses they need to work and no licenses are assigned to inactive accounts.

Managing the cost of Office 365 and Microsoft 365 licenses has always been important. As Microsoft puts more focus on driving revenue through high-priced add-ons such as Teams Premium ($120/year) and Copilot for Microsoft 365 ($360/year), it’s even more essential to keep close tabs on license assignments. There’s no point in assigning a Copilot license to someone who’s inactive or whose usage pattern indicates that they might not take advantage of the license. No one is rewarded for overspending on licenses.

Adding Cost by Department and Cost by Country to the Microsoft 365 Licensing Report

Almost immediately after releasing the updated script, calls came in to ask if it was possible to generate an analysis of licensing cost by country and by department. My initial response was “sure” and I set to figuring out the best way to implement the change.

Because the report script tracks license costs per user, the simple method is to:

  • Find the sets of departments and countries in user accounts.
  • For each department (or country), calculate the sum of license costs.
  • Include the information in the report.

The same approach works to analyze license costs for any user account property fetched by the initial Get-MgUser command at the start of the script. If the set of regular account properties don’t work for your organization, you could use an Exchange custom attribute to store the required values. For instance, you could include a cost center number in a custom attribute. Here’s how to access Exchange custom attributes with Get-MgUser. You’ll need to extract the information from the custom attribute before you can use it in the script.

The Problems Caused by Inaccurate Directory Data

The obvious problem is that sometimes the properties of user accounts don’t include a department or country. Account properties should hold accurate properties, but unfortunately this sometimes doesn’t happen because administrators fail to add properties to accounts, or a synchronization process linking a HR system to Entra ID encounters problems, or something else conspires to erode directory accuracy. The point is that inaccurate or missing user account properties result in bad license accounting.

The first order of business is therefore to validate that the account properties that you want to use for license cost reporting exist and are correct. This article explains how to detect user accounts with missing properties. Making sure that properties are accurate requires an extra level of review. The value of the country property assigned to user accounts shouldn’t change frequently, but properties like department and office might.

Reporting Licensing Costs for Country and Department

After making sure that all the necessary user account properties are in place (and accurate), the code to generate cost analyses based on department and country worked like a dream. The script also required an update to insert the new data into the output report, including warnings for administrators when costs cannot be attribute to countries or departments because of missing account properties. Figure 1 shows the result.

Costs for departments and countries shown in Microsoft 365 Licensing Report.
Figure 1: Costs for departments and countries shown in Microsoft 365 Licensing Report

The code changes are in version 1.6 of the report script, which you can download from GitHub. If you haven’t run the script before, make sure that you read the previous Practical365.com articles to understand how the script works and how to generate the two (SKU and service plan) CSV files used by the script.

Remember that this script is intended to demonstrate the principles of interacting with and interpreting Entra ID user account and license information with the Microsoft Graph PowerShell SDK. It’s not intended to be a bulletproof license cost management solution. Have fun with PowerShell!


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

]]>
https://office365itpros.com/2024/02/14/microsoft-365-licensing-report/feed/ 21 63686
How Many Message Center Announcements End Up Being Delayed? https://office365itpros.com/2024/02/09/message-center-posts-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=message-center-posts-sdk https://office365itpros.com/2024/02/09/message-center-posts-sdk/#comments Fri, 09 Feb 2024 01:00:00 +0000 https://office365itpros.com/?p=63615

Use the Microsoft Graph PowerShell SDK to Analyze Service Update Messages

In November 2020, I wrote an article about the number of Microsoft 365 message center posts about new features that ended up being delayed. At the time, 29.27% of message center posts needed to adjust their published date for feature availability. Being of a curious nature, I wondered if Microsoft is better at predicting when they can deliver software across the spectrum of Microsoft 365 applications.

The code I used in 2020 is now obsolete. Microsoft moved the service communication API from the old manage.office.com endpoint to the service communications Graph API and access to message center posts is through the service update message resource. Because the service communications API is a full-fledged Graph API, cmdlets in the Microsoft Graph PowerShell SDK are available to work with message center posts. For instance, the Get-MgServiceAnnouncementMessage cmdlet retrieves message center posts. This command shows how to retrieve posts for the last seven days:

$SevenDaysAgo = (Get-Date).AddDays(-7)
$CheckDate = (Get-Date($SevenDaysAgo) -format s) + "Z"  
[array]$MCPosts = Get-MgServiceAnnouncementMessage -filter "StartDateTime ge $CheckDate"

Adding the “Z” to the sortable date generated by the Get-Date cmdlet is important for the filter to work.

Updating the Code

The code written in 2020 uses a registered Entra ID app to obtain an access token and fetch the message center posts. Updating the script involved:

  • Removing the code to obtain an access token and replacing it with a call to the Connect-MgGraph cmdlet specifying the ServiceMessage.Read.All scope (permission).
  • Run the Get-MgServiceAnnouncement cmdlet with the All parameter to fetch all available message center posts.
  • The data returned for message center posts using the service communications Graph API differs from that returned by the old API. Some adjustment was necessary in the script to update property names and the content returned for some properties.
  • Addition of some code to calculate the percentage of delayed feature announcements. In 2020, this was done using Excel. The basic test for a delay is the presence of the string “(Updated)” in the title for a message center post. No attempt is made to compute the length of the delay because message center posts don’t contain a structured property with this information. Instead, information about delays is conveyed in the text. For example, “We will begin rolling out in mid-September 2023 (previously late August) and expect completion by mid-February 2024 (previously late January).

Comparing Results

In 2020, the results looked like this:

 		Notifications	Updates		Percent updated
Teams		58		22		37.93%
SharePoint	37		14		37.84%
Exchange	30		9		30%
Yammer		10		4		44.44%
Intune		8		0		—-
Power Apps	5		0		—-

On February 5, 2024, the Get-MgServiceAnnouncement cmdlet fetched 552 message center posts for my tenant. This is a higher amount than in 2020 because the tenant subscriptions now include some Microsoft 365 E5 licenses covering more apps. The number of message center posts available in a tenant vary depending on the active subscriptions that exist within the tenant.

Figure 1 shows the results. Nearly a third of all message center posts are delayed. Teams remains the workload that issues most message center posts (83), but its performance in terms of avoiding delays has worsened from 38.93% to 57.24% This might be due to the transition from the classic Teams client to the new Teams client (due to be complete by the end of March), or it might be that the Teams product managers have real difficulty in predicting when software might be ready for deployment.

Percentage of delayed message center posts by workload.
Figure 1: Percentage of delayed message center posts by workload

Some message center posts cover multiple workloads and it’s hard to know where the responsibility lies for a delay. The data is therefore indicative rather than definitive. To be sure about where delays lie, you’d need to examine the text of each message center post and extract and collate the details.

You can download the updated script from GitHub.

Easier to Work with Message Center Posts

Being able to work with service communication data through Microsoft Graph PowerShell SDK cmdlets makes the information more accessible than before. Some of the improvements introduced by Microsoft for message center posts since 2020 aren’t available. The relevance property appears to have disappeared from the Microsoft 365 admin center and the number of active users for a workload, which does show up in the message center, is missing from the properties returned by the SDK cmdlet. But the rest of the information you might want is available and ready to be sliced and diced as you want.


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.

]]>
https://office365itpros.com/2024/02/09/message-center-posts-sdk/feed/ 2 63615
Use the Graph SDK to Access Microsoft 365 Service Health Information https://office365itpros.com/2024/02/07/service-health-data-api/?utm_source=rss&utm_medium=rss&utm_campaign=service-health-data-api https://office365itpros.com/2024/02/07/service-health-data-api/#comments Wed, 07 Feb 2024 01:00:00 +0000 https://office365itpros.com/?p=63487

Graph-based Service Communications API is now the Route to Service Health Data

In January 2021, I wrote about how to use the Office 365 Service Communications API to programmatically retrieve the service health information that’s available in the Microsoft 365 admin center (Figure 1).

Service Health information viewed in the Microsoft 365 admin center.

Microsoft 365 service health data.
Figure 1: Service Health advisory messages viewed in the Microsoft 365 admin center

At the time, the API used the manage.office.com endpoint. In December 2021, Microsoft deprecated the manage.office.com endpoint and introduced the Service Communications Graph API as the replacement. In this article, I explain how to use the API with Microsoft Graph PowerShell SDK cmdlets to retrieve service health information.

Retrieving Service Health Data

As shown in Figure 1, the active items Microsoft is working on are those that impact the service in some way, usually by removing the ability of users to do something. To find these items, run the Get-MgServiceAnnouncementIssue cmdlet and filter for items classified as advisory with a status of ‘serviceDegration’:

[array]$ServiceHealthItems = Get-MgServiceAnnouncementIssue -All `
    -Filter "classification eq 'Advisory' and status eq 'serviceDegradation'" | `
    Sort-Object {$_.LastModifiedDateTime -as [datetime]} -Descending

$ServiceHealthItems | Format-Table Id, Title, FeatureGroup, LastModifiedDateTime

If you don’t filter the service health items, the Get-MgServiceAnnouncementIssue cmdlet, including those where Microsoft resolved the issue (as with many SDK cmdlets, the All switch tells the cmdlet to fetch everything). This data reveals the areas where most issues occur. In my tenant, the 346 available issues broke down as follows:

$Data = Get-MgServiceAnnouncementIssue -All
$Data | Group-Object FeatureGroup -Noelement | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize

Name                                    Count
----                                    -----
Teams Components                           80
Administration                             39
E-Mail and calendar access                 27
SharePoint Features                        25
Portal                                     23
Management and Provisioning                22
Microsoft Defender for Endpoint            21
Cloud App Security                         13
Viva Engage                                10

Another interesting grouping is by service:

$Data | Group-Object Service -Noelement | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize

Name                                      Count
----                                      -----
Microsoft Teams                              80
Microsoft 365 suite                          64
Exchange Online                              60
Microsoft Defender XDR                       32
SharePoint Online                            30
Microsoft Defender for Cloud Apps            25
Microsoft Viva                               12
OneDrive for Business                         8

The start date for the oldest issue was March 1, 2023. The oldest last modified date for an issue was July 31, 2023. This suggests that Microsoft might keep about six months of service issue data online. Your mileage might vary.

Fetching Overall Service Health Data

Underneath the advisory items, the Microsoft 365 admin center displays an overview showing the health for individual services like Exchange Online, Teams, SharePoint Online, and so on. This information is accessible by running the Get-MgServiceAnnouncementHealthOverview cmdlet. In my tenant, this generates a list of 32 individual services, some of which (like Sway and Microsoft Managed Desktop), I’m not interested in. I therefore amend the output by filtering the services that I consider most important:

[array]$ImportantServices = "Exchange", "Teams", "SharePoint", "OrgLiveID", "Planner", "microsoftteams", "O365Client", "OneDriveForBusiness"
[array]$ImportantServiceStatus = Get-MgServiceAnnouncementHealthOverview | Where-Object {$_.Id -in $ImportantServices}
$ImportantServiceStatus | Sort-Object Service | Format-Table Service, Status -AutoSize

Service            Status
-------            ------
Exchange Online    serviceDegradation
Microsoft 365 apps serviceOperational
Microsoft Entra    serviceOperational
Microsoft Teams    serviceDegradation
Planner            serviceOperational
SharePoint Online  serviceDegradation

Using Service Health Data to Highlight Current Advisories

Many people will be perfectly happy to access service health information via the Microsoft 365 admin center. The advantage of using an API to retrieve the same information is that you can then use it in whatever way you think appropriate. As a working example to demonstrate what’s possible, I wrote a script that can run interactively or as an Azure Automation runbook using a managed identity.

The script retrieves the open service health advisories and creates an email with an HTML-format report containing the service data that is sent to nominated recipients (any mixture of mail-enabled objects, including individual mailboxes, distribution lists, and Microsoft 365 groups). The idea is to keep the recipients updated about progress with open issues that Microsoft is working on. Figure 2 shows an example email generated using the service advisories published in my tenant.

Email detailing open service health advisories.
Figure 2: Email detailing open service health advisories

After it’s extracted, the report can be disseminated in other ways. For instance, you could publish it as a Teams channel message.

You can download the script from GitHub.

Disrupted Change

Changing the details of an API is always disruptive. It’s not just the new endpoint. It’s also the way that the API returns data. Everything must be checked and verified. At least now the Service Communications API is part of the Microsoft Graph. As such, the level of change should be minimal in the future and we have the added benefit of PowerShell cmdlets to work with.


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.

]]>
https://office365itpros.com/2024/02/07/service-health-data-api/feed/ 4 63487
New MSIdentityTools Cmdlet to Report OAuth Permissions https://office365itpros.com/2024/02/05/export-msidappconsentgrantreport/?utm_source=rss&utm_medium=rss&utm_campaign=export-msidappconsentgrantreport https://office365itpros.com/2024/02/05/export-msidappconsentgrantreport/#respond Mon, 05 Feb 2024 00:06:00 +0000 https://office365itpros.com/?p=63591

The Export-MsIdAppConsentGrantReport Cmdlet Makes it Easier for Tenant Administrators to Track OAuth Permissions for Apps

As readers of my articles know, I have often discussed the topic of monitoring and checking OAuth permissions assigned to apps, usually using the Microsoft Graph PowerShell SDK to fetch and interpret permissions in a way that makes sense to tenant administrators. A recent example is an article about how to generate a report about OAuth permissions.

The need to understand the permissions assigned to apps was underscored by the recent Midnight Blizzard attack on Microsoft corporate mailboxes. The fact that an OAuth app can exist with permissions necessary to exfiltrate email and attachments from mailboxes without Microsoft’s administrators and security professionals detecting its presence for several months, highlights the challenge facing every tenant administrator.

A New MsIdentityTools Cmdlet

And that’s why the creation of the Export-MsIdAppConsentGrantReport cmdlet is such welcome news. Not every tenant administrator can master the PowerShell cmdlets used to interrogate apps or understand the data that comes back. It’s a lot easier when a single cmdlet does the job. Export-MsIdAppConsentGrantReport is part of the MSIdentity Tools module, developed and maintained by members of the Entra ID product group to help with different aspects of directory management.

You can get version 2.0.52 of the MsIdentityTools module by installing it from the PowerShell gallery.

Install-Module -Name MSIdentityTools -Force -Scope AllUsers -RequiredVersion 2.0.52

Because of a dependency, the MSIdentityTools module also installs the Microsoft.Graph.Authentication module (part of the Microsoft Graph PowerShell SDK). Oddly, it installs version 2.9.1 of the Authentication module instead of the current version (2.12). Apart from occupying some extra disk space, no great harm is done and MSIdentityTools is happy to use 2.12.

Running Export-MsIdAppConsentGrantReport

Generating a report with the Export-MsIdAppConsentGrantReport cmdlet is easy. This code connects to the Microsoft Graph PowerShell SDK, imports the ImportExcel module (needed to generate an Excel worksheet), and creates the report in the form of a worksheet:

Connect-MgGraph -Scopes Directory.Read.All -NoWelcome
Import-Module ImportExcel
Export-MsIdAppConsentGrantReport -ReportOutputType ExcelWorkbook -ExcelWorkbookPath c:\temp\OAuthAppPermissionsReport.xlsx

The cmdlet uses Microsoft Graph API calls to read and analyze information about service principals. It then calls cmdlets from the ImportExcel module to generate a multi-sheet workbook. Figure 1 shows one of the sheets listing Graph and other permissions (like the right for an app to run cmdlets from the Teams PowerShell module as an administrator).

Excel worksheet generated by the Export-MsIdAppConsentGrantReport cmdlet
Figure 1: Excel worksheet generated by the Export-MsIdAppConsentGrantReport cmdlet

Even better, the Export-MsIdAppConsentGrantReport cmdlet can generate its data as a PowerShell object:

[array]$AppData = Export-MsIdAppConsentGrantReport -ReportOutputType PowerShellObjects

The reason why this facility is so good is that the cmdlet does a lot of heavy lifting to fetch information about service principals and permissions and delivers them in an array that’s easy for PowerShell scripts to consume. In effect, this eliminates a lot of code in scripts like those that I’ve written to report permission assignments. Instead of running Get-MgServicePrincipal and parsing the results to find and interpret data, developers can run Export-MsIdAppConsentGrantReport and use its output instead.

For example, this command finds the service principals that hold the Mail.Send permission. This is a high-priority permission because Mail.Send allows the app to send email from any mailbox unless limited by RBAC for Applications.

$Appdata | Where-Object Permission -match 'Mail.Send' | Format-Table ClientDisplayName, Appid, Permissiontype

ClientDisplayName                                                 AppId                                PermissionType
-----------------                                                 -----                                --------------
MalwareExample                                                    d868053d-58bc-4010-a659-23de72d14669 Application
PowerShellGraph                                                   8f005189-8c58-4fb5-a226-8851e13490cb Application
MailSendApp                                                       970e01d1-ce75-46ba-a054-4b61c787f682 Application
ExoAutomationAccount_Y6LgjDYIfPnxmFzrqdbaClsnTD/gN4BNnVMywiju5hk= 45923847-be5b-4e29-98c5-bc9ab0b5dc95 Application
ManagedIdentitiesAutomation                                       b977a222-3534-4625-980d-e2f864d3a2d5 Application
Microsoft Graph PowerShell SDK Cert                               d86b1929-b818-411b-834a-206385bf5347 Application
PnP Management Shell                                              31359c7f-bd7e-475c-86db-fdb8c937548e Delegated-AllPr…
MailSendAppDelegate                                               0fb521aa-8d32-4c0b-b124-565a1d8c4abe Delegated-AllPr…
MailSendAppDelegate                                               0fb521aa-8d32-4c0b-b124-565a1d8c4abe Delegated-AllPr…
PowerShellGraph                                                   8f005189-8c58-4fb5-a226-8851e13490cb Delegated-AllPr…
IMAP access to Shared Mailbox                                     6a90af02-6ac1-405a-85e6-fb6ede844d92 Delegated-AllPr…
Microsoft Graph Command Line Tools                                14d82eec-204b-4c2f-b7e8-296a70dab67e Delegated-AllPr…
Microsoft Graph Command Line Tools                                14d82eec-204b-4c2f-b7e8-296a70dab67e Delegated-AllPr…

Notice that some duplicates are present. These are probably due to a glitch in the cmdlet that will be squashed soon.

Because the array is a PowerShell object, you can export it in whatever format you want, including CSV, Excel, and HTML.

Not a Panacea, Just a Tool

The Export-MsIdAppConsentGrantReport cmdlet is a valuable contribution to the tenant administrator toolbox, but it’s not a silver bullet that will stop over permissioned OAuth apps. It’s also not a replacement for administrators acquiring knowledge about how Entra ID apps acquire and use permissions (application and delegated) and how to extract that information from Entra ID using Graph API requests or Microsoft Graph PowerShell SDK cmdlets. Think of Export-MsIdAppConsentGrantReport as a useful tool, no more, no less. It’s great to have.


Make sure that you’re not surprised about changes that appear inside Office 365 applications by subscribing to the Office 365 for IT Pros eBook. Our monthly updates make sure that our subscribers stay informed.

]]>
https://office365itpros.com/2024/02/05/export-msidappconsentgrantreport/feed/ 0 63591
Microsoft Deprecates Old Exchange Audit Search Cmdlets https://office365itpros.com/2024/01/29/search-unifiedauditlog-changes/?utm_source=rss&utm_medium=rss&utm_campaign=search-unifiedauditlog-changes https://office365itpros.com/2024/01/29/search-unifiedauditlog-changes/#comments Mon, 29 Jan 2024 01:00:00 +0000 https://office365itpros.com/?p=63505

Future Focused on Unified Search Log

A January 26 post in the Microsoft Technical Community announced that Microsoft intends to retire the old cmdlets that report Exchange mailbox and administrative audit events on April 30, 2024. The cmdlets involved are Search-AdminAuditLog, Search-MailboxAuditLog, New-AdminAuditLogSearch, and New-MailboxAuditLogSearch. Microsoft says that the replacement is the Search-UnifiedAuditLog cmdlet.

Microsoft’s assertion is correct. Unlike their plan to retire the Search-Mailbox cmdlet at the end of March 2024, I think it is a good idea to deprecate the four search cmdlets because they only confuse the Microsoft 365 audit search landscape. The cmdlets appeared in Exchange 2010 as part of the introduction of audit functionality for Exchange Server. Today, the audit events gathered by Exchange Online flow into the unified audit log and there’s no need to interrogate the copies of the audit events retained in user mailboxes. The unified audit log is what is searched using the Audit Log feature in the Purview compliance portal (Figure 1).

 Running a search against the unified audit log.

Search-UnifiedAuditLog
Figure 1: Running a search against the unified audit log.

It might be the case that some old scripts exist that depend on finding mailbox or admin audit events in Exchange, but it’s relatively easy to convert those scripts to use Search-UnifiedAuditLog.

Until the Search-UnifiedAuditLog Cmdlet Changes Without Warning

At least, it would be if Microsoft didn’t change how the Search-UnifiedAuditLog cmdlet works without warning, which is what they did in late summer 2023. Unannounced and unexplained change allied to slow delivery of commitments to make some important audit events available to Office 365 E3 tenants have shaken my confidence in Search-UnifiedAuditLog recently,

Anything to do with auditing needs to be consistent and precise. As seen with unannounced change, consistency is not something that I associate with the Search-UnifiedAuditLog cmdlet. Precision is often poor too. The group that manages the flow of audit events into the unified audit log insists on consistency for the base properties, such as the timestamp, name of the operation, the user responsible for an action, and so on. Things become far murkier when it comes to the AuditData property, which holds information deemed necessary by a workload to communicate details of an action.

The Mysteries of AuditData

AuditData is a JSON-formatted structure. There’s nothing wrong with that. My objections focus on the arbitrary inclusion of information in the structure. As an example, reporting details of license assignments to Entra ID user accounts is challenging. Entra ID generates audit events, but the content of AuditData is often obscure and defies interpretation. With over 1,600 different audit events flowing into the unified audit log, insisting on coherence and clarity in all events must be like cleaning the mythical Augean stables. But without full and precise information in audit events, the unified audit log loses credibility and becomes less valuable than it could be.

I should say that I regard the unified audit log as an extraordinarily valuable source of information about what actually happens within a Microsoft 365 tenant. All tenant administrators should know how to interrogate the audit log and understand (at least roughly) what the audit events returned by a search mean. Skilled tenant administrators go deeper and use the audit log as a source of understanding for how Microsoft 365 workloads work. Not everyone has the time to master the audit log at this depth, but it’s certainly a good goal to work toward.

Remove Decrepit Cmdlets But Fix Search-UnifiedAuditLog

I have zero problem with Microsoft removing old and decrepit cmdlets from the Exchange Online management module. It’s the right thing to do. I just wish that Microsoft would fix the problems in the Search-UnifiedAuditLog cmdlet before they did anything else. Everyone who works with Microsoft 365 audit data would benefit and it would establish a solid foundation for the future. Which would be nice.


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.

]]>
https://office365itpros.com/2024/01/29/search-unifiedauditlog-changes/feed/ 2 63505
How to Update Tenant Corporate Branding for the Entra ID Sign-in Screen with PowerShell https://office365itpros.com/2024/01/25/corporate-branding-for-entra-id/?utm_source=rss&utm_medium=rss&utm_campaign=corporate-branding-for-entra-id https://office365itpros.com/2024/01/25/corporate-branding-for-entra-id/#comments Thu, 25 Jan 2024 01:00:00 +0000 https://office365itpros.com/?p=63398

Use Graph SDK Cmdlets to Apply Annual Updates to Corporate Branding for Entra ID Sign-in Screen

Back in 2020, I took the first opportunity to apply corporate branding to a Microsoft 365 tenant and added custom images to the Entra ID web sign-in process. Things have moved on and company branding has its own section in the Entra ID admin center with accompanying documentation. Figure 1 shows some custom branding elements (background screen, banner logo, and sign-in page text) in action.

Corporate branding applied to the Entra ID sign-in screen.

Corporate Branding for Entra ID.
Figure 1: Corporate branding applied to the Entra ID sign-in screen

Entra ID displays the custom elements after the initial generic sign-in screen when a user enters their user principal name (UPN). The UPN allows Entra ID to identify which tenant the account comes from and if any custom branding should be displayed.

Company branding is available to any tenant with Entra ID P1 or P2 licenses. The documentation mentions that Office 365 licenses are needed to customize branding for the Office apps. This mention is very non-specific. I assume it means Office 365 E3 and above enterprise tenants can customize branding to appear in the web Office apps. Certainly, no branding I have attempted has ever affected the desktop Office apps.

Scripting the Annual Branding Refresh

Every year, I like to refresh the custom branding elements, if only to update the sign-in text to display the correct year. It’s certainly easy to make the changes through the Entra ID admin center (Figure 2), but I like to do it with PowerShell because I can schedule an Azure Automation job to run at midnight on January 1 and have the site customized for the year.

Editing corporate branding settings in the Entra ID admin center.
Figure 2: Editing corporate branding settings in the Entra ID admin center

The Graph APIs include the organizational branding resource type to hold details of a tenant’s branding (either default or custom). Updating the properties of the organizational branding resource type requires the Organization.Rewrite.All permission. Properties are divided into string types (like the sign-in text) and stream types (like the background image).

The script/runbook executes the following steps:

  • Connects to the Graph using a managed identity.
  • Retrieves details of the current sign-in text using the Get-MgOrganizationBranding cmdlet.
  • Checks if the sign-in text has the current year. If not, update the sign-in text and run the Update-MgOrganizationBranding cmdlet to refresh the setting. The maximum size of the sign-in text is 1024 characters. The new sign-in text should be displayed within 15 minutes.
  • Checks if a new background image is available. The code below uses a location on a local disk to allow the script to run interactively. To allow the Azure Automation runbook to find the image, it must be stored in a network location like a web server. The background image should be sized 1920 x 1080 pixels and must be less than 300 KB. Entra ID refuses to upload larger files.
  • If a new image is available, update the branding configuration by running the Invoke-MgGraphRequest cmdlet. I’d like to use the Set-MgOrganizationBrandingLocalizationBackgroundImage cmdlet from the SDK, but it has many woes (issue #2541), not least the lack of a content type parameter to indicate the type of image being passed. A new background image takes longer to distribute across Microsoft’s network but should be available within an hour of the update.

Connect-MgGraph -Scopes Organization.ReadWrite.All -NoWelcome 
# If running in Azure Automation, use Connect-MgGraph -Scopes Organization.ReadWrite.All -NoWelcome -Identity

$TenantId = (Get-MgOrganization).Id
# Get current sign-in text
[string]$SignInText = (Get-MgOrganizationBranding -OrganizationId $TenantId -ErrorAction SilentlyContinue).SignInPageText 
If ($SignInText.Length -eq 0) {
   Write-Host "No branding information found - exiting" ; break
}
[string]$CurrentYear = Get-Date -format yyyy
$DefaultYearImage = "c:\temp\DefaultYearImage.jpg"
$YearPresent = $SignInText.IndexOf($CurrentYear)
If ($YearPresent -gt 0) {
    Write-Output ("Year found in sign in text is {0}. No update necessary" -f $CurrentYear)
} Else {
    Write-Output ("Updating copyright date for tenant to {0}" -f $CurrentYear )
    $YearPosition = $SignInText.IndexOf('202')
    $NewSIT = $SignInText.SubString(0, ($YearPosition)) + $CurrentYear
    # Create hash table for updated parameters
    $BrandingParams = @{}
    $BrandingParams.Add("signInPageText",$NewSIT)
    Update-MgOrganizationBranding -OrganizationId $TenantId -BodyParameter $BrandingParams
    If (Test-Path $DefaultYearImage) {
        Write-Output "Updating background image..."
        $Uri = ("https://graph.microsoft.com/v1.0/organization/{0}/branding/localizations/0/backgroundImage" -f $TenantId)
        Invoke-MgGraphRequest -Method PUT -Uri $Uri -InputFilePath $DefaultYearImage -ContentType "image/jpg"
    } Else {
        Write-Output "No new background image available to update"
    }
}

The script is available in GitHub.

Figure 2 shows the updated sign-in screen (I deliberately updated the year to 2025).

The refreshed corporate branding for the Entra ID sign-in screen.

Corporate branding Entra Id
Figure 3: The refreshed corporate branding for the Entra ID sign-in screen.

If you run the code in Azure Automation, the account must have the Microsoft.Graph.Authentication and Microsoft.Graph.Identity.DirectoryManagement modules loaded as resources in the automation account to use the cmdlets in the script.

Full Corporate Branding Possible

The documentation describes a bunch of other settings that can be tweaked to apply full custom branding to a tenant. Generally, I prefer to keep customization light to reduce ongoing maintenance, but I know that many organizations are strongly attached to corporate logos, colors, and so on.

Corporate Branding for Entra ID Isn’t Difficult

Applying customizations to the Entra ID sign-in screens is not complicated. Assuming you have some appropriate images to use, updating takes just a few minutes with the Entra ID admin center. I only resorted to PowerShell to process the annual update, but you could adopt it to have different sign-in screens for various holidays, company celebrations, and so on.


Learn about using Entra ID and the rest of the Microsoft 365 ecosystem 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.

]]>
https://office365itpros.com/2024/01/25/corporate-branding-for-entra-id/feed/ 1 63398
Mastering Microsoft Graph PowerShell SDK Foibles https://office365itpros.com/2024/01/12/user-extension-attributes-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=user-extension-attributes-sdk https://office365itpros.com/2024/01/12/user-extension-attributes-sdk/#respond Fri, 12 Jan 2024 01:00:00 +0000 https://office365itpros.com/?p=63200

Microsoft 365 Groups, Entra ID, and User Extension Attributes

Last year, I wrote about some of the foibles encountered by scripters as they work with the Microsoft Graph PowerShell SDK. At the time, we were waiting for V2 of the SDK, which duly arrived in July 2023. At the time of writing, the current version of the SDK is 2.11.1, meaning that updates appear frequently.

A foible that recently came to my attention is that the fifteen custom single-value attributes available for customers to populate for Exchange Online mailboxes are synchronized to Entra ID and available through the Get-MgUser cmdlet, but the same attributes are not available for Entra ID groups even though they exist in Exchange. The five multi-value custom attributes available for mail-enabled objects in Exchange do not synchronize with Entra ID.

Custom attributes allow organizations to store whatever data they like for mailboxes, groups, and other mail-enabled objects. Mailboxes are linked to user accounts and Entra ID synchronizes the 15 custom attributes to OnPremisesExtensionAttributes for Entra ID accounts. An Entra ID account does not have to be mailbox-enabled to use these attributes, but most are.

Figure 1 shows the extension attributes for my account as viewed through the Entra ID admin center. Extension attribute 9 is used to hold details of my favorite drink, which is then exposed to those who need to know by customizing the Microsoft 365 user profile card.

User extension attributes for an account shown in the Entra ID admin center.
Figure 1: User extension attributes shown in the Entra ID admin center

Another example of using custom attributes is to set an expiration date for guest accounts that can then be actioned by processes to detect and remove expired accounts.

Entra ID groups don’t currently support custom attributes and that’s where the problem lies. Given that Microsoft 365 groups and distribution lists support these attributes and show up as Entra ID groups, it seems like a gap exists in the connection between Exchange Online and Entra ID. The Graph APIs can’t make data appear where it not present.

I’ve asked Microsoft why groups don’t support custom attributes and discussions continue. Hopefully, Microsoft will close the gap in the future. In the meantime, if you need to work with custom attributes for groups, use the Exchange Online cmdlets.

Reporting Graph SDK Problems

I reported the problem with custom attributes for groups to Microsoft via the PowerShell GitHub repro. This is the right place to report issues and suggestions for the Microsoft Graph PowerShell SDK. The SDK development team monitors the issues that come in and will respond. Before you add a new issue, it’s worthwhile scanning the set of existing issues to see if someone else reported the same problem. Reading problems can also be a good way to learn how SDK cmdlets work and how people are using them to solve problems.

Understanding Graph Permissions

Apps, including the Microsoft Graph PowerShell SDK, need permissions (scopes) to access data via Graph APIs. It’s sometimes difficult to understand what permission is needed to do something, especially when contemplating interactive sessions (delegate permissions and administrative roles assigned to the signed-in account) versus other forms of use like certificate-based authentication, Azure Automation runbooks, and registered apps, all of which use application permissions. Administrative roles can also come into the frame too. The bottom line is that picking the right permissions – and the least-permissioned of those permissions -can take some effort.

This article covers how to use Graph SDK cmdlets like Find-MgGraphPermission to find the right permissions. Christian Rittler used Find-MgGraphPermission to create a useful function called Get-GraphScriptPermission that accepts a script block as input and parses the cmdlets in the script block to find the required permissions. The idea is that instead of checking individual cmdlets, you can check what permissions are needed for an entire script. For example, this code creates a script block containing SDK cmdlets to retrieve user accounts and check each account to find if a manager exists.

$Script = {
[array]$Users = Get-MgUser -All -Filter "userType eq 'Member'"
ForEach ($User in $Users) {
   $Manager = Get-MgUser -UserId $User.Id | Select-Object userPrincipalName, @{n="Manager";e={(Get-MgUserManager -UserId $_.Id).AdditionalProperties.userPrincipalName}}
   If ($Manager) {
      Write-Host ("User {0}'s manager is {1}" -f $User.displayName, $Manager.Manager)
   }
 }
}

To use the function, call it and pass the variable containing the script block. The output lists the cmdlets found and the permissions needed.

Get-GraphScriptPermission -Script $Script

Cmdlet : Get-MgUser
Source : Microsoft.Graph.Users
Verb   : Get
Type   : MgUser
Scopes : DeviceManagementApps.Read.All (admin: True), DeviceManagementApps.ReadWrite.All (admin: True),DeviceManagementConfiguration.Read.All (admin: False), DeviceManagementConfiguration.Read.All (admin: True), DeviceManagementConfiguration.ReadWrite.All (admin: True), DeviceManagementManagedDevices.Read.All (admin:False), DeviceManagementManagedDevices.ReadWrite.All (admin: False),DeviceManagementManagedDevices.ReadWrite.All (admin: True), DeviceManagementServiceConfig.Read.All (admin: False), DeviceManagementServiceConfig.Read.All (admin: True), DeviceManagementServiceConfig.ReadWrite.All (admin: False), DeviceManagementServiceConfig.ReadWrite.All (admin: True), Directory.Read.All (admin: False), Directory.ReadWrite.All (admin: False), User.Read (admin: False), User.Read.All (admin: False), User.Read.All (admin: True), User.ReadBasic.All (admin: False), User.ReadWrite (admin: False), User.ReadWrite.All (admin:False), User.ReadWrite.All (admin: True)

Cmdlet : Get-MgUserManager
Source : Microsoft.Graph.Users
Verb   : Get
Type   : MgUserManager
Scopes : Directory.Read.All (admin: True), Directory.ReadWrite.All (admin: True), User.Read.All (admin: True), User.ReadWrite.All (admin: True)

When a permission has admin: True, it means that the account running the code must hold a suitable administrative role to use the cmdlet. Many of the scopes listed for Get-MgUser can be used without an administrative role to allow users to retrieve details of their account, but an administrative role is needed to run Get-MgUserManager.

I amended the original function to generate scopes as strings rather than an array along with some other minor changes. You can download my version from GitHub, use the original, or create your own.

In the past, developers had to consult the documentation for the underlying Graph APIs to find details of required permissions. Microsoft has started to include this information in the documentation for the Graph SDK cmdlets, and that’s a welcome step forward.

SDK Improving Slowly

There’s no doubt that the Graph SDK is improving all the time, albeit slowly, especially with the retirement of the MSOL and Azure AD modules fast approaching (March 30, 2024). Perhaps this is familiarity talking and someone will less experience of dealing with SDK foibles, permissions, and missing features might not be quite so positive. But nothing is perfect (especially software). Upwards and onwards.

]]>
https://office365itpros.com/2024/01/12/user-extension-attributes-sdk/feed/ 0 63200
Managing Passwords for Entra ID Accounts with PowerShell https://office365itpros.com/2024/01/08/password-profiles/?utm_source=rss&utm_medium=rss&utm_campaign=password-profiles https://office365itpros.com/2024/01/08/password-profiles/#respond Mon, 08 Jan 2024 01:00:00 +0000 https://office365itpros.com/?p=63070

Using Password Profiles for Entra ID Accounts

Although passwordless authentication is in the future for many Entra ID accounts, the indications are that it will take time for Microsoft 365 tenants to get to the point where going passwordless is possible. The ongoing struggle to encourage tenants to adopt multifactor authentication (MFA) as the norm is one such indication. All of which means that tenant administrators will need to manage Entra ID account passwords for some time to come.

The Microsoft 365 admin center and Entra ID admin center both include facilities to reset user account passwords. The Entra ID option is effective but basic. As shown in Figure 1, Entra ID generates a temporary password and shows it to the administrator. The user must reset their password when they next sign in.

Resetting a user account password in the Entra ID admin center.

Password profiles.
Figure 1: Resetting a user account password in the Entra ID admin center

The Microsoft 365 admin center option is more flexible because the administrator can choose what password to set, whether the user must reset their password at first sign-in, and can have Microsoft 365 email the password to the administrator’s mailbox.

Nice as it is to have administrative GUIs for password management, automation through PowerShell is often more important for tenant operations. The Microsoft Graph PowerShell SDK contains capabilities to add passwords to new accounts or update passwords for existing accounts.

Generating User Account Passwords

To start, we need a password. Subject to the Entra ID password limitations, you can make up and assign any kind of password to an account. However, it’s better if the password is complex enough to provide protection until the account owner resets the password. There are many examples of password generators for PowerShell available. One thing to be aware of is that some code works for PowerShell 5 but not for PowerShell 7. For instance, the first of the three examples in this article doesn’t work when run on PowerShell 7. The other two examples do work and the last is a good basis to start with.

Adding a Password to a New User Account

To create a password for a new user account, we need a hash table to hold a “password profile.” A password profile is a Graph resource type representing password settings for an account. To create a random password, I generated it using the function described in the article mentioned above. In this case, the profile tells Entra ID the value to use to set the account password and to require the account to change the password the next time they sign in.

$NewPassword = Get-RandomPassword 8

$NewPasswordProfile = @{}
$NewPasswordProfile.Add("Password", $NewPassword)
$NewPasswordProfile.Add("ForceChangePasswordNextSignIn",$True)

The New-MgUser cmdlet takes the password profile as the value for the PasswordProfile parameter along with all the other parameters passed to create an account:

$NewUser = New-MgUser -UserPrincipalName "Ann.Conroy@office365itpros.com" `
  -DisplayName "Ann Conroy (GM Datacenters)" `
  -PasswordProfile $NewPasswordProfile -AccountEnabled `
  -MailNickName Ann.Conroy -City NYC `
  -CompanyName "Office 365 for IT Pros" -Country "United States" `
  -Department "IT Operations" -JobTitle "GM Datacenter Operations" `
  -BusinessPhones "+1 676 830 1201" -MobilePhone "+1 617 4466515" `
  -State "New York" -StreetAddress "1, Avenue of the Americas" `
  -Surname "Conroy" -GivenName "Ann" `
  -UsageLocation "US" -OfficeLocation "NYC" -PreferredLanguage 'en-US'

Because the ForceChangePasswordNextSignIn setting is true, the user can use the assigned password to sign in, whereupon Entra ID forces them to set a new password (Figure 2).

Password profile settings prompt a user to change their password.
Figure 2: A user is prompted to change their password

See this article for more information about creating new Entra ID accounts.

Updating a Password for a User Account

Updating a user account with a new password follows the same path. Create a password profile containing the parameters and run the Update-MgUser cmdlet to change the password. If you don’t want to force the user to create a new password after they sign in, make sure that the ForceChangePasswordNextSignIn setting in the password profile is false.

$PasswordProfile = @{}
$PasswordProfile.Add($NewPasswordProfile.Add("Password", $UpdatedPassword)
Update-MgUser -UserId $NewUser.Id -PasswordProfile $PasswordProfile

If you subsequently want a user to set up multifactor authentication (MFA) for their account, use a different password profile where the forceChangePasswordNextSignInWithMfa setting is $True. Don’t include a password value in the profile.

After updating the account, the next time the user attempts to sign in, Entra ID prompts them to configure an authentication method and then forces a password change. Here’s an example of a password profile to force an account to configure MFA:

$MFAResetProfile = @{}
$MFAResetProfile.Add("ForceChangePasswordNextSignIn",$true)
$MFAResetProfile.Add("ForceChangePasswordNextSignInWithMFA",$true)
Update-MgUser -UserId $UserId -PasswordProfile $MFAResetProfile

Disabling Password Expiration

Microsoft recommends that organizations do not force users to change passwords and that they disable the requirement to change passwords in the password expiration policy (accessed through the Security and Privacy tab of Org settings in the Microsoft 365 admin center). This setting applies to all user accounts. You can disable password expiration for an account as follows:

Update-MgUser -UserId Ann.Conroy@Office365itpros.com -PasswordPolicies DisablePasswordExpiration

Disabling password expiration isn’t something I would do without the additional protection afforded by MFA, especially for accounts holding administrative roles. Microsoft’s initiative to roll out managed conditional access policies to eligible tenants (those with Entra ID premium licenses) is yet another attempt to increase the percentage of accounts protected by MFA. Expect to see more efforts in this space as 2024 develops.


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.

]]>
https://office365itpros.com/2024/01/08/password-profiles/feed/ 0 63070
Reporting Entra ID Admin Consent Requests https://office365itpros.com/2023/12/22/admin-consent-requests/?utm_source=rss&utm_medium=rss&utm_campaign=admin-consent-requests https://office365itpros.com/2023/12/22/admin-consent-requests/#comments Fri, 22 Dec 2023 01:00:00 +0000 https://office365itpros.com/?p=62930

Use PowerShell to Find and Report Details of Admin Consent Requests

Dinesh asked “How can I generate a report of Admin Consent Requests received by Entra ID? I’m specifically looking for information such as who sent the consent request, which application was involved, what API permissions the application requested, and how many users have already requested the consent.”

I was busy and didn’t pay too much attention to the question apart from offering some suggestions about using Fiddler (or even Graph X-Ray) to see what requests the Entra ID admin center generated. Like in many situations with Microsoft 365, the key to starting a PowerShell script is to find out what cmdlet to fetch information with.

In any case, I was delighted when Dinesh reported that he had found the necessary cmdlet (Get-MgIdentityGovernanceAppConsentRequest from the Microsoft Graph PowerShell SDK) to answer his question. It’s always great (and far too rare) when someone who asks a question goes ahead to do the necessary research to answer their own question.

Workflow for Admin Consent Requests

Administrator consent requests are an Entra ID workflow to allow users to request administrators to grant consent for enterprise applications that they want to use. You do not want end users to grant consent for applications to access data, but you also don’t want to get in the way of people doing real work. The answer is to enable the workflow to permit users to submit requests for administrator approval.

When the workflow is active, when users attempt to use an enterprise application with permissions that are not yet approved, Entra ID prompts the user to request approval. Figure 1 shows what happens when a user attempts to sign into the Microsoft Technical Community.

A user requests consent for permissions for an application.

Admin consent request.
Figure 1: A user requests consent for permissions for an application

The first time this happens in a tenant, the application attempts to create a service principal as its representation in the tenant. This cannot happen until consent is gained for the permissions it needs. In this case, the user cannot grant consent, so Entra ID routes the request to the users identified as approvers. Requests arrive via email (Figure 2). The user who generates the request also receives email notification that their request is under review.

Email notification to administrator seeking consent for application permissions.
Figure 2: Email notification to administrator seeking consent for application permissions

Oddly, the request email shows the alternative email address for the requestor instead of their primary SMTP address. This might be a glitch. In any case, when the reviewer opens the request in the Entra ID admin center, they see details of the application (Figure 3). To approve the request, they must sign in to see the requested permissions and proceed to give or refuse consent.

Reviewing a user request for consent for application permissions.
Figure 3: Reviewing a user request for consent for application permissions

The user who generates a request receives an email notification to tell them about the reviewer’s decision. Overall, it’s a simple but effective workflow.

The Code

Dinesh’s code works and is a good example of extracting and processing Entra ID information. I reworked it a little to add a check for high-profile permissions that should draw additional attention from administrators. These permissions include the ability to read everything from the directory, access all users, groups, sites, and so on. The data returned for consent requests includes some user details (user principal name and identifier). I added a call to Get-MgUser to retrieve other details that might be useful such as their department, job title, and country.

You can download the script from GitHub. Normal caveats apply – better error checking and formatting of the output would be beneficial. Nevertheless, the code proves the principles involved in using PowerShell to retrieve and process admin consent requests.

The Power of Community

I receive many requests for assistance, some of which are along the lines of “please write a script for me.” I ignore these requests because I am not in the business of doing work that other people should do for themselves. It’s always better when someone works out how to accomplish a task using their own brainpower, just like Dinesh did.


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.

]]>
https://office365itpros.com/2023/12/22/admin-consent-requests/feed/ 10 62930
Entra ID Improves Registered App Security https://office365itpros.com/2023/12/11/app-instance-property-lock/?utm_source=rss&utm_medium=rss&utm_campaign=app-instance-property-lock https://office365itpros.com/2023/12/11/app-instance-property-lock/#comments Mon, 11 Dec 2023 01:00:00 +0000 https://office365itpros.com/?p=62783

Changes to App Instance Property Lock and Sign-In Audience

In March 2023, I wrote about a preview feature that allows application developers to lock the properties of service principal objects using the app instance property lock. That feature is now embedded in Entra ID and according to a recent “what’s new in Entra ID” post in the Microsoft Technical Community, “starting March 2024, new applications created using (the) Microsoft Graph application API will have “App instance lock” enabled by default.”

The same post also says that the default sign-in audience for new Entra ID apps will be “AzureADMyOrg” (just the owning tenant) rather than “AzureADandPersonalMicrosoftAccount.” That’s a good idea because most Entra ID apps are created for exclusive use within a tenant.

Both changes are intended to reduce the potential attack surface exposed through Entra ID apps. The first limits what administrators can do to service principals created for enterprise apps in their tenant and closes a hole exploited by attackers in the past. The second makes it more likely that app creators will opt to restrict access to their apps to the owning tenant. Given the number of apps that exist in Microsoft 365 tenants, both are welcome changes.

Locking App Properties

Only the app developer can choose to use the app instance property lock. This decision typically made by developers of multi-tenant enterprise applications of the type distributed by Microsoft, Adobe, and other software vendors. Entra ID creates a service principal within the tenant where the app runs to hold permissions assigned by the host tenant. The service principal inherits properties from the enterprise app, but if the app instance lock is not in force, the credentials used by the app can be changed using Graph API requests or Microsoft Graph PowerShell SDK cmdlets. If an attacker gains access to a tenant, they could therefore create credentials to allow them to use the app and the permissions assigned to the app. These permissions could allow extensive access to user data, such as all sites, all accounts, all mailboxes, and so on.

Tenants can set the app instance property lock for their own apps. New apps created using the Entra ID admin center set the app instance property lock by default for all supported properties, but older apps probably don’t have the lock enabled. I’m not sure when Entra ID changed the default behavior, but the apps created in my tenant prior to September 2023 do not have the lock enabled. You can update an app by selecting its Authentication properties and then App Instance Property Lock (Figure 1).

Updating the app instance property lock for a registered Entra ID app.
Figure 1: Updating the app instance property lock for a registered Entra ID app

Some apps that show up in a tenant’s app registration list are not created by the tenant. For instance, two apps called SharePoint Online Client Extensibility Web Application Principal and SharePoint Online Client Extensibility Web Application Principal Helper are created automatically for use with the SharePoint Framework to access Microsoft Graph and third-party APIs. It’s unclear why Microsoft doesn’t use a multi-tenant enterprise app instead.

Updating the App Instance Property Lock

Given that new apps have the app instance property lock set, it’s probably a good idea (and can do no harm) to update existing apps to set the lock. This is easily done with the Microsoft Graph PowerShell SDK by:

  • Run Get-MgApplication to find the set of apps.
  • Check each app to see if the lock is set.
  • If not, call Update-MgApplication to set the lock.

Here’s some example code to illustrate the principal:

ForEach ($App in $Apps) {
  $ServiceLock = $App | Select-Object -ExpandProperty ServicePrincipalLockConfiguration
  Write-Host ("Now processing {0}" -f $App.displayName)
  If ($ServiceLock.IsEnabled -eq $True) {
    Write-Host ("The {0} app is already enabled" -f $App.displayName) -ForegroundColor Red
  } Else {
    Write-Host ("App Instance Property Lock Not enabled for {0}; updating app" -f $App.displayName)
    Update-MgApplication -ApplicationId $App.Id -ServicePrincipalLockConfiguration $AppInstanceLockConfiguration
}

You can download the full script from GitHub. The script includes some setup that’s necessary such as signing into the Graph SDK with the necessary permission and creating a hash table containing the parameters for use by Update-MgApplication. The script also generates a report about the apps it updates.

Maintain Your Apps

The changes Microsoft is making is a good reminder that it’s important to keep an eye on the apps registered in a tenant to ensure their security and that they have appropriate credentials and permissions, and to remove unrequired apps. I know I could do a better job of app maintenance, but at least the app instance property lock is set for all apps now.


Keep up to date with developments in Entra ID by subscribing to the Office 365 for IT Pros eBook. Our monthly updates make sure that our subscribers understand the most important changes happening across Office 365.

]]>
https://office365itpros.com/2023/12/11/app-instance-property-lock/feed/ 1 62783
Entra ID Captures Timestamp for Last Successful Sign In for User Accounts https://office365itpros.com/2023/12/08/lastsuccessfulsignindatetime/?utm_source=rss&utm_medium=rss&utm_campaign=lastsuccessfulsignindatetime https://office365itpros.com/2023/12/08/lastsuccessfulsignindatetime/#comments Fri, 08 Dec 2023 01:00:00 +0000 https://office365itpros.com/?p=62759

Big Difference Between Last Sign in and Last Successful Sign In

Yesterday, I saw a tweet from Entra ID program manager Merill Ferando announcing that the Graph signInActivity resource type (beta) now supports the lastSuccessfulSignInDateTime property. This is good news because it makes it much easier to find out when a user last successfully accessed a tenant. Being forced to filter the Entra ID sign-in logs to separate out bad attempts to sign-in from successful attempts has long been a frustration for administrators (here’s an example).

Using the LastSignInDateTime Property

Until now, the signInActivity resource supported the LastSignInDateTime property. The property is useful when reviewing account activity. For instance, this PowerShell snippet finds user accounts with a sign-in in the last 14 days.

[array]$Users = Get-MgUser -Filter "signInActivity/lastSignInDateTime ge $([datetime]::UtcNow.AddDays(-14).ToString("s"))Z" -All `
-Property displayname, Id, userPrincipalName, SignInActivity, userType | `
Sort-Object DisplayName | Select-Object @{n="Last Sign in";e={$_.SignInActivity.lastSignInDateTime}}, DisplayName, Id, UserPrincipalName, UserType
[array]$TenantUsers = $Users | Where-Object {$_.UserType -eq "Member"} | Sort-Object {$_.'Last Signin' -as [datetime] } -Descending
$TenantUsers | Format-Table 'Last Sign in', DisplayName, UserPrincipalName

Last Sign in         DisplayName                      UserPrincipalName
-----------          -----------                      -----------------
06/12/2023 13:03:57  Lotte Vetler                     Lotte.Vetler@office365itpros.com
06/12/2023 13:01:22  Chris Bishop                     Chris.Bishop@office365itpros.com
04/12/2023 22:04:43  Rene Artois                      Rene.Artois@office365itpros.com

More developed examples include using the lastSignInDateTime property to find underused accounts, or reporting the timestamp when assessing if guest accounts are in active use.

The difference between lastSignInDateTime and lastSuccessfulSignInDateTime property is:

  • lastSignInDateTime is the timestamp for the last interactive sign-in for a user account. An attempted sign-in might be unsuccessful (for example, a multi-factor authentication challenge fails), but Entra ID still updates the timestamp.
  • lastSuccessfulSignInDateTime is the timestamp for the last successful sign-in (interactive or non-interactive) for a user account.

Taking the example above, some of the timestamps reported might not represent successful sign ins, and that’s the issue the new property aims to address.

Caveats for LastSuccessfulSignInDateTime

Before we all get excited, some caveats exist:

  • Tenants need Entra ID P1 licenses to access sign-in reports via the Graph. If you attempt to run the example code described here and the tenant doesn’t have an Entra ID P1 license, you’ll see a “Neither tenant is B2C or tenant doesn’t have premium license” error. Microsoft’s documentation is unclear about whether the account used needs a license or the existence of Entra ID P1 in the tenant is sufficient. To be sure, use a licensed account.
  • The last successful sign in timestamp is currently available only through the beta endpoint. There’s no indication when it might be available through the V1.0 API endpoint. Some tenants have restrictions governing code written against the beta endpoint.
  • The Get-MgBetaUser cmdlet in the Microsoft Graph PowerShell SDK supports the last successful timestamp using SDK V2.11.1 or later.

$User = Get-MgBetaUser -Userid aff4cd58-1bb8-4899-94de-795f656b4a18 -Property SigninActivity

$User.signinactivity | Select-Object Last*

LastNonInteractiveSignInDateTime  : 15/12/2023 19:08:20
LastNonInteractiveSignInRequestId : c8c27d68-1a8f-4b33-a04d-4439404f1500
LastSignInDateTime                : 15/12/2023 14:46:43
LastSignInRequestId               : 1ebe266d-c3cd-479b-b7e6-abc0be5ace00
LastSuccessfulSignInDateTime      : 15/12/2023 19:08:20
LastSuccessfulSignInRequestId     : c8c27d68-1a8f-4b33-a04d-4439404f1500

Microsoft’s documentation says that from December 1, 2023, Entra ID captures the lastSuccessfulSignInDateTime property for user accounts. However, I see the property populated for accounts from mid-November. The difference can be accounted for by the time required to deploy changes across all Microsoft 365 tenants.

Population of the lastSuccessfulSignInDateTime property is not retrospective, so the only values available are from December 1, 2023. Currently, the property is available only through the beta API. Access to sign-in activity logs requires Entra ID P1 licenses.

Testing the LastSuccessfulSignInDateTime Property

There’s nothing like writing a PowerShell script to exercise a new property. I wrote a script (downloadable from GitHub) to find user accounts with licenses and report the lastSuccessfulSignInDateTime and lastSignInDateTime properties for each account. The script also computes the number of days since a last successful sign in and last sign in. As you can see from Figure 1, a difference does exist between the two properties.

Differences between the lastSuccessfulSignInDateTime and lastSignInDateTime properties.
Figure 1: Differences between the lastSuccessfulSignInDateTime and lastSignInDateTime properties

As noted above, the new property is only available through the beta endpoint. If this causes you a problem, you’ll have to wait for Microsoft to apply the necessary magic to upgrade the signInActivity resource type in the V1.0 endpoint. If not, consider reviewing scripts that perform activity date checks for user and guest accounts to figure out if reporting successful sign-in actions makes a difference to the accuracy of the script output.


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.

]]>
https://office365itpros.com/2023/12/08/lastsuccessfulsignindatetime/feed/ 11 62759
Reporting User and Group Assignments for Enterprise Applications https://office365itpros.com/2023/11/22/enterprise-app-assignments/?utm_source=rss&utm_medium=rss&utm_campaign=enterprise-app-assignments https://office365itpros.com/2023/11/22/enterprise-app-assignments/#respond Wed, 22 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62501

How to Find and Document Assignments for Entra ID Enterprise Applications

A reader asked:

I am trying to execute Microsoft Graph that it can grab all my Enterprise Applications in my tenancy and export to CSV the application name and user and groups assigned to the groups.”

There’s a couple of things to unpack here before discussing potential answers. First, enterprise applications are Entra ID registered applications. Companies like Microsoft or Apple create enterprise applications for use in multiple tenants. For example, if you signed up to attend a Microsoft conference using your Entra ID credentials, the process is handled by an enterprise app called Microsoft Events. The home tenant identifier registered for the app is 72f988bf-86f1-41af-91ab-2d7cd011db47, which this site tells us is the identifier for Microsoft’s tenant.

Often enterprise applications act as an entry point to a service. For example, the properties of the IdPowerToys app (Figure 1) contain a link to the site where the service runs to document conditional access policies in PowerPoint.

Enterprise app registration for the idPowerToys app
Figure 1: Enterprise app registration for the idPowerToys app

Service Principals

When an enterprise application is used within a tenant, Entra ID creates a service principal to hold the permissions and assignments for the application within that tenant. If you want, the service principal is the instantiation of the application within the tenant that holds permissions and other information for the application. Other objects, like Azure Automation accounts also have service principals used to hold permissions and roles, such as those needed to access user data via Graph APIs.

By default, enterprise applications are accessible by all users. To control access, administrators can update application properties to require assignment. This means that Entra ID will only issue an access token for the application to users and groups granted access through assignment. It is the way to lock down access to enterprise applications.

Finding Enterprise Applications

To answer the question, we must find the set of enterprise applications in the tenant that are homed in other tenants. The way to do this is to run the Get-MgServicePrincipal cmdlet from the Microsoft Graph PowerShell SDK. Two steps are necessary. First, find the service principals known in the tenant. Second, filter the set to extract those with a tenant identifier that is not the same as your tenant:

[array]$ServicePrincipals = Get-MgServicePrincipal -All
[array]$EnterpriseApps = $ServicePrincipals | Where-Object {$_.AppOwnerOrganizationId -ne $TenantId} | Sort-Object DisplayName

The filter shown above creates a set of enterprise apps. If you want to further refine the filter to only find apps where role assignment is required, change it to:

 [array]$EnterpriseApps = $ServicePrincipals | Where-Object {$_.AppOwnerOrganizationId -ne $TenantId -and $_.AppRoleAssignmentRequired -eq $True} | `
        Sort-Object DisplayName

The next step is to loop through the set of apps and run the Get-MgServicePrincipalAppRoleAssignedTo cmdlet to check if any assignments exist. If any do, it’s easy to grab the details for a report.

ForEach ($App in $EnterpriseApps) {
    [array]$Assignments = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $App.Id | Where-Object {$_.PrincipalType -ne 'ServicePrincipal'}
    If ($Assignments) {
        $i++
        Write-Host ("Found assignments for {0}" -f $App.DisplayName)
        ForEach ($Assignment in $Assignments) {
            $ReportLine = [PSCustomObject]@{
                TimeStamp   = $Assignment.CreatedDateTime  
                Id          = $Assignment.Id
                DisplayName = $Assignment.PrincipalDisplayName 
                UserId      = $Assignment.PrincipalId
                Type        = $Assignment.PrincipalType
                Resource    = $Assignment.ResourceDisplayName
                ResourceId  = $Assignment.ResourceId
            }
            $Report.Add($ReportLine)
        }
    }

Note the filter used with the Get-MgServicePrincipalAppRoleAssignedTo cmdlet. This removes assignments to service principals such as those used to hold permissions for Azure Automation accounts. Here’s an example of an assignment to an Azure Automation account to allow it to act like an account holding the Exchange Administrator role.

TimeStamp   : 28/01/2022 15:47:35
Id          : ag5Go0LJzUWdGNo2BTCsaYJIbAAI79JLkTVN2fzhjh0
DisplayName : ExoAutomationAccount_Y6LgjDYIfPnxmFzrqdbaClsnTD/gN4BNnVMywiju5hk=
UserId      : a3460e6a-c942-45cd-9d18-da360530ac69
Type        : ServicePrincipal
Resource    : Office 365 Exchange Online
ResourceId  : dacf6086-a190-467a-aadd-d519472b8d1d

You can download the script I used from GitHub.

The Output

After filtering, what remains are the app assignments to users and groups, the details of which the script captures and reports. Figure 2 shows an example of the output.

Enterprise apps and user/group assignments
Figure 1: Enterprise apps and user/group assignments

My name features heavily in the list because I installed many of the apps in my tenant. Some of the apps and associated assignments are quite old, a fact that underlines the need to review and remove unused or obsolete apps periodically. The duplicate entries for the Graph Explorer is due to an assignment captured when the app was first installed followed by an explicit assignment to prevent access to the app to anyone but my account.

None of this is particularly difficult to do. The trick, as is often the case with Microsoft 365, is to know where to start looking. And perhaps some luck when navigating through the documentation!


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.

]]>
https://office365itpros.com/2023/11/22/enterprise-app-assignments/feed/ 0 62501
A New Approach to Reporting Exchange Mailbox Statistics https://office365itpros.com/2023/11/21/graph-usage-data-mailboxes/?utm_source=rss&utm_medium=rss&utm_campaign=graph-usage-data-mailboxes https://office365itpros.com/2023/11/21/graph-usage-data-mailboxes/#respond Tue, 21 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62520

Exploit Graph Usage Data Instead of PowerShell Cmdlets

The first report generated by Exchange administrators as they learn PowerShell is often a list of mailboxes. The second is usually a list of mailboxes and their sizes. A modern version of the code used to generate such a report is shown below.

Get-ExoMailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited | Sort-Object DisplayName | Get-ExoMailboxStatistics | Format-Table DisplayName, ItemCount, TotalItemSize -AutoSize

I call the code “modern” because it used the REST-based cmdlets introduced in 2019. Many examples persist across the internet that use the older Get-Mailbox and Get-MailboxStatistics cmdlets.

Instead of piping the results of Get-ExoMailbox to Get-ExoMailboxStatistics, a variation creates an array of mailboxes and loops through the array to generate statistics for each mailbox.

[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox -ResultSize Unlimited
Write-Host ("Processing {0} mailboxes..." -f $Mbx.count)

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

ForEach ($M in $Mbx) {
  $MbxStats = Get-ExoMailboxStatistics -Identity $M.ExternalDirectoryObjectId -Properties LastUserActionTime
  $DaysSinceActivity = (New-TimeSpan $MbxStats.LastUserActionTime).Days
  $ReportLine = [PSCustomObject]@{
    UPN               = $M.UserPrincipalName
    Name              = $M.DisplayName
    Items             = $MbxStats.ItemCount
    Size              = $MbxStats.TotalItemSize.Value.toString().Split("(")[0]
    LastActivity      = $MbxStats.LastUserActionTime
    DaysSinceActivity = $DaysSinceActivity
   } 
   $OutputReport.Add($ReportLine)
 }
$OutputReport | Format-Table Name, UPN, Items, Size, LastActivity

In both cases, the Get-ExoMailboxStatistics cmdlet fetches information about the number of items in a mailbox, their size, and the last recorded user interaction. There’s nothing wrong with this approach. It works (as it has since 2007) and generates the requested information. The only downside is that it’s slow to run Get-ExoMailboxStatistics for each mailbox. You won’t notice the problem in small tenants where a script only needs to process a couple of hundred mailboxes, but the performance penalty mounts as the number of mailboxes increases.

Graph Usage Data and Microsoft 365 Admin Center Reports

Microsoft 365 administrators are probably familiar with the Reports section of the Microsoft 365 admin center. A set of usage reports are available to help organizations understand how active their users are in different workloads, including email (Figure 1).

Email usage reports in the Microsoft 365 admin center

Graph usage data
Figure 1: Email usage reports in the Microsoft 365 admin center

The basis of the usage reports is the Graph Reports API, including the email activity reports and mailbox usage reports through Graph API requests and Microsoft Graph PowerShell SDK cmdlets. Here are examples of fetching email activity and mailbox usage data with the SDK cmdlets. The specified period is 180 days, which is the maximum:

Get-MgReportEmailActivityUserDetail -Period 'D180' -Outfile EmailActivity.CSV
[array]$EmailActivityData = Import-CSV EmailActivity.CSV
Get-MgReportMailboxUsageDetail -Period 'D180' -Outfile MailboxUsage.CSV
[array]$MailboxUsage = Import-CSV MailboxUsage.CSV

I cover how to use Graph API requests in the Microsoft 365 user activity report. This is a script that builds up a composite picture of user activity across different workloads, including Exchange Online, SharePoint Online, OneDrive for Business, and Teams. One difference between the Graph API requests and the SDK cmdlets is that the cmdlets download data to a CSV file that must then be imported into an array before it can be used. The raw API requests can fetch data and populate an array in a single call. It’s just another of the little foibles of the Graph SDK.

The combination of email activity and mailbox usage allows us to replace calls to Get-ExoMailboxStatistics (or Get-MailboxStatistics, if you insist on using the older cmdlet). The basic idea is that the script fetches the usage data (as above) and references the arrays that hold the data to fetch the information about item count, mailbox size, etc.

You can download a full script demonstrating how to use the Graph usage data for mailbox statistics from GitHub.

User Data Obfuscation

To preserve user privacy, organizations can choose to obfuscate the data returned by the Graph and replace user-identifiable data with MD5 hashes. We obviously need non-obfuscated user data, so the script checks if the privacy setting is in force. If this is true, the script switches the setting to allow the retrieval of user data for the report.

$ObfuscatedReset = $False
If ((Get-MgBetaAdminReportSetting).DisplayConcealedNames -eq $True) {
    $Parameters = @{ displayConcealedNames = $False }
    Update-MgBetaAdminReportSetting -BodyParameter $Parameters
    $ObfuscatedReset = $True
}

At the end of the script, the setting is switched back to privacy mode.

Faster but Slightly Outdated

My tests (based on the Measure-Command cmdlet) indicate that it’s much faster to retrieve and use the email usage data instead of running Get-ExoMailboxStatistics. At times, it was four times faster to process a set of mailboxes. Your mileage might vary, but I suspect that replacing cmdlets that need to interact with mailboxes with lookups against arrays will always be faster. Unfortunately the technique is not available for Exchange Server because the Graph doesn’t store usage data for on-premises servers.

One downside is that the Graph usage data is always at least two days behind the current time. However, I don’t think that this will make much practical difference because it’s unlikely that there will be much variation in mailbox size over a couple of days.

The point is that old techniques developed to answer questions in the earliest days of PowerShell might not necessarily still be the best way to do something. New sources of information and different ways of accessing and using that data might deliver a better and faster outcome. Always stay curious!


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.

]]>
https://office365itpros.com/2023/11/21/graph-usage-data-mailboxes/feed/ 0 62520
Report Email Proxy Addresses for Exchange Online Mail-Enabled Objects https://office365itpros.com/2023/11/16/email-proxy-address-report/?utm_source=rss&utm_medium=rss&utm_campaign=email-proxy-address-report https://office365itpros.com/2023/11/16/email-proxy-address-report/#respond Thu, 16 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62456

List All Email Proxy Addresses for Exchange Online Objects

A reader of the Office 365 for IT Pros eBook asked if there’s an easy way to see a list of all the email addresses used in a tenant. The simple answer is that there’s no out-of-the-box method to see this information. Some work is needed to extract and report a list of all email addresses. It’s not just the primary SMTP address for mailboxes either – such a report must include all the proxy SMTP addresses for all mail-enabled objects.

Proxy Addresses

An Exchange Online mail-enabled object such as a user mailbox can have up to 300 different proxy addresses. Although most inbound email arrives addressed to a mail-enabled object’s primary SMTP address, Exchange Online can deliver messages to any of the proxy addresses (aliases) assigned to a mail-enabled object. Outbound, Exchange Online mailboxes can send email using any proxy address.

Proxy addresses are permanently assigned to a mail-enabled object and stored in the Exchange Online directory. They’re not the same as plus addressing, which individual people can use to create a specific form of a proxy address to receive email from certain senders. Apart from SMTP addresses, the proxy addresses assigned to user mailboxes include SIP and SPO addresses. The first is used for federated chat; the second is used to store SharePoint Online information into user mailboxes. The Microsoft 365 substrate ingests SharePoint Online content into mailboxes to create ‘digital twins’ that are used by cloud processes and services.

Creating a PowerShell Script to Report SMTP Proxy Addresses

Now that we’ve got the definitions out of the way, let’s use PowerShell to answer the question. The steps involved are very straightforward and can be summarized as:

For each type of mail-enabled objects supported by Exchange Online, find the SMTP proxy addresses and report them.

I wrote a script to do the job (you can download the code from GitHub). It breaks processing up into:

  • Mailboxes (user, shared, room, and resource).
  • Group mailboxes (for Microsoft 365 groups).
  • Mail-enabled public folders.
  • Distribution lists.
  • Dynamic distribution lists.

For instance, here’s the code to process mailboxes:

Write-Host "Fetching details of user, shared, equipment, and room mailboxes..."
[array]$Mbx = Get-ExoMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox
Write-Host ("Processing details for {0} mailboxes..." -f $Mbx.count)
ForEach ($M in $Mbx) {
    ForEach ($Address in $M.EMailAddresses) {
        $AddressType = $Address.Split(":")[0]
        $AddressProxy = $Address.Split(":")[1]
        If ($AddressType -eq 'smtp') {
            $ReportLine = [PSCustomObject]@{ 
                ProxyAddress = $AddressProxy
                Name         = $M.DisplayName
                UPN          = $M.userPrincipalName
                ObjectId     = $M.ExternalDirectoryObjectId
                Type         = $M.RecipientTypeDetails
            }
            $Report.Add($ReportLine)
        }
    }
}

The code examines each proxy address. If its address type is SMTP, the script records the address and some other information. It’s really that simple. Figure 1 shows some of the list of SMTP proxy addresses extracted from my tenant.

Listing of Exchange Online proxy addresses for different objects

Email proxy address
Figure 1: Listing of Exchange Online proxy addresses for different objects

Using the List of Email Proxy Addresses

The next question is how to use such a list? One idea is to load some of the list of proxy addresses into a hash table and use the table to add extra detail to the information provided in message trace logs by resolving email addresses to find the display name for message recipients.

To test the idea, I enhanced some code from the article about using message trace logs to analyze email traffic to add the creation and population of a hash table using data imported from the CSV file output for the list of proxy addresses. For each message in the trace data, I then attempt to find a match in the hash table and include the name of the recipient if found. The added code is in italics.

[array]$EmailProxies = Import-CSV "C:\Temp\EmailProxyAddresses.csv"
$EmailProxyHash = @{}
ForEach ($E in $EmailProxies) {
   $EmailProxyHash.Add($E.ProxyAddress, $E.Name) }

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

ForEach ($M in $Messages) {
   $Direction = "Inbound"
   $DisplayName = $Null
   $SenderDomain = $M.SenderAddress.Split("@")[1]
   $RecipientDomain = $M.RecipientAddress.Split("@")[1]
   If ($SenderDomain -in $Domains) {
      $Direction = "Outbound" 
   }
   $DisplayName = $EmailProxyHash[$M.RecipientAddress] 

   $ReportLine = [PSCustomObject]@{
     TimeStamp       = $M.Received
     Sender          = $M.SenderAddress
     Recipient       = $M.RecipientAddress
     Name            = $DisplayName
     Subject         = $M.Subject
     Status          = $M.Status
     Direction       = $Direction
     SenderDomain    = $SenderDomain
     RecipientDomain = $RecipientDomain
    }
    $MessageReport.Add($ReportLine)
}

No doubt others will find more creative ways to use the listing of email proxy addresses. If you do, let us know in the comments.


Learn more about how the Office 365 applications really work on an ongoing basis by subscribing to the Office 365 for IT Pros eBook. The monthly updates for the book keep subscribers informed about what’s important across the Office 365 ecosystem.

]]>
https://office365itpros.com/2023/11/16/email-proxy-address-report/feed/ 0 62456
Customizing the Microsoft 365 User Profile Card with the Microsoft Graph PowerShell SDK https://office365itpros.com/2023/11/15/user-profile-card-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=user-profile-card-sdk https://office365itpros.com/2023/11/15/user-profile-card-sdk/#comments Wed, 15 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62444

Use SDK Cmdlets to Add Properties to the Microsoft 365 User Profile Card

The Microsoft 365 profile card displays information about users. The information show in a profile card comes from the user’s Entra ID account. By default, Microsoft 365 limits the properties shown by the profile card to the set that they consider to be most important. Organizations can customize the Contact tab of the  profile card to reveal some of the properties that are not shown by default. However, not every property of an Entra ID user account is available for the profile card. Some, like the employee hire date, are not supported.

In 2020, I wrote about how to customize the profile card. At that time, the profile card API was in beta. The production Graph endpoint now supports the profile card API, and it’s also supported by cmdlets in the Microsoft Graph PowerShell SDK. I used version 2.9 of the SDK for this article.

The Microsoft documentation for customizing the profile card is a little outdated. At the time of writing, it uses V1.0 of the SDK and is based on the beta API. Because the API is now in production, it follows that the latest SDK cmdlets use that API. In any case, the instructions contained in the documentation are a reasonable guide.

Customized User Profile Card Available to All

The most important point about customizing the profile card is that any change is exposed to all users. You cannot customize the profile card for a subset of the user population such as a targeted administrative unit. This fact creates difficulties in multinational organizations where local privacy regulations might prevent the display of certain information held in user account properties.

As already mentioned, only certain user account properties are available for customization. Basically, you can add six standard Entra ID properties to the user profile card:

  • UserPrincipalName.
  • Fax.
  • StreetAddress.
  • PostalCode.
  • StateOrProvince.
  • Alias.

Of course, including these properties in the profile card is useless unless information is populated in the directory for all user accounts. That often doesn’t happen.

The the fifteen custom attributes inherited from Exchange Server (different to the custom security attributes that can be defined for Entra ID) are also supported. Many organizations use these attributes to store information about users such as personnel numbers, cost centers, employee type, employee job codes, seniority level, and even the date of last promotion. Dynamic distribution lists and dynamic Microsoft 365 groups.

Add an Attribute to the Profile Card

To add one of the six standard or fifteen custom attributes to the profile card, construct a payload in a PowerShell hash table. The table contains the property name and its display name (annotation). Then run the New-MgAdminPeopleProfileCardProperty cmdlet passing the hash table as the body parameter (the same as passing a request body to a Graph request). This command adds CustomAttribute15 and tells Microsoft 365 that the user profile card should refer to the property as the “Employee Type.”

Connect-MgGraph -Scopes "PeopleSettings.ReadWrite.All" -NoWelcome

$AddPropertyDetails = @{
  directoryPropertyName = "CustomAttribute15"
  annotations = @(
    @{ displayName = "Employee Type" }
  )
}

$AddPropertyDetails

Name                           Value
----                           -----
directoryPropertyName          CustomAttribute15
annotations                    {Employee Type}

New-MgAdminPeopleProfileCardProperty -BodyParameter $AddPropertyDetails

Id DirectoryPropertyName
-- ---------------------
   CustomAttribute15

It takes at least 24 hours before the profile card picks up customized properties. When they do, they appear on the Contact tab of the profile card. Figure 1 shows three custom properties for cost center, preferred drink (a historic part of the Active Directory schema), and employee type. If a custom properties doesn’t contain any information for a user, it won’t appear on the profile card.

Custom properties shown on the Microsoft 365 user profile card
Figure 1: Custom properties shown on the Microsoft 365 user profile card

To check the set of attributes added to the profile card with PowerShell, run the Get-MgAdminPeopleProfileCardProperty cmdlet. This output tells us that the profile card is configured to display three optional properties and three custom properties:

Get-MgAdminPeopleProfileCardProperty

Id DirectoryPropertyName
-- ---------------------
   userPrincipalName
   customAttribute12
   StreetAddress
   Postalcode
   CustomAttribute9
   CustomAttribute15

If you make a mistake, you can remove a property from the profile card by running the Remove-MgAdminPeopleProfileCardProperty cmdlet. For example, this command removes the Drink attribute (stored in CustomAttribute9) from the profile card:

Remove-MgAdminPeopleProfileCardProperty -ProfileCardPropertyId CustomAttribute9

Note that the profile card property id parameter is case sensitive. You must pass the property name as returned by the GetMgAdminPeopleProfileCardProperty cmdlet.

Adding a Translated Label for a Custom Profile Card Property

The documentation makes a big thing about defining a language-specific value for a custom property. This is done using the Update-MgAdminPeopleProfileCardProperty cmdlet. This example shows how to add a French language label for CustomAtrribute15:

$LocalizationHash = @{}
$LocalizationHash.Add("languagetag","fr-FR")
$LocalizationHash.Add("displayName","Type d’employé")

$UpdatePropertyDetails = @{
   annotations = @(
    @{
    displayName = "Cost Center"
   localizations = @( $LocalizationHash )
     }
    )
}
Update-MgAdminPeopleProfileCardProperty -ProfileCardPropertyId 'customAttribute15' -BodyParameter $UpdatePropertyDetails

This command works, but it only works for a single language. If you want to have labels for multiple languages, you’ll be sadly disappointed because Update-MgAdminPeopleProfileCardProperty (and its Graph API counterpart) overwrite the language configuration each time.

You could argue that this is disappointing for multinational organizations that want to have fully-translated interfaces for all languages in use. Being restricted to a single language alternative is a strange approach to localization, especially for a company that does so much to deliver local language translations for user interfaces. The counterargument is that the properties chosen for display in the profile cards are likely to be well understood by anyone in an organization.

Work Still to Do

Not much has changed in customizing the profile card since 2020. The API is now production rather than beta and the Graph SDK supports the action, but that’s it. Coverage for multiple local language labels would be nice but that’s still elusive.


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.

]]>
https://office365itpros.com/2023/11/15/user-profile-card-sdk/feed/ 37 62444
Reporting the Storage Used by Loop Workspaces https://office365itpros.com/2023/11/08/loop-workspace-storage/?utm_source=rss&utm_medium=rss&utm_campaign=loop-workspace-storage https://office365itpros.com/2023/11/08/loop-workspace-storage/#comments Wed, 08 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62331

Understand the Impact Loop Workspaces Have on SharePoint Online Quota

Message center notification MC678308 (updated 2 November 2023) explains that the storage consumed by Loop workspaces (created with the Loop app rather than Loop components in Teams and Outlook) will count against tenant storage quotas. During the preview, Microsoft allowed people to use the Loop app without a license and create as many workspaces as they liked. The only limitation was on the size of an individual workspace, which was capped at 5 GB. Workspace data is held in a special form of SharePoint storage called Syntex repository services and Microsoft didn’t limit the storage occupied by the workspaces.

All good things come to an end. As the Loop app approaches the end of its public preview stage and moves toward general availability (I expect an announcement at the Ignite conference), Microsoft has revealed its hand with respect to licensing and storage. Only people with certain Microsoft 365 product licenses will be able to create new workspaces

Loop Counts Against Storage Now

According to MC678308, Microsoft will start counting Loop workspaces against tenant storage quotas between late October and late November 2023. When the change goes into effect for a tenant, the maximum size of a workspace increases from 5 GB to 1 TB.

The exact impact on a tenant is hard to know unless you use the Get-SPOContainer cmdlet in the SharePoint Online management module to fetch details of each tenant. For example, this command fetches details of existing workspaces:

[array]$LoopWorkspaces = Get-SPOContainer -OwningApplicationID a187e399-0c36-4b98-8f04-1edc167a0996
If (!($LoopWorkspaces)) {
    Write-Host "Can't get Loop workspaces - exiting"; break
}

The details reported by Get-SPOContainer miss some important information. For instance, while the creation date for a workspace is available, the last updated date is not, nor is detail about the person who last updated the workspace. Understanding the date when a workspace was last changed is critical to knowing if a workspace is in active use.

Reporting Loop Workspaces

This code generates a report with details of the storage used by each workspace and whether the workspace owners have one of the four licenses required to create new Loop workspaces:

$Report = [System.Collections.Generic.List[Object]]::new()
$TotalBytes = 0; $LicenseOK = 0; $i = 0
ForEach ($LoopSpace in $LoopWorkspaces) {
    $i++
    Write-Output ("Analyzing workspace {0} {1}/{2}" -f $LoopSpace.ContainerId, $i, $LoopWorkspaces.count)
    # Get detail of the workspace
    $LoopSpaceDetails =  Get-SPOContainer -OwningApplicationID a187e399-0c36-4b98-8f04-1edc167a0996 -Identity $LoopSpace.ContainerId
    # Get detail about the owner
    [array]$Owners = $LoopSpaceDetails.Owners
    ForEach ($Owner in $Owners) {
        $LicenseFound = $Null; $LoopLicenseStatus = "Unlicensed";  $LicenseName = $Null
        # Find if the Loop service plan is successfully provisioned for the account
        [array]$UserLicenseData = Get-MgUserLicenseDetail -UserId $Owner
        $LoopLicense = $UserLicenseData | Select-Object -ExpandProperty ServicePlans | `
             Where-Object {$_.ServicePlanId -eq $LoopServicePlan} | Select-Object -ExpandProperty ProvisioningStatus
        If ($LoopLicense -eq 'Success') {
            $LicenseOK++
            $LoopLicenseStatus = "OK"
        }
        # Find what SKU the Loop service plan belongs to
        $User = Get-MgUser -UserId $Owner -Property Id, displayName, department, UserPrincipalName
        [array]$SKUs = $UserLicenseData.SkuId
        ForEach ($Sku in $Skus) {
            $LicenseFound = $LoopValidLicenses[$Sku]
            If ($LicenseFound) {
                $LicenseName = $LicenseFound
            }
        }
    }
    [array]$Members = $Null
    [array]$Managers = $LoopSpaceDetails.Managers
    ForEach ($Manager in $Managers) {
        $Member = Get-MgUser -UserId $Manager
        $Members += $Member.DisplayName
    }

    $StorageUsed = "{0:N2}" -f ($LoopSpaceDetails.StorageUsedInBytes/1MB)
    $TotalBytes = $TotalBytes + $LoopSpaceDetails.StorageUsedInBytes

    $ReportLine = [PSCustomObject]@{
        ContainerId    = $LoopSpace.ContainerId
        App            = $LoopSpaceDetails.OwningApplicationName
        Name           = $LoopSpace.ContainerName
        Description    = $LoopSpace.Description
        Owner          = $User.DisplayName
        UPN            = $User.UserPrincipalName
        License        = $LoopLicenseStatus
        Product        = $LicenseName
        Members        = ($Members -Join ", ")
        Created        = $LoopSpaceDetails.CreatedOn
        SiteURL        = $LoopSpaceDetails.ContainerSiteUrl
        "Storage (MB)" = $StorageUsed
    }
    $Report.Add($ReportLine)
}

Figure 1 shows an extract of the information captured by the script. You can see that the James Ryan account is deemed to be unlicensed. This is because the account doesn’t hold a product licenses containing the Microsoft Loop service plan. Also note that new users all receive the Ideas workspace to help get them started with the Loop app. The workspace isn’t large (0.11 MB), but it’s a bit cheeky for Microsoft to charge for it.

 Reporting Loop workspace storage
Figure 1: Reporting Loop workspace storage

Checking individual workspace containers is not a fast operation. The script can be sped up by removing the Get-MgUser commands used to fetch details about the licenses possessed by workspace owners.

You can download the complete script from GitHub. Remember that the intention of the script is to illustrate a principal rather than being a complete solution. Feel free to make whatever changes you deem to meet the circumstances of your tenant.

Update: The original script was limited to reporting the first 200 workspaces in a tenant. An updated script handles pagination to find and report all workspaces.

No Immediate Impact

It’s unlikely that Loop workspaces will have much of an impact on SharePoint Online tenant storage quotas in the immediate future. Documents will continue to be the major consumer of quota, even when tenants have the Microsoft 365 licenses necessary for users to create new Loop workspaces. Even so, it’s a good idea to keep an eye on how Loop is being used and how much space its files occupy.


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.

]]>
https://office365itpros.com/2023/11/08/loop-workspace-storage/feed/ 9 62331
Reducing the Memory Footprint of Exchange Online PowerShell https://office365itpros.com/2023/11/06/exchange-online-powershell-memory/?utm_source=rss&utm_medium=rss&utm_campaign=exchange-online-powershell-memory https://office365itpros.com/2023/11/06/exchange-online-powershell-memory/#respond Mon, 06 Nov 2023 01:00:00 +0000 https://office365itpros.com/?p=62298

Three Steps to Shrinking Memory Demands for Exchange Online PowerShell

Exchange Online PowerShell Performance
Exchange Online PowerShell memory

I read the Exchange team’s blog post about Exchange Online PowerShell performance. The team behind the Exchange Online management module made three recommendations:

  • Don’t load the module help when running scripts in non-interactive sessions. In other words, humans might need some help to understand what cmdlets do but computers don’t. Computers never ask for more detail about the commands they’re asked to execute. They just get on with the job.
  • Restrict the number of cmdlets loaded into a PowerShell session. The Exchange Online management module spans some 800 cmdlets. Any session is likely to use less than ten cmdlets. In fact, many scripts might only use the REST-based cmdlets introduced in 2019. These cmdlets (like Get-ExoMailbox and Get-ExoMailboxStatistics) are always loaded by the module into memory along with some of the more recently introduced cmdlets, like Get-UserBriefingConfig. Along with the REST cmdlets, a script might use one or two modernized cmdlets (like Get-User). Modernized means that the cmdlets no longer support basic authentication and have discarded dependencies like WinRM.
  • Create a new PowerShell process for each Exchange Online session. The idea here is that the new session starts off with an empty cache and that reduces the memory footprint. Sounds good, but I bet not many will follow this guidance. I say this for two reasons. First, people are generally lazy and don’t want to go through the hassle of starting and shutting down perfectly good PowerShell sessions. Second, most people working with Microsoft 365 load several modules such as the Microsoft Graph PowerShell SDK and Teams.

In any case, the advice is appreciated and should be considered in the light of whatever work you do with the Exchange Online management module.

New Parameter for Connect-ExchangeOnline

To support avoiding cmdlet help, the latest version of the Exchange Online management module (3.4) boasts the SkipLoadingCmdletHelp parameter. The implementation works very nicely and speeds up module loading. I recommend that you use this parameter in every script that runs without human intervention. For instance, I have some work to do to upgrade scripts (runbooks) written to run using Azure Automation. The next time I touch the code for any of the runbooks, I’ll be sure to add the parameter to Connect-ExchangeOnline.

Avoiding memory overhead like this should be very helpful in any script that combines the Exchange Online management module with the Microsoft Graph PowerShell SDK. When Microsoft created V2 of the Microsoft Graph PowerShell SDK, they split the cmdlets into V1.0 and beta sets to reduce the overhead of loading the SDK for Azure Automation runbooks.

Remember to update your Azure Automation accounts with the latest module so that runbooks can take advantage of the new feature. That might be even more important than keeping the module updated on your workstation (here’s a handy script that I use for that purpose).

Figuring Out Exchange Online PowerShell Cmdlets

Building a list of Exchange Online cmdlets used in a script is a matter of checking the cmdlets called and understanding if they come from the Exchange Online management module and then constructing a list to use with Connect-ExchangeOnline. The Get-Command module returns a small subset of the available cmdlets.

Get-Command -Module ExchangeOnlineManagement

Do not include any of these cmdlets in the set passed to Connect-ExchangeOnline as you’ll get an error like “OperationStopped: No cmdlet assigned to the user have this feature enabled.”

When you’ve determined the set of cmdlets needed by a session, put them in an array and pass the array in the CommandName parameter. This example combines the parameter to skip loading cmdlet help with a defined set of cmdlets to load into a session.

$Cmdlets = "Set-User", "Get-User", "Get-OWAMailboxPolicy", "Set-OWAMailboxPolicy"
connect-ExchangeOnline -SkipLoadingCmdletHelp -CommandName  $Cmdlets

Detail is Important When Running Exchange Online PowerShell

Of course, you could ignore these recommendations and continue running your scripts as before. It’s a good tactic if the scripts work and you’re happy. On the other hand, if you’re looking for some extra performance and reduced memory consumption, these tips are worth considering. I suspect that the folks who will benefit most are those who run PowerShell against tens of thousands of objects (mailboxes, user accounts, etc.).


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.

]]>
https://office365itpros.com/2023/11/06/exchange-online-powershell-memory/feed/ 0 62298
Using Microsoft Graph SDK Cmdlets to Create a SharePoint Online List https://office365itpros.com/2023/10/30/create-sharepoint-list-graph/?utm_source=rss&utm_medium=rss&utm_campaign=create-sharepoint-list-graph https://office365itpros.com/2023/10/30/create-sharepoint-list-graph/#comments Mon, 30 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=62117

Easier to Create SharePoint Lists with PnP.PowerShell

Updated 14 August 2024

Last week, I wrote about how to use cmdlets from the PnP.PowerShell module to create and populate a list in a SharePoint Online site using data generated by the Teams Directory script. As benefits a module deeply rooted in SharePoint history, the cmdlets worked well and the script wasn’t too difficult to write.

The Microsoft Graph is supposed to be “the gateway to data and intelligence in Microsoft 365. It provides a unified programmability model that you can use to access the tremendous amount of data in Microsoft 365…” I don’t have much argument with this assertion because in most cases, it’s true. That is, until you come to SharePoint Online where the coverage of SharePoint objects and data is not as good as for other workloads.

This article describes some of the challenges involved in writing a script based on Microsoft Graph PowerShell SDK cmdlets and Graph API requests to create SharePoint lists similar to what I did using PnP.PowerShell. Let’s see how I got on.

How to Create SharePoint List Script in a Nutshell

The script is simple in concept. The data comes from a CSV file generated by the Teams Directory script. The script creates a list in a SharePoint Online site and populates the list with items imported from the CSV file. The plan was to use cmdlets from the Microsoft Graph PowerShell SDK (V2.8) because there appears to be cmdlets available for everything the script needs to do.

Connecting to the Graph and the Target Site

The first steps connect to the Graph with the relevant permissions and retrieve details of the site holding the list. The script then checks if the list already exists and if found, removes the list. Rebuilding a list from scratch is easier than attempting to synchronize changes.

Connect-MgGraph -Scopes Sites.ReadWrite.All, Sites.Manage.All -NoWelcome
$ListName = "Teams Directory - Graph"
# Get target site 
Write-Host "Fetching details of the target site and list..."
$Site =  Get-MgSite -Search 'Office 365 for IT Pros Communications'
# Get List
$List = Get-MgSiteList -SiteId $Site.Id -Filter "displayName eq 'Teams Directory - Graph'"
If ($List) {
    # Delete the list
    Write-Host ("Removing previous version of list {0}" -f $List.DisplayName)
    Remove-MgSiteList -SiteId $Site.Id -ListId $List.Id
}

Removing a list like this won’t work if a retention label applies to the list.

Create SharePoint List with New-MgSiteList

The next step creates the list. The Graph SDK includes the New-MgSiteList cmdlet, but no matter what I did with the cmdlet, it refused to co-operate. Even the example from the Microsoft documentation failed with the following error:

New-MgSiteList_Create: Unable to determine type of provided column definition
 
Status: 400 (BadRequest)
ErrorCode: invalidRequest
Date: 2023-10-20T16:44:06

As described in this SDK bug report, the problem is that the columns shown in the example define the data type for each column but not what’s acceptable in the column (see this page for more detail about the supported types for list columns). For instance, the text data type can be plain text or rich text or both. If you don’t want to be this specific when creating a list (because you want to customize the list through the GUI afterwards), you can run the Invoke-MgGraphRequest cmdlet to create the list as shown below:

Write-Host "Defining the new list"
$Uri = ("https://graph.microsoft.com/v1.0/sites/{0}/Lists" -f $Site.Id)
$ListDetails = '{
    "displayName": "Teams Directory - Graph",
    "description": "Discover teams to join in Office 365 for IT Pros",
    "columns": [
      {
        "name": "Deeplink",
        "description": "Link to access the team",
        "text": { }
      },{
        "name": "Description",
        "description": "Purpose of the team",
        "text": { }
      },
      {
        "name": "Owner",
        "description": "Team owner",
        "text": { }
      },      
      {
        "name": "OwnerSMTP",
        "description": "Primary SMTP address for owner",
        "text": { }
      },
      {
        "name": "Members",
        "description": "Number of tenant menbers",
        "number": { }
      },
      {
        "name": "ExternalGuests",
        "description": "Number of external guest menbers",
        "number": { }
      },
      {
        "name": "Access",
        "description": "Public or Private access",
        "text": { }
      },
    ],
  }'
Invoke-MgGraphRequest -Uri $Uri -Method POST -Body $ListDetails | Out-Null

The Graph request creates a blank list. The new list includes the specified columns and a single column called Title inherited from the template. If you want to use a column called Title, you can leave it as is. If not, you can rename the column, which is what the script does to make the Title column to be TeamName. The internal name of the column remains Title, which is important to remember when updating records.

$List = Get-MgSiteList -SiteId $Site.Id -Filter "displayName eq 'Teams Directory - Graph'"
$ColumnId = (Get-MgSiteListColumn -SiteId  $Site.Id -ListId $List.Id | `
    Where-Object {$_.Name -eq 'Title'}).Id
Update-MgSiteListColumn -ColumnDefinitionId $ColumnId -SiteId $Site.Id -ListId $List.Id `
  -Description 'Name of the team' -DisplayName 'Team Name' -Name 'TeamName' | Out-Null

Adding Records to the List

After preparing the list, the script populates it with data imported from the Teams Directory. I ran into issues with the New-MgSiteListItem cmdlet. This could be a documentation issue, but some internet forums (like this example) indicate that this cmdlet has not had a happy history. I ended up creating each item as a custom object, wrapping the item data inside another custom object, converting it to JSON, and using the JSON content as a payload to post to the items endpoint:

$Uri = ("https://graph.microsoft.com/v1.0/sites/{0}/lists/{1}/items" -f $Site.Id, $List.Id)
ForEach ($Team in $TeamsData) {
  Write-Host ("Adding directory record for team {0} {1}/{2}" -f $Team.Team, $i, $TeamsData.Count)
  $i++
  $FieldsDataObject  = [PSCustomObject] @{
        Title          = $Team.Team
        Deeplink       = $Team.Deeplink
        Description    = $Team.Description
        Owner          = $Team.Owner
        OwnerSMTP      = $Team.OwnerSMTP
        Members        = $Team.Members
        ExternalGuests = $Team.ExternalGuests
        Access         = $Team.Access
  }
  $NewItem = [PSCustomObject] @{
        fields         = $FieldsDataObject
  } 
  $NewItem = $NewItem | ConvertTo-Json
  $Status = Invoke-MgGraphRequest -Method POST -Uri $Uri -Body $NewItem
  If ($Status.Id) {
     Write-Host ("Record added to list with id {0}" -f $Status.Id)
  }
}   

This approach works, but I could never write to a hyperlink field (something that the Add-PnPListItem cmdlet can do). Apparently, the Graph doesn’t currently support list hyperlink fields, so I ended up writing the deeplink to a team to a text field. The result is the list shown in Figure 1 where users see deeplinks that are not clickable. Users can copy the link to a browser tab and navigate to Teams that way, but that’s not very user-friendly. For small lists, you can create a hyperlink field in the list and copy deeplinks to that field. Users can then click on the link in the hyperlink field. Such a solution is unacceptable at any scale.

Teams directory data written to a SharePoint list using the Graph

Create sharepoint list
Figure 1: Teams directory data written to a SharePoint list using the Graph

You can download the full script from GitHub.

Choose PnP.PowerShell to Create SharePoint Lists

What I learned from the exercise is that the PnP.PowerShell module is a more robust and reliable tool to use when working with SharePoint Online lists. PnP has its own quirks, but it works. I spent far too long chasing Graph SDK cmdlets that didn’t work as documented or couldn’t do what I wanted, so I recommend that you use PnP until Microsoft sorts out the SDK cmdlets and documentation.

In closing, I asked Bing Chat Enterprise to write a script to create and populate a list in a SharePoint site Online based on the Microsoft Graph PowerShell SDK. The results were impressive (Figure 2).

Bing Chat Enterprise script to create and populate a SharePoint Online list
Figure 2: Bing Chat Enterprise script to create and populate a SharePoint Online list

After this experience, I might use Bing Chat Enterprise more often in the future to sketch out the basics of scripts. In this case, Bing Chat Enterprise was helpful. In others, it’s been awful. But that’s the nature of generative AI in respect of its ability to regurgitate errors couched in what seems to be impressive terms.


Keep up to date with developments like how to create SharePoint lists with the Microsoft Graph PowerShell SDK by subscribing to the Office 365 for IT Pros eBook. Our monthly updates make sure that our subscribers understand the most important changes happening across Office 365.

]]>
https://office365itpros.com/2023/10/30/create-sharepoint-list-graph/feed/ 1 62117
Creating a Teams Directory in a SharePoint Online List https://office365itpros.com/2023/10/24/create-sharepoint-list-pnp/?utm_source=rss&utm_medium=rss&utm_campaign=create-sharepoint-list-pnp https://office365itpros.com/2023/10/24/create-sharepoint-list-pnp/#comments Tue, 24 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=62079

Create SharePoint List from Data Extracted from Teams

The article discussing a PowerShell script to generate a Teams directory explains how to create output files in different formats that can be used to make the directory available to users. For instance, you could post a HTML format version of the directory in a SharePoint Online site. Discussion about the post generated some nice ideas, amongst which was the suggestion to output the directory as a SharePoint list (aka Microsoft Lists).

I haven’t done much to manage SharePoint lists with PowerShell, so this seemed like a nice opportunity to explore the idea and increase my knowledge.

Choosing the Right Module to Create SharePoint List

The first order of business is to choose a PowerShell module for the task. I started off with the Microsoft Graph PowerShell SDK, which includes cmdlets like New-MgSiteList and Get-MgSiteList. Unhappily, I ran into several problems with SDK cmdlets (V2.8) that I’ve reported to Microsoft. The documentation and examples for these SDK site cmdlets are not as good as other areas covered by the SDK, so the problems could be due to misunderstanding on my part.

This brought me to the Pnp.PowerShell module (aka “Microsoft 365 Patterns and Practices PowerShell Cmdlets”). PnP is a community effort to create resources that help people to build app on the Microsoft 365 platform. The big advantage of PnP is that its cmdlets can interact with SharePoint Online content like list items where the Microsoft SharePoint management module is limited to tenant and site settings.

Basic Steps in the Script to Add Teams Directory Records and Create SharePoint List

The basic steps in the script are:

  • Connect to the site that stores the list. I created a communications site for this purpose.
  • Look for the list and if found, remove it because it’s easier to create and populate a new list instead of attempting to synchronize changes since the last update for the team directory.
  • Create the list and the columns used to store team directory information. Many templates are available for Lists. I used the Links template and removed one of the two default columns.
  • Populate the list with new items. To do this, the script reads the information in from the CSV file created by the original script and writes them as new list items.

PnP.PowerShell Cmdlets Used to Create SharePoint List

Translating the above into PnP PowerShell, the script uses the following cmdlets:

  • Connect-PnpOnline to connect to the target site. PnP supports different forms of authentication. For the purpose of this demonstration, the script prompts for credentials of a site administrator and uses those to connect.
  • Get-PnPList to check if the target list already exists and Remove-PnPList to remove the list if found.
  • New-PnPList to create the target list.
  • Add-PnPField to define the set of fields used to store directory information.
  • Remove-PnPField to remove the standard Notes field inherited from the Links template. Here’s how the script creates the list and the fields used to store Teams directory information:

New-PnpList -Title $ListName -Template Links -EnableVersioning -Connection $Connection | Out-Null
# Add fields
Add-PnpField -List $ListName -DisplayName 'Team Name' -Internalname TeamName -Type Text -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'Description' -Internalname Description -Type Text -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'Owner' -Internalname Owner -Type Text -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'Owner SMTP Address' -Internalname OwnerSMTP -Type Text -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'Member count' -Internalname MemberCount -Type Number -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'External count' -Internalname ExternalCount -Type Number -AddToDefaultView | Out-Null
Add-PnpField -List $ListName -DisplayName 'Access' -Internalname AccessMode -Type Text -AddToDefaultView | Out-Null
# Remove the Notes field inherited from the Links template
Remove-PnPField -List $ListName -Identity Notes -Force
  • Add-PnPListItem to populate the list with items imported from the CSV file. Here’s how the script populates the list:
[array]$TeamsData = Import-CSV -Path $CSVFile
[int]$i = 0
ForEach ($Team in $TeamsData) {
    $i++
    Write-Host ("Adding record for team {0} {1}/{2}" -f $Team.Team, $i, $TeamsData.count)
    Add-PnPListItem -List $ListName -Values @{
        "URL" = $($Team.Deeplink);
        "TeamName" = $($Team.Team);
        "Description" = $($Team.Description);
        "Owner" = $($Team.Owner);
        "OwnerSMTP" = $($Team.OwnerSMTP);
        "MemberCount" = $($Team.Members);
        "ExternalCount" = $($Team.ExternalGuests);
        "AccessMode" = $($Team.Access);
    } | Out-Null
}

The original version of the Teams Directory script generates a directory record for each team including a clickable deeplink to allow users to open Teams in the selected team. They can then join the team (public teams) or request the team owner to join (private teams). The deeplink generated by the script is formatted to make it clickable when exported to a HTML report. I updated the script to include a simple deeplink because SharePoint list entries don’t need the formatting.

Figure 1 shows the Teams directory records in a SharePoint Online list. I’m sure that the visual appearance of the list could be improved by tweaking the columns, but what’s here is sufficient to demonstrate the principles behind creating and populating a list.

The Teams Directory in a SharePoint Online list

Create SharePoint list using Pnp.PowerShell
Figure 1: The Teams Directory in a SharePoint Online list

You can download a copy of the full script from GitHub.

Lots to Explore in Lists

The SharePoint community understands and takes full advantage of lists (here’s an example). Others in the Microsoft 365 world might not. Perhaps this example of extracting information from one area of Microsoft to create a SharePoint list and populate the list with Teams directory information might get your creative juices flowing.


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.

]]>
https://office365itpros.com/2023/10/24/create-sharepoint-list-pnp/feed/ 1 62079
Teams and Microsoft Exchange PowerShell Modules Clash Over Required DLL https://office365itpros.com/2023/10/20/powershell-module-clash-exo/?utm_source=rss&utm_medium=rss&utm_campaign=powershell-module-clash-exo https://office365itpros.com/2023/10/20/powershell-module-clash-exo/#comments Fri, 20 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=62024

PowerShell Module Clash After Recent Microsoft 365 Module Updates

Updated 27-March-2024

When Teams and Exchange PowerShell modules clash

There’s been a bunch of updates for PowerShell modules used to manage Microsoft 365 tenants lately. The Microsoft Graph PowerShell SDK reached version 2.7.0 and then retreated to version 2.6.1 because of a problem with the Restore-MgDirectoryDeletedItem cmdlet. The Microsoft Teams module is now at version 5.7.1 and the Exchange Online module has reached version 3.4. With so much updating to do, I’m glad that I have scripts to update the Microsoft 365 modules on my PC and update the Microsoft 365 modules used as resources in Azure Automation accounts.

After a frenzy of updating, I spun up a new PowerShell session (itself updated to version 7.3.8) and ran the Connect-MicrosoftTeams cmdlet to connect to Teams followed by Connect-Exchange Online. Teams connected but Exchange Online barfed because of an issue loading the Microsoft.Identity.Client DLL. The PowerShell module clash looked like this:

PowerShell 7.3.8
Connect-MicrosoftTeams

Account                            Environment Tenant                               TenantId
-------                            ----------- ------                               --------
Tony.Redmond@office365itpros.com   AzureCloud  a562313f-14fc-43a2-9a7a-d2e27f4f3478 a662313f-14fc-43a2-9a7a-d2e27f4f34…

Connect-ExchangeOnline
OperationStopped: Could not load file or assembly 'Microsoft.Identity.Client, Version=4.44.0.0, Culture=neutral, PublicKeyToken=0a613f4dd989e8ae'.

Different Versions and Dependencies Cause PowerShell Module Clash

A dynamic link library (DLL) is a hunk of sharable code that applications can use. In this case, the two modules call functions in Microsoft.IdentityClient.Dll to authenticate against Entra ID. The problem is that Teams loads version 4.299 of the DLL and when Exchange Online comes along, it has a declared dependency on version 4.44 of the same DLL. PowerShell 7 runs on .NET core and only allows one version of a DLL to load at any time, so the request made by Exchange Online to load version 4.44 is blocked by version 4.299 loaded by Teams. Exchange Online isn’t happy to use a lower version of the module because it might be dependent on something in version 4.44, which leads us to the barf because the assembly couldn’t load.

It’s all perfectly logical and if you reverse the process and connect to Exchange Online first in a session, it loads version 4.44. If you then connect to Teams, the versioning rules for .NET Core allows the Teams module to load a higher version of the module, which is already present. The Teams module is therefore happy to use version 4.44 instead of 4.299. The only workaround to the problem is to close the PowerShell session and restart, loading the Exchange Online module first before loading the Teams module.

Update: The problem persists with version 3.6 of the Exchange Online management and version 6.5 of the Microsoft Teams PowerShell modules.

Lack of Coordination within Microsoft

Logical as the explanation is (and better than the all-too-often instances when we can’t understand why software fails), it’s disappointing that two Microsoft engineering groups working in the Microsoft 365 ecosystem cannot agree on which version of a critical DLL to use.

Exchange Online has done a lot of work recently to remove basic authentication from email connection protocols. Recently, they also removed support for Remote PowerShell. It’s understandable that their code base is in a state of change. By comparison, since the deprecation of the Skype for Business Connector, the degree of major change for the Teams PowerShell module has been less evident. Sure, Microsoft has modernized cmdlets, fixed bugs, added properties, and so on, but they haven’t ripped the guts out of their authentication stack. It’s plausible that the Teams developers were happy with the older version of the DLL because not much recent change happened (for them) in authentication and this might have allowed a gap to open in DLL versions.

I don’t know if this is what happened. It’s plausible based on observation, but that’s about all. Only Microsoft can say exactly why the two engineering groups arrived in a state of conflict. In any case, because modules like Teams and Exchange are often used together in scripts and interactive sessions, it would be nice if the development groups that produce PowerShell modules used in Microsoft 365 tested for clashes before releasing new versions of their modules.

It’s worth making the point that a dependency clash can happen for any module. I’ve experienced the same problem recently with the Pnp.PowerShell module (here’s a known issue). For instance, in a PowerShell 7 session, run Connect-MgGraph to connect to the Graph and then run Connect-PnpOnline to see another barf:

Connect-PnPOnline: Could not load file or assembly 'Microsoft.Identity.Client, Version=4.50.0.0, Culture=neutral, PublicKeyToken=0a613f4dd989e8ae'. Could not find or load a specific file. (0x80131621)

The truth is that once software has a dependency on something, things can go wrong when the underlying dependency changes. Pnp.PowerShell is a community initiative, so it can’t be blamed for something like this, but Microsoft engineering groups…

Stay Calm and Keep on Updating Your Modules

Microsoft knows about the problem and I believe work is under way to straighten things out. I’m not sure when the results of that activity will be available.

I still believe in updating PowerShell modules soon after new modules become available. It’s better to take advantage of fixes for reported problems than run old modules and find known bugs. But it’s still wise to test updated modules just in case something weird happens, like a version mismatch that causes a PowerShell module clash.


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.

]]>
https://office365itpros.com/2023/10/20/powershell-module-clash-exo/feed/ 3 62024
How to Control the Creation of Microsoft 365 Groups with the Microsoft Graph PowerShell SDK https://office365itpros.com/2023/10/18/control-group-creation-sdk/?utm_source=rss&utm_medium=rss&utm_campaign=control-group-creation-sdk https://office365itpros.com/2023/10/18/control-group-creation-sdk/#comments Wed, 18 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61987

Control Group Creation to Avoid Group Sprawl

Microsoft’s documentation covering the topic of “Manage who can create Microsoft 365 Groups” begins with: “By default, all users can create Microsoft 365 groups. This is the recommended approach because it allows users to start collaborating without requiring assistance from IT.”

I can’t say how strongly I disagree with this perspective. All it does is result in group sprawl, or more likely, teams sprawl. We learned the lesson with Exchange Server public folders in 1996 when users created new folders with abandon. Organizations are still clearing up the mess today, which is one of the reasons for the persistence of public folders in Exchange Online. The same need will arise to clean up unused and unwanted teams if organizations follow Microsoft’s advice to allow group creation by any and all. Microsoft promised to develop functionality to help with group sprawl in 2021. So far, there’s little sign of progress in this space, unless you include the ownerless group policy (2022) and the group expiration policy (available since 2020).

Group Creation Using the Microsoft Graph PowerShell SDK

The Microsoft documentation explains how to restrict group creation by running PowerShell to configure the Entra ID groups policy. Unhappily, the current version of the documentation uses cmdlets from the Azure AD Preview module, which is due for deprecation in March 2024, The same work can be done using cmdlets from the Microsoft Graph PowerShell SDK, which is what I cover here.

The basic approach is:

  • Create a security group to control group creation. The members of this group will be allowed to create new Microsoft 365 groups via user applications like Outlook and Teams. Accounts holding roles like Global administrator, Teams service administrator, Groups administrator, SharePoint administrator, User administrator, and Exchange administrator can always use administrative interfaces like PowerShell or the Microsoft 365 admin center to create new groups. The members of this group need Entra ID Premium P1 licenses.
  • Update the Entra ID groups policy to block group creation by anyone except the members of the security group.

I have no idea why Microsoft doesn’t make control over Microsoft 365 group creation available through an option in the Microsoft 365 admin center. My cynical side says that this is because they don’t want tenants to control group creation, so they force administrators to use PowerShell.

Create a Security Group to Control Group Creation

A simple security group is sufficient to define the set of accounts allowed to create new Microsoft 365 groups (Figure 1). You can either create a new group or use an existing group. Creating a new group is probably best because you can give the group an appropriate name and description and be sure that the group will only be used to control group creation.

A security group created to control group creation
Figure 1: A security group created to control group creation

Create a Groups Policy Object

Microsoft 365 uses a directory setting object to hold the settings to control creation and other aspects of Microsoft 365 groups. By default, tenants use default settings. To change these settings, you must create a copy of the template directory settings object and modify it. Here’s how to create a new directory settings object by retrieving the identifier of the default object and creating a new object for the tenant:

Connect-MgGraph -Scopes Directory.ReadWrite.All
$PolicyId = (Get-MgBetaDirectorySettingTemplate | Where-Object {$_.DisplayName -eq "Group.Unified"}).Id 
New-MgBetaDirectorySetting -TemplateId $PolicyId

The New-MgBetaDirectorySetting cmdlet fails if a tenant-specific directory settings object already exists.

Updating the Groups Policy to Limit Creation

With a groups policy object in place, we can update the settings. You can see the default settings by running:

Get-MgBetaDirectorySetting | Where-Object {$_.DisplayName -eq "Group.Unified"} | ForEach Values

To control group creation, two settings are updated:

  • EnableGroupCreation: This setting controls if users can create new groups. The default is true. We update it to false.
  • GroupCreationAllowedGroupId: This setting holds the identifier for the group whose members are allowed to create new groups.

The setting names are case-sensitive and should be passed exactly as shown.

To update the settings, fetch the identifier for the group (or have it available). Then populate an array with the current settings before updating the two settings described above. Finally, update the directory settings object with the new policy settings. Here’s the code:

$GroupId = (Get-MgGroup -Filter "displayName eq 'GroupCreationEnabled'").Id
$TenantSettings = Get-MgBetaDirectorySetting | Where-Object {$_.DisplayName -eq "Group.Unified"}
[array]$Values = $TenantSettings.Values
($Values | Where-Object Name -eq 'EnableGroupCreation').Value = "false"
($Values | Where-Object Name -eq 'GroupCreationAllowedGroupId').Value = $GroupId
Update-MgBetaDirectorySetting -DirectorySettingId $TenantSettings.Id -Values $Values

Figure 2 shows these commands being run.

Running the PowerShell code to control group creation
Figure 2: Running the PowerShell code to control group creation

Updating the group policy settings (for instance, to switch the group defining who can create new groups) uses the same approach: find values, update values, update the directory setting object.

If you make a mess of the Groups policy, you can start over by removing the directory settings object and creating a new policy. Here’s how to remove the policy:

$PolicyId = (Get-MgBetaDirectorySetting | Where-Object {$_.DisplayName -eq "Group.Unified"}).Id
Remove-MgBetaDirectorySetting -DirectorySettingId $PolicyId

Keeping Groups Under Control

Even if you decide to limit group creation, it’s a good idea to keep a close eye on what groups and teams are in active use and trim (or archive) those that don’t meet usage thresholds. The Teams and Groups activity report script can help with this process. Another point to consider is that Teams doesn’t come with any form of directory to allow users check if a team already exists for a topic. It’s possible to create such a directory, but making people check the list is a different challenge.

Another example of using directory objects to control groups is to block guest access for individual groups and teams. You can do this with sensitivity labels or by updating the directory setting for individual Microsoft 365 groups with PowerShell.


]]>
https://office365itpros.com/2023/10/18/control-group-creation-sdk/feed/ 3 61987
How to Execute Bulk Updates of Primary SMTP Address for Distribution Lists https://office365itpros.com/2023/10/17/distribution-list-proxy-address/?utm_source=rss&utm_medium=rss&utm_campaign=distribution-list-proxy-address https://office365itpros.com/2023/10/17/distribution-list-proxy-address/#respond Tue, 17 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61972

Updating Distribution List Proxy Addresses is a Good Example of PowerShell in Action

A question in the Microsoft Technical Community asks how to perform a bulk update of all distribution lists whose primary SMTP address uses the tenant service domain and replace this address with one from a “vanity” domain owned by the tenant. For example, replace onmicrosoft.contoso.com with contoso.com. This sometimes happens when a tenant begins operations and forgets to assign an address from a vanity domain when creating new distribution lists (Figure 1). It can also occur if an organization decides to use a different domain for whatever reason.

Assigning a distribution list proxy address during object creation. This becomes the DL's primary SMTP address.
Figure 1: The proxy address assigned during creation becomes a distribution list’s primary SMTP address

Updating primary SMTP addresses for any mail-enabled object is a great example of how PowerShell is so useful to tenant administrators. Let’s figure out what you might do.

Find Distribution Lists to Update

The first step is to extract the set of domains known for the tenant and find the domain marked as the default. We’ll use that domain to create new primary SMTP addresses:

[array]$Domains = Get-AcceptedDomain
$PreferredDomain = $Domains | Where-Object {$_.Default -eq $True} | Select-Object -ExpandProperty DomainName
If (!($PreferredDomain)) { Write-Host "Can't find the default domain" ; break }

You can use any of the accepted domain defined for the tenant, so if you want to use a different domain, amend the script to insert the desired domain in the $PreferredDomain variable.

Find the Target Distribution Lists

Now let’s find the set of distribution lists that use the service domain for their primary SMTP address:

[array]$DLs = Get-DistributionGroup | Where-Object {$_.PrimarySMTPAddress -like "*onmicrosoft.com*"}
If (!($DLs)) {
   Write-Host "No distribution lists use the service domain for their primary SMTP address" ; break
} Else {
   Write-Host ("{0} distribution lists found to update" -f $DLs.count)
}

To check details of the distribution lists, run this command:

$DLs | Format-Table DisplayName, PrimarySMTPAddress

Update the Distribution Lists with a New Primary SMTP Address

If you’re happy to go ahead, this code uses the Set-DistributionGroup cmdlet to update the primary SMTP address for the distribution lists. The code builds the new primary SMTP address for a list from its alias and the preferred domain:

ForEach ($DL in $DLs) {
  $NewSMTPAddress = $DL.Alias + "@" + $PreferredDomain
  Write-Host ("Updating distribution list {0} with new address {1}..." -f $DL.DisplayName, $NewSMTPAddress )
  Set-DistributionGroup -Identity $DL.Alias -PrimarySMTPAddress $NewSMTPAddress
}

Exchange Online keeps all previous SMTP proxy addresses (including the prior primary SMTP address) to make sure that it can correctly handle messages sent to the distribution lists using those addresses. You can see this by running the Get-DistributionGroup cmdlet to examine the email addresses:

Get-DistributionGroup -Identity 'San Francisco Rooms' | Select-Object -ExpandProperty EmailAddresses
SMTP:SanFranciscoRooms@Office365itpros.com
smtp:SanFranciscoRooms@office365itpros.onmicrosoft.com

Alternatively, you can see the proxy addresses by examining the distribution list properties in the Exchange admin center.

The primary SMTP address is only one distribution list proxy address. Like any Exchange Online recipient, a distribution list can have up to 300 proxy addresses (depending on the length of the addresses), so there’s usually plenty of room to store proxy addresses created for different reasons.

Distribution Lists Still Have a Place

Despite Microsoft’s ongoing efforts to persuade customers that Microsoft 365 groups are better than distribution lists, there’s no doubt that these objects still have a place in any tenant. A Microsoft 365 group is a great choice when you want to use Teams or Outlook groups for collaboration, but scenarios still exist where a simple distribution list is the best way to communicate, especially when you want to include external email addresses (guest accounts can also be members of distribution lists).

Here’s a script to report distribution list membership, just in case you’re looking for another project after fixing distribution list proxy addresses and making sure that the right primary SMTP address is in place.


Learn about using 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.

]]>
https://office365itpros.com/2023/10/17/distribution-list-proxy-address/feed/ 0 61972
Microsoft 365 Groups with Long Names Cause Graph Errors https://office365itpros.com/2023/10/16/group-display-name-error-120/?utm_source=rss&utm_medium=rss&utm_campaign=group-display-name-error-120 https://office365itpros.com/2023/10/16/group-display-name-error-120/#respond Mon, 16 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61883

Keep Group Display Names Short to Avoid Problems

A recent discussion revealed that Graph API requests against the Groups endpoint for groups with display names longer than 120 characters generate an error. As you might know, The Groups Graph API supports group display names up to a maximum of 256 characters, so an error occurring after 120 seems bizarre. Then again, having extraordinarily long group names is also bizarre (Figure 1).

Teams settings for a group with a very long name
Figure 1: Teams settings for a group with a very long name

Group display names longer than 30 or so characters make it difficult for clients to list groups or teams, which is why Microsoft’s recommendation for Teams clients says that between 30 and 36 characters is a good limit for a team name. Using very long group names also creates formatting and layout issues when generating output like the Teams and Groups activity report, especially if only one or two groups have very long names.

Replicating the Problem

In any case, here’s an example. After creating a group with a very long name, I populated a variable with the group’s display name (146 characters). I then created a URI to request the Groups endpoint to return any Microsoft 365 group that has the display name. Finally, I executed the Invoke-MgGraphRequest cmdlet to issue the request, which promptly failed with a “400 bad request” error:

$Name = "O365Grp-Team with an extraordinary long name that makes it much more than 120 characters so that we can have some fun with it  with Graph requests"
$Uri = "https://graph.microsoft.com/v1.0/groups?`$filter= displayName eq '${name}'"
$Data = Invoke-MgGraphRequest -Uri $Uri -Method Get

Invoke-MgGraphRequest: GET Invoke-MgGraphRequest: GET https://graph.microsoft.com/v1.0/groups?$filter=%20displayName%20eq%20'O365Grp-Team%20with%20an%20extraordinary%20long%20name%20that%20makes%20it%20much%20more%20than%20120%20characters%20so%20that%20we%20can%20have%20some%20fun%20with%20it%20%20with%20Graph%20requests'
HTTP/1.1 400 Bad Request
Cache-Control: no-cache

The Get-MgGroup cmdlet also fails. This isn’t at all surprising because the Graph SDK cmdlets run the underlying Graph API requests, so if those requests fail, the cmdlets can’t apply magic to make everything work again:

Get-MgGroup -Filter "displayName eq '$Name'"

Get-MgGroup_List: Unsupported or invalid query filter clause specified for property 'displayName' of resource 'Group'.

The same happens if you try to use the Get-MgTeam cmdlet from the Microsoft Graph PowerShell SDK.

Get-MgTeam -Filter "displayName eq '$Name'"

Get-MgTeam_List: Unsupported or invalid query filter clause specified for property 'displayName' of resource 'Group'.
Status: 400 (BadRequest)
ErrorCode: BadRequest
Date: 2023-10-06T04:53:39
 

The Workaround for Group Display Name Errors

But here’s the thing. The Get-MgGroup cmdlet (and the underlying Graph API request) work if you add the ConsistencyLevel header and an output variable to accept the count of returned items. The presence of the header makes the request into an advanced query against Entra ID.

Get-MgGroup -ConsistencyLevel Eventual -Filter "displayName eq '$Name'" -CountVariable X | Format-Table DisplayName
 
DisplayName
-----------
O365Grp-Team with an extraordinary long name that makes it much more than 120 characters so that we can have some fun …

Oddly, the Get-MgTeam cmdlet doesn’t support the ConsistencyLevel header so this workaround isn’t possible using this cmdlet. Given that Teams (the app) finds its teams through Graph requests, this inconsistency is maddening, and it’s probably due to a flaw in the metadata read by the ‘AutoREST’ process Microsoft runs regularly to generate the SDK cmdlets and build new versions of the SDK modules.

None of the Teams clients that I’ve tested have any problem displaying team names longer than 120 characters, so I suspect that the clients do the necessary magic when fetching lists of teams.

Inconsistency in Entra ID Admin Center

The developers of the Entra ID admin center must know about the 120 character limit (and not about the workaround) because they restrict group names (Figure 2).

The Entra ID admin center wants to restrict group names to 120 characters

Group display name error
Figure 2: The Entra ID admin center wants to restrict group names to 120 characters

A StackOverflow thread from 2017 reported that attempts to use the Graph to create new groups with display names longer than 120 characters resulted in errors. However, it’s possible to now use cmdlets like New-MgGroup to create groups with much longer names.

Given that the Groups Graph API allows for 256 characters, it’s yet another oddity that the Entra ID admin center focuses on a lower limit – unless the developers chose to emphasize to administrators that it’s a really bad idea to use overly long group names.

Time to Update SDK Foibles

I shall have to add this issue to my list of Microsoft Graph PowerShell SDK foibles (aka, things developers should know before they try coding PowerShell scripts using the SDK). The fortunate thing is that you’re unlikely to meet this problem in real life. At least, I hope that you are. And if you do, you’ll know what to do now.


Make sure that you’re not surprised about changes that appear inside Microsoft 365 applications or the Graph by subscribing to the Office 365 for IT Pros eBook. Our monthly updates make sure that our subscribers stay informed.

]]>
https://office365itpros.com/2023/10/16/group-display-name-error-120/feed/ 0 61883
How to Block User Access to Microsoft 365 PowerShell Modules https://office365itpros.com/2023/10/12/block-powershell-m365/?utm_source=rss&utm_medium=rss&utm_campaign=block-powershell-m365 https://office365itpros.com/2023/10/12/block-powershell-m365/#comments Thu, 12 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61922

Use Enterprise Applications to Block PowerShell Modules

A question arose about the best way to block Microsoft 365 user accounts from being able to run PowerShell. It seemed like a worthy problem to consider. In some cases an obvious answer exists, like stopping Exchange Online users from accessing PowerShell, but that’s a technique that only works for Exchange, and the block needs to be imposed for every new mailbox. We need something more generic that works across Microsoft 365.

Microsoft documents a process to block access to PowerShell for EDU tenants. The script to block PowerShell uses cmdlets from the Azure AD module, which Microsoft is deprecating with retirement scheduled for March 30, 2024. A replacement script using Microsoft Graph PowerShell SDK cmdlets is needed. Fortunately, I’ve been down this path with an article covering secure access to the SDK and can reuse many of the concepts explained there.

Update August 23, 2024: Microsoft now documents how to use the EXOModuleEnabled setting for accounts to control access to Exchange Online PowerShell. The technique described here works for other PowerShell modules.

Key Steps to Block PowerShell Modules

Every application that authenticates against Entra ID is known to the directory. Some applications are created within a tenant (registered apps). Others are created by companies like Microsoft as multi-tenant applications that can run anywhere. These are enterprise applications. The PowerShell modules that connect to Microsoft 365 endpoints like Exchange or Teams authenticate using enterprise applications created by Microsoft. The Microsoft Graph PowerShell SDK is the most obvious of these applications, but other applications exist for the Exchange Online management module, SharePoint Online management module, and the Microsoft Teams module.

Most administrators are unaware that these PowerShell enterprise applications exist. The applications don’t show up in the Entra ID admin center because normally they do not have a service principal. Applications use service principals to store permissions, like the Graph permissions used by the Microsoft Graph PowerShell SDK. Applications without service principals use roles instead.

For instance, when you run the Connect-ExchangeOnline cmdlet to connect to Exchange Online, the ability to work with Exchange data is gated by the roles possessed by the signed-in user account. If the account holds the Exchange administrator or Global administrator role, they can manage all aspects of Exchange Online (this also applies to Azure Automation accounts). If not, they can manage their own mailbox.

The key steps to restrict access to a PowerShell module are:

  • Find the application identifier for the module. We’ll get to doing that in a minute.
  • Create a service principal for the application.
  • Update the service principal so that it uses application role assignments.
  • Create a security group to manage assignments of permission to use the module.
  • Add the security group as an assignment to the service principal.

Finding Application Identifiers for PowerShell Modules

The first step is to find the application identifiers. The easiest way to do this is to check the Entra ID sign-in logs for events when people connect using a PowerShell module. Figure 1 shows an example of a sign-in event logged when an administrator connected with the SharePoint Online management module. We can see that the application identifier is 9bc3ab49-b65d-410a-85ad-de819febfddc.

Finding the application identifier for a PowerShell module from an Entra ID sign-in event

Block PowerShell access
Figure 1: Finding the application identifier for a PowerShell module from an Entra ID sign-in event

Application identifiers for other modules include:

  • Exchange Online management: fb78d390-0c51-40cd-8e17-fdbfab77341b (covers both regular Exchange and the compliance endpoint).
  • Microsoft Teams: 12128f48-ec9e-42f0-b203-ea49fb6af367
  • Azure: 1950a258-227b-4e31-a9cf-717495945fc2
  • Microsoft Graph PowerShell SDK: 14d82eec-204b-4c2f-b7e8-296a70dab67e

Example: Block Access to Exchange Online PowerShell

Now that we know the application identifiers, we can go ahead and create the service principal for the modules to block. Here are the PowerShell commands to connect an interactive Graph session and create a block for Exchange Online:

# Connect to the Grph
Connect-MgGraph -Scopes Directory.ReadWrite.All, Group.ReadWrite.All, Application.ReadWrite.All

# Create security group to control access to Exchange Online PowerShell
$Group = New-MgGroup -DisplayName "Allow access to EXO PowerShell" -MailEnabled:$False -SecurityEnabled:$True -MailNickName 'EXO.PowerShell'

# Create the service principal for the Exchange Online PowerShell app
$ServicePrincipal = New-MgServicePrincipal -Appid 'fb78d390-0c51-40cd-8e17-fdbfab77341b'

# Check that the Service Principal exists
Get-MgServicePrincipal -ServicePrincipalId $ServicePrincipal.Id | Format-Table DisplayName, Id, AppId

DisplayName                                  Id                                   AppId
-----------                                  --                                   -----
Microsoft Exchange REST API Based PowerShell 8d32ebd2-7295-4236-a3da-7c45be69a0b3 fb78d390-0c51-40cd-8e17-fdbfab77341b

# Update the Service Principal so that it requires application role assignments
Update-MgServicePrincipal -ServicePrincipalId $ServicePrincipal.Id -AppRoleAssignmentRequired:$True

# Add the security group as an assignment to the service principal
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -AppRoleId ([Guid]::Empty.ToString()) -ResourceId $ServicePrincipal.Id -PrincipalId $Group.Id

After running these commands, no one can run the Connect-ExchangeOnline cmdlet to connect to Exchange unless they are added to the security group (Figure 2).

Members of the security group permitted to run Exchange Online PowerShell
Figure 2: Members of the security group permitted to run Exchange Online PowerShell

Entra ID rejects connection attempts from unauthorized accounts with an AADSTS50105 error (Figure 3). The “Microsoft Exchange REST API Based PowerShell” name is assigned to the enterprise application by Microsoft.

Error when attempting to run the Exchange Online PowerShell module
Figure 3: Error when attempting to run the Exchange Online PowerShell module

Discovering Who Accesses PowerShell

Often it’s simple to know who should be allowed to be members of the security group controlling access to a module. The tenant administrator, any administrators for a workload (like Teams service administrators), break glass accounts, service accounts such as those used by Azure Automation, and so on. But to be definite, we should review the Entra ID sign-in logs to see who uses a module.

This command retrieves the last 5,000 sign-in records and filters them for any sign-in for the Exchange Online application:

[array]$AuditRecords = Get-MgAuditLogSignIn -Top 5000 -Sort "createdDateTime DESC" -Filter "AppId eq 'fb78d390-0c51-40cd-8e17-fdbfab77341b'"

A simple Group-Object command gives the answer:

$AuditRecords | Group-Object UserPrincipalName -NoElement | Sort-Object Count -Descending| Select-Object Name, Count

Name                               Count
----                               -----
tony.redmond@office365itpros.com      10
EXOAdmin@office365itpros.com           7
James.Atkinson@office365itpros.com     3

You can then decide if any or all of the people who have accessed the module should be added to the security group. To check another module, replace the application identifier in the Get-MgAuditLogSignIn command.

Should My Tenant Block PowerShell?

The factors driving the decision to block PowerShell access for user accounts will differ from organization to organization. At least now you know the best way to block the most common PowerShell modules used with Microsoft 365 and how to find out who’s using the modules.


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.

]]>
https://office365itpros.com/2023/10/12/block-powershell-m365/feed/ 16 61922
How to Remove Licenses From Disabled Accounts with PowerShell https://office365itpros.com/2023/10/11/disabled-accounts-licenses/?utm_source=rss&utm_medium=rss&utm_campaign=disabled-accounts-licenses https://office365itpros.com/2023/10/11/disabled-accounts-licenses/#comments Wed, 11 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61870

The Reasons for Disabled Accounts

Many reasons exist why organizations disable user accounts, including when employees go on sabbaticals, take time off due to illness, or have leave following childbirth. A less innocuous explanation is when employees are suspended for some reason. In all cases, accounts might remain in a disabled state for long periods.

Disabling an account means that Entra ID won’t let the user sign into their account. Data remains online and accessible for corporate purposes such as eDiscovery. Here’s how to disable an account using the Update-MgUser cmdlet from the Microsoft Graph PowerShell SDK:

Update-MgUser -UserId Andy.Ruth@office365itpros.com -AccountEnabled:$False

When the user returns, run Update-MgUser again to restore access by setting the AccountEnabled property to $True. To find the set of disabled accounts, run the Get-MgUser cmdlet like this:

Get-MgUser -Filter "accountEnabled eq false" -Property AccountEnabled, Id, DisplayName -All

Licensing of Disabled Accounts

Because accounts might be disabled for a long time, thoughts turn to the monthly license charges levied by Microsoft. If someone’s away for six months, should the organization pay for six months’ of charges. If the account has a Microsoft 365 E3 license and perhaps an add-on license (like SharePoint-Syntex advanced management) and a Teams calling plan, the costs could mount to $300 or thereabouts while the user is away.

One or two accounts incurring charges without use might not be a big deal. Interest about controlling license costs mounts as the number of disabled accounts mount. Twenty disabled accounts means $6,000 over six months. At that point, it might be worthwhile taking action to remove licenses from disabled accounts until their owners return to work.

Removing Exchange Online Licenses Leads to Disabled Mailboxes

Before rushing to remove all licenses from disabled accounts, let me sound a note of caution about removing products that include Exchange Online. An Exchange Online service plan is included in many Office 365 and Microsoft 365 products. For instance, Exchange Online Plan 2 (necessary for option such as archive mailboxes) is part of the Office 365 E3 and Office 365 E5 products. If you remove disable the Exchange Online service plan or remove the license for a product that includes Exchange Online from an account, the mailbox goes into a disabled state. One way to find mailboxes without licenses is to use the Get-EXOMailbox cmdlet to check if mailboxes have a valid SKU (product license):

Get-EXOMailbox -Filter {SkuAssigned -eq $True} | Format-Table DisplayName, UserPrincipalName, ExternalDirectoryObjectId

Exchange Online permanently removes disabled mailboxes after 30 days. To move from the disabled state, the owner’s account must be assigned a license that includes an Exchange Online service plan.

When removing licenses from disabled accounts, it’s important to check for Exchange Online to make sure that a removal doesn’t lead to potential data loss. Two options are available:

  • Retain assigned licenses that include Exchange Online for disabled accounts.
  • Replace the assigned license with a lower-cost license that includes Exchange Online. For example, you could assign inexpensive Office 365 E1 or F3 licenses to keep account mailboxes in a healthy state.

Exchange Online supports license stacking, meaning that it’s possible to assign multiple licenses to accounts that include an Exchange Online service plan. When this happens, Exchange Online uses the most functional plan.

Scripting License Removal

This article covers the basics of license management with the Microsoft Graph PowerShell SDK. The outline of a script to find and remove licenses from disabled accounts might include the following steps:

  • Connect to the Graph.
  • Define exclusions for licenses that should not be removed from accounts (those with Exchange Online).
  • Find disabled accounts.
  • Loop through each account to examine the assigned licenses and decide if any can be removed.
  • Run the Set-MgUserLicense cmdlet to remove the licenses.
  • Report the actions taken.

If an organization uses group-based licensing, Set-MgUserLicense cannot remove licenses assigned using this mechanism. Instead, the correct approach is to remove the account from the group used by Entra ID to control license assignments.

My version of a script to process license removals for disabled accounts can be downloaded from GitHub. It includes code to exclude licenses containing Exchange Online service plans. As mentioned earlier, the alternative is to replace licenses with a cheaper version. The code to do this would be simple to add. The script excludes licenses assigned through group-based licenses. Again, it would be easy to add code to remove accounts from the groups used to assign licenses. Figure 1 shows the script in action.

Removing licenses from disabled accounts
Figure 1: Removing licenses from disabled accounts

The Shared Mailbox Approach

Another way to handle the question of what to do with mailboxes belonging to long-term absentees is to turn them into shared mailboxes for the duration of their owner’s absence. When the owner returns, revert the shared mailbox to make it a regular mailbox again. This technique preserves the mailbox because shared mailboxes don’t need licenses. Here’s what you do:

  1. Convert the mailbox into a shared mailbox.
  2. Disable the account and change the password.
  3. Remove all licenses.
  4. (Optional) Hide the shared mailbox from Exchange address lists.
  5. (Optional) Remove the shared mailbox from distribution lists so that mail doesn’t pile up in the mailbox during the owner’s absence.

When the user returns:

  1. Convert the shared mailbox to a regular mailbox.
  2. Enable the account and assign a new password.
  3. Assign licenses to the account.
  4. Unhide (if necessary) the mailbox.
  5. Restore distribution list membership.

Check and Verify Before Use

Remember that the script illustrates the principles behind license removal for disabled accounts. It is not a production-ready solution. Like any code downloaded from the internet, you should verify and test the script and adapt it to meet your needs (especially because it removes licenses from accounts). The nice thing is that everything’s done in PowerShell, so please go ahead and modify the code as you wish.


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.

]]>
https://office365itpros.com/2023/10/11/disabled-accounts-licenses/feed/ 1 61870
How to Create Dynamic Microsoft 365 Groups (and Teams) for Departments https://office365itpros.com/2023/10/10/dynamic-microsoft-365-groups/?utm_source=rss&utm_medium=rss&utm_campaign=dynamic-microsoft-365-groups https://office365itpros.com/2023/10/10/dynamic-microsoft-365-groups/#respond Tue, 10 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61844

Create Dynamic Microsoft 365 Groups and Teams with PowerShell

No sooner had I published the article about creating dynamic administrative units with PowerShell, the first email arrived asking if the same was possible for dynamic Microsoft 365 groups. The answer is “of course,” but with the caveat that it’s not just a matter of some minor updates to the script.

That being said, the outline for the script to create dynamic groups is broadly the same:

  • Find the licensed users in the tenant and extract a list of departments. The departments should be accurate and some care should be taken to eliminate inconsistencies. For instance, some people might be listed as belonging to IT while others belong to the Information Technology department. Decide on one value and apply it to all.
  • You might not want to create groups for all departments. The script defines an array of excluded departments that are removed from the set to process.
  • Find the set of dynamic Microsoft 365 groups. We need this information to check if a dynamic group already exists for a department.
  • For each department, check if a group already exists. If not, define some parameters for the new group, including the membership rule that Entra ID uses to calculate the group members, and run the New-MgGroup cmdlet to create the group.
  • Following a successful creation, proceed to team-enable the new group by running the New-MgTeam cmdlet. This is an optional step, but seeing that Teams is the heaviest workload for Microsoft 365 groups, it seemed like a good thing to include.

Let’s examine some of the steps.

Scripting the Creation of a Dynamic Microsoft 365 Group

Here’s an example of creating a new dynamic Microsoft 365 group for the department whose name is stored in the $Dept variable:

Write-Host ("Checking groups for department {0}" -f $Dept)
$Description = ("Dynamic Microsoft 365 group created for the {0} department on {1}" -f $Dept, (Get-Date))
$DisplayName = ("{0} Dynamic group" -f $Dept)
$MailNickName = ("Dynamic.{0}.Group" -f ($Dept -replace " ",""))
$MembershipRule = '(User.Department -eq "' + $Dept +'")'

If ($DisplayName -in $Groups.DisplayName) {
   Write-Host ("Group already exists for {0}" -f $Dept) -ForegroundColor Red
} Else {
# Create the new dynamic Microsoft 365 Group
   $NewGroup = New-MgGroup -DisplayName $DisplayName -Description $Description ` 
   -MailEnabled:$True -SecurityEnabled:$False `
   -MailNickname $MailNickName -GroupTypes "DynamicMembership", "Unified" `
   -MembershipRule $MembershipRule -MembershipRuleProcessingState "On"
}

Wait Before Progressing to Teams

Flushed with the successful creation, you might want to rush to team-enable the new group. However, it’s best to wait 10-15 seconds before proceeding to allow Teams to learn about the new group from Entra ID. If you attempt to team-enable a group immediately after creation, you’ll probably see an error like this:

Failed to execute Templates backend request CreateTeamFromGroupWithTemplateRequest. Request Url: https://teams.microsoft.com/fabric/emea/templates/api/groups/bab7a3a8-2e30-4996-9405-48ca395b99c6/team, Request Method: PUT, Response Status Code: NotFound, Response Headers: Strict-Transport-Security: max-age=2592000
x-operationid: a228258204c3466dbd64c4d88373a416
x-telemetryid: 00-a228258204c3466dbd64c4d88373a416-82a9b5015f332574-01
X-MSEdge-Ref: Ref A: FC01DAADBD0D4A1A9ECBB9826707CC17 Ref B: DB3EDGE2518 Ref C: 2023-10-04T15:00:51Z
Date: Wed, 04 Oct 2023 15:00:52 GMT
ErrorMessage : {"errors":[{"message":"Failed to execute GetGroupMembersMezzoCountAsync.","errorCode":"Unknown"}],"operationId":"a228258204c3466dbd64c4d88373a416"}

Team-Enabling a Group

To team-enable a group, run the New-MgTeam cmdlet and provide a hash table containing information to allow Teams to find the new group (the Graph URI for the group) plus the Teams template to use. This code does the trick.

$GroupUri = "https://graph.microsoft.com/v1.0/groups('" + $NewGroup.Id + "')"
$NewTeamParams = @{
   "template@odata.bind"="https://graph.microsoft.com/v1.0/teamsTemplates('standard')"
   "group@odata.bind"="$($GroupUri)"
}
$NewTeam = New-MgTeam -BodyParameter $NewTeamParams
If ($NewTeam) {
   Write-Host ("Successfully team-enabled the {0}" -f $NewGroup.DisplayName)
}

Checking Groups Post-Creation

Figure 1 shows some of the dynamic Microsoft 365 groups created in my tenant. Note the groups for “Information Technology” and the “IT Department.” Obviously my checking of user departments was deficient prior to running the script. The fix is easy though. Decide on which department name to use and update user accounts to have that. Then remove the now-obsolete group. Entra ID will make sure that the accounts with reassigned departments show up in the correct group membership.

Dynamic Microsoft 365 groups created for departments
Figure 1: Dynamic Microsoft 365 groups created for departments

In this case, only one account had “IT Department,” so I quickly updated its department property with:

Update-MgUser -UserId Jack.Smith@office365itpros.com -Department "Information Technology"

I then removed the IT Department dynamic group:

$Group = Get-MgGroup -Filter "displayName eq 'IT Department Dynamic Group'"
Remove-MgGroup -GroupId $Group.Id

Soon afterwards, the membership of the Information Department Dynamic group was correct (Figure 2) and all was well.

Membership of a dynamic Microsoft 365 group for a department
Figure 2: Membership of a dynamic Microsoft 365 group for a department

You can download the complete script from GitHub. It would be easy to adapt the code to run as an Azure Automation runbook to scan for new departments and create groups as necessary.

Simple PowerShell Results in Big Benefits

Scripting the creation of dynamic Microsoft 365 groups for each department in a tenant isn’t too difficult. The membership rule is simple but could be expanded to include different criteria. Once the groups are created, they should be self-maintaining. That is, if you make sure that the department property for user accounts is accurate.


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 like dynamic Microsoft 365 groups work.

]]>
https://office365itpros.com/2023/10/10/dynamic-microsoft-365-groups/feed/ 0 61844
Microsoft Removes Exchange Online User Photo Cmdlets https://office365itpros.com/2023/10/09/user-photo-cmdlets/?utm_source=rss&utm_medium=rss&utm_campaign=user-photo-cmdlets https://office365itpros.com/2023/10/09/user-photo-cmdlets/#comments Mon, 09 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61833

Use Graph SDK Cmdlets to Manage User and Group Photos

According to message center notification MC678855 (2 October), effective November 30, 2023, Microsoft will retire the Exchange Online management cmdlets to manipulate photos for mailboxes (Get-, Set-, and Remove-UserPhoto). This is part of the work to improve the way Microsoft 365 manages and displays user photos and moves the photo storage location away from Exchange Online to Entra ID. Microsoft says that this will create “a coherent user profile image experience by retiring legacy profile photo services.

Basically, this effort resolves the inconsistencies that crept into user photo handling through Exchange and SharePoint doing their own thing, largely because of their on-premises roots. Delve attempted to fix the problem in 2015 but never really went anywhere. After that, Microsoft started to use Exchange Online to host photos and synchronize from there, but it’s a better idea to use Entra ID and have all workloads come to a common place for photo data.

Replacement User Photo Cmdlets

The replacement cmdlets for user photo management are in the Microsoft Graph PowerShell SDK:

  • Set-MgUserPhotoContent: Add a photo to an Entra ID account. You can add JPEG or PNG files of up to 4 MB. Entra ID can store photos with a large pixel count. I have commonly uploaded photos sized at 8256 x 5504 pixels. When applications fetch photos to use, they can specify what sized photo they wish Entra ID to provide ranging from a thumbnail (48 x 48 pixels) to a high-definition photo as used in Teams meetings.
  • Get-MgUserPhoto: Check if an account has photo data in the profilePhoto property.
  • Update-MgUserPhoto: According to the documentation, this cmdlet “updates the navigation photo in users.” That doesn’t make much sense, so I asked the SDK development group to ask what the text really means. As it turns out, this cmdlet is a duplicate of Set-MgUserPhotoContent, so you can ignore it.
  • Remove-MgUserPhoto: Remove user photo information from an account.

For example:

Set-MgUserPhotoContent -Userid Jim.Smith@office365itpros.com -Infile "c:\temp\Jim.Smith.jpg"

 A user photo updated in Entra ID
Figure 1: A user photo updated in Entra ID

Updating Scripts

From an administrator perspective, the impact of the change is a need to review scripts that call the old cmdlets to replace them with the SDK cmdlets. The changes to the script are likely to involve:

  • Call the Connect-MgGraph cmdlet to connect to the SDK.
  • Find target user accounts instead of mailboxes.
  • Remove the references to Get-UserPhoto and Set-UserPhoto.
  • Use the Get-MgUserPhoto cmdlet to find if a target mailbox has a photo and the Set-MgUserPhotoContent cmdlet to update the photo if necessary (and a suitable file is available).

To provide a working example, I updated the script mentioned in this article. You can download the full script from GitHub. Remember that Graph permissions work differently to the permissions granted when an account holds the Exchange administrator or Global administrator roles for a tenant. Using the SDK in an interactive session to update photos will only work if the signed in account holds one of the two roles mentioned above and consent is granted for the SDK app to use the Directory.ReadWrite.All permission.

Group Photos

Because it’s a mailbox cmdlet and supports the GroupMailbox switch, the Set-UserPhoto cmdlet can set photos for Microsoft 365 groups. The Set-MgUserPhotoContent cmdlet only handles user accounts. To update the photos for Microsoft 365 groups, it’s necessary to use the Set-MgGroupPhotoContent cmdlet. Alternatively, for team-enabled groups, you can use the Set-TeamPicture cmdlet from the Microsoft Teams module.

I wrote an article describing how to update photos for Microsoft 365 groups. Updating the associated script wasn’t quite as simple because the Get-MgGroupPhoto cmdlet doesn’t return a thumbnail identifier. The foundation of the original script is that the thumbnail identifier could tell the script if the group already had a photo. This is now not possible, so the updated script (available from GitHub) is a rewritten and simplified version of the original.

Another Example of Change

This transition is yet another example of recent change in the Microsoft 365 PowerShell space. Exchange Online has just turned off Remote PowerShell and we’re on the final stretch of deprecation for the Microsoft Online Services module (the cmdlets that deal with license assignment have already stopped working). Keeping up to date with cmdlet changes can take some time but it’s an essential task.


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.

]]>
https://office365itpros.com/2023/10/09/user-photo-cmdlets/feed/ 2 61833
How to Convert Custom Background Images for Teams 2.1 https://office365itpros.com/2023/10/04/custom-background-image-teams21/?utm_source=rss&utm_medium=rss&utm_campaign=custom-background-image-teams21 https://office365itpros.com/2023/10/04/custom-background-image-teams21/#comments Wed, 04 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61781

Tracking the Evolution of Custom Background Images for Teams Meetings

I’ve written about using custom background effects in Teams meetings since their introduction in early 2020, including how to fetch and use the Bing daily images as custom background inages for Teams meetings.

Recently, I’ve used the Teams 2.1 client as my daily driver. Microsoft expected that the new Teams client would become the default client in late September. The switchover is very close and the new client is in good shape. It’s as stable as its Electron-based predecessor and performs better, so using Teams 2.1 is a reasonable option for someone like me. The task of deploying the new client across a large enterprise will take some planning and coordination.

No Custom Background Images After Switch to Teams 2.1

As part of my personal move, I noticed that the custom background images I use for Teams meetings are unavailable in Teams 2.1. Some investigation revealed that Teams 2.1 uses a different folder to store custom images. Here are the folders used by the two clients.

  • Teams 1: C:\Users\userx\AppData\Roaming\Microsoft\Teams\Backgrounds\Uploads
  • Teams 2.1: C:\Users\userx\AppData\Local\Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\Backgrounds\Uploads

Switching to Teams 2.1 doesn’t transfer custom background images to the new location, possibly because Microsoft uses a new naming scheme for the images. Instead of regular names like My favorite beach scene.jpg, Teams 2.1 uses names like 42b9a5ad-460e-46df-8c1b-f4d7c42dffc0.jpg. Each image exists in a high-resolution (at least 1920 x 1080 pixels) version and a lower-resolution thumbnail (220 x 158 pixels). The high-resolution version is the image that Teams loads as a meeting background. The thumbnails are displayed in the gallery of available background images.

Transferring Custom Background Images for Teams 2.1

Because of the naming scheme used by Teams 2.1, it’s not enough to simply copy custom background images from the Teams 1 folder because Teams 2.1 ignores any files that don’t follow its naming convention (with or without a thumbnail).

The obvious fix for the problem is to upload custom background images into the Teams 2.1 client. That’s acceptable when only a couple of files are involved. Things get boring thereafter, which is why I wrote a PowerShell script (downloadable from GitHub). The script:

  • Loads a Resize-Image function to resize images to the desired sizes (originally written by Christopher Walker and found in GitHub).
  • Defines the folders used by Teams 1 and Teams 2.1.
  • Finds JPG files in the Teams 1 folder. The script ignores any thumbnails and only processes high-resolution images.
  • Call the Resize-Image function to generate new high-resolution files sized at 1920 x 1080 and thumbnails. The file names follow the Teams 2.1 scheme.
  • Copy the generated files to the Teams 2.1 folder and remove them from Teams 1 folder.
  • Lists the JPG files now in the Teams 2.1 folder.

It’s not a particularly complex script, but it worked. The acid test is that I can select my custom background images from the gallery when in a meeting with the Teams 2.1 client (Figure 1).

Custom background images in the gallery of the Teams 2.1 client
Figure 1: Custom background images in the gallery of the Teams 2.1 client

Bumps in the Changeover

The changeover from Teams 1 to Teams 2.1 will reveal some flaws. The architecture and internal functions of the two clients are very different. Using a different folder to store custom background images is a very small indicator of the kind of change involved in the transition. I have no idea why Microsoft decided to switch folders as the Teams 1 folder seemed perfectly acceptable, but they did. Now you know how to transfer custom background images to the new location, the issue goes away.

I don’t see any reason why the same technique could not be used to distribute a set of organization images to workstations. If you can access the folder, you can copy custom background images to it and make those images available for Teams meetings.


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.

]]>
https://office365itpros.com/2023/10/04/custom-background-image-teams21/feed/ 22 61781
How to Update Shared Mailbox Owners About Quota Usage https://office365itpros.com/2023/10/03/shared-mailbox-quota-report/?utm_source=rss&utm_medium=rss&utm_campaign=shared-mailbox-quota-report https://office365itpros.com/2023/10/03/shared-mailbox-quota-report/#comments Tue, 03 Oct 2023 01:00:00 +0000 https://office365itpros.com/?p=61729

Shared Mailbox Quota Report a Take on an Old Script

In September 2019, I wrote about using PowerShell to generate an Exchange Online mailbox quota report. The idea was to allow administrators to identify mailboxes that surpassed a certain quota threshold (say 85%) so that they could proactively manage the situation and prevent users from exceeding quota. It’s never good for someone to be unable to send and receive email because of a blown quota.

The 2019 article came to mind when I was asked about writing a script to report quota usage for shared mailboxes. These mailboxes don’t have formal owners, but the idea was to regard anyone with full access to the mailbox as an owner. The purpose of the script is to capture details of quota usage and email that information to the mailbox owners.

Stitching Bits Together to Create a New Script

One of the nice things about PowerShell is that it’s easy to reuse code from scripts to form a new solution. In this case, I used the following:

Reusing code saves time, which is one of the prime benefits cited for GitHub Copilot. Why write code from scratch when you can find it on the internet (always test this code first) or on your workstation?

Script Code Flow to Create and Email Shared Mailbox Quota Reports

The major steps in the script are:

  • Define settings such as the account used to send email, the account that will serve as the default recipient if no accounts with full access are found for a mailbox, app and tenant identifiers, and the certificate thumbprint to use for authentication.
  • Sign into Exchange Online to use cmdlets like Get-ExoMailboxStatistics.
  • Sign into the Graph using an app and certificate.
  • Find the set of shared mailboxes with Get-ExoMailbox.
  • For each mailbox, find the set of accounts with full access rights. This set might include security groups, so some processing is needed to identify groups and extract their membership.
  • Check if mailboxes are assigned a product license containing the Exchange Online Plan 2 service plan. If so, their quota is higher (100 GB) than the default (50 GB). Some unlicensed mailboxes have the higher quota, but that’s only because Microsoft hasn’t reduced those quotas (yet).
  • Fetch the current mailbox statistics.
  • Compute the percentage of quota used.
  • Write data about each shared mailbox into a list.

After processing the shared mailboxes, a second step loops through the list to create and send messages to the mailbox owners to tell them how much quota is used. Figure 1 shows an example of a quota message generated by the script.

Email notification for shared mailbox quota usage
Figure 1: Email notification for shared mailbox quota usage

The message is sparse and lots of possibilities exist for including other information in it, such as pointers to tell recipients what to do if the percentage quota used is more than a certain threshold. You’re only limited by your imagination!

You can download the full script from GitHub.

PowerShell Fills the Gaps

This is yet another example of how to use PowerShell to fill in the gaps in Microsoft 365 tenant administration. Some people might not care too much about shared mailbox quotas, other will be very concerned. PowerShell gives you the ability to code your own solution if you think it’s necessary.


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.

]]>
https://office365itpros.com/2023/10/03/shared-mailbox-quota-report/feed/ 1 61729
How to Monitor New Members Added to Teams https://office365itpros.com/2023/09/27/monitor-new-teams-members/?utm_source=rss&utm_medium=rss&utm_campaign=monitor-new-teams-members https://office365itpros.com/2023/09/27/monitor-new-teams-members/#respond Wed, 27 Sep 2023 01:00:00 +0000 https://office365itpros.com/?p=61747

Monitor New Teams Members and Remove Undesired Members Automatically

A question in the Office 365 Technical Discussions Facebook group asked about a script to monitor member additions to Teams. If the new member comes from specific departments, they should be removed. The script should then post details of new members that pass the check to a team channel.

On the surface, this seems to be an ideal situation for Information Barriers, a Microsoft Purview solution designed to keep designated groups of users from communicating with each other. However, Information Barriers require Office 365 E5 or above and it’s a solution that’s best suited when hard barriers must be enforced.

Sketching Out a Solution to Monitor New Teams Members

A custom solution isn’t too difficult to design. The essential steps are:

  • Periodically search the unified audit log to find events captured when a new member joins a group. The example script searches for events occurring within the last three hours.
  • Check if the group is one of the monitored set (defined in an array of group identifiers). If it is, record details of the group and the user. Clearly, you could use whatever criteria you wanted to check new team members.
  • For some reason, searching the unified audit log can return multiple instances of add member events. This might be part of the problems the audit log has suffered recently. To remove duplicates, the script sorts the list of detected events.
  • Loop through the deduplicated events and check the department for each added member. If the department is on the banned list, remove the user from the group. If not, post a message to a designated channel in the team to announce their arrival.

Teams also posts notices about new users to the information pane (Figure 1). The advantage of doing it this way is the ability to remove members plus do whatever other processing is desired.

Notification of new members in the information pane
Figure 1: Notification of new members in the information pane

Posting Teams Channel Messages

As covered in this article, several methods exist to post messages to Teams channels. Briefly:

  • The Submit-PnpTeamsChannelMessage cmdlet. A connection to PnP must be established first and the signed in account must be a member of the target team.
  • The New-MgTeamChannelMessage from the Microsoft Graph PowerShell SDK. This cmdlet only supports delegate permissions (Channel.Send.Message), meaning that the signed-in account must be a member of the target team.
  • Connect the Incoming Webhook connector to the target channel and post a JSON-format message to the connector. This method works without authentication.

To illustrate the principles behind the solution, I choose to use the SDK method because the script already used the Get-MgUser cmdlet to fetch details of user departments.

Diving Into the Code to Monitor New Teams Members

Here’s the code used to search for audit records and extract information from records of interest:

$StartDate = (Get-Date).AddHours(-3)
$EndDate = (Get-Date).AddHours(1)

Write-Host "Searching for audit records..."
[array]$Records = Search-UnifiedAuditLog -Start $StartDate -End $EndDate -Operations "Add member to group" -Formatted -ResultSize 500
If (!($Records)) { Write-Host "No member additions to groups to check" ; break }

Write-Host "Processing audit records..."
$MembersReport = [System.Collections.Generic.List[Object]]::new() 
ForEach ($Rec in $Records) {
  $AuditData = $Rec.AuditData | ConvertFrom-Json
  $GroupId = $AuditData.ModifiedProperties | Where-Object {$_.Name -eq 'Group.ObjectID'} | Select-Object -ExpandProperty NewValue
  $GroupName = $AuditData.ModifiedProperties | Where-Object {$_.Name -eq 'Group.DisplayName'} | Select-Object -ExpandProperty NewValue
  $UserAdded = $AuditData.ObjectId
  $Actor = $Rec.UserIds
  If ($GroupId -in $GroupsToCheck) {
    $UserData = Get-MgUser -UserId $UserAdded -Property Id, displayName, department
    $ReportLine = [PSCustomObject]@{
      Team       = $GroupName
      User       = $UserAdded
      UserName   = $UserData.displayName
      UserId     = $UserData.Id
      Addedby    = $Actor
      Timestamp  = $Rec.CreationDate
      Department = $UserData.Department
      GroupId    = $GroupId
      Id         = ("{0}_{1}_{2}" -f $GroupName, $UserAdded, $Rec.CreationDate) }
    $MembersReport.Add($Reportline)
  }
}

And here’s how the script processes member removals and posting notifications for approved new members:

ForEach ($R in $MembersReport) {
  If ($R.Department -in $ExcludedDepartments) {
     Write-Host ("User {0} with department {1} will be removed from team" -f $R.User, $R.Department) -ForegroundColor Red
     Remove-MgGroupMemberByRef -DirectoryObjectId $R.UserId -GroupId $R.GroupId 
  } Else {
    Write-Host ("Sending channel message about new team member {0}" -f $R.UserName) -ForegroundColor Yellow
    [string]$UserName = $R.UserName
    $HtmlContent = "<h1>New User Has Joined Our Team</h1>
               <h2>$UserName has joined this team</h2><p></p>
               <p>Please welcome <b>$UserName</b> to the team. They will bring great joy to all of us!</p>"
    $Message = (New-MgTeamChannelMessage -TeamId $TargetTeamId -ChannelId $TargetTeamChannelId -Body @{Content = $HTMLContent; ContentType = "html"} -Subject "New User Join Report" -Importance "High")
  }
}

Figure 2 shows an example of the message posted to Teams. The content of the message is very simple HTML and could easily be enhanced to communicate whatever sentiments are desired.

New member notification posted to a Teams channel

Monitor new teams members
Figure 2: New member notification posted to a Teams channel

Improve the Solution to Monitor New Teams Members

I don’t pretend that this script is a complete solution. It would be more effective to run as a scheduled Azure Automation runbook (here’s an example of a runbook that monitors audit events). At a pinch, it could run as a scheduled task on a workstation, but I prefer Azure Automation to Windows Scheduler for several reasons. In any case, the principle is proven and now it’s up to you to take the code forward and make it work the way you want. You can download the sample script from GitHub.


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.

]]>
https://office365itpros.com/2023/09/27/monitor-new-teams-members/feed/ 0 61747
How to Analyze User Email Traffic by Internal or External Destination https://office365itpros.com/2023/09/26/message-trace-user-analysis/?utm_source=rss&utm_medium=rss&utm_campaign=message-trace-user-analysis https://office365itpros.com/2023/09/26/message-trace-user-analysis/#comments Tue, 26 Sep 2023 01:00:00 +0000 https://office365itpros.com/?p=61708

Use PowerShell to Analyze Message Trace Data to Find Out Who’s Sending External Email

Updated 8 November 2023

In an August 2023 article, I explain how to use PowerShell to analyze message trace data to report the volume of traffic going to external domains. A follow-up question about that article asked if it was possible to create a report showing the volume of external and internal email sent by each user. It seemed like this should be a straightforward thing to do, and that’s what I explain how to do here.

Message trace data captures the sender and recipient for each message. If the recipient address doesn’t belong to one of the domains registered for the tenant, we can conclude that it’s external email. The previous article explains how to fetch message data for the last ten days from Exchange Online and construct a list of messages suitable for analysis. This script uses exactly the same code to fetch message trace data. The difference is what we do with that data.

Finding Messages for Users

The first thing is to find the set of mailboxes we’re interested in reporting. The example script processed both user and shared mailboxes.

[array]$Mbx = Get-ExoMailbox -RecipientTypeDetails UserMailbox, SharedMailbox -ResultSize Unlimited

Now the script loops through each mailbox and finds if any message trace transactions are available for the mailbox. If true, the script counts internal and external messages and calculates the percentage of the overall total for each category. The script also records to which external domains a mailbox sends messages before capturing the data in a PowerShell list:

ForEach ($User in $Mbx) {
  Write-Host ("Processing email for {0}" -f $User.DisplayName)
  # Get messages sent by the user
  [array]$UserMessages = $Messages| Where-Object {$_.Sender -eq $User.PrimarySmtpAddress}
  If ($UserMessages) {
  # We’ve found some messages to process, so let’s do that
  [int]$ExternalEmail = 0; [int]$InternalEmail = 0; [array]$ExternalDomains = $Null
  ForEach ($M in $UserMessages) {
    $MsgRecipientDomain = $M.RecipientAddress.Split('@')[1]    
        If ($MsgRecipientDomain -in $Domains) {
            $InternalEmail++ 
        } Else {
            $ExternalEmail++
            $ExternalDomains += $MsgRecipientDomain
        }
  }
  $ExternalDomains = $ExternalDomains | Sort-Object -Unique
  $PercentInternal = "N/A"; $PercentExternal = "N/A"
  If ($InternalEmail -gt 0) {
     $PercentInternal = ($InternalEmail/($UserMessages.count)).toString("P") }
  If ($ExternalEmail -gt 0) {
     $PercentExternal = ($ExternalEmail/($UserMessages.count)).toString("P") }

  $ReportLine = [PSCustomObject]@{
    User          = $User.UserPrincipalName
    Name          = $User.DisplayName
    Internal      = $InternalEmail
    "% Internal"  = $PercentInternal
    External      = $ExternalEmail 
    "% External"  = $PercentExternal
    "Ext.Domains" = $ExternalDomains -Join ", "
    “Mbx Type”    = $User.RecipientTypeDetails }
  $MessageReport.Add($ReportLine)
 } # End if user
} # End ForEach mailboxes

An example of the information reported for a mailbox is shown below:

User        : Tony.Redmond@office365itpros.com
Name        : Tony Redmond (User)
Internal    : 115
% Internal  : 64.97%
External    : 62
% External  : 35.03%
Ext.Domains : bermingham.com, codetwo.com, eastman.com, eightwone.com, microsoft.com, nordan.ie, o365maestro.onmicrosoft.com, office365.microsoft.com, ravenswoodtechnology.com, sharepointeurope.com, thecluelessguy.de

Generating an Analysis Report

After generating the report file, the script creates two output files: a CSV file that might be used for further analysis with Excel or another tool (perhaps it might be interesting to visualize the data in Power BI) and a HTML report (Figure 1).

Mail Traffic User Analysis report

Message trace data analysis
Figure 1: Mail Traffic User Analysis report

An “N/A” value in either of the field reporting the percentage of email sent internally or externally means that the user sent no messages of this type during the reporting period (last 10 days). If a mailbox doesn’t send any email during that time, the script doesn’t include it in the report.

You can download the full script from GitHub.

It’s PowerShell

The point is that none of what the script does is magic. The message trace data is easily accessible and available for analysis. All you need to do is slice and dice the data as you wish, using PowerShell to sort, refine, or otherwise process the information. Learning how to use PowerShell is such a fundamental part of working with tenant data that it always surprises me when I meet tenant administrators who seem unwilling to master the shell. Oh well, at least it gives me topics to write about!


Learn about maximizing the use of Exchange Online data 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.

]]>
https://office365itpros.com/2023/09/26/message-trace-user-analysis/feed/ 4 61708
How to Create Dynamic Administrative Units with PowerShell https://office365itpros.com/2023/09/25/dynamic-administrative-units-ps/?utm_source=rss&utm_medium=rss&utm_campaign=dynamic-administrative-units-ps https://office365itpros.com/2023/09/25/dynamic-administrative-units-ps/#comments Mon, 25 Sep 2023 01:00:00 +0000 https://office365itpros.com/?p=61642

Creating a Dynamic Administrative Unit Per Department

I wrote about using dynamic Entra ID administrative units earlier this year. Not much has changed since then as the feature remains in preview, but an interesting question asked about creating dynamic administrative units with PowerShell. I could have referred the questioner to Microsoft’s documentation, but its examples feature cmdlets from the soon-to-be-deprecated Azure AD module. An example using the Microsoft Graph PowerShell SDK seems like a better idea, so that’s what I cover here.

The question asked about using a CSV file containing department names with the idea of creating a separate dynamic administrative unit for each department. Using CSV files is an effective way of driving scripts, but if the tenant directory is accurate and maintained, it’s easy to extract a list of departments from user accounts.

Scripting the Creation of Dynamic Administrative Units

The steps in a script to create a dynamic administrative unit per department are as follows:

  • Run the Get-MgUser cmdlet to fetch the set of licensed Entra ID member accounts in the tenant. It’s important to fetch licensed accounts to exclude accounts used with shared mailboxes, room mailboxes, and member accounts created through synchronization for multi-tenant organizations.
  • Create an array of departments from user accounts.
  • Create an array of existing administrative units that we can check against to avoid creating duplicate administrative units.
  • For each department, run the New-MgDirectoryAdministrativeUnit cmdlet to create a new administrative unit.
  • Calculate the membership rule to find accounts belonging to the department.
  • Run the Update-MgDirectoryAdministrativeUnit to transform the administrative unit to use dynamic membership.

Here’s the code used to create a new administrative unit:

$Description = ("Dynamic administrative unit created for the {0} department created {1}" -f $Department, (Get-Date))
    $DisplayName = ("{0} dynamic administrative unit" -f $Department)

    If ($DisplayName -in $CurrentAUs.DisplayName) {
        Write-Host ("Administrative unit already exists for {0}" -f $DisplayName)
    } Else {
    # Create the new AU
    $NewAUParameters = @{
        displayName = $DisplayName
        description = $Description
        isMemberManagementRestricted = $false
       }
       $NewAdminUnit = (New-MgDirectoryAdministrativeUnit -BodyParameter $NewAUParameters)
    }

And here’s the code to transform it into a dynamic administrative unit:

$MembershipRule = '(user.department -eq "' + $Department + '" -and user.usertype -eq "member")'
# Create hash table with the parameters
$UpdateAUParameters = @{
    membershipType = "Dynamic"
    membershipRuleProcessingState = "On"
    membershipRule = $MembershipRule
}
    
Try {
      Update-MgDirectoryAdministrativeUnit -AdministrativeUnitId $NewAdminUnit.Id -BodyParameter $UpdateAUParameters -ErrorAction Stop
      Write-Host ("Created dynamic administrative unit for the {0} department called {1}" -f $Department, $NewAdminUnit.DisplayName)
} Catch {
      Write-Host ("Error updating {0} with dynamie properties" -f $NewAdminUnit.DisplayName )
}


        

Figure 1 shows the properties of a dynamic administrative unit created by the script, which you can download from GitHub.

Properties of a dynamic administrative unit
Figure 1: Properties of a dynamic administrative unit

Membership Rules Glitches

The membership rule determines the membership of a dynamic administrative unit. Although you can construct filters to use with the Get-MgUser cmdlet to find licensed user accounts belonging to a department, the same flexibility doesn’t exist for the rules used to interrogate Entra ID to find members for a dynamic administrative unit (or dynamic Microsoft 365 group).

The problem is that membership rules don’t allow you to mix properties of different types. For instance, the rule can find user accounts belonging to a department (a string property), but it can’t combine that clause with a check against the assignedLicenses property to make sure that the account is licensed. That’s because assignedLicenses is a multi-value property and the rule can’t mix checks against strings with checks against multi-value properties. If you try, Entra ID signals a “mixed use of properties from different types of object” error. In effect, because we want to create dynamic administrative units based on department, the membership rule is limited to string properties.

However, you can check for the existance of a service plan in the set of assigned licenses held by accounts. Not being able to check licenses while being able to check service plans is a tad strange. For example, this rule finds all member accounts in the Sales department with an enabled Exchange Online Plan 2 service plan in their assigned licenses:

(user.assignedPlans -any (assignedPlan.servicePlanId -eq "efb87545-963c-4e0d-99df-69c6916d9eb0" -and assignedPlan.capabilityStatus -eq "Enabled")) -and (user.department -eq "Sales") -and (user.usertype -eq "member")'

Finding the Right Cmdlet to Do the Job

I bet some folks reading this article ask the question “how do I find out what cmdlets to use to interact with Entra ID objects?” It’s a fair question. The SDK modules contain hundreds of cmdlets, some of which have extraordinarily long and complex names. My answer is to use the Graph X-ray add-on to gain insight into what the Entra ID admin center does to manipulate objects. If a method is good enough for the Entra ID admin center, it’s probably good enough for you.


Learn about using Entra ID, the Microsoft Graph PowerShell SDK, and the rest of Microsoft 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.

]]>
https://office365itpros.com/2023/09/25/dynamic-administrative-units-ps/feed/ 4 61642
Chasing Performance When Reporting Teams SharePoint Site URLs https://office365itpros.com/2023/09/21/teams-sharepoint-url/?utm_source=rss&utm_medium=rss&utm_campaign=teams-sharepoint-url https://office365itpros.com/2023/09/21/teams-sharepoint-url/#comments Thu, 21 Sep 2023 01:00:00 +0000 https://office365itpros.com/?p=61654

Improving the Speed of reporting Teams SharePoint URLs by Replacing the Get-UnifiedGroup Cmdlet

Last week, following a response to a reader question, I updated an article describing how to create a report of Teams and the URLs for the SharePoint Online sites used to store shared files. The only real improvement I made to the script was to use the Get-ExoRecipient cmdlet to resolve the members of the ManagedBy property to output display names instead of mailbox names. This change is necessary since Exchange Online moved to using the External Directory Object ID (EDOID) as the mailbox name to ensure uniqueness. Not everyone can recognize a mailbox GUID and know what mailbox it refers to.

The script uses the Get-UnifiedGroup cmdlet to find team-enabled groups. After reviewing the code, I wondered if it was possible to speed up processing by replacing the Exchange Online cmdlets with Microsoft Graph PowerShell SDK cmdlets or API requests. It’s always been true that the Get-UnifiedGroup cmdlet is relatively slow. This situation is explainable because the cmdlet fetches a lot of data about a Microsoft 365 group from multiple workloads. Microsoft has improved the performance of Get-UnifiedGroup over the years, but it’s still not the most rapid cmdlet you’ll ever use.

Converting to Graph SDK Cmdlets

Converting the script to use Microsoft Graph PowerShell SDK cmdlets isn’t very difficult. Here’s the code:

# Check that we are connected to Exchange Online
$ModulesLoaded = Get-Module | Select-Object -ExpandProperty Name
If (!($ModulesLoaded -match "ExchangeOnlineManagement")) {Write-Host "Please connect to the Exchange Online Management module and then restart the script"; break}
 
Connect-MgGraph -NoWelcome -Scopes Group.Read.All, Sites.Read.All
Write-Host "Finding Teams..."
[array]$Teams = Get-MgGroup -Filter "resourceProvisioningOptions/any(x:x eq 'Team')" -All
     
If (!($Teams)) {
   Write-Host "Can't find any Teams for some reason..."
} Else {
  Write-Host ("Processing {0} Teams..." -f $Teams.count)
  $TeamsList = [System.Collections.Generic.List[Object]]::new()    
  ForEach ($Team in $Teams) { 
   $SPOSiteURL = (Get-UnifiedGroup -Identity $Team.Id).SharePointSiteURL  [array]$Channels = Get-MgTeamChannel -TeamId $Team.Id
   [array]$Owners = (Get-MgGroupOwner -GroupId $Team.Id).AdditionalProperties.displayName
   $DisplayNames = $Owners -join ", "
   $TeamLine = [PSCustomObject][Ordered]@{
      Team      = $Team.DisplayName
      SPOSite   = $SPOSiteURL
      Owners    = $DisplayNames  }
   $TeamsList.Add($TeamLine)
  }
  $TeamsList | Out-GridView
  $TeamsList | Export-CSV -NoTypeInformation c:\temp\TeamsSPOList.CSV
}

Figure 1 shows the result.

Reporting the URLs for SharePoint Online sites used by Teams
Figure 1: Reporting the URLs for SharePoint Online sites used by Teams

You’ll notice that I still use the Get-UnifiedGroup cmdlet to fetch the Teams SharePoint URL. It’s possible to retrieve this information using the Graph with code like:

   $Uri = ("https://graph.microsoft.com/v1.0/groups/{0}/drive/root/webUrl" -f $Team.Id)
   $SPOData = Invoke-MgGraphRequest -Uri $Uri -Method Get
   [string]$SPODocLib = $SPOData.Value
   $SPOSiteUrl = $SPODocLib.SubString(0, $SPODocLib.LastIndexOf("/"))

Or:

   $Uri = ("https://graph.microsoft.com/v1.0/groups/{0}/sites/root" -f $Team.Id)
   $SPOData = Invoke-MgGraphRequest -URI $Uri -Method Get
   $SPOSiteUrl = $SPOData.WebURL

The Problem with Permissions when Fetching Teams SharePoint URLs

In both cases, the code works. However, the code fails for some teams due to the restriction placed on interactive use of the Graph SDK. When you connect an interactive session to the Graph, you’re restricted to using delegate permissions. The only data that the Graph SDK cmdlets can access is whatever the signed-in user can access. This is very different to the permissions model used by modules like the Exchange Online management module, which allow access to data based on RBAC controls, meaning that a tenant administrator can access everything.

The restriction disappears when running the SDK cmdlets using a registered app or an Azure Automation runbook. Now the cmdlets can use application permissions, so they can access any data permitted by the Graph permissions assigned to the service principal of the app.

Using either version of the code shown above works perfectly and returns the SharePoint site URL, but only for sites accessible to the signed-in user. Attempts to access any other site returns a 403 forbidden error.

I even tried using the Teams Graph cmdlets:

[array]$Channels = Get-MgTeamChannel -TeamId $Team.Id
$Files = (Get-MgTeamChannelFileFolder -TeamId $Team.Id -ChannelId $Channels[0].Id).WebURL
$SPOSiteUrl =  $Files.SubString(0,$Files.IndexOf("sites/")) + "sites/" + $Team.MailNickName

Again, this approach works for teams that the signed-in user is a member of, but not for other teams.

Going Back to Pure Exchange Cmdlets to Report Teams SharePoint URLs

The problem with permissions meant that I had to use a hybrid of Graph SDK cmdlets to get everything except the SharePoint site URL. And while this approach works, it’s slower than the original implementation using only Exchange Online cmdlets. In several runs against 88 teams the hybrid version took an average of 42 seconds to finish. The Exchange version required an average of 31 seconds.

The learning here is that Graph SDK cmdlets aren’t always the best choice for speed, no matter what you read on the internet. It’s always worth testing to find which approach is the most functional and fastest. Sometimes both boxes are ticked, and that’s a result.


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.

]]>
https://office365itpros.com/2023/09/21/teams-sharepoint-url/feed/ 2 61654
Use Message Trace Data to Analyze Email Traffic https://office365itpros.com/2023/08/23/message-trace-analysis/?utm_source=rss&utm_medium=rss&utm_campaign=message-trace-analysis https://office365itpros.com/2023/08/23/message-trace-analysis/#respond Wed, 23 Aug 2023 01:00:00 +0000 https://office365itpros.com/?p=61275

Analyze Traffic for Inbound and Outbound Domains Over the Last Ten Days

Updated 4-June-2025

I’ve covered how to use the Exchange Online message trace facility several times in the past to handle tasks like analyzing email sent to external domains. A reader asked if it’s possible to summarize the top inbound and outbound domains using the same data. The answer is that it’s certainly possible to extract this information, but only for the last ten days because that’s how long Exchange Online keeps message trace data online.

Figure 1 shows the output of the script I wrote to demonstrate the principles of the solution. You can download the script from GitHub and make whatever improvements you like.

Update: The GitHub script now uses Get-MessageTraceV2.

Top 10 outbound and inbound domains computed from message trace data
Figure 1: Top 10 outbound and inbound domains computed from message trace data

Fetching Message Trace Data

After connecting to Exchange Online, the first task is to retrieve message trace data for analysis. The Get-MessageTrace cmdlet fetches message trace events in pages of up to 5,000 objects. To fetch all available data, the script retrieves information page-by-page until there’s nothing left. This code does the job with a While loop:

[int]$i = 1
$MoreMessages = $True
[array]$Messages = $Null
$StartDate = (Get-Date).AddDays(-10)
$EndDate = (Get-Date).AddDays(1)

Write-Host ("Message trace data will be analyzed between {0} and {1}" -f $StartDate, $EndDate)
While ($MoreMessages -eq $True) {
    Write-Host ("Fetching message trace data to analyze - Page {0}" -f $i)
    [array]$MessagePage = Get-MessageTrace -StartDate $StartDate -EndDate $EndDate -PageSize 1000 -Page $i -Status "Delivered"
    If ($MessagePage)  {
        $i++
        $Messages += $MessagePage
    } Else {
        $MoreMessages = $False
    }
}

Update: Get-MessageTraceV2 uses a different paging mechanism. See the GitHub script for details.

My tenant includes public folders. Public folder mailboxes synchronize hierarchy data between each other to make sure that users can connect and access public folders no matter which public folder mailbox they select. The synchronization messages aren’t very interesting, so the script removes them:

# Remove Exchange Online public folder hierarchy synchronization messages
$Messages = $Messages | Where-Object {$_.Subject -NotLike "*HierarchySync*"}

Creating Data to Analyze

Next, the script fetches the set of accepted domains and extracts the domain names into an array. When the script analyzes messages, it uses the domain names to decide if a message is inbound or outbound based on the sender’s email address:

[array]$Domains = Get-AcceptedDomain | Select-Object -ExpandProperty DomainName

The script then loops through the message trace records to create a list with the sender domain extracted and the direction (inbound or outbound) determined:

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

ForEach ($M in $Messages) {
   $Direction = "Inbound"
   $SenderDomain = $M.SenderAddress.Split("@")[1]
   $RecipientDomain = $M.RecipientAddress.Split("@")[1]
   If ($SenderDomain -in $Domains) {
      $Direction = "Outbound" 
   }
   $ReportLine = [PSCustomObject]@{
     TimeStamp       = $M.Received
     Sender          = $M.SenderAddress
     Recipient       = $M.RecipientAddress
     Subject         = $M.Subject
     Status          = $M.Status
     Direction       = $Direction
     SenderDomain    = $SenderDomain
     RecipientDomain = $RecipientDomain
    }
    $Report.Add($ReportLine)

}

After that, it’s simply a matter of splitting the data into separate arrays containing inbound and outbound messages and piping the results to the Group-Object cmdlet to count the number of times domains appear in the set. We then display the top 10 domains for inbound traffic and the same for outbound traffic, which is what you see in Figure 1. For example, here’s the code to display the top ten outbound domains:

$OutboundMessages | Group-Object RecipientDomain -NoElement | Sort-Object Count -Descending | Select-Object -First 10 | Format-Table Name, Count -AutoSize

Traffic Sent to Groups

One thing to be aware of for inbound traffic is that entries for a message delivered to a Microsoft 365 group or distribution list appears in the message trace data for each recipient. This is logical because Exchange Online needs to track the progress of a message to its final destination. However, it does amplify the number of messages that an external domain appears to send to your tenant.

Use PowerShell to Supplement Standard Reports

The Reports section of the Exchange admin center features a top domain mail flow status report with tabs for inbound and outbound traffic. On the surface, these reports seem like they do the same job. They don’t because these reports are focused on different factors (read the documentation for details). Between what Microsoft provide and what you can create using PowerShell, you’ll have a pretty good idea of what’s happening for email traffic to and from your tenant.


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.

]]>
https://office365itpros.com/2023/08/23/message-trace-analysis/feed/ 0 61275
Monitor and Report Additions to Teams Membership https://office365itpros.com/2023/08/22/teams-membership-monitoring/?utm_source=rss&utm_medium=rss&utm_campaign=teams-membership-monitoring https://office365itpros.com/2023/08/22/teams-membership-monitoring/#respond Tue, 22 Aug 2023 01:00:00 +0000 https://office365itpros.com/?p=61294

Use PowerShell and the Audit Log to Find Targeted Accounts in Teams Memberships

A request came into the Office 365 Technical Discussions Facebook group for a way to monitor member additions to teams. The idea is that if a team owner adds an account with a specific attribute in the display name, something picks up the addition and notifies someone that the action happened.

PowerShell is the normal way to answer questions of this nature. That is, if you can get at the data. In this instance, the unified audit log captures events for team membership additions, so the raw data exists, even if a little manipulation is necessary to extract the information we need (thankfully, the needed manipulation is less than in other scenarios, such as tracking updates for properties of user accounts).

Specifying User Accounts to Monitor in Teams Memberships

The first thing to do is identify the set of users to check for. The original request didn’t specify what kind of attribute to look for in the display name, so the solution outlined here assumes that it’s a string after the combination of first name and last name. For instance, “Tom Smith (Project Management).”

Identifying the accounts to monitor is a key part of the solution. Here’s the code to use the Get-MgUser cmdlet with the Search parameter to find licensed member accounts that include “Project” in the display name.

[array]$Users = Get-MgUser -Search "displayName:Project" -Filter "assignedLicenses/`$count ne 0 and userType eq 'Member'" -ConsistencyLevel Eventual
If (!($Users)) { 
    Throw "No users found"
}

There might be many user accounts that need to be monitored. To speed things up when we check audit records, the script creates a hash table composed of the user principal name and display name.

$UserLookup = @{}
ForEach ($User in $Users) {
   $UserLookup.Add($User.UserPrincipalName, $User.DisplayName)
}

Searching the Audit Log for Additions to Teams Memberships

Next, the script calls the Search-UnifiedAuditLog cmdlet to look for MemberAdded events generated by Teams over the past seven days:

$StartDate = (Get-Date).AddDays(-7)
$EndDate = (Get-Date).AddDays(1)
[array]$Records = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Formatted -ResultSize 5000 -RecordType MicrosoftTeams -Operations MemberAdded -SessionCommand ReturnLargeSet
$Records = $Records | Sort-Object Identity -Unique

To check the audit events, the script converts the AuditData property for each event from JSON and examines what’s stored in the Members property (an array). For each item in Members, the script looks up the hash table to see if the account is monitored, and if so, captures details of the event in a list:

$Report = [System.Collections.Generic.List[Object]]::new()  
ForEach ($Rec in $Records) {
    $Role = $Null
    $AuditData = $Rec.AuditData | ConvertFrom-Json
    # Check the members noted as added to a group
    ForEach ($Member in $AuditData.Members) {
        If ($UserLookup[$Member.Upn]) {
           Write-Host ("User {0} added to team {1}" -f $Member.DisplayName, $AuditData.TeamName) 
           Switch ($Member.Role) {
            "1"  { $Role = "Member" }
            "2"  { $Role = "Owner"}
            "3"  { $Role = "Guest" }
           }
           $ReportLine = [PSCustomObject]@{
             Date = $AuditData.CreationTime
             User = $Member.Upn   
             Name = $Member.DisplayName
             Team = $AuditData.TeamName
             Role = $Role
             AddedBy = $AuditData.UserId
           }
          $Report.Add($ReportLine)
        }
    }
}

Here’s an example of the output:

Date    : 20/08/2023 12:12:55
User    : Hans.Geering@office365itpros.com
Name    : Hans Geering (Project Management)
Team    : Office 365 Adoption
Role    : Member
AddedBy : Tony.Redmond@office365itpros.com

Sharing the Results

To share the results, we send email from a shared mailbox. Two ways are available to sent the message. You can use an app with consent to use the Mail.Send application permission (which allows the app to send email from any mailbox). Alternatively, you can use the Mail.Send.Shared permission in an interactive session. In either case, the Send-MgUsermail cmdlet sends the message using a variation of the code explained in this article. Figure 1 shows an example of an email sent to the designated recipient (which should probably be a distribution list in production) to report results.

Email to report additions made to Teams memberships
Figure 1: Email to report additions made to Teams memberships

Posting the information to a Teams channel is another way to share details about new membership additions. Another option is to upload the file to a SharePoint Online document library, a topic explored in this article when Azure Automation runs a script to create content like a report. Monitoring for changes in a Microsoft 365 tenant is the kind of task that is well suited to Azure Automation, and it’s the way that I would go in production.

You can download the sample script from GitHub. Feel free to change (hopefully improve) the code.


Learn about using the Graph SDK, the unified audit log, 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.

]]>
https://office365itpros.com/2023/08/22/teams-membership-monitoring/feed/ 0 61294
Entra ID Guest Accounts Can Now Have Sponsors https://office365itpros.com/2023/08/17/guest-account-sponsors/?utm_source=rss&utm_medium=rss&utm_campaign=guest-account-sponsors https://office365itpros.com/2023/08/17/guest-account-sponsors/#comments Thu, 17 Aug 2023 01:00:00 +0000 https://office365itpros.com/?p=61219

Defining Guest Account Sponsors with GUI and PowerShell

In July 2023, Microsoft added a new preview feature to allow organizations to assign ‘sponsors’ for Entra ID guest accounts. The idea is that an organization should be able to assign people or groups to be the sponsor of guest accounts. The sponsor should be “a responsible individual,” meaning someone who understand why a guest account is present in the directory, how that guest account is used, and what access they have to data. A sponsor can be an individual account or a group, and a guest account can have up to five sponsors (a mixture of accounts and groups).

When the time comes to review guest accounts and decide to keep or remove the account, sponsors can justify the retention of the guest account or ask for its removal. For instance, if a group owner uses a tool like Entra ID Access Review to conduct a periodic review of the membership of a group (team) and doesn’t recognize a guest account, they can contact the sponsor for more information. Whether or not the group owner gets anything useful from the sponsor is another matter.

Defining Entra ID Guest Account Sponsors

According to Microsoft’s documentation, “If you don’t specify a sponsor, the inviter will be added as a sponsor.” They then go on to explain how to invite an external user and add a sponsor to the new Entra ID guest account (Figure 1).

Adding sponsor information for a new guest account
Figure 1: Adding sponsor information for a new guest account

However, if you don’t add a sponsor to the new external account, the sponsor information is not filled in with the identifier of the account used to create and send the invitation. Maybe my tenant is missing some bits, which is entirely possible.

Sponsor information isn’t filled in either if you add a guest account by adding an external user to a team or sharing a document with them. This isn’t surprising because the sponsors feature is in preview and it takes time for applications like Teams, Outlook, SharePoint Online, and OneDrive for Business to catch up and populate new guest account properties.

In summary, if you want to update the sponsor for a guest account using a GUI, the only way is to edit the account properties in the Entra ID admin center.

Programmatic Updates for Guest Account Sponsors

A beta Graph API is available to list, update, and remove guest account sponsors. As usual, the Graph Explorer is an invaluable tool to help understand how a Graph API works (Figure 2).

Getting sponsor information for a guest account with the Graph Explorer
Figure 2: Getting sponsor information for a guest account with the Graph Explorer

The Get-MgBetaUser cmdlet from the beta module of the Microsoft Graph PowerShell SDK (now at V2.3) can fetch information about sponsors. For example, this code fetches information about a guest account including the sponsors. It then uses the Get-MgUser cmdlet to resolve the set of user identifiers into display names.

$User = Get-MgBetaUser -UserId 7bfd3f83-be63-4a5a-bbf8-c821e2836920 -Property Id, displayName, Sponsors -ExpandProperty Sponsors
ForEach ($Id in $User.Sponsors.Id) { Get-MgUser -UserId $Id | Select-Object DisplayName }

Of course, the code doesn’t handle the situation where a sponsor is a group, but that’s easily added if needed.

If you wanted to scan all guest accounts that don’t have sponsors defined and add a default sponsor, you could do something like this. The code:

  • Defines an account to be the default sponsor.
  • Builds a payload to use when updating the guest accounts.
  • Finds guest accounts in the tenant.
  • Checks each guest account for sponsors. If none are found, the script applies the default sponsor.

Connect-MgGraph -Scopes User.ReadWrite.All

$DefaultSponsorId = (Get-MgUser -UserId James.Ryan@office365itpros.com).Id
$Body = '{"@odata.id": "https://graph.microsoft.com/beta/users/' + $DefaultSponsorId + '"}'

[array]$Guests = Get-MgBetaUser -Filter "userType eq 'Guest'" -All -Property Id, displayName, Sponsors -ExpandProperty Sponsors | Sort-Object displayName
If ($Guests) {
    Write-Host "Scanning for sponsors"
    ForEach ($Guest in $Guests) {
      If ($Null -eq $Guest.Sponsors.Id) {
         Write-Host ("Guest {0} has no sponsors - updating with default sponsor" -f $Guest.displayName) 
         $Uri = ("https://graph.microsoft.com/beta/users/{0}/sponsors/`$ref" -f $Guest.Id)
         Invoke-MgGraphRequest -Uri $Uri -Method Post -Body $Body
      }
    }
}

Auditing Updates to Guest Account Sponsors

Last week I wrote about the way that Entra ID auditing does not capture details of changes to the usage location property for user accounts. As it turns out, updating a guest account with sponsor information creates an audit record without details of the change. Again, this could be a matter of timing and an update is coming to make sure that audit log events for account updates capture sponsor information correctly.

Tracking Guest Additions

Since Azure B2B Collaboration introduced guest accounts in summer 2016, administrators have been tracking the creation of guest accounts in different ways (for instance, here’s how to track the addition of guest accounts to teams). In many cases, the reason for doing so was to know who was responsible for the creation of a guest account. With sponsors, that need might go away, or at least it might be easier to retrieve the “who created that account information” by using the sponsor information stored for accounts. That is, once the apps record sponsors.


Learn about using Entra ID, PowerShell, the Microsoft Graph, 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.

]]>
https://office365itpros.com/2023/08/17/guest-account-sponsors/feed/ 2 61219