I have had some versions of my home-grown PowerShell script around to clean out the residues of obsolete Azure PowerShell modules from my workstations and laptops. Many of you know that those tend to accumulate over time. As a hobby project, I refined it to share with the world. You can find the script on my GitHub page WorkingHardInIT/AzurePowerShellModulesCleanUp: My Ultimate Azure PowerShell Module Cleanup Script
🔍 Az PowerShell Module Cleanup Script
A PowerShell utility for cleaning up old or duplicate versions of Azure PowerShell (Az) modules across Windows PowerShell and PowerShell Core. It ensures that only the latest version of each Az module is retained—keeping your environment clean, fast, and free from version conflicts.
⚙️ Features
✅ Detects all installed Az and Az.* modules
🧩 Groups by PowerShell edition and installation scope (CurrentUser vs AllUsers)
⛔ Skips removal of AllUsers modules if not run as Administrator
🔄 Keeps only the latest version of each module
📋 Logs results to both Markdown and HTML
🎨 Color-coded output with emoji support in PowerShell Core, fallback labels in Windows PowerShell
🧰 Requirements
PowerShell 5.1 (Windows PowerShell) or PowerShell Core (7+)
Administrator privileges (for full cleanup including AllUsers modules)
💡 If not run as Administrator, the script will prompt to elevate. If declined, only CurrentUser modules will be cleaned. ❗ If you don’t have Windows Terminal, get it or adapt the script to launch Powershell.exe or pwsh.exe directly
📝 Logs
After execution, logs are saved in the following directory:
AzCleanup_<timestamp>.html – HTML log (colored, styled)
📦 Example Output
🔍 Scanning for duplicate Az module versions by scope and edition...
📌 Az.Accounts (PowerShellCore in AllUsers):
🧩 Versions Installed: 3
❗ Versions to Remove: 2
📋 All Versions: 2.2.0, 2.1.0, 1.9.5
✅ Successfully uninstalled Az.Accounts version 2.1.0
✅ Successfully uninstalled Az.Accounts version 1.9.5
✅ Cleanup complete. Only the latest versions of Az modules are retained.
⚠️ Notes
Deletion of modules is attempted first via Uninstall-Module. If that fails, the script tries to remove directories manually.
To force elevation in PowerShell Core, wt.exe (Windows Terminal) is used to relaunch with admin rights.
Last week, I was on service desk duty. It’s not my favorite field of endeavour, and I’m past my due date for that kind of work. However, doing it once in a while is an eye-opener to how disorganized and chaotic many organizations are. Some requests are ideal to solve by automating them and handing them over to the team to leverage. That is how I wrote a script to bulk invite guest users to Azure Entra ID, as I could not bring myself to click through the same action dozens of times in the portal. You can find the script on my GitHub page https://github.com/WorkingHardInIT/BulkInviteGuestUsersToEntraIdAzure
Script to Bulk Invite Guest Users to Azure Entra ID
📌 Overview
This PowerShell script allows administrators to bulk invite guest users to an Azure Entra ID (formerly Azure Active Directory) tenant using Microsoft Graph. It includes retry logic for connecting to Microsoft Graph, supports both interactive and device code login, and reads user details from a CSV file.
✨ Features
Connects to Microsoft Graph securely using MS Graph PowerShell SDK
Retry logic with customizable attempt count
Supports both interactive and device-based authentication
Invites guest users based on a CSV file input
Allows optional CC recipients in the invitation email (limited to one due to API constraints)
Includes meaningful console output and error handling
Note: Only the first ccRecipient will be used due to a known Microsoft Graph API limitation.
🔧 Script Configuration
Open the script and configure the following variables:
$Scopes = "User.Invite.All" # Required scope
$csvFilePath = ".\BulkInviteGuestUsersToAzureEntraID.csv"
$TenantID = "<your-tenant-id-guid-here>" # Replace with your tenant ID
$emailAddresses = $Null # Optional static list of CC recipients
▶️ How to Run
Open PowerShell as Administrator
Install Microsoft Graph module (if not already):Install-Module Microsoft.Graph -Scope CurrentUser
Multi-user CC: If different users need unique CCs, adapt the script to parse a ccRecipients column with user-specific values.
📤 Example Output
✅ Using device login for Microsoft Graph...
✅ Microsoft Graph is connected to the correct Azure tenant (xxxx-xxxx-xxxx).
✅ Invitation sent to Guest One using [email protected]
⚠️ Skipped a user due to missing email address.
⚠️ Failed to invite Guest Two: Insufficient privileges to complete the operation
🧽 Cleanup / Disconnect
Graph sessions are managed per execution. If needed, manually disconnect with:
Why would we clear the Git commits and history from the local & remote master repository? When preparing training labs leveraging Azure DevOps and Git, I often need to do a lot of testing and experimenting to empirically get the scenarios right. That means the commit history is cluttered with irrelevant commits for the lab training I am presenting.
Ideally, I reset the history to start a training lab when the repository is at the right stage. The students are then not bothered by the commits of previous demos. But how can we clear the Git commits and history from the local & remote master repository?
Clear the Git commits and history from the local & remote master repository
Git is meant to keep the commit history, as are repositories like Azure DevOps. That means there is no way to reset the commit history in Azure DevOps. Git, being a very powerful and, to a certain extent, also a dangerous tool, can help you overcome this. But how to do it is not always obvious. That said, you can also shoot yourself in the foot with Git, so pay attention and be careful.
Step 1 to Clear the Git commits and history from the local & remote master repository
If you keep branches around things get complicated. For my needs, I don’t need them. To delete a branch via git we need three (3) deletes.
You can also delete remote branches in Azure DevOps via the GUI by selecting a branch and selecting “Delete branch” in the menu. Locally you’ll need to use Git commands or the Git GUI.
Step 2
Create a new orphaned branch
git checkout --orphan myresetbranch
The Git option –orphan creates a branch that is in a git init “like” state. That is why we have an alternative option and that is to delete the .git folder in your local repository and run git init in it. That is why i normally keep a copy around of the “perfect” situation with the .git folder removed. I can copy that to create a new local master branch by running git init. I then have that track a new remote repository that still needs initializing via:
But that is not what I am doing here, I am using another method.
Step 3
On your workstation in the local repository, make sure to clean and delete or edit and add all the files and folders we want to be in our master repository initial commit.
git add -A
git commit -m “Initial commit”
Note: we use -A here instead of “.” Because we also want to delete any tracked files and folder that are currently being tracked. At the same time, it adds new items to be tracked. In practice it is like running both git -u and git .
Step 4
Now delete the current master branch
git branch -D master
Step 5
Rename the temporary branch to “master”
git branch -m master
We now have a master repository again locally.
Step 6
We now need to update the remote repository with the option –force or -f. That allows us to delete branches and tags as well as rewrite the history. Normally that is no allowed so we nee to temporarily allow this in Azure DevOps.
Now we can run
git push -f origin master
If we had not allowed Force push the above command would fail with an error indicating we need to allow “Force push”. TF401027: You need the Git ‘ForcePush’ permission to perform this action.
Important: do not forget to set “Force push” back to “Not set”
Step 7 to Clear the Git commits and history from the local & remote master repository
Finally, make sure that the local master branch is set up to track origin/master.
git push --set-upstream origin master
That’s it, you now have a master repository in Azure DevOps that is ready to be cloned and used for labs with a clean commit history. Student can clone it, create branches, work on that repository and they will only see their changes and commit.
Conclusion
Resetting the git commit history of a repository is not a recommend action on production repositories under normal situations. But in situations like training lab repositories, it gives me a clean commit history to start my demos from.
Connect to an Azure VM via Bastion with native RDP using only Azure PowerShell
To connect to an Azure VM via Bastion with native RDP using only RDP requires a custom solution. By default, the user must leverage Azure CLI. It also requires the user to know the Bastion subscription and the resource ID of the virtual machine. That’s all fine for an IT Pro or developer, but it is a bit much to handle for a knowledge worker.
That is why I wanted to automate things for those users and hide that complexity away from the users. One requirement was to ensure the solution would work on a Windows Client on which the user has no administrative rights. So that is why, for those use cases, I wrote a PowerShell script that takes care of everything for an end user. Hence, we chose to leverage the Azure PowerShell modules. These can be installed for the current user without administrative rights if needed. Great idea, but that left us with two challenges to deal with. These I will discuss below.
A custom PowerShell Script
The user must have the right to connect to their Virtual Machine in Azure over the (central) bastion deployment. These are listed below. See Connect to a VM using Bastion – Windows native client for more information.
Reader role on the virtual machine.
Reader role on the NIC with private IP of the virtual machine.
Reader role on the Azure Bastion resource.
Optionally, the Virtual Machine Administrator Login or Virtual Machine User Login role
When this is OK, this script generates an RDP file for them on the desktop. That script also launches the RDP session for them, to which they need to authenticate via Azure MFA to the Bastion host and via their VM credentials to the virtual machine. The script removes the RDP files after they close the RDP session. The complete sample code can be found here on GitHub.
I don’t want to rely on Azure CLI
Microsoft uses Azure CLI to connect to an Azure VM via Bastion with native RDP. We do not control what gets installed on those clients. If an installation requires administrative rights, that can be an issue. There are tricks with Python to get Azure CLI installed for a user, but again, we are dealing with no technical profiles here.
So, is there a way to get around the requirement to use Azure CLI? Yes, there is! Let’s dive into the AZ CLI code and see what they do there. As it turns out, it is all Python! We need to dive into the extension for Bastion, and after sniffing around and wrapping my brain around it, I conclude that these lines contain the magic needed to create a PowerShell-only solution.
In PowerShell, that translates into the code below. One thing to note is that if this code is to work with PowerShell for Windows, we cannot use “keep-alive” for the connection setting. PowerShell core does support this setting. The latter is not installed by default.
# Connect & authenticate to the correct tenant and to the Bastion subscription
Connect-AzAccount -Tenant $TenantId -Subscription $BastionSubscriptionId | Out-Null
#Grab the Azure Access token
$AccessToken = (Get-AzAccessToken).Token
If (!([string]::IsNullOrEmpty($AccessToken))) {
#Grab your centralized bastion host
try {
$Bastion = Get-AzBastion -ResourceGroupName $BastionResoureGroup -Name $BastionHostName
if ($Null -ne $Bastion ) {
write-host -ForegroundColor Cyan "Connected to Bastion $($Bastion.Name)"
write-host -ForegroundColor yellow "Generating RDP file for you to desktop..."
$target_resource_id = $VmResourceId
$enable_mfa = "true" #"true"
$bastion_endpoint = $Bastion.DnsName
$resource_port = "3389"
$url = "https://$($bastion_endpoint)/api/rdpfile?resourceId=$($target_resource_id)&format=rdp&rdpport=$($resource_port)&enablerdsaad=$($enable_mfa)"
$headers = @{
"Authorization" = "Bearer $($AccessToken)"
"Accept" = "*/*"
"Accept-Encoding" = "gzip, deflate, br"
#"Connection" = "keep-alive" #keep-alive and close not supported with PoSh 5.1
"Content-Type" = "application/json"
}
$DesktopPath = [Environment]::GetFolderPath("Desktop")
$DateStamp = Get-Date -Format yyyy-MM-dd
$TimeStamp = Get-Date -Format HHmmss
$DateAndTimeStamp = $DateStamp + '@' + $TimeStamp
$RdpPathAndFileName = "$DesktopPath\$AzureVmName-$DateAndTimeStamp.rdp"
$progressPreference = 'SilentlyContinue'
}
else {
write-host -ForegroundColor Red "We could not connect to the Azure bastion host"
}
}
catch {
<#Do this if a terminating exception happens#>
}
finally {
<#Do this after the try block regardless of whether an exception occurred or not#>
}
Finding the resource id for the Azure VM by looping through subscriptions is slow
As I build a solution for a Windows client, I am not considering leveraging a tunnel connection (see Connect to a VM using Bastion – Windows native client). I “merely” want to create a functional RDP file the user can leverage to connect to an Azure VM via Bastion with native RDP.
Therefore, to make life as easy as possible for the user, we want to hide any complexity for them as much as possible. Hence, I can only expect them to know the virtual machine’s name in Azure. And if required, we can even put that in the script for them.
But no matter what, we need to find the virtual machine’s resource ID.
Azure Graph to the rescue! We can leverage the code below, and even when you have to search in hundreds of subscriptions, it is way more performant than Azure PowerShell’s Get-AzureVM, which needs to loop through all subscriptions. This leads to less waiting and a better experience for your users. The Az.ResourceGraph module can also be installed without administrative rights for the current users.
$VMToConnectTo = Search-AzGraph -Query "Resources | where type == 'microsoft.compute/virtualmachines' and name == '$AzureVmName'" -UseTenantScope
Note using -UseTenantScope, which ensures we search the entire tenant even if some filtering occurs.
Creating the RDP file to connect to an Azure Virtual Machine over the bastion host
Next, I create the RDP file via a web request, which writes the result to a file on the desktop from where we launch it, and the user can authenticate to the bastion host (with MFA) and then to the virtual machine with the appropriate credentials.
try {
$progressPreference = 'SilentlyContinue'
Invoke-WebRequest $url -Method Get -Headers $headers -OutFile $RdpPathAndFileName -UseBasicParsing
$progressPreference = 'Continue'
if (Test-Path $RdpPathAndFileName -PathType leaf) {
Start-Process $RdpPathAndFileName -Wait
write-host -ForegroundColor magenta "Deleting the RDP file after use."
Remove-Item $RdpPathAndFileName
write-host -ForegroundColor magenta "Deleted $RdpPathAndFileName."
}
else {
write-host -ForegroundColor Red "The RDP file was not found on your desktop and, hence, could not be deleted."
}
}
catch {
write-host -ForegroundColor Red "An error occurred during the creation of the RDP file."
$Error[0]
}
finally {
$progressPreference = 'Continue'
}
Finally, when the user is done, the file is deleted. A new one will be created the next time the script is run. This protects against stale tokens and such.
Pretty it up for the user
I create a shortcut and rename it to something sensible for the user. Next, I changed the icon to the provided one, which helps visually identify the shortcut from any other Powershell script shortcut. They can copy that shortcut wherever suits them or pin it to the taskbar.