Microsoft 365 & Entra ID Automation Runbook: PowerShell, Graph API & FinOps

What is Microsoft 365 PowerShell Automation? Microsoft 365 PowerShell automation uses the Microsoft Graph PowerShell SDK and Azure Automation Managed Identities to eliminate manual sysadmin tasks across Entra ID and Exchange Online. It standardizes zero-trust onboarding, instant 7-step offboarding, and automated FinOps license reclamation without storing passwords or expiring client secrets.

Enterprise IT organizations managing hundreds or thousands of Microsoft 365 seats face two continuous operational bottlenecks: human error in security offboarding and license expenditure waste (FinOps).

When an employee departs, a delayed deprovisioning or missed token revocation leaves a dangerous security gap for credential theft. Simultaneously, companies spend tens of thousands of dollars annually paying for premium Microsoft 365 E5 and E3 licenses assigned to inactive users, contractors who left months ago, or shared mailboxes that do not require paid licensing.

In this comprehensive production runbook, we walk through building an autonomous enterprise lifecycle engine using Microsoft Graph PowerShell SDK v2 and Azure Automation. This architecture eliminates legacy basic authentication, discards fragile service accounts, and operates securely using cloud-native System-Assigned Managed Identities.


Architecture: Zero-Secret Azure Automation with Managed Identities

Legacy automation scripts relied on storing administrator credentials in cleartext or managing client secrets in Entra App Registrations that inevitably expire, breaking production scripts on weekends.

Modern enterprise automation uses an Azure Automation Account with a System-Assigned Managed Identity:

Required Microsoft Graph API Application Roles

Before executing runbooks, assign the following least-privilege Graph permissions to the Automation Account’s Managed Identity:

Microsoft Graph Role Type Justification
User.ReadWrite.All Application Provision new accounts, update attributes, and disable accounts upon termination.
Directory.ReadWrite.All Application Manage dynamic/assigned group memberships and security attributes.
AuditLog.Read.All Application Inspect signInActivity (lastSignInDateTime) to locate inactive accounts for FinOps.
Organization.Read.All Application Query available tenant license SKUs and consumption quotas.

PowerShell Command to Grant Permissions to Managed Identity:

# Connect to Microsoft Graph with Privileged Role Administrator
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All", "Application.Read.All"

$AutomationAccountName = "aa-m365-production-automation"
$GraphAppId = "00000003-0000-0000-c000-000000000000" # Well-known Microsoft Graph Resource App ID

# Locate the Managed Identity Service Principal
$SpManagedIdentity = Get-MgServicePrincipal -Filter "displayName eq '$AutomationAccountName'"
$GraphSp = Get-MgServicePrincipal -Filter "appId eq '$GraphAppId'"

$RolesToGrant = @(
    "User.ReadWrite.All",
    "Directory.ReadWrite.All",
    "AuditLog.Read.All",
    "Organization.Read.All"
)

foreach ($RoleName in $RolesToGrant) {
    $AppRole = $GraphSp.AppRoles | Where-Object { $_.Value -eq $RoleName -and $_.AllowedMemberTypes -contains "Application" }
    New-MgServicePrincipalAppRoleAssignment `
        -ServicePrincipalId $SpManagedIdentity.Id `
        -PrincipalId $SpManagedIdentity.Id `
        -ResourceId $GraphSp.Id `
        -AppRoleId $AppRole.Id
    Write-Host "[+] Granted $RoleName to $($SpManagedIdentity.DisplayName)" -ForegroundColor Green
}

1. Automated Zero-Trust Employee Offboarding (7-Step Security Protocol)

Manual offboarding via the Microsoft 365 Admin Center typically takes 15–25 minutes per user and is prone to human error—such as forgetting to convert a mailbox to shared or leaving active refresh tokens alive on mobile devices.

This automated PowerShell script executes a strict 7-step zero-trust deprovisioning flow in under 8 seconds:

  1. Instant Session Revocation: Invalidate all active OAuth tokens and mobile sessions via Revoke-MgUserSignOut.
  2. Account Disablement: Set AccountEnabled = false and scramble password.
  3. Mailbox Conversion: Convert to Shared Mailbox via Set-Mailbox -Type Shared (retaining 50 GB storage with zero license cost).
  4. Legal Discovery Hold: Apply Litigation Hold or Purview retention.
  5. Departure Auto-Reply: Activate external out-of-office message redirecting clients.
  6. Security Group Purge: Remove from all security and Microsoft 365 groups.
  7. FinOps License Reclamation: De-allocate premium E5/E3 license SKUs back to tenant pool.

Production PowerShell Offboarding Script:

<#
.SYNOPSIS
    Automated Zero-Trust Offboarding Protocol for Entra ID & Microsoft 365.
