Solutions Onboarding

Leaver offboarding

Access removed on the last day, not whenever somebody gets round to it.

01 / What it looks like running

Start to finish, and where a person still comes in.

01 / Trigger Last day reached
02 / Automated Sessions ended and sign-in blocked
03 / Automated Mail forwarded, mailbox converted to shared
04 / Automated Access, groups and admin roles removed
05 / Automated Licence reclaimed, once the mailbox is safe
06 / Automated Evidence of every step recorded
07 / Needs a person Handover of the work itself

Automated Needs a person 5 of 7 run without anyone

02 / What stops happening

7 steps become 1.

The struck lines are the ones nobody performs after this is live.

  1. HR tells IT, eventually
  2. Someone disables the account
  3. Someone remembers the other systems, or does not
  4. Licences keep being paid for
  5. Someone chases the equipment back
  6. Nobody can prove afterwards what was removed and when
  7. Handing over the work the leaver was doing

03 / What good looks like

Eleven steps, and the order that matters

Most of these are destructive. Three of them destroy data outright if they run before the step that preserves it, which is why an offboarding checklist with tick boxes in any order is worse than no checklist at all.

Revoke Before anything else, and in this order
  1. End every session A session already open survives a password change. Revoke first or you leave a window.
  2. Block sign-in Takes effect immediately. Disabling the account alone can take up to 24 hours to propagate.
  3. Reset the password to something random Nobody needs to know it. A password written into a handover document is a credential left lying around.
Preserve Everything that keeps data or continuity
  1. Forward the mail, or answer it Either send it to whoever took the work, or reply saying they have left. Silence loses customers who think they are being ignored.
  2. Convert the mailbox to shared This is the step that makes the licence safe to remove. Under 50GB a shared mailbox needs no licence and keeps every message.
  3. Hide them from the address list Stops colleagues writing to an address nobody reads.
  4. Move the OneDrive content The slow one, and the one most often forgotten. Deleting the account starts a 30 day clock on those files.
Remove Only once the preserving is done
  1. Remove group and distribution list membership Stale members in a distribution list means confidential mail going to someone who left.
  2. Remove admin roles The highest risk item on the list, and the one least likely to be on the checklist.
  3. Wipe company data from their phone Account data only. A personal phone keeps its personal content.
  4. Reclaim the licence, then close the account In that order. Both are reversible for 30 days, and neither is reversible after.

automatiq-m365-offboarding.ps1

Runs the sequence above, refuses to remove a licence before the mailbox is preserved, and refuses to delete an account before you confirm the OneDrive is handled. MIT licensed, use it, change it, ship it.

