Remediation/DomainsAndDNS/RELEASE-DOMAIN.ps1

# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk.
<#
.SYNOPSIS
    Releases the source from what stays on the domains, before the domains are removed (procedure RELEASE-DOMAIN).
 
.DESCRIPTION
    Run this script between the release of the UPNs and the removal of the domains. It waits for that moment: until the row UPNs released
    says Done in decisions\CUTOVER-STEPS.csv, it changes nothing, because taking an address off a domain still active bounces the
    mail sent to it.
 
    It plans, for each source:
      - the initial domain as the default domain: a default domain cannot be removed;
      - the dynamic lists, contacts and mail users still on the domain, read live with Get-Recipient because the snapshot does not give their
        secondary addresses; each object keeps its undo;
      - the accounts with no mailbox that nothing releases: Exchange does not see them and proxyAddresses is read-only.
 
    Decision: decisions\RELEASE-DOMAIN.csv, one row per account that would stay when the domains are removed.
      Delete : deletes the account, then removes it from the recycle bin. Irreversible after the purge.
      Forced removal: removes the domain with forceDelete, which renames UPN, mail and proxyAddresses of the remaining accounts, without
                      disabling them. The domain is then already gone. Microsoft limit: 1000 objects.
      Keep : the domain cannot be removed while the account stays on it.
 
    Emergency accounts, kept accounts and migration team accounts are never touched: the decision Delete is ignored and the forced
    removal of a domain is refused as soon as it aims at one. A display name held by several accounts gets no proposal: it is handled
    by hand. All the actions are critical: a failure stops the plan before the purge and the forced removal.
    Bookings pages are decided in the matrix (recreate or drop).
#>

$c = Open-TcRemediation

$RemovalMinutes = 60

function Test-AddressesReleased {
    <# True when the row UPNs released says Done: the addresses and UPNs are released, the freeze is in place. #>
    param([string]$Source)
    return (Test-TcCutoverStep -Tenant $Source -Step 'UPNs released')
}

function Test-AddressDomain {
    param([string]$Address, [string[]]$Domains)
    return $Address -match '^(?i)smtp:' -and (Get-TcAddressDomain $Address) -in $Domains
}

function Get-RemainingRecipient {
    param([string]$Tenant, [string[]]$Domains)
    $r = @(Invoke-TcServiceCommand -Tenant $Tenant -Service Exo -Command 'Get-Recipient' -Parameters @{ ResultSize = 'Unlimited'; RecipientType = @('DynamicDistributionGroup', 'MailContact', 'MailUser') })
    return @($r | Where-Object { @((Get-TcMember $_ 'EmailAddresses') | Where-Object { Test-AddressDomain ([string]$_) $Domains }).Count })
}

$proposals = foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t
    $i = Get-TcRemediationTenant $t
    $m = Get-TcRemediationIdentity $t
    Write-TcRemediationNote ("{0} Bookings page(s) to decide in the matrix, handled when mail addresses are released in the source" -f (Get-TcMember $m 'Bookings'))
    Write-TcRemediationNote ("{0} dynamic list(s), {1} contact(s) or mail user(s) read; their addresses are read live during the -Apply run" -f @(Get-TcRemediationTable $t 'dynamicGroups').Count, @(Get-TcRemediationTable $t 'contacts').Count)
    $names = @(Get-TcDomainHolder $t)
    Write-TcRemediationNote ("{0} object(s) would stay on the domains when they are removed from the source" -f $names.Count)
    $users = @(Get-TcRemediationTable $t 'users')
    foreach ($n in $names) {
        $found = @($users | Where-Object { $_['Name'] -eq $n })
        if ($found.Count -gt 1) { Write-TcRemediationAlert "$n is held by $($found.Count) accounts: no proposal, handle by hand."; continue }
        if (-not $found.Count) { Write-TcRemediationAlert "$n stays on the domain and is not an account in the snapshot: handle by hand."; continue }
        $u = $found[0]
        $l = @((Get-TcMember $m 'rows') | Where-Object { (Get-TcMember $_ 'Source UPN') -eq $u['UPN'] }) | Select-Object -First 1
        $over = @(([string]$u['Addresses'] -split '[;,\s]+') + @([string]$u['UPN']) | ForEach-Object { Get-TcAddressDomain $_ } | Where-Object { $_ -in (Get-TcMember $i 'Domains') } | Select-Object -Unique)
        [ordered]@{ Tenant = $t; Account = $u['UPN']; Name = $u['Name']; Id = $u['Id']; Active = $u['Enabled']; Licenses = $u['License count']; Matrix = $(if ($l) { (Get-TcMember $l 'Decision') } else { 'Missing' }); Domains = ($over -join ', ')
                    Proposal = $(if (Test-TcProtectedAccount $u['UPN'] $i) { 'Keep' } elseif ($l -and (Get-TcMember $l 'Decision') -eq 'Exclude') { 'Delete' } else { 'Forced removal' }) }
    }
}
$proposals = @($proposals)
[void](Export-TcRemediationReport -Name 'accounts-without-mailbox' -Rows $proposals)
$decisions = @(Get-TcDecision -Name 'RELEASE-DOMAIN' -Proposal $proposals -Key 'Tenant', 'Account' -Choice 'Delete', 'Forced removal', 'Keep')