.DESCRIPTION
    Revokes sessions, disables sign-in, converts mailbox to Shared,
    strips paid license SKUs, and removes sensitive group memberships.
#>
[CmdletBinding()]
param (
    [Parameter(Mandatory = $true)]
    [string]$UserPrincipalName,

    [Parameter(Mandatory = $false)]
    [string]$AutoReplyMessage = "This employee has parted ways with the company. Please direct all inquiries to support@yourdomain.com."
)

# Connect via Managed Identity inside Azure Automation
try {
    Connect-MgGraph -Identity -NoWelcome
    Write-Host "[+] Connected to Microsoft Graph via System-Assigned Managed Identity." -ForegroundColor Cyan
} catch {
    Write-Error "Failed to authenticate to Microsoft Graph: $_"
    exit 1
}

# 1. Fetch Target User
$User = Get-MgUser -UserId $UserPrincipalName -Property Id, DisplayName, UserPrincipalName, AssignedLicenses, AccountEnabled
if (-not $User) {
    Write-Error "User $UserPrincipalName not found in Entra ID."
    exit 1
}

Write-Host ">>> Commencing Zero-Trust Offboarding for: $($User.DisplayName) ($($User.UserPrincipalName))" -ForegroundColor Yellow

# STEP 1: Instant Session Revocation (Invalidates Refresh Tokens & Mobile Access)
try {
    Revoke-MgUserSignOut -UserId $User.Id | Out-Null
    Write-Host "[1/7] Successfully revoked all active Entra ID tokens and OAuth sessions." -ForegroundColor Green
} catch {
    Write-Warning "Could not revoke sign-out tokens: $_"
}

# STEP 2: Disable Entra ID Account & Reset Password
try {
    $RandomPassword = [System.Web.Security.Membership]::GeneratePassword(24, 4)
    Update-MgUser -UserId $User.Id -AccountEnabled:$false -PasswordProfile @{
        ForceChangePasswordNextSignIn = $false
        Password = $RandomPassword
    }
    Write-Host "[2/7] Account disabled and credential scrambled." -ForegroundColor Green
} catch {
    Write-Error "Failed to disable account: $_"
}

# STEP 3 & 4: Exchange Online Mailbox Conversion & Retention Hold
try {
    # Convert to Shared Mailbox (Zero-license cost storage up to 50 GB)
    Set-Mailbox -Identity $User.UserPrincipalName -Type Shared -ErrorAction Stop
    Write-Host "[3/7] Mailbox converted to Shared Mailbox (Zero-license cost storage)." -ForegroundColor Green

    # Enable Litigation Hold to preserve legal discovery records
    Set-Mailbox -Identity $User.UserPrincipalName -LitigationHoldEnabled $true -ErrorAction SilentlyContinue
    Write-Host "[4/7] Litigation Hold verified active." -ForegroundColor Green

    # STEP 5: Set Out of Office Auto-Reply
    Set-MailboxAutoReplyConfiguration -Identity $User.UserPrincipalName `
        -AutoReplyState Enabled `
        -InternalMessage $AutoReplyMessage `
        -ExternalMessage $AutoReplyMessage `
        -ErrorAction SilentlyContinue
    Write-Host "[5/7] Out of Office departure notice activated." -ForegroundColor Green
} catch {
    Write-Warning "Exchange Online task skipped or mailbox not present: $_"
}

# STEP 6: Remove Sensitive Group Memberships
try {
    $MemberGroups = Get-MgUserMemberOf -UserId $User.Id -All | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' }
    foreach ($Group in $MemberGroups) {
        Remove-MgGroupMemberByRef -GroupId $Group.Id -DirectoryObjectId $User.Id -ErrorAction SilentlyContinue
    }
    Write-Host "[6/7] Removed user from $($MemberGroups.Count) security and Microsoft 365 groups." -ForegroundColor Green
} catch {
    Write-Warning "Group removal encountered an issue: $_"
}

# STEP 7: Strip Paid Licenses (FinOps Cost Reclaim)
try {
    if ($User.AssignedLicenses.Count -gt 0) {
        $LicensesToRemove = $User.AssignedLicenses.SkuId
        Set-MgUserLicense -UserId $User.Id -AddLicenses @() -RemoveLicenses $LicensesToRemove -ErrorAction Stop
        Write-Host "[7/7] Successfully stripped $($LicensesToRemove.Count) paid license SKU(s). License returned to tenant pool!" -ForegroundColor Green
    } else {
        Write-Host "[7/7] User had no direct license SKUs assigned." -ForegroundColor Gray
    }
} catch {
    Write-Error "Failed to reclaim license SKUs: $_"
}