Read it here
<#
================================================================================
 Automatiq, Microsoft 365 leaver offboarding
 https://automatiqsys.com/solutions/leaver-offboarding/

 Runs the leaver steps in an order that cannot lose data, reports what it did,
 and says what to do about anything it could not do.

 WHY THE ORDER IS FIXED
 ----------------------
 Most offboarding scripts offer a menu of steps. That is the wrong shape for
 this job, because these steps are not independent: removing the licence before
 the mailbox is preserved destroys the mailbox, and deleting the account before
 the OneDrive is moved starts a 30 day clock on the files.

 So this runs one sequence: revoke access first, preserve second, remove third.
 The destructive steps are opt in, and the licence step refuses to run unless
 preservation actually succeeded.

 WHAT IT DOES NOT DO
 -------------------
 It does not move OneDrive content. That needs a destination decision and a
 copy that can take hours, which does not belong inside a script that is
 otherwise instant and reversible. It reports the OneDrive URL so a person or a
 separate job can take it, and it refuses to delete the account until you
 confirm that has happened.

 REQUIREMENTS
 ------------
   Microsoft.Graph            Install-Module Microsoft.Graph -Scope CurrentUser
   ExchangeOnlineManagement   Install-Module ExchangeOnlineManagement -Scope CurrentUser

 Graph scopes: User.ReadWrite.All, Group.ReadWrite.All, Directory.ReadWrite.All,
 RoleManagement.ReadWrite.Directory, User.EnableDisableAccount.All

 EXAMPLES
 --------
   # See what would happen. Changes nothing.
   .\automatiq-m365-offboarding.ps1 -UserPrincipalName [email protected] -WhatIf

   # Revoke and preserve. Keeps the licence.
   .\automatiq-m365-offboarding.ps1 -UserPrincipalName [email protected] -ForwardTo [email protected]

   # Full offboarding, licence reclaimed.
   .\automatiq-m365-offboarding.ps1 -UserPrincipalName [email protected] `
       -ForwardTo [email protected] -RemoveLicence

 Licence: MIT. Use it, change it, ship it.
================================================================================
#>

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    # One or more leavers. Accepts pipeline input, so a CSV works:
    #   Import-Csv leavers.csv | Select -Expand UPN | .\automatiq-m365-offboarding.ps1
    [Parameter(Mandatory, ValueFromPipeline)]
    [string[]] $UserPrincipalName,

    # Where their mail should go. Without this, mail to the address bounces.
    [string] $ForwardTo,

    # Shown to anyone who writes to them after they have gone.
    [string] $AutoReply = 'This person has left the organisation. Your message has been forwarded to a colleague.',

    # Reclaims the licence. Refused unless the mailbox was preserved first.
    [switch] $RemoveLicence,

    # Deletes the account. Refused unless you have confirmed OneDrive is handled.
    [switch] $DeleteAccount,

    # Asserts that the leaver's OneDrive content has already been moved.
    [switch] $OneDriveHandled,

    [string] $ReportPath = ".\automatiq-offboarding-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
)

begin {
    $ErrorActionPreference = 'Stop'
    $results = [System.Collections.Generic.List[object]]::new()

    function Write-Step {
        param([string]$Upn, [string]$Step, [string]$Status, [string]$Detail = '')

        $colour = switch ($Status) {
            'done'    { 'Green' }
            'skipped' { 'DarkGray' }
            'blocked' { 'Yellow' }
            default   { 'Red' }
        }
        Write-Host ('  {0,-28} {1}' -f $Step, $Status) -ForegroundColor $colour
        if ($Detail) { Write-Host ('  {0,-28} {1}' -f '', $Detail) -ForegroundColor DarkGray }

        $results.Add([pscustomobject]@{
            User = $Upn; Step = $Step; Status = $Status; Detail = $Detail
            When = (Get-Date).ToString('s')
        })
    }

    # Every step goes through here, so a failure is reported and the sequence
    # continues rather than the whole run stopping on one leaver.
    function Invoke-Step {
        param([string]$Upn, [string]$Step, [scriptblock]$Action, [string]$Detail = '')
        try {
            & $Action
            Write-Step -Upn $Upn -Step $Step -Status 'done' -Detail $Detail
            return $true
        } catch {
            Write-Step -Upn $Upn -Step $Step -Status 'failed' -Detail $_.Exception.Message
            return $false
        }
    }

    foreach ($module in 'Microsoft.Graph.Users', 'ExchangeOnlineManagement') {
        if (-not (Get-Module -ListAvailable -Name $module)) {
            throw "$module is not installed. Install-Module $module -Scope CurrentUser"
        }
    }

    Write-Host "`nConnecting" -ForegroundColor Cyan
    Connect-MgGraph -NoWelcome -Scopes @(
        'User.ReadWrite.All', 'Group.ReadWrite.All', 'Directory.ReadWrite.All',
        'RoleManagement.ReadWrite.Directory', 'User.EnableDisableAccount.All'
    )
    Connect-ExchangeOnline -ShowBanner:$false
}

