Public/offboard-user.ps1

# offboard-user.ps1
# Full M365 user offboarding. Performs all steps in sequence with confirmation
# and logs every action to a timestamped CSV on the Desktop.
#
# Steps performed:
# 1. Block sign-in
# 2. Reset password to random string
# 3. Revoke all active sessions
# 4. Remove all group memberships
# 5. Remove all admin roles
# 6. Remove MFA methods
# 7. Cancel future calendar events
# 8. Convert mailbox to shared (preserves data, removes licence requirement)
# 9. Set Out of Office reply
# 10. Hide from Global Address List
# 11. Remove licence assignments
# 12. Export summary CSV
#
# Requires: Graph (User.ReadWrite.All, Directory.ReadWrite.All,
# UserAuthenticationMethod.ReadWrite.All, RoleManagement.ReadWrite.Directory)
# Exchange Online

if (-not (Get-ConnectionInformation)) { Connect-ExchangeOnline }
if (-not (Get-MgContext)) {
    Connect-MgGraph -Scopes "User.ReadWrite.All","Directory.ReadWrite.All","UserAuthenticationMethod.ReadWrite.All","RoleManagement.ReadWrite.Directory" -ContextScope Process
}

$upn = Read-Host "Enter UPN of user to offboard"

# Verify user exists
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'" -Property "Id,DisplayName,UserPrincipalName,AssignedLicenses"
if (-not $user) {
    Write-Host "User not found: $upn" -ForegroundColor Red
    return
}

Write-Host "`nOffboarding: $($user.DisplayName) ($upn)" -ForegroundColor Yellow
Write-Host "This will perform all offboarding steps. Continue? (y/n)" -ForegroundColor Yellow
if ((Read-Host) -ne "y") { Write-Host "Aborted."; return }

$log = [System.Collections.Generic.List[PSCustomObject]]::new()

function Log-Action {
    param($Step, $Status, $Notes = "")
    $entry = [PSCustomObject]@{
        Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
        UPN       = $upn
        Step      = $Step
        Status    = $Status
        Notes     = $Notes
    }
    $log.Add($entry)
    $colour = if ($Status -eq "OK") { "Green" } elseif ($Status -eq "SKIPPED") { "DarkGray" } else { "Red" }
    Write-Host " [$Status] $Step$(if ($Notes) { " — $Notes" })" -ForegroundColor $colour
}

# Portable password generator — replaces [System.Web.Security.Membership]
# which is unavailable in PowerShell 7. Guarantees one of each character
# class to satisfy M365 complexity rules.
function New-OffboardPassword {
    param([int]$Length = 20)
    $upper = [char[]]'ABCDEFGHJKLMNPQRSTUVWXYZ'
    $lower = [char[]]'abcdefghjkmnpqrstuvwxyz'
    $digit = [char[]]'23456789'
    $sym   = [char[]]'!@#$%^&*-_=+'
    $all   = @($upper + $lower + $digit + $sym)
    $rng   = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    $buf   = [byte[]]::new(4)
    $pick  = {
        param($pool)
        $rng.GetBytes($buf)
        $pool[[BitConverter]::ToUInt32($buf, 0) % [uint32]$pool.Length]
    }
    $chars = [System.Collections.Generic.List[char]]::new()
    $chars.Add((& $pick $upper))
    $chars.Add((& $pick $lower))
    $chars.Add((& $pick $digit))
    $chars.Add((& $pick $sym))
    while ($chars.Count -lt $Length) { $chars.Add((& $pick $all)) }
    for ($i = $chars.Count - 1; $i -gt 0; $i--) {
        $rng.GetBytes($buf)
        $j = [int]([BitConverter]::ToUInt32($buf, 0) % [uint32]($i + 1))
        $tmp = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $tmp
    }
    -join $chars
}

Write-Host ""

# 1. Block sign-in
try {
    Update-MgUser -UserId $user.Id -AccountEnabled $false
    Log-Action "Block sign-in" "OK"
} catch { Log-Action "Block sign-in" "FAILED" $_ }

# 2. Reset password to random string. The generated password is recorded
# in the export so the engineer has it for the audit trail; the file lands
# on the offboarder's Desktop, not the user's.
try {
    $rnd = New-OffboardPassword -Length 20
    Update-MgUser -UserId $user.Id -PasswordProfile @{ Password = $rnd; ForceChangePasswordNextSignIn = $false }
    Log-Action "Reset password" "OK" "New password: $rnd"
} catch { Log-Action "Reset password" "FAILED" $_ }

