Remediation/EntraID/ENT-INACTIVE.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
    Has the inactive accounts settled and applies in the target what is decided (procedure ENT-INACTIVE).
 
.DESCRIPTION
    Same rule as the finding of the plan: enabled member accounts with no sign-in for the InactiveDays setting (or ever),
    created at least that long ago.
 
    Sources: decisions\ENT-INACTIVE.csv, choices Create or Exclude. Nothing is written to a source: these decisions feed the
    matrix (ID-02, IDENTITY-MATRIX). Accounts that are also likely duplicates are settled in CON-IDENTITIES.csv
    (CON-IDENTITIES). For each excluded account, the script asks to decide what happens to its data.
 
    Target: decisions\ENT-INACTIVE-TARGET.csv, choices Keep, Disable or Delete. Delete aims at an account already disabled: it is a
    second decision, entered after the agreed delay. A synced account is handled in on-premises AD.
 
    Guards, whatever the decisions: emergency accounts, kept accounts and migration team accounts are never touched; a target
    account that a duplicate points to is neither disabled nor deleted.
 
    Limits: the last sign-in comes from the snapshot, also check non-interactive sign-ins before excluding. While the reports of a
    source are concealed, the mailbox size stays empty there (AUT-CONCEALED-REPORTS, then a new snapshot).
#>

$c = Open-TcRemediation

$Threshold = [int](Get-TcRemediationOption 'InactiveDays')

function Test-AccountsDisabled {
    param([object[]]$Accounts)
    foreach ($x in $Accounts) {
        $u = @(Invoke-TcGraph -Tenant 'Target' -Uri ("v1.0/users?`$filter=id eq '{0}'&`$select=accountEnabled" -f (Get-TcMember $x 'Id')) -All)
        if ($u.Count -and (Get-TcMember $u[0] 'accountEnabled')) { return $false }
    }
    return $true
}

$matrix = @{}
$duplicateTargets = @{}
foreach ($s in (Get-TcSourceRole)) {
    foreach ($l in (Get-TcMember (Get-TcRemediationIdentity $s) 'rows')) {
        $likely = if ((Get-TcMember $l 'Notes') -match 'likely duplicate of ([^\s;]+)') { $Matches[1] } else { '' }
        $matrix[("{0}|{1}" -f $s, (Get-TcMember $l 'Source UPN').ToLowerInvariant())] = @{ Decision = (Get-TcMember $l 'Decision'); Duplicate = ((Get-TcMember $l 'Decision') -eq 'Duplicate' -or $likely -or (Get-TcMember $l 'Source UPN') -match '#EXT#') }
        if ((Get-TcMember $l 'Decision') -eq 'Duplicate' -and (Get-TcMember $l 'Target UPN')) { $duplicateTargets[$l.'Target UPN'.ToLowerInvariant()] = 1 }
        if ($likely) { $duplicateTargets[$likely.ToLowerInvariant()] = 1 }
    }
}

$sourceProperties = [System.Collections.Generic.List[object]]::new()
$targetProperty = [System.Collections.Generic.List[object]]::new()
foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t 'Accounts with no recent sign-in'
    $i = Get-TcRemediationTenant $t
    $memberRows = @(Get-TcRemediationTable $t 'users' | Where-Object { $_['Type'] -eq 'Member' -and (ConvertTo-TcBoolean $_['Enabled']) })
    $inactive = @($memberRows | Where-Object { ([int]$_['Days inactive'] -ge $Threshold -or [int]$_['Days inactive'] -eq -1) -and [int]('0' + $_['Days since creation']) -ge $Threshold })
    $never = @($inactive | Where-Object { [int]$_['Days inactive'] -eq -1 }).Count
    $protected = @($inactive | Where-Object { Test-TcProtectedAccount $_['UPN'] $i })
    $mailboxes = Get-TcRowIndex (Get-TcRemediationTable $t 'mailboxes') 'UPN'
    $usage = Get-TcRowIndex (Get-TcRemediationTable $t 'mailboxUsage') 'UPN'
    $onedrive = Get-TcRowIndex (Get-TcRemediationTable $t 'oneDrive') 'Owner'
    Write-TcRemediationNote ("Of {0} active member accounts, {1} have not signed in for {2} days or more, including {3} never; {4} protected account(s) set aside" -f $memberRows.Count, $inactive.Count, $Threshold, $never, $protected.Count)
    if (ConvertTo-TcBoolean (Get-TcRemediationValue $t 'reportSettings' 'Names concealed in reports')) { Write-TcRemediationNote 'Concealed reports: mailbox size stays empty until AUT-CONCEALED-REPORTS and a new snapshot' }

    $duplicates = 0
    foreach ($u in $inactive | Where-Object { -not (Test-TcProtectedAccount $_['UPN'] $i) }) {
        $k = ([string]$u['UPN']).ToLowerInvariant()
        $b = $mailboxes[$k]
        $o = $onedrive[$k]
        $base = [ordered]@{ Tenant = $t; Account = $u['UPN']; Name = $u['Name']; 'Days inactive' = $(if ([int]$u['Days inactive'] -eq -1) { 'never' } else { $u['Days inactive'] }); Licenses = $u['Licenses']
                            'Mailbox type' = $(if ($b) { $b['Type'] }); 'Mailbox GB' = $(if ($usage[$k]) { $usage[$k]['Size GB'] }); 'OneDrive GB' = $(if ($o) { [math]::Round([double]$o['Storage MB'] / 1024, 2) }) }
        if ($t -eq 'Target') {
            $target = [bool]$duplicateTargets[$k]
            $base.Id = $u['Id']
            $base['Synced'] = $(if (ConvertTo-TcBoolean $u['Synced']) { 'Yes' } else { 'No' })
            $base['Duplicate target'] = $(if ($target) { 'Yes' } else { 'No' })
            $base.Proposal = $(if ($target -or ($b -and $b['Type'] -ne 'UserMailbox')) { 'Keep' } else { '' })
            $targetProperty.Add($base)
        }
        else {
            $m = $matrix["$t|$k"]
            if ($m -and (Get-TcMember $m 'Duplicate')) { $duplicates++; continue }
            $base.Draft = $(if ($m) { (Get-TcMember $m 'Decision') } else { 'missing from the matrix' })
            $base.Proposal = $(if ([int]$u['Days inactive'] -eq -1 -and -not [int]('0' + $u['License count'])) { 'Exclude' } else { '' })
            $sourceProperties.Add($base)
        }
    }
    if ($duplicates) { Write-TcRemediationNote ("{0} inactive account(s) are also likely duplicates: to settle in CON-IDENTITIES.csv" -f $duplicates) }
}