foreach ($t in $c.Tenants) {
    $i = Get-TcRemediationTenant $t
    if ($c.Apply -and -not (Test-AddressesReleased $t)) {
        Write-TcRemediationAlert "${t}: the UPNs are not released in the source yet. Nothing is released: run this script at the right moment of the cutover."
        continue
    }

    $defaultValue = @(Get-TcRemediationTable $t 'domains' | Where-Object { $_['Default'] -and $_['Domain'] -in (Get-TcMember $i 'Domains') } | ForEach-Object { $_['Domain'] }) | Select-Object -First 1
    if ($defaultValue) {
        Add-TcGraphAction -Tenant $t -Method PATCH -Uri "v1.0/domains/$((Get-TcMember $i 'InitialDomain'))" -Body @{ isDefault = $true } -Object (Get-TcMember $i 'InitialDomain') -Description "Make $((Get-TcMember $i 'InitialDomain')) the default domain in place of $defaultValue" -Critical `
            -Undo @{ type = 'Graph'; tenant = $t; method = 'PATCH'; uri = "v1.0/domains/$defaultValue"; body = @{ isDefault = $true } } `
            -Test { param($a) [bool](Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri "v1.0/domains/$($a.object)") 'isDefault') }
    }

    Add-TcBlockAction -Tenant $t -Service Exo -Object 'Recipients outside the release step' -Description "Release the dynamic lists, contacts and mail users still on $((Get-TcMember $i 'Domains') -join ', ')" -Critical `
        -Data @{ Domains = @((Get-TcMember $i 'Domains')); Initial = (Get-TcMember $i 'InitialDomain') } `
        -Test {
            param($a)
            $a.data.Remaining = @(Get-RemainingRecipient -Tenant $a.tenant -Domains $a.data.Domains)
            return -not $a.data.Remaining.Count
        } `
        -Block {
            param($a)
            $commands = @{ DynamicDistributionGroup = 'Set-DynamicDistributionGroup'; MailContact = 'Set-MailContact'; MailUser = 'Set-MailUser' }
            $domains = @($a.data.Domains); $initial = $a.data.Initial
            $undos = [System.Collections.Generic.List[object]]::new()
            $failures = [System.Collections.Generic.List[string]]::new()
            foreach ($r in $a.data.Remaining) {
                $name = (Get-TcText $r 'PrimarySmtpAddress')
                $command = $commands[(Get-TcText $r 'RecipientType')]
                if ((Get-TcAddressDomain ((Get-TcText $r 'ExternalEmailAddress'))) -in $domains) { $failures.Add("${name}: external address on the domain, to fix by hand"); continue }
                $before = @((Get-TcMember $r 'EmailAddresses') | ForEach-Object { [string]$_ })
                $kept = @($before | Where-Object { -not (Test-AddressDomain $_ $domains) })
                if (-not @($kept | Where-Object { $_ -cmatch '^SMTP:' }).Count) {
                    $resume = @($kept | Where-Object { $_ -match '^(?i)smtp:' -and (Get-TcAddressDomain $_) -eq $initial }) | Select-Object -First 1
                    if ($resume) { $kept = @($kept | ForEach-Object { if ($_ -eq $resume) { 'SMTP:' + ($_ -replace '^(?i)smtp:', '') } else { $_ } }) }
                    else { $kept += 'SMTP:{0}@{1}' -f (Get-TcAddressPrefix $name), $initial }
                }
                try {
                    Invoke-TcServiceCommand -Tenant $a.tenant -Service Exo -Command $command -Parameters @{ Identity = (Get-TcText $r 'Guid'); EmailAddresses = $kept } | Out-Null
                    $undos.Add(@{ tenant = $a.tenant; service = 'Exo'; command = $command; parameters = @{ Identity = (Get-TcText $r 'Guid'); EmailAddresses = $before } })
                }
                catch { $failures.Add(("{0}: {1}" -f $name, (Get-TcErrorText $_))) }
            }
            $a.undo = $undos.ToArray()
            if ($failures.Count) { throw ("{0} recipient(s) not released out of {1}: {2}" -f $failures.Count, @($a.data.Remaining).Count, ($failures -join ' ; ')) }
        }

    foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Delete' }) {
        if (Test-TcProtectedAccount (Get-TcMember $d 'Account') $i) { Write-TcRemediationAlert ("{0} is a protected account: decision Delete ignored" -f (Get-TcMember $d 'Account')); continue }
        Add-TcGraphAction -Tenant $t -Method DELETE -Uri "v1.0/users/$((Get-TcMember $d 'Id'))" -Object (Get-TcMember $d 'Account') -Description "Delete the account $((Get-TcMember $d 'Account'))" -Critical `
            -Undo @{ type = 'Graph'; tenant = $t; method = 'POST'; uri = "v1.0/directory/deletedItems/$((Get-TcMember $d 'Id'))/restore" } `
            -Test { param($a) Test-TcGraphAbsent -Tenant $a.tenant -Uri $a.uri }
        Add-TcGraphAction -Tenant $t -Method DELETE -Uri "v1.0/directory/deletedItems/$((Get-TcMember $d 'Id'))" -Object (Get-TcMember $d 'Account') -Description "Remove $((Get-TcMember $d 'Account')) from the recycle bin" -Critical `
            -Reason 'Final purge: a deleted account keeps its reference to the domain while it is in the recycle bin.' `
            -Test { param($a) Test-TcGraphAbsent -Tenant $a.tenant -Uri $a.uri }
    }

    foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Keep' }) { Write-TcRemediationAlert ("{0} stays on {1}: the domain removal stops while this account is there" -f (Get-TcMember $d 'Account'), (Get-TcMember $d 'Domains')) }

    $forced = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Forced removal' })
    foreach ($n in @($forced | ForEach-Object { (Get-TcMember $_ 'Domains') -split ',\s*' } | Where-Object { $_ } | Select-Object -Unique)) {
        $aimedAt = @($forced | Where-Object { $n -in ((Get-TcMember $_ 'Domains') -split ',\s*') })
        $protected = @($aimedAt | Where-Object { Test-TcProtectedAccount (Get-TcMember $_ 'Account') $i } | ForEach-Object { (Get-TcMember $_ 'Account') })
        if ($protected.Count) { Write-TcRemediationAlert ("Forced removal of {0} refused: it would rename {1}, protected account(s)" -f $n, ($protected -join ', ')); continue }
        Add-TcBlockAction -Tenant $t -Service Graph -Object $n -Description "Forced removal of ${n}: renames the remaining accounts without disabling them" -Critical `
            -Data @{ Domain = $n; Accounts = @($aimedAt | ForEach-Object { (Get-TcMember $_ 'Id') }) } `
            -Reason 'The domain leaves the source: to go back, add it back to the source and rename the accounts.' `
            -Test { param($a) -not (Test-TcTenantDomain -Tenant $a.tenant -Domain $a.data.Domain) } `
            -Block {
                param($a)
                $n = $a.data.Domain
                $refs = @(Invoke-TcGraph -Tenant $a.tenant -Uri "v1.0/domains/$n/domainNameReferences" -All)
                $others = @($refs | Where-Object { (Get-TcMember $_ 'id') -notin $a.data.Accounts })
                if ($others.Count) { throw ("{0}: {1} reference(s) outside the decisions, forced removal refused: {2}" -f $n, $others.Count, (($others | ForEach-Object { (Get-TcMember $_ 'displayName') }) -join ', ')) }
                $info = Get-TcRemediationTenant $a.tenant
                $liveItems = @($refs | Where-Object { Test-TcProtectedAccount ((Get-TcText $_ 'userPrincipalName')) $info })
                if ($liveItems.Count) { throw ("{0}: forced removal refused, protected account(s) on the domain: {1}" -f $n, (($liveItems | ForEach-Object { (Get-TcMember $_ 'userPrincipalName') }) -join ', ')) }
                Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri "v1.0/domains/$n/forceDelete" -Body @{ disableUserAccounts = $false } | Out-Null
                $end = (Get-TcRemediationNow).AddMinutes($RemovalMinutes)
                while (Test-TcTenantDomain -Tenant $a.tenant -Domain $n) {
                    if ((Get-TcRemediationNow) -ge $end) { throw "$n still present after $RemovalMinutes minutes: the operation goes on, run again later." }
                    Wait-TcRemediation -Seconds 30
                }
            }
    }
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    if (-not (Test-AddressesReleased $t)) { New-TcRemediationResult Info 'check skipped: the UPNs are not released in the source yet'; return }
    foreach ($d in (Get-TcMember (Get-TcRemediationTenant $t) 'Domains')) {
        if (-not (Test-TcTenantDomain -Tenant $t -Domain $d)) { New-TcRemediationResult OK "${d}: already removed from the source"; continue }
        $refs = @(Invoke-TcGraph -Tenant $t -Uri "v1.0/domains/$d/domainNameReferences" -All)
        if ($refs.Count) { New-TcRemediationResult Blocking ("{0}: still {1} reference(s): {2}" -f $d, $refs.Count, (($refs | Select-Object -First 8 | ForEach-Object { (Get-TcMember $_ 'displayName') }) -join ', ')) }
        else { New-TcRemediationResult OK "${d}: no reference, the domain can be removed" }
    }
}
Close-TcRemediation