Write-Host ">>> Offboarding process completed successfully for $($User.UserPrincipalName)." -ForegroundColor Cyan

2. FinOps License Hunter: Automatically Detecting & Reclaiming Inactive Seats

One of the largest hidden drains on enterprise IT budgets is paying for premium licenses ($22 to $38/seat/month for Microsoft 365 Business Premium, E3, or E5) assigned to accounts that have not authenticated for 90+ days.

Microsoft Graph exposes the signInActivity property on user objects, revealing the exact timestamp of interactive and non-interactive logins.

☁️
Want to Audit Inactive Licenses Instantly Without Writing Scripts?
Free FinOps Tool

Use our companion open web utility CloudTrim FinOps Console to analyze orphaned Entra ID accounts, unassigned Teams numbers, and unattached disks in 100% in-browser RAM with zero data persistence.


Launch CloudTrim FinOps Audit
→

Inactive User Hunter Script:

<#
.SYNOPSIS
    Identifies licensed users inactive for >90 days and exports financial reclamation estimates.
#>
param (
    [int]$InactiveDaysThreshold = 90,
    [switch]$AutoDeallocateLicenses = $false
)

Connect-MgGraph -Identity -NoWelcome
$CutoffDate = (Get-Date).AddDays(-$InactiveDaysThreshold)

Write-Host ">>> Scanning for licensed users inactive since: $($CutoffDate.ToString('yyyy-MM-dd'))" -ForegroundColor Cyan

# Fetch all licensed users with their last signInActivity
$LicensedUsers = Get-MgUser -All -Filter "assignedLicenses/`$count ne 0" -ConsistencyLevel eventual `
    -Property Id, DisplayName, UserPrincipalName, Mail, UserType, AssignedLicenses, SignInActivity

$OrphanedUsers = @()
$EstimatedMonthlyWaste = 0

foreach ($User in $LicensedUsers) {
    if ($User.UserType -eq "Guest") { continue }

    $LastSignIn = $User.SignInActivity.LastSignInDateTime
    $LastNonInteractive = $User.SignInActivity.LastNonInteractiveSignInDateTime
    
    $LatestActivity = $null
    if ($LastSignIn -and $LastNonInteractive) {
        $LatestActivity = if ($LastSignIn -gt $LastNonInteractive) { $LastSignIn } else { $LastNonInteractive }
    } elseif ($LastSignIn) {
        $LatestActivity = $LastSignIn
    } elseif ($LastNonInteractive) {
        $LatestActivity = $LastNonInteractive
    }

    if (-not $LatestActivity -or ($LatestActivity -lt $CutoffDate)) {
        $EstimatedCost = ($User.AssignedLicenses.Count * 25)
        $EstimatedMonthlyWaste += $EstimatedCost

        $OrphanedUsers += [PSCustomObject]@{
            DisplayName       = $User.DisplayName
            UserPrincipalName = $User.UserPrincipalName
            LastActive        = if ($LatestActivity) { $LatestActivity.ToString("yyyy-MM-dd") } else { "Never Logged In" }
            LicensesCount     = $User.AssignedLicenses.Count
            MonthlySavingsUSD = $EstimatedCost
        }

        if ($AutoDeallocateLicenses) {
            $LicensesToRemove = $User.AssignedLicenses.SkuId
            Set-MgUserLicense -UserId $User.Id -AddLicenses @() -RemoveLicenses $LicensesToRemove -ErrorAction SilentlyContinue
            Write-Host "[-] Auto-deallocated license from $($User.UserPrincipalName)" -ForegroundColor Yellow
        }
    }
}

Write-Host "`n=== FINOPS RECLAMATION AUDIT SUMMARY ===" -ForegroundColor Green
Write-Host "Total Inactive Licensed Users Found: $($OrphanedUsers.Count)"
Write-Host "Estimated Monthly Wasted Spend:      `$$($EstimatedMonthlyWaste) / month"
Write-Host "Estimated Annual Recurring Savings:  `$$($EstimatedMonthlyWaste * 12) / year`n"

$OrphanedUsers | Format-Table -AutoSize

3. Automated Onboarding & Dynamic Group Assignment

Manual account creation frequently causes missing phone numbers, mismatched Department strings, and forgotten security group assignments.

By standardizing onboarding around Microsoft Graph SDK, you can execute user creation, automated usage location assignment (mandatory for licensing), and license allocation in one reproducible transaction:

<#
.SYNOPSIS
    Automated User Provisioning with Usage Location and License Allocation.
#>
param (
    [Parameter(Mandatory = $true)][string]$FirstName,
    [Parameter(Mandatory = $true)][string]$LastName,
    [Parameter(Mandatory = $true)][string]$Department,
    [Parameter(Mandatory = $true)][string]$JobTitle,
    [Parameter(Mandatory = $true)][string]$UsageLocation = "IN",
    [Parameter(Mandatory = $true)][string]$Domain = "yourdomain.com",
    [Parameter(Mandatory = $true)][string]$LicenseSkuId
)

