<# .SYNOPSIS Offboards a Microsoft Entra ID user: blocks sign-in, kills sessions, cleans up groups and licenses, and can hand their mailbox off before the license goes away. .DESCRIPTION Uses the Microsoft Graph PowerShell SDK for the directory work and Exchange Online PowerShell for the mailbox work. Every step is logged to a CSV so there's a record of exactly what happened. One failed step doesn't stop the rest. Supports -WhatIf. .PARAMETER UserPrincipalName The user to offboard. .PARAMETER ForwardTo Forward the user's mail to this address (usually their manager). Needs ExchangeOnlineManagement. .PARAMETER ConvertToShared Convert the mailbox to a shared mailbox before licenses are removed, so the mail is kept. .PARAMETER KeepGroups Leave group memberships alone. .PARAMETER LogPath Where to write the CSV log. Defaults to a timestamped file in the current folder. .EXAMPLE .\Invoke-UserOffboarding.ps1 -UserPrincipalName jane.doe@contoso.com -WhatIf .EXAMPLE .\Invoke-UserOffboarding.ps1 -UserPrincipalName jane.doe@contoso.com -ConvertToShared -ForwardTo manager@contoso.com #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string]$UserPrincipalName, [string]$ForwardTo, [switch]$ConvertToShared, [switch]$KeepGroups, [string]$LogPath = (Join-Path (Get-Location) ('offboarding-{0}-{1:yyyyMMdd-HHmm}.csv' -f ($UserPrincipalName -replace '[^\w.-]', '_'), (Get-Date))) ) $ErrorActionPreference = 'Stop' $log = [System.Collections.Generic.List[object]]::new() $exchangeReady = $false function Write-Step { param([string]$Action, [string]$Target, [string]$Result, [string]$Detail = '') $log.Add([pscustomobject]@{ Time = (Get-Date).ToString('s'); Action = $Action; Target = $Target; Result = $Result; Detail = $Detail }) $color = switch ($Result) { 'Done' { 'Green' } 'Skipped' { 'Yellow' } 'WhatIf' { 'Cyan' } default { 'Red' } } Write-Host ("[{0}] {1}: {2} {3}" -f $Result, $Action, $Target, $Detail).TrimEnd() -ForegroundColor $color } # Runs one step, honors -WhatIf, and logs the outcome. Returns $true if the step ran cleanly. function Invoke-Step { param([string]$Action, [string]$Target, [scriptblock]$Do) if (-not $PSCmdlet.ShouldProcess($Target, $Action)) { Write-Step $Action $Target 'WhatIf'; return $true } try { & $Do; Write-Step $Action $Target 'Done'; return $true } catch { Write-Step $Action $Target 'Failed' $_.Exception.Message; return $false } } # Connects to Exchange Online the first time it's needed. Returns $true when it's usable. function Connect-Exchange { if ($script:exchangeReady) { return $true } if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) { Write-Step 'Connect to Exchange Online' 'ExchangeOnlineManagement' 'Failed' 'Module not installed: Install-Module ExchangeOnlineManagement' return $false } if (-not (Get-ConnectionInformation -ErrorAction SilentlyContinue)) { Connect-ExchangeOnline -ShowBanner:$false } $script:exchangeReady = $true return $true } # --- Connect and look the user up --------------------------------------------------------- $scopes = 'User.ReadWrite.All', 'GroupMember.ReadWrite.All', 'LicenseAssignment.ReadWrite.All' if (-not (Get-MgContext)) { Connect-MgGraph -Scopes $scopes -NoWelcome } $user = Get-MgUser -UserId $UserPrincipalName -Property 'Id,DisplayName,UserPrincipalName,AccountEnabled,OnPremisesSyncEnabled,LicenseAssignmentStates' $upn = $user.UserPrincipalName Write-Host "Offboarding $($user.DisplayName) <$upn>" # --- 1. Block sign-in ----------------------------------------------------------------------- if ($user.OnPremisesSyncEnabled) { Write-Step 'Block sign-in' $upn 'Skipped' 'Synced from on-prem AD. Disable it there, or the next sync turns it back on.' } elseif (-not $user.AccountEnabled) { Write-Step 'Block sign-in' $upn 'Skipped' 'Already disabled.' } else { [void](Invoke-Step 'Block sign-in' $upn { Update-MgUser -UserId $user.Id -AccountEnabled:$false }) } # --- 2. Kill existing sessions -------------------------------------------------------------- [void](Invoke-Step 'Revoke sign-in sessions' $upn { Revoke-MgUserSignInSession -UserId $user.Id | Out-Null }) # --- 3. Mailbox hand-off (before licenses, or the mailbox is on a 30-day clock) -------------- $mailboxSafe = -not $ConvertToShared if ($ConvertToShared -or $ForwardTo) { if (Connect-Exchange) { if ($ConvertToShared) { $mailboxSafe = Invoke-Step 'Convert mailbox to shared' $upn { Set-Mailbox -Identity $upn -Type Shared } } if ($ForwardTo) { [void](Invoke-Step 'Forward mail' "$upn -> $ForwardTo" { Set-Mailbox -Identity $upn -ForwardingSmtpAddress "smtp:$ForwardTo" -DeliverToMailboxAndForward $true }) } } } # --- 4. Group memberships ------------------------------------------------------------------- if ($KeepGroups) { Write-Step 'Remove from groups' $upn 'Skipped' '-KeepGroups was set.' } else { $memberships = @(Get-MgUserMemberOf -UserId $user.Id -All | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' }) foreach ($membership in $memberships) { $group = Get-MgGroup -GroupId $membership.Id -Property 'Id,DisplayName,Mail,GroupTypes,MailEnabled,SecurityEnabled,OnPremisesSyncEnabled' $name = $group.DisplayName if ($group.GroupTypes -contains 'DynamicMembership') { Write-Step 'Remove from group' $name 'Skipped' "Dynamic group. Membership follows its rule, so change the user's attributes instead." continue } if ($group.OnPremisesSyncEnabled) { Write-Step 'Remove from group' $name 'Skipped' 'Synced from on-prem AD. Remove the user there.' continue } if ($group.MailEnabled -and -not ($group.GroupTypes -contains 'Unified')) { # Distribution lists and mail-enabled security groups are read-only in Graph. if (Connect-Exchange) { [void](Invoke-Step 'Remove from distribution group' $name { Remove-DistributionGroupMember -Identity $group.Mail -Member $upn -BypassSecurityGroupManagerCheck -Confirm:$false }) } continue } [void](Invoke-Step 'Remove from group' $name { Remove-MgGroupMemberDirectoryObjectByRef -GroupId $group.Id -DirectoryObjectId $user.Id }) } } # --- 5. Licenses ---------------------------------------------------------------------------- # Plain property access here: ForEach-Object -MemberName honors -WhatIf and would silently return nothing. $directSkus = @(@($user.LicenseAssignmentStates | Where-Object { -not $_.AssignedByGroup }).SkuId | Select-Object -Unique) $groupLicense = @($user.LicenseAssignmentStates | Where-Object { $_.AssignedByGroup }) if (-not $mailboxSafe) { Write-Step 'Remove licenses' $upn 'Skipped' "The mailbox didn't convert to shared, so licenses were kept to protect the mail." } elseif ($directSkus.Count -eq 0) { Write-Step 'Remove licenses' $upn 'Skipped' 'No directly assigned licenses.' } else { [void](Invoke-Step "Remove $($directSkus.Count) direct license(s)" $upn { Set-MgUserLicense -UserId $user.Id -AddLicenses @() -RemoveLicenses $directSkus | Out-Null }) } if ($groupLicense.Count -and $KeepGroups) { Write-Step 'Group-based licenses' $upn 'Skipped' "$($groupLicense.Count) license(s) come from group membership and stay until the user leaves those groups." } # --- Save the record ------------------------------------------------------------------------ $log | Export-Csv -Path $LogPath -NoTypeInformation -WhatIf:$false Write-Host "Log saved to $LogPath"