# 3. Revoke sessions
try {
    Revoke-MgUserSignInSession -UserId $user.Id | Out-Null
    Log-Action "Revoke active sessions" "OK"
} catch { Log-Action "Revoke active sessions" "FAILED" $_ }

# 4. Remove group memberships
# -------------------------------------------------------------------------
# Iterate-and-collect pattern (see ADR-0021): for batch operations where
# individual items can fail independently, we count successes and failures
# separately, then write ONE summary line plus ONE per-failure line per
# failed item. This keeps the audit log honest — without this, a partial
# failure would be invisible in the CSV (the previous implementation
# silently swallowed exceptions and reported the attempted count as the
# "OK" count). Honest audit trails matter for offboarding above all other
# operations, since the CSV is the engineer's only proof that each step
# was performed (or attempted).
try {
    $groups = Get-MgUserMemberOf -UserId $user.Id | Where-Object { $_.OdataType -eq "#microsoft.graph.group" }
    $succeeded = 0
    $failures  = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($group in $groups) {
        # Best-effort name resolution. The displayName lives in the group's
        # AdditionalProperties bag; if the lookup throws (rare — usually a
        # transient Graph hiccup) we fall back to the GUID so the CSV row
        # still identifies the group unambiguously.
        $groupName = try { $group.AdditionalProperties.displayName } catch { $group.Id }
        if (-not $groupName) { $groupName = $group.Id }
        try {
            Remove-MgGroupMemberByRef -GroupId $group.Id -DirectoryObjectId $user.Id -ErrorAction Stop
            $succeeded++
        } catch {
            $failures.Add([PSCustomObject]@{ Name = $groupName; Error = $_.Exception.Message })
        }
    }
    # Conditional summary status: OK if everything worked or some worked,
    # FAILED only if nothing worked. The Notes column distinguishes the
    # mixed-success case so the engineer sees at a glance whether follow-up
    # is needed.
    $summary = if ($failures.Count -eq 0) {
        "OK", "Removed from $succeeded group(s)"
    } elseif ($succeeded -gt 0) {
        "OK", "Removed from $succeeded group(s); $($failures.Count) failed (see rows below)"
    } else {
        "FAILED", "$($failures.Count) failure(s) (see rows below); 0 succeeded"
    }
    Log-Action "Remove group memberships" $summary[0] $summary[1]
    # Per-failure detail rows. The leading "↳" makes the parent/child
    # relationship visible in the CSV when sorted by timestamp; Log-Action
    # also colours these red on screen so the operator sees them live.
    foreach ($f in $failures) {
        Log-Action " ↳ group: $($f.Name)" "FAILED" $f.Error
    }
} catch { Log-Action "Remove group memberships" "FAILED" $_ }

# 5. Remove admin roles
# -------------------------------------------------------------------------
# Same iterate-and-collect pattern as step 4. Admin role failures are
# arguably more important to surface than group failures — a left-over
# admin role on a departed user is a real security exposure.
try {
    $roles = Get-MgUserMemberOf -UserId $user.Id | Where-Object { $_.OdataType -eq "#microsoft.graph.directoryRole" }
    $succeeded = 0
    $failures  = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($role in $roles) {
        $roleName = try { $role.AdditionalProperties.displayName } catch { $role.Id }
        if (-not $roleName) { $roleName = $role.Id }
        try {
            Remove-MgDirectoryRoleMemberByRef -DirectoryRoleId $role.Id -DirectoryObjectId $user.Id -ErrorAction Stop
            $succeeded++
        } catch {
            $failures.Add([PSCustomObject]@{ Name = $roleName; Error = $_.Exception.Message })
        }
    }
    $summary = if ($failures.Count -eq 0) {
        "OK", "Removed $succeeded role(s)"
    } elseif ($succeeded -gt 0) {
        "OK", "Removed $succeeded role(s); $($failures.Count) failed (see rows below)"
    } else {
        "FAILED", "$($failures.Count) failure(s) (see rows below); 0 succeeded"
    }
    Log-Action "Remove admin roles" $summary[0] $summary[1]
    foreach ($f in $failures) {
        Log-Action " ↳ role: $($f.Name)" "FAILED" $f.Error
    }
} catch { Log-Action "Remove admin roles" "FAILED" $_ }