Connect-MgGraph -Identity -NoWelcome

$UPN = "$($FirstName.ToLower()).$($LastName.ToLower())@$Domain"
$DisplayName = "$FirstName $LastName"
$InitialPassword = "P@ssw0rd!" + (Get-Random -Minimum 1000 -Maximum 9999) + "#"

$PasswordProfile = @{
    Password = $InitialPassword
    ForceChangePasswordNextSignIn = $true
}

$NewUserParams = @{
    DisplayName       = $DisplayName
    GivenName         = $FirstName
    Surname           = $LastName
    UserPrincipalName = $UPN
    MailNickname      = "$($FirstName.ToLower()).$($LastName.ToLower())"
    Department        = $Department
    JobTitle          = $JobTitle
    UsageLocation     = $UsageLocation
    AccountEnabled    = $true
    PasswordProfile   = $PasswordProfile
}

try {
    $CreatedUser = New-MgUser @NewUserParams -ErrorAction Stop
    Write-Host "[+] User created successfully: $($CreatedUser.UserPrincipalName) (ID: $($CreatedUser.Id))" -ForegroundColor Green

    Start-Sleep -Seconds 3

    Set-MgUserLicense -UserId $CreatedUser.Id `
        -AddLicenses @(@{ SkuId = [Guid]$LicenseSkuId }) `
        -RemoveLicenses @() `
        -ErrorAction Stop

    Write-Host "[+] License SKU assigned. Temporary password: $InitialPassword" -ForegroundColor Green
} catch {
    Write-Error "Failed to provision new user: $_"
}

4. Microsoft Teams Webhook & Audit Logging

To maintain audit compliance under ITIL and ISO 27001, your automated runbooks must generate real-time alerts whenever a user is provisioned or offboarded.

function Send-TeamsLifecycleNotification {
    param (
        [string]$WebhookUrl,
        [string]$EventTitle,
        [string]$UserUPN,
        [string]$ActionDetails,
        [string]$StatusColor = "0076D7"
    )

    $Payload = @{
        "@type"      = "MessageCard"
        "@context"   = "http://schema.org/extensions"
        "themeColor" = $StatusColor
        "summary"    = $EventTitle
        "sections"   = @(
            @{
                "activityTitle"    = "⚡ **$EventTitle**"
                "activitySubtitle" = "Microsoft 365 Autonomous Runbook Telemetry"
                "facts"            = @(
                    @{ "name" = "Target UPN:"; "value" = $UserUPN },
                    @{ "name" = "Timestamp:"; "value" = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss UTC") },
                    @{ "name" = "Actions Taken:"; "value" = $ActionDetails }
                )
                "markdown"         = $true
            }
        )
    } | ConvertTo-Json -Depth 4

    Invoke-RestMethod -Uri $WebhookUrl -Method Post -Body $Payload -ContentType 'application/json' | Out-Null
    Write-Host "[+] Teams audit notification dispatched." -ForegroundColor Green
}

Common Enterprise Pitfalls & Solutions

1. Hybrid Active Directory vs. Cloud-Only Accounts

Warning: If your environment synchronizes users from on-premises Active Directory via Microsoft Entra Connect or Cloud Sync, running Update-MgUser -AccountEnabled:$false directly in Microsoft Graph will be overwritten on the next sync cycle. For hybrid tenants, account disabling must be performed on-premises, while session revocation and license stripping occur in the cloud.

For a full architectural comparison, read our guide on Microsoft Entra Connect vs. Cloud Sync Migration.

2. Graph API Rate Limiting (HTTP 429 Too Many Requests)

When scanning thousands of users for signInActivity, Microsoft Graph enforces tenant throttling limits. Always implement exponential backoff retry logic in production runbooks.


Frequently Asked Questions

Why use Managed Identities instead of App Registrations with client secrets?

Client secrets expire after a maximum of 24 months, causing production jobs to fail abruptly. Managed Identities have no passwords or credentials to rotate, meeting strict zero-trust compliance standards.

What happens to a user’s OneDrive files during automated offboarding?

When an Entra ID user is disabled, OneDrive retains files for 30 days by default (configurable up to 3,650 days). Before stripping licenses, the runbook can delegate secondary site collection access to the direct manager.

Does converting to a Shared Mailbox require an Exchange license?

No. Shared mailboxes with up to 50 GB storage do not require a paid Exchange Online license, allowing companies to preserve communication history at zero subscription cost.


Related Architecture Guides

Similar Posts

Leave a Reply

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