Better Methods Exist to Run Microsoft 365 PowerShell Scripts
I am bemused when I read articles advising Microsoft 365 tenant administrators to run PowerShell scripts using the Windows Task Scheduler. I understand the reason why people use the Task Scheduler. It’s easy, built into Windows, allows scripts to run unattended (always an advantage when dealing with long-running scripts), and gets the job done. On the other hand, the task scheduler is an old-fashioned utility that struggles to handle modern PowerShell implementations. A better method exists for IT Pros to use. Let me explain why.
Running Microsoft Graph PowerShell SDK Scripts
To illustrate the points, I created a simple PowerShell script that uses the Microsoft Graph PowerShell SDK to find user accounts created in the last year and export details to a CSV file. Here’s the code:
Connect-MgGraph Select-MgProfile Beta [array]$Users = Get-MgUser -Filter "usertype eq 'Member' and CreatedDateTime ge $([datetime]::UtcNow.AddYears(-1).ToString("s"))Z" -All -Property Id, DisplayName, UserPrincipalName, CreatedDateTime $Users = $Users | Select-Object Id, UserPrincipalName, DisplayName, CreatedDateTime $Users | Export-CSv -NoTypeInformation c:\temp\UsersLastYear.CSV
After making sure that the script ran successfully in an interactive session, I created a task. Everything ran as expected when running a task with a signed-in account. Problems started when attempting to run scripts when a user is not signed in because Task Scheduler won’t accept credentials from an Azure AD account (Figure 1).
You can argue that this is not a big issue. In most cases, people stay signed into their workstations, and tasks can run successfully.
Moving on to the credentials required to connect to a PowerShell endpoint, the script ran without a hitch because the task scheduler launched PowerShell and invoked the Connect-MgGraph cmdlet. The Microsoft Graph PowerShell SDK automatically uses cached credentials and picks up the set of consented Graph permissions held by the service principal for the SDK app. In effect, the task runs as if the workstation owner started PowerShell and ran the script.
This might not be what you want. For one thing, the set of effective permissions available to run SDK cmdlets depends on the signed-in account. If the signed-in account holds some Azure AD administrative roles, the permissions available are more extensive than when someone else signs in. This is because the permissions consented for the SDK app are delegated, meaning that when the SDK cmdlets run, they can access data available to that user and nothing more.
Use Azure AD Registered Apps
A better and more secure approach is to use an Azure AD-registered app and connect the SDK to the app using a certificate. This allows more precise control over the set of permissions available to the script. It also means that the script uses application permissions instead of delegated permissions. Finally, it removes the dependency on a specific signed-in account. The classic symptom of problems caused by a specific account is when someone reports that no data is available when they run a script that works perfectly when someone else (an account with an administrative role) runs the code.
To change to certificate-based authentication, follow the instructions in this article to create a certificate, upload it to an app, and connect with a command like:
Connect-MgGraph -TenantId "a562313f-14fc-43a2-9a7a-d2e27f4f3478" -AppId "45b178af-aaa3-4728-956f-35425fe5b6e6" -CertificateThumbprint "A6918A18EBBED10AF6B0D873A688F743020C742F"
In this instance, the only Graph permission required is Directory.Read.All. That’s a reasonably safe permission to assign to an app and is much better than running code with the full-blown set of permissions accrued by the SDK service principal.
Even Better, Dump Task Scheduler
If you move to using Azure AD-registered apps to run scheduled tasks, the natural progression is to dump the Windows Task Scheduler completely and replace it with Azure Automation. The downside is that an Azure subscription is necessary to pay the (usually small) bills incurred for running scripts (runbooks). The advantages are:
- Ability to use secure managed identities to connect to Microsoft 365 endpoints like the SDK, Exchange Online, and Teams. Certificate-based authentication is available for other endpoints.
- Remove the dependency that scripts run on a specific workstation.
- Azure Automation supports RBAC for Applications to limit access to Exchange Online mailboxes.
- Robust scheduling engine for runbooks.
- Microsoft puts more development effort into Azure Automation than it does for the Windows Task Scheduler (which doesn’t appear to have received much love since Windows 2003).
Here’s a good example of a fully worked-out runbook to check the unified audit log for high-priority events that might indicate when attackers try to compromise a tenant.
Adapting Scripts for Azure Automation
The normal approach to take a script and adapt it to run on Azure Automation is:
- Select an Automation account (or create one in the Azure portal).
- Create a runbook for a PowerShell script.
- Make sure the service principal for the Azure Automation account has consent to use all required permissions.
- Adjust the code for Azure Automation.
- Test the code to make sure it runs properly.
- Link the runbook to an Azure Automation schedule.
Usually, code adjustments focus on two areas: authentication (using a managed identity is the best approach) and output. For instance, the sample script generates a CSV file and stores it on a local drive. Because Azure Automation runs scripts on headless servers, a different approach must be used, such as creating output files in SharePoint Online or sending them as email attachments.
Moving scripts to Azure Automation takes some time and persistence. Debugging is more difficult and sometimes bewildering. But it’s worthwhile in the end, and that’s the important thing. Dump Task Scheduler and move to a more modern platform.