# 6. Remove MFA methods
# -------------------------------------------------------------------------
# Two enumerations (phone methods, TAPs) that previously had no per-item
# error handling at all — a single failure aborted the whole step. Same
# iterate-and-collect treatment applied; phone methods and TAPs are
# tracked together since they're both "credentials to revoke" from the
# operator's perspective. The per-failure rows distinguish them via the
# Step prefix ("phone" vs "tap").
try {
    $succeeded = 0
    $failures  = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($pm in (Get-MgUserAuthenticationPhoneMethod -UserId $user.Id -ErrorAction SilentlyContinue)) {
        # Identifier for the failure row: phone type + first 8 chars of the
        # method ID is enough to distinguish multiple methods of the same
        # type without dumping the full GUID into the CSV.
        $label = "$($pm.PhoneType) [$($pm.Id.Substring(0,[Math]::Min(8,$pm.Id.Length)))]"
        try {
            Remove-MgUserAuthenticationPhoneMethod -UserId $user.Id -PhoneAuthenticationMethodId $pm.Id -ErrorAction Stop
            $succeeded++
        } catch {
            $failures.Add([PSCustomObject]@{ Kind = "phone"; Name = $label; Error = $_.Exception.Message })
        }
    }

    foreach ($tap in (Get-MgUserAuthenticationTemporaryAccessPassMethod -UserId $user.Id -ErrorAction SilentlyContinue)) {
        $label = "tap [$($tap.Id.Substring(0,[Math]::Min(8,$tap.Id.Length)))]"
        try {
            Remove-MgUserAuthenticationTemporaryAccessPassMethod -UserId $user.Id -TemporaryAccessPassAuthenticationMethodId $tap.Id -ErrorAction Stop
            $succeeded++
        } catch {
            $failures.Add([PSCustomObject]@{ Kind = "tap"; Name = $label; Error = $_.Exception.Message })
        }
    }

    $summary = if ($failures.Count -eq 0) {
        "OK", "Removed $succeeded method(s)"
    } elseif ($succeeded -gt 0) {
        "OK", "Removed $succeeded method(s); $($failures.Count) failed (see rows below)"
    } else {
        "FAILED", "$($failures.Count) failure(s) (see rows below); 0 succeeded"
    }
    Log-Action "Remove MFA methods" $summary[0] $summary[1]
    foreach ($f in $failures) {
        Log-Action " ↳ $($f.Kind): $($f.Name)" "FAILED" $f.Error
    }
} catch { Log-Action "Remove MFA methods" "FAILED" $_ }

# 7. Cancel future calendar events
try {
    Remove-CalendarEvents -Identity $upn -CancelOrganizedMeetings -QueryWindowInDays 120 -Confirm:$false
    Log-Action "Cancel future calendar events" "OK" "120 day window"
} catch { Log-Action "Cancel future calendar events" "FAILED" $_ }

# 8. Convert to shared mailbox
try {
    Set-Mailbox -Identity $upn -Type Shared
    Log-Action "Convert to shared mailbox" "OK" "Mailbox preserved, licence can be removed"
} catch { Log-Action "Convert to shared mailbox" "FAILED" $_ }

# 9. Set Out of Office
$oooMessage = Read-Host "`nOut of Office message (leave blank to skip)"
if ($oooMessage) {
    try {
        Set-MailboxAutoReplyConfiguration -Identity $upn `
            -AutoReplyState Enabled `
            -InternalMessage $oooMessage `
            -ExternalMessage $oooMessage
        Log-Action "Set Out of Office" "OK"
    } catch { Log-Action "Set Out of Office" "FAILED" $_ }
} else {
    Log-Action "Set Out of Office" "SKIPPED" "No message provided"
}

# 10. Hide from GAL
try {
    Set-Mailbox -Identity $upn -HiddenFromAddressListsEnabled $true
    Log-Action "Hide from address lists" "OK"
} catch { Log-Action "Hide from address lists" "FAILED" $_ }

# 11. Remove licences
try {
    $licences = (Get-MgUser -UserId $user.Id -Property AssignedLicenses).AssignedLicenses
    if ($licences.Count -gt 0) {
        Set-MgUserLicense -UserId $user.Id -AddLicenses @() -RemoveLicenses ($licences.SkuId)
        Log-Action "Remove licences" "OK" "Removed $($licences.Count) licence(s)"
    } else {
        Log-Action "Remove licences" "SKIPPED" "No licences assigned"
    }
} catch { Log-Action "Remove licences" "FAILED" $_ }

# Export log
$path = "$env:USERPROFILE\Desktop\Offboard_$($upn.Split('@')[0])_$(Get-Date -Format 'yyyyMMdd_HHmm').csv"
$log | Export-Csv -Path $path -NoTypeInformation

Write-Host "`nOffboarding complete. Log saved to: $path" -ForegroundColor Cyan
Write-Host "Review the log for any FAILED steps that need manual follow-up." -ForegroundColor Yellow