process {
    foreach ($upn in $UserPrincipalName) {
        $upn = $upn.Trim()
        Write-Host "`n$upn" -ForegroundColor Cyan

        $user = Get-MgUser -UserId $upn -Property Id, DisplayName, UserPrincipalName -ErrorAction SilentlyContinue
        if (-not $user) {
            Write-Step -Upn $upn -Step 'find account' -Status 'failed' -Detail 'No such user. Check the address.'
            continue
        }

        $mailbox = Get-Mailbox -Identity $upn -ErrorAction SilentlyContinue
        $preserved = $false

        # -- 1. Revoke access ---------------------------------------------------
        # First, and in this order: a session already open survives a password
        # reset, so revoking sessions after blocking sign-in leaves a window.

        if ($PSCmdlet.ShouldProcess($upn, 'Revoke all sessions')) {
            Invoke-Step $upn 'revoke sessions' { Revoke-MgUserSignInSession -UserId $upn | Out-Null } | Out-Null
        }

        if ($PSCmdlet.ShouldProcess($upn, 'Block sign-in')) {
            Invoke-Step $upn 'block sign-in' { Update-MgUser -UserId $upn -AccountEnabled:$false } | Out-Null
        }

        if ($PSCmdlet.ShouldProcess($upn, 'Reset password')) {
            # Random, never displayed, never logged. Nobody needs to know it,
            # the account is blocked, and a password written to a file is a
            # credential lying around.
            $bytes = [byte[]]::new(24)
            [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
            $random = [Convert]::ToBase64String($bytes)

            Invoke-Step $upn 'reset password' {
                Update-MgUser -UserId $upn -PasswordProfile @{
                    Password = $random; ForceChangePasswordNextSignIn = $true
                }
            } -Detail 'Random, not recorded' | Out-Null
        }

        # -- 2. Preserve --------------------------------------------------------
        # Everything that keeps data or continuity, before anything is removed.

        if ($mailbox) {
            if ($ForwardTo -and $PSCmdlet.ShouldProcess($upn, "Forward mail to $ForwardTo")) {
                Invoke-Step $upn 'forward mail' {
                    Set-Mailbox -Identity $upn -ForwardingSmtpAddress $ForwardTo -DeliverToMailboxAndForward $true
                } -Detail $ForwardTo | Out-Null
            }

            if ($PSCmdlet.ShouldProcess($upn, 'Set automatic reply')) {
                Invoke-Step $upn 'automatic reply' {
                    Set-MailboxAutoReplyConfiguration -Identity $upn -AutoReplyState Enabled `
                        -InternalMessage $AutoReply -ExternalMessage $AutoReply
                } | Out-Null
            }

            if ($PSCmdlet.ShouldProcess($upn, 'Convert to shared mailbox')) {
                # This is the step that makes the licence safe to remove. A shared
                # mailbox under 50GB needs no licence and keeps every message.
                $preserved = Invoke-Step $upn 'convert to shared' {
                    Set-Mailbox -Identity $upn -Type Shared -WarningAction SilentlyContinue
                } -Detail 'Mailbox retained without a licence'
            }

            if ($PSCmdlet.ShouldProcess($upn, 'Hide from address list')) {
                Invoke-Step $upn 'hide from GAL' {
                    Set-Mailbox -Identity $upn -HiddenFromAddressListsEnabled $true
                } | Out-Null
            }
        } else {
            Write-Step -Upn $upn -Step 'mailbox steps' -Status 'skipped' -Detail 'No Exchange mailbox on this account'
        }

        # OneDrive is reported, not moved. See the header.
        try {
            $drive = Get-MgUserDefaultDrive -UserId $user.Id -ErrorAction SilentlyContinue
            if ($drive) {
                Write-Step -Upn $upn -Step 'onedrive' -Status 'blocked' `
                    -Detail "Move the content, then re-run with -OneDriveHandled. $($drive.WebUrl)"
            }
        } catch {
            Write-Step -Upn $upn -Step 'onedrive' -Status 'skipped' -Detail 'No OneDrive provisioned'
        }

        # -- 3. Remove ----------------------------------------------------------

        if ($PSCmdlet.ShouldProcess($upn, 'Remove group memberships')) {
            Invoke-Step $upn 'remove groups' {
                $groups = Get-MgUserMemberOf -UserId $user.Id -All |
                    Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group' -and
                                   $_.AdditionalProperties['groupTypes'] -notcontains 'DynamicMembership' }
                foreach ($g in $groups) {
                    try {
                        Remove-MgGroupMemberByRef -GroupId $g.Id -DirectoryObjectId $user.Id -ErrorAction Stop
                    } catch {
                        # Mail-enabled security and distribution groups are not
                        # writable through Graph. Exchange owns them.
                        Remove-DistributionGroupMember -Identity $g.Id -Member $upn `
                            -BypassSecurityGroupManagerCheck -Confirm:$false -ErrorAction SilentlyContinue
                    }
                }
            } -Detail 'Dynamic groups left alone; their rules decide membership' | Out-Null
        }

        if ($PSCmdlet.ShouldProcess($upn, 'Remove admin roles')) {
            Invoke-Step $upn 'remove admin roles' {
                Get-MgUserMemberOf -UserId $user.Id -All |
                    Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.directoryRole' } |
                    ForEach-Object {
                        Remove-MgDirectoryRoleMemberByRef -DirectoryRoleId $_.Id -DirectoryObjectId $user.Id
                    }
            } | Out-Null
        }

        if ($mailbox -and $PSCmdlet.ShouldProcess($upn, 'Wipe and block mobile devices')) {
            Invoke-Step $upn 'wipe mobile devices' {
                Get-MobileDevice -Mailbox $upn -ErrorAction SilentlyContinue | ForEach-Object {
                    Clear-MobileDevice -Identity $_.Identity -AccountOnly -Confirm:$false
                }
            } -Detail 'Account data only. Personal devices keep personal content' | Out-Null
        }

        # -- 4. Reclaim, only where it is safe ----------------------------------

        if ($RemoveLicence) {
            if ($mailbox -and -not $preserved) {
                Write-Step -Upn $upn -Step 'remove licence' -Status 'blocked' `
                    -Detail 'Mailbox was not converted to shared. Removing the licence now would delete it.'
            } elseif ($PSCmdlet.ShouldProcess($upn, 'Remove all licences')) {
                Invoke-Step $upn 'remove licence' {
                    $skus = (Get-MgUserLicenseDetail -UserId $upn).SkuId
                    if ($skus) { Set-MgUserLicense -UserId $upn -RemoveLicenses $skus -AddLicenses @() | Out-Null }
                } | Out-Null
            }
        }

        if ($DeleteAccount) {
            if (-not $OneDriveHandled) {
                Write-Step -Upn $upn -Step 'delete account' -Status 'blocked' `
                    -Detail 'OneDrive not confirmed handled. Deleting starts a 30 day clock on those files.'
            } elseif ($PSCmdlet.ShouldProcess($upn, 'DELETE the account')) {
                Invoke-Step $upn 'delete account' { Remove-MgUser -UserId $user.Id } `
                    -Detail 'Recoverable for 30 days' | Out-Null
            }
        }
    }
}

end {
    if ($results.Count) {
        $results | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
        Write-Host "`nReport: $ReportPath" -ForegroundColor Cyan

        $blocked = $results | Where-Object Status -in 'blocked', 'failed'
        if ($blocked) {
            Write-Host "`n$($blocked.Count) step(s) need a person:" -ForegroundColor Yellow
            $blocked | ForEach-Object { Write-Host "  $($_.User)  $($_.Step)  $($_.Detail)" }
        } else {
            Write-Host "`nEverything completed." -ForegroundColor Green
        }
    }

    Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
    Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
}

04 / Built on

In your tenant, under your licences.

  • Power Automate
  • Microsoft 365
  • Azure

Yours may differ. What a process runs on is decided by what it touches, not by what we prefer building with.

Does yours run like this?

It probably runs nearly like this, with two steps that are entirely your own. Those two are the interesting part.