#Requires -Version 5.1 <# .SYNOPSIS Interactive M365 leaver processing script for Alleviate use. .DESCRIPTION Performs standard leaver actions against any tenant: - Convert mailbox to Shared - Add mailbox access permissions and/or forwarding - Rename Display Name to "ARCHIVE - " - Hide from Global Address List (GAL) - Remove all M365 licenses - Block sign-in - Remove from all groups (security, M365, distribution) Tenant-agnostic: prompts for connection at runtime. .NOTES Requires modules: ExchangeOnlineManagement, Microsoft.Graph Requires roles: Exchange Admin + User Admin (or Global Admin) in target tenant. #> #-------------------------------------------------------------------- # Helpers #-------------------------------------------------------------------- function Write-Step { param($m) Write-Host "`n=== $m ===" -ForegroundColor Cyan } function Write-Ok { param($m) Write-Host " [OK] $m" -ForegroundColor Green } function Write-Warn { param($m) Write-Host " [WARN] $m" -ForegroundColor Yellow } function Write-Err { param($m) Write-Host " [FAIL] $m" -ForegroundColor Red } function Confirm-Action { param($m) ($r = Read-Host " $m (y/n)") -and ($r -match '^(y|yes)$') } # Prompt for a recipient, validate it resolves, and confirm identity back to the operator. # Accepts UPN / alias / email / display name. Returns PrimarySmtpAddress, or $null if cancelled. function Get-ValidatedRecipient { param([string]$Prompt = "Recipient (UPN / email / alias)") while ($true) { $term = Read-Host " $Prompt (blank to cancel)" if ([string]::IsNullOrWhiteSpace($term)) { return $null } $rcpt = Get-Recipient -Identity $term -ErrorAction SilentlyContinue if (-not $rcpt) { Write-Warn "Could not resolve '$term' - check spelling and try again"; continue } if ($rcpt.Count -gt 1) { Write-Warn "'$term' is ambiguous - be more specific (try the full UPN)"; continue } Write-Host " Resolved: $($rcpt.DisplayName) <$($rcpt.PrimarySmtpAddress)> [$($rcpt.RecipientTypeDetails)]" -ForegroundColor White if (Confirm-Action " Is this correct?") { return $rcpt.PrimarySmtpAddress } # else loop and re-prompt } } #-------------------------------------------------------------------- # Module check #-------------------------------------------------------------------- function Initialize-Module { param($Name) if (-not (Get-Module -ListAvailable -Name $Name)) { Write-Warn "$Name not installed." if (Confirm-Action "Install $Name now?") { Install-Module $Name -Scope CurrentUser -Force -AllowClobber } else { throw "$Name is required. Aborting." } } } Write-Step "Checking prerequisites" Initialize-Module ExchangeOnlineManagement Initialize-Module Microsoft.Graph Write-Ok "Modules present" #-------------------------------------------------------------------- # Connect (tenant-agnostic - prompts interactively each run) #-------------------------------------------------------------------- Write-Step "Connecting to target tenant" $adminUpn = Read-Host "Enter admin UPN for the TARGET tenant (or leave blank for interactive picker)" try { if ($adminUpn) { Connect-ExchangeOnline -UserPrincipalName $adminUpn -ShowBanner:$false -ErrorAction Stop } else { Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop } Write-Ok "Connected to Exchange Online" } catch { Write-Err "EXO connection failed: $_"; return } try { Connect-MgGraph -Scopes "User.ReadWrite.All","Group.ReadWrite.All","Directory.ReadWrite.All","Organization.Read.All" -NoWelcome -ErrorAction Stop $ctx = Get-MgContext Write-Ok "Connected to Graph (tenant: $($ctx.TenantId))" } catch { Write-Err "Graph connection failed: $_"; return } #-------------------------------------------------------------------- # Identify the leaver #-------------------------------------------------------------------- Write-Step "Leaver identification" $leaverUpn = Read-Host "Enter the leaver's UPN / email address" try { $mgUser = Get-MgUser -UserId $leaverUpn -Property Id,DisplayName,UserPrincipalName,AccountEnabled -ErrorAction Stop $mailbox = Get-Mailbox -Identity $leaverUpn -ErrorAction Stop } catch { Write-Err "Could not resolve user/mailbox: $_"; return } Write-Host "`n Found:" -ForegroundColor White Write-Host " Display Name : $($mgUser.DisplayName)" Write-Host " UPN : $($mgUser.UserPrincipalName)" Write-Host " Mailbox Type : $($mailbox.RecipientTypeDetails)" Write-Host " Sign-in : $(if($mgUser.AccountEnabled){'Enabled'}else{'Blocked'})" Write-Host " In GAL : $(if($mailbox.HiddenFromAddressListsEnabled){'Hidden'}else{'Visible'})" if (-not (Confirm-Action "Proceed with processing THIS user as a leaver?")) { Write-Warn "Aborted by operator."; return } $originalName = $mgUser.DisplayName # Ordered collector for the end-of-run ticket summary. $results = [ordered]@{ 'Convert to Shared Mailbox' = 'Not run' 'Access permissions' = 'None added' 'Mail forwarding' = 'Not configured' 'Rename Display Name' = 'Not run' 'Hide from GAL' = 'Not run' 'Remove licenses' = 'Not run' 'Block sign-in' = 'Not run' 'Revoke sessions' = 'Not run' 'Remove group memberships' = 'Not run' } $permGrants = @() # collect access grants for the summary $revert = @() # collect exact revert commands for each action performed #-------------------------------------------------------------------- # 1. Convert to Shared Mailbox #-------------------------------------------------------------------- Write-Step "1. Convert mailbox to Shared" $converted = $false if ($mailbox.RecipientTypeDetails -eq 'SharedMailbox') { Write-Ok "Already a shared mailbox - skipping" $results['Convert to Shared Mailbox'] = 'Already shared - no change' } elseif (Confirm-Action "Convert '$leaverUpn' to a Shared Mailbox?") { try { Set-Mailbox -Identity $leaverUpn -Type Shared -ErrorAction Stop Write-Ok "Converted to Shared Mailbox" $converted = $true $results['Convert to Shared Mailbox'] = 'Converted to Shared' $revert += "# Revert mailbox type (was: $($mailbox.RecipientTypeDetails))" $revert += "Set-Mailbox -Identity '$leaverUpn' -Type Regular" } catch { Write-Err "Conversion failed: $_"; $results['Convert to Shared Mailbox'] = "FAILED - $_" } } else { Write-Warn "Skipped mailbox conversion"; $results['Convert to Shared Mailbox'] = 'Skipped by operator' } #-------------------------------------------------------------------- # 2. Access permissions + forwarding #-------------------------------------------------------------------- Write-Step "2. Access permissions & forwarding" # --- Full Access / Send As grants --- while (Confirm-Action "Add a mailbox access permission for someone?") { $grantee = Get-ValidatedRecipient -Prompt "Grantee (UPN / email / alias / name)" if (-not $grantee) { Write-Warn "Cancelled - no grantee selected"; continue } Write-Host " 1) Full Access 2) Send As 3) Both" switch (Read-Host " Choose access type") { '1' { $fa=$true; $sa=$false } '2' { $fa=$false; $sa=$true } '3' { $fa=$true; $sa=$true } default { Write-Warn "Invalid choice, skipping"; continue } } $autoMap = Confirm-Action " Enable automapping (Full Access only)?" try { if ($fa) { Add-MailboxPermission -Identity $leaverUpn -User $grantee -AccessRights FullAccess ` -InheritanceType All -AutoMapping:$autoMap -ErrorAction Stop | Out-Null Write-Ok "Full Access granted to $grantee (AutoMap: $autoMap)" $permGrants += "FullAccess -> $grantee (AutoMap: $autoMap)" $revert += "Remove-MailboxPermission -Identity '$leaverUpn' -User '$grantee' -AccessRights FullAccess -Confirm:`$false" } if ($sa) { Add-RecipientPermission -Identity $leaverUpn -Trustee $grantee ` -AccessRights SendAs -Confirm:$false -ErrorAction Stop | Out-Null Write-Ok "Send As granted to $grantee" $permGrants += "SendAs -> $grantee" $revert += "Remove-RecipientPermission -Identity '$leaverUpn' -Trustee '$grantee' -AccessRights SendAs -Confirm:`$false" } } catch { Write-Err "Permission grant failed: $_" } } if ($permGrants.Count -gt 0) { $results['Access permissions'] = ($permGrants -join '; ') } # --- Forwarding --- if (Confirm-Action "Set up mail forwarding for this mailbox?") { $fwdTarget = Get-ValidatedRecipient -Prompt "Forward TO (UPN / email / alias / name)" if (-not $fwdTarget) { Write-Warn "Cancelled - no forwarding configured" } else { $keepCopy = Confirm-Action " Keep a copy in the shared mailbox?" try { Set-Mailbox -Identity $leaverUpn -ForwardingSmtpAddress $fwdTarget ` -DeliverToMailboxAndForward:$keepCopy -ErrorAction Stop Write-Ok "Forwarding set to $fwdTarget (KeepCopy: $keepCopy)" $results['Mail forwarding'] = "-> $fwdTarget (KeepCopy: $keepCopy)" $revert += "Set-Mailbox -Identity '$leaverUpn' -ForwardingSmtpAddress `$null -DeliverToMailboxAndForward `$false" } catch { Write-Err "Forwarding failed: $_"; $results['Mail forwarding'] = "FAILED - $_" } } } else { Write-Warn "No forwarding configured" } #-------------------------------------------------------------------- # 3. Rename Display Name -> ARCHIVE - name #-------------------------------------------------------------------- Write-Step "3. Rename Display Name" $newName = "ARCHIVE - $originalName" if (Confirm-Action "Rename display name to '$newName'?") { try { Set-Mailbox -Identity $leaverUpn -DisplayName $newName -ErrorAction Stop Update-MgUser -UserId $mgUser.Id -DisplayName $newName -ErrorAction Stop Write-Ok "Display name set to '$newName'" $results['Rename Display Name'] = "'$originalName' -> '$newName'" $revert += "Set-Mailbox -Identity '$leaverUpn' -DisplayName '$originalName'" $revert += "Update-MgUser -UserId '$($mgUser.Id)' -DisplayName '$originalName'" } catch { Write-Err "Rename failed: $_"; $results['Rename Display Name'] = "FAILED - $_" } } else { Write-Warn "Skipped rename"; $results['Rename Display Name'] = 'Skipped by operator' } #-------------------------------------------------------------------- # 4. Hide from Global Address List (GAL) #-------------------------------------------------------------------- Write-Step "4. Hide from Global Address List (GAL)" if ($mailbox.HiddenFromAddressListsEnabled) { Write-Ok "Already hidden from GAL - skipping" $results['Hide from GAL'] = 'Already hidden - no change' } elseif (Confirm-Action "Hide '$leaverUpn' from the GAL?") { try { Set-Mailbox -Identity $leaverUpn -HiddenFromAddressListsEnabled $true -ErrorAction Stop Write-Ok "Hidden from GAL" $results['Hide from GAL'] = 'Hidden from GAL' $revert += "Set-Mailbox -Identity '$leaverUpn' -HiddenFromAddressListsEnabled `$false" } catch { Write-Err "Hide from GAL failed: $_"; $results['Hide from GAL'] = "FAILED - $_" } } else { Write-Warn "Left visible in GAL"; $results['Hide from GAL'] = 'Skipped by operator' } #-------------------------------------------------------------------- # 5. Remove all licenses #-------------------------------------------------------------------- Write-Step "5. Remove Microsoft 365 licenses" if ($converted) { Write-Warn "Waiting 30s for shared mailbox conversion to settle before removing licenses..." Start-Sleep -Seconds 30 } try { $licensed = Get-MgUserLicenseDetail -UserId $mgUser.Id -ErrorAction Stop if (-not $licensed) { Write-Ok "No licenses assigned" $results['Remove licenses'] = 'None assigned' } else { Write-Host " Assigned: $(($licensed.SkuPartNumber) -join ', ')" if (Confirm-Action "Remove ALL licenses?") { $skuIds = @($licensed.SkuId) $params = @{ UserId = $mgUser.Id AddLicenses = @() RemoveLicenses = $skuIds } Set-MgUserLicense @params -ErrorAction Stop | Out-Null Write-Ok "Removed: $(($licensed.SkuPartNumber) -join ', ')" $results['Remove licenses'] = "Removed: $(($licensed.SkuPartNumber) -join ', ')" $addBlocks = ($skuIds | ForEach-Object { "@{SkuId='$_'}" }) -join ',' $revert += "# Re-add licenses (SKUs: $(($licensed.SkuPartNumber) -join ', ')). Set usage location first if not already set." $revert += "Set-MgUserLicense -UserId '$($mgUser.Id)' -AddLicenses @($addBlocks) -RemoveLicenses @()" } else { Write-Warn "Skipped license removal"; $results['Remove licenses'] = 'Skipped by operator' } } } catch { Write-Err "License removal failed: $_"; $results['Remove licenses'] = "FAILED - $_" } #-------------------------------------------------------------------- # 6. Block sign-in #-------------------------------------------------------------------- Write-Step "6. Block sign-in" if (Confirm-Action "Block sign-in for $leaverUpn?") { try { Update-MgUser -UserId $mgUser.Id -AccountEnabled:$false -ErrorAction Stop Write-Ok "Sign-in blocked" $results['Block sign-in'] = 'Sign-in blocked' $revert += "Update-MgUser -UserId '$($mgUser.Id)' -AccountEnabled:`$true" if (Confirm-Action "Also revoke existing sessions/tokens now?") { Revoke-MgUserSignInSession -UserId $mgUser.Id -ErrorAction Stop | Out-Null Write-Ok "Sessions revoked" $results['Revoke sessions'] = 'Sessions revoked' # (no revert - the user simply signs in again) } else { $results['Revoke sessions'] = 'Skipped by operator' } } catch { Write-Err "Block sign-in failed: $_"; $results['Block sign-in'] = "FAILED - $_" } } else { Write-Warn "Sign-in left enabled"; $results['Block sign-in'] = 'Skipped by operator' } #-------------------------------------------------------------------- # 7. Remove group memberships #-------------------------------------------------------------------- Write-Step "7. Remove group memberships" try { $memberships = Get-MgUserMemberOf -UserId $mgUser.Id -All -ErrorAction Stop | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' } if (-not $memberships) { Write-Ok "No group memberships found" $results['Remove group memberships'] = 'No memberships found' } else { Write-Host " Member of $($memberships.Count) group(s):" foreach ($g in $memberships) { Write-Host " - $($g.AdditionalProperties['displayName'])" } Write-Host " 1) Remove from ALL 2) Review each group individually 3) Skip" $mode = Read-Host " Choose" if ($mode -in '1','2') { $removed = @(); $kept = @(); $failed = @() foreach ($g in $memberships) { $gName = $g.AdditionalProperties['displayName'] $isDynamic = ($g.AdditionalProperties['groupTypes'] -contains 'DynamicMembership') $mailEnabled = $g.AdditionalProperties['mailEnabled'] if ($isDynamic) { Write-Warn "Skipped '$gName' (dynamic membership)"; $kept += "$gName (dynamic)"; continue } if ($mode -eq '2') { if (-not (Confirm-Action "Remove from '$gName'?")) { Write-Warn "Kept '$gName'"; $kept += $gName; continue } } try { if ($mailEnabled -and -not ($g.AdditionalProperties['groupTypes'] -contains 'Unified')) { Remove-DistributionGroupMember -Identity $g.Id -Member $leaverUpn ` -Confirm:$false -ErrorAction Stop $revert += "Add-DistributionGroupMember -Identity '$($g.Id)' -Member '$leaverUpn' # $gName" } else { Remove-MgGroupMemberByRef -GroupId $g.Id -DirectoryObjectId $mgUser.Id -ErrorAction Stop $revert += "New-MgGroupMember -GroupId '$($g.Id)' -DirectoryObjectId '$($mgUser.Id)' # $gName" } Write-Ok "Removed from '$gName'" $removed += $gName } catch { Write-Err "Could not remove from '$gName': $_"; $failed += $gName } } $grpSummary = "Removed from $($removed.Count) of $($memberships.Count)" if ($kept.Count) { $grpSummary += "; kept: $($kept -join ', ')" } if ($failed.Count) { $grpSummary += "; FAILED: $($failed -join ', ')" } $results['Remove group memberships'] = $grpSummary } else { Write-Warn "Skipped group removal"; $results['Remove group memberships'] = "Skipped by operator ($($memberships.Count) memberships left)" } } } catch { Write-Err "Group enumeration failed: $_"; $results['Remove group memberships'] = "FAILED - $_" } #-------------------------------------------------------------------- # Summary + disconnect #-------------------------------------------------------------------- Write-Step "Leaver processing complete" # Build a plain-text, ticket-ready block. Printed with no colour/prefixes # so the engineer can highlight and copy it straight into the ticket. $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm') # Windows account that ran the script (not the tenant admin used to connect). $operator = try { (whoami) -join '' } catch { "$env:USERDOMAIN\$env:USERNAME" } if ([string]::IsNullOrWhiteSpace($operator)) { $operator = "$env:USERDOMAIN\$env:USERNAME" } $ticket = @() $ticket += "====================================================================" $ticket += " M365 LEAVER PROCESSING - TICKET SUMMARY" $ticket += "====================================================================" $ticket += " Leaver : $originalName" $ticket += " UPN : $leaverUpn" $ticket += " Processed by : $operator" $ticket += " Processed on : $stamp" $ticket += "--------------------------------------------------------------------" $ticket += " ACTIONS PERFORMED:" foreach ($k in $results.Keys) { $ticket += (" {0,-26}: {1}" -f $k, $results[$k]) } $ticket += "--------------------------------------------------------------------" $ticket += " REVERT STEPS (run in a connected EXO + Graph session to undo):" if ($revert.Count -eq 0) { $ticket += " (nothing to revert - no changes were made)" } else { foreach ($r in $revert) { $ticket += " $r" } $ticket += "" $ticket += " NOTE: Reverting the shared->regular conversion may need a licence" $ticket += " re-applied for the mailbox to function as a user mailbox." } $ticket += "====================================================================" Write-Host "" Write-Host " Copy the block below into the ticket:" -ForegroundColor Cyan Write-Host "" # Emit as a single newline-joined string so no host (ISE / VS Code / transcript) # can re-flow or concatenate individual array elements. Write-Host ($ticket -join [Environment]::NewLine) Write-Host "" if (Confirm-Action "Disconnect all sessions?") { Disconnect-ExchangeOnline -Confirm:$false Disconnect-MgGraph | Out-Null Write-Ok "Disconnected" }