$sourceDecisions = @(Get-TcDecision -Name 'ENT-INACTIVE' -Proposal @($sourceProperties) -Key 'Tenant', 'Account' -Choice 'Create', 'Exclude')
$targetDecision = @(Get-TcDecision -Name 'ENT-INACTIVE-TARGET' -Proposal @($targetProperty) -Key 'Account' -Choice 'Keep', 'Disable', 'Delete')

foreach ($t in $c.Tenants | Where-Object { $_ -ne 'Target' }) {
    $excludedItems = @($sourceDecisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Exclude' })
    if (-not $excludedItems.Count) { continue }
    $f = Export-TcRemediationReport -Name "excluded-accounts-$t" -Rows $excludedItems
    Add-TcManualAction -Tenant $t -Object 'Data of excluded accounts' -Who 'Customer' -Description ("Decide what happens to the mailbox and OneDrive of {0} excluded account(s)" -f $excludedItems.Count) `
        -Instruction @("List: $f", 'The data of an excluded account is not copied: it disappears with the source tenant. Have it decided before closing (see SPO-ONEDRIVE).')
}

$toHandleInAd = [System.Collections.Generic.List[object]]::new()
$targetInfo = Get-TcRemediationTenant 'Target'
foreach ($d in $targetDecision | Where-Object { (Get-TcMember $_ 'Decision') -in 'Disable', 'Delete' }) {
    if (Test-TcProtectedAccount (Get-TcMember $d 'Account') $targetInfo) { Write-TcRemediationAlert ("{0} is a protected account: decision ignored" -f (Get-TcMember $d 'Account')); continue }
    if ($duplicateTargets[(Get-TcMember $d 'Account').ToLowerInvariant()]) { Write-TcRemediationAlert ("{0} is targeted by a duplicate: decision {1} ignored until CON-IDENTITIES has set it aside" -f (Get-TcMember $d 'Account'), (Get-TcMember $d 'Decision')); continue }
    if ((Get-TcMember $d 'Synced') -eq 'Yes') { $toHandleInAd.Add($d); continue }
    $uri = "v1.0/users/$((Get-TcMember $d 'Id'))"
    if ((Get-TcMember $d 'Decision') -eq 'Disable') {
        Add-TcGraphAction -Tenant 'Target' -Method PATCH -Uri $uri -Body @{ accountEnabled = $false } -Object (Get-TcMember $d 'Account') -Description ("Disable the inactive account {0}" -f (Get-TcMember $d 'Account')) `
            -Test { param($a) -not (Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri ('{0}?$select=accountEnabled' -f $a.uri)) 'accountEnabled') } `
            -Undo @{ type = 'Graph'; tenant = 'Target'; method = 'PATCH'; uri = $uri; body = @{ accountEnabled = $true } }
    }
    else {
        Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Account') -Description ("Delete the disabled account {0}" -f (Get-TcMember $d 'Account')) -Data @{ Id = (Get-TcMember $d 'Id') } `
            -Undo @{ type = 'Graph'; tenant = 'Target'; method = 'POST'; uri = "v1.0/directory/deletedItems/$((Get-TcMember $d 'Id'))/restore" } `
            -Test { param($a) -not @(Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users?`$filter=id eq '{0}'&`$select=id" -f $a.data.Id) -All).Count } `
            -Block {
                param($a)
                if ((Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}?`$select=accountEnabled" -f $a.data.Id)) 'accountEnabled')) { throw "Account still active: decide Disable first, then respect the agreed delay." }
                Invoke-TcGraph -Tenant $a.tenant -Method DELETE -Uri ("v1.0/users/{0}" -f $a.data.Id)
            }
    }
}

if ($toHandleInAd.Count) {
    $f = Export-TcRemediationReport -Name 'inactive-accounts-on-premises' -Rows @($toHandleInAd)
    Add-TcManualAction -Tenant 'Target' -Object 'Synced accounts' -Description ("Handle {0} inactive account(s) synced from on-premises AD" -f $toHandleInAd.Count) -Minutes 0 `
        -Instruction @("List: $f", 'These accounts come from on-premises AD: disable or remove them there, Graph would refuse the change. Run the script again after the synchronization for the check.') `
        -WaitUntil { Test-AccountsDisabled @($toHandleInAd) }
}

Invoke-TcRemediationPlan
Close-TcRemediation