All scripts
Automation 471

Start-M365UserOffboarding

Full Microsoft 365 offboarding pass for one user or a CSV batch: disables sign in, revokes sessions, clears group/role/app assignments, cleans up the mailbox (hide from GAL, remove aliases, delete inbox rules, wipe mobile device, convert to shared, optional delegate access), removes licenses, and writes a CSV report. Supports -WhatIf.

Start-M365UserOffboarding.ps1
<#
.SYNOPSIS
    Runs a full Microsoft 365 offboarding pass for one user or a batch of
    users from a CSV, and writes a per-user results report at the end.

.DESCRIPTION
    Covers the checks and cleanup an offboarding usually needs: disabling
    sign in, revoking active sessions, clearing group and directory role
    memberships, clearing enterprise app role assignments, and the mailbox
    side of things (hide from the address list, remove secondary aliases,
    delete inbox rules, wipe the mobile device partnership, convert to
    shared, and optionally grant a manager delegate access). Licenses are
    removed last so the account frees up a seat once everything else is
    done. Every step is independent, so one failure does not stop the run,
    and every outcome lands in the results table at the end.

.PARAMETER UserPrincipalName
    UPN of a single user to offboard.

.PARAMETER CsvPath
    Path to a CSV with a UserPrincipalName column, for offboarding several
    users in one run. Takes priority over -UserPrincipalName if both are
    supplied.

.PARAMETER DelegateEmail
    Optional. Grants this mailbox Full Access and Send As on each user's
    mailbox after it is converted to shared.

.PARAMETER IncludePasswordReset
    Optional switch. Also resets each account's password to a random
    value and writes it to a separate, more restricted log file, useful
    as a second layer on top of disabling the account and revoking
    sessions.

.PARAMETER ReportPath
    Optional. Folder to write the results CSV and error log to. Defaults
    to the current directory.

.EXAMPLE
    .\Start-M365UserOffboarding.ps1 -UserPrincipalName jane.doe@contoso.com -DelegateEmail manager@contoso.com

.EXAMPLE
    .\Start-M365UserOffboarding.ps1 -CsvPath .\leavers.csv -IncludePasswordReset -WhatIf

.NOTES
    Requires the Microsoft.Graph.Users, Microsoft.Graph.Groups,
    Microsoft.Graph.Identity.DirectoryManagement,
    Microsoft.Graph.Applications and ExchangeOnlineManagement modules,
    connected with at least User.ReadWrite.All, Group.ReadWrite.All,
    RoleManagement.ReadWrite.Directory and
    AppRoleAssignment.ReadWrite.All scopes.

.AUTHOR
    Shehryar Hassan
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter()]
    [string]$UserPrincipalName,

    [Parameter()]
    [string]$CsvPath,

    [Parameter()]
    [string]$DelegateEmail,

    [Parameter()]
    [switch]$IncludePasswordReset,

    [Parameter()]
    [string]$ReportPath = (Get-Location).Path
)

if ($CsvPath) {
    $targets = (Import-Csv -Path $CsvPath).UserPrincipalName
} elseif ($UserPrincipalName) {
    $targets = @($UserPrincipalName)
} else {
    throw "Supply either -UserPrincipalName or -CsvPath."
}

$stamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
$reportFile = Join-Path $ReportPath "offboarding-report_$stamp.csv"
$passwordFile = Join-Path $ReportPath "offboarding-passwords_$stamp.csv"
$errorFile = Join-Path $ReportPath "offboarding-errors_$stamp.log"

function Invoke-OffboardingStep {
    param([string]$Name, [scriptblock]$Action, [string]$Target)
    try {
        $detail = & $Action
        return [pscustomobject]@{ Step = $Name; Status = "Done"; Detail = $detail }
    } catch {
        "$Target | $Name | $($_.Exception.Message)" | Out-File -Append $errorFile
        return [pscustomobject]@{ Step = $Name; Status = "Failed"; Detail = $_.Exception.Message }
    }
}

$report = [System.Collections.Generic.List[pscustomobject]]::new()

foreach ($upn in $targets) {
    $upn = $upn.Trim()
    Write-Host ""
    Write-Host "Offboarding $upn" -ForegroundColor Cyan

    $user = Get-MgUser -UserId $upn -ErrorAction SilentlyContinue
    if (-not $user) {
        Write-Warning "No matching user for $upn, skipping."
        continue
    }

    $steps = [System.Collections.Generic.List[pscustomobject]]::new()

    if ($PSCmdlet.ShouldProcess($upn, "Disable account")) {
        $steps.Add((Invoke-OffboardingStep "Disable account" { Update-MgUser -UserId $user.Id -AccountEnabled:$false } $upn))
        $steps.Add((Invoke-OffboardingStep "Revoke sign in sessions" { Revoke-MgUserSignInSession -UserId $user.Id | Out-Null } $upn))
    }

    if ($IncludePasswordReset -and $PSCmdlet.ShouldProcess($upn, "Reset password")) {
        $steps.Add((Invoke-OffboardingStep "Reset password" {
            $newPassword = -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 14 | ForEach-Object { [char]$_ })
            $profile = @{ forceChangePasswordNextSignIn = $true; password = $newPassword }
            Update-MgUser -UserId $user.Id -PasswordProfile $profile
            [pscustomobject]@{ UserPrincipalName = $upn; TemporaryPassword = $newPassword } | Export-Csv -Path $passwordFile -Append -NoTypeInformation
            "logged separately"
        } $upn))
    }

    if ($PSCmdlet.ShouldProcess($upn, "Clear group and role memberships")) {
        $steps.Add((Invoke-OffboardingStep "Remove group memberships" {
            $memberships = Get-MgUserMemberOf -UserId $user.Id -All
            $groupCount = 0
            foreach ($m in $memberships | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group' -and $_.AdditionalProperties.groupTypes -notcontains 'DynamicMembership' }) {
                try { Remove-MgGroupMemberByRef -GroupId $m.Id -DirectoryObjectId $user.Id -ErrorAction Stop; $groupCount++ } catch {}
            }
            "$groupCount group(s) removed"
        } $upn))

        $steps.Add((Invoke-OffboardingStep "Remove directory role assignments" {
            $memberships = Get-MgUserMemberOf -UserId $user.Id -All
            $roleCount = 0
            foreach ($r in $memberships | Where-Object { $_.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.directoryRole' }) {
                try { Remove-MgDirectoryRoleMemberByRef -DirectoryRoleId $r.Id -DirectoryObjectId $user.Id -ErrorAction Stop; $roleCount++ } catch {}
            }
            "$roleCount role(s) removed"
        } $upn))

        $steps.Add((Invoke-OffboardingStep "Remove app role assignments" {
            $assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
            foreach ($a in $assignments) { Remove-MgUserAppRoleAssignment -UserId $user.Id -AppRoleAssignmentId $a.Id -ErrorAction SilentlyContinue }
            "$($assignments.Count) assignment(s) removed"
        } $upn))
    }

    $hasMailbox = [bool](Get-Mailbox -Identity $upn -ErrorAction SilentlyContinue)
    if ($hasMailbox -and $PSCmdlet.ShouldProcess($upn, "Clean up mailbox")) {
        $steps.Add((Invoke-OffboardingStep "Hide from address lists" { Set-Mailbox -Identity $upn -HiddenFromAddressListsEnabled $true } $upn))

        $steps.Add((Invoke-OffboardingStep "Remove secondary aliases" {
            $aliases = (Get-Mailbox $upn).EmailAddresses | Where-Object { $_ -clike "smtp:*" }
            if ($aliases) { Set-Mailbox $upn -EmailAddresses @{Remove = $aliases} -WarningAction SilentlyContinue }
            "$($aliases.Count) alias(es) removed"
        } $upn))

        $steps.Add((Invoke-OffboardingStep "Delete inbox rules" {
            $rules = Get-InboxRule -Mailbox $upn
            $rules | Remove-InboxRule -Confirm:$false
            "$($rules.Count) rule(s) removed"
        } $upn))

        $steps.Add((Invoke-OffboardingStep "Wipe mobile device partnership" {
            $devices = Get-MobileDevice -Mailbox $upn -ErrorAction SilentlyContinue
            $devices | Clear-MobileDevice -Confirm:$false -ErrorAction SilentlyContinue
            "$($devices.Count) device(s) wiped"
        } $upn))

        $steps.Add((Invoke-OffboardingStep "Convert mailbox to shared" { Set-Mailbox -Identity $upn -Type Shared -WarningAction SilentlyContinue } $upn))

        if ($DelegateEmail) {
            $steps.Add((Invoke-OffboardingStep "Grant delegate mailbox access" {
                Add-MailboxPermission -Identity $upn -User $DelegateEmail -AccessRights FullAccess -InheritanceType All -AutoMapping $false | Out-Null
                Add-RecipientPermission -Identity $upn -Trustee $DelegateEmail -AccessRights SendAs -Confirm:$false | Out-Null
                "Full Access and Send As for $DelegateEmail"
            } $upn))
        }
    }

    if ($PSCmdlet.ShouldProcess($upn, "Remove licenses")) {
        $steps.Add((Invoke-OffboardingStep "Remove licenses" {
            $skus = (Get-MgUserLicenseDetail -UserId $user.Id).SkuId
            if ($skus) { Set-MgUserLicense -UserId $user.Id -AddLicenses @() -RemoveLicenses $skus | Out-Null }
            "$($skus.Count) license(s) removed"
        } $upn))
    }

    foreach ($s in $steps) {
        $report.Add([pscustomobject]@{
            UserPrincipalName = $upn
            Step              = $s.Step
            Status            = $s.Status
            Detail            = $s.Detail
        })
    }
    $steps | Format-Table -AutoSize
}

$report | Export-Csv -Path $reportFile -NoTypeInformation
Write-Host ""
Write-Host "Report written to $reportFile" -ForegroundColor Cyan
if ($IncludePasswordReset) { Write-Host "Temporary passwords written to $passwordFile" -ForegroundColor Yellow }
if (Test-Path $errorFile) { Write-Host "Some steps failed, see $errorFile" -ForegroundColor Red }

Read it before you run it, and test in a safe tenant first.