Remediation/EntraID/ENT-GUESTS.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 guests of the sources confirmed, reinvites them to the target after the copy, and cleans the stale guests of the target (procedure ENT-GUESTS).
 
.DESCRIPTION
    Sources: decisions\ENT-GUESTS.csv, one row per useful guest, choices Reinvite or Ignore, to settle with the owners. Stale guests
    (invitation never accepted, or inactive past the threshold) and people from a tenant of the project are set aside: the first go in
    a report, the second belong to CMP-CROSS-GUESTS (ID-03).
 
    The reinvitation is planned only once the copy of teams and sites is finished: decisions\ENT-GUESTS-COPY-DONE.csv, one row per
    source, decision Done. The invitation message goes to the guest: warn them through the owner.
    An address on a domain of the project is never reinvited: the person will have a member account in the target.
 
    Target: decisions\ENT-GUESTS-TARGET.csv, one row per stale guest outside cross guests, choices Delete or Keep.
 
    Limits: the memberships of guests are not in the snapshot. Enter the teams and sites to keep in the Note column; the owners
    restore the access. Automatic expiry of inactive guests (access review) needs a customer choice and is not set up here.
#>

$c = Open-TcRemediation

$Threshold = [int](Get-TcRemediationOption 'InactiveDays')
$InvitationReturn = 'https://myapps.microsoft.com'

$projectDomains = @(@(Get-TcRemediationTable 'Target' 'domains' | ForEach-Object { $_['Domain'] }) + @((Get-TcSourceRole) | ForEach-Object { $i = Get-TcRemediationTenant $_; @((Get-TcMember $i 'Domains')) + (Get-TcMember $i 'InitialDomain') }) | ForEach-Object { ([string]$_).ToLowerInvariant() })

function Test-Stale {
    param($Guest)
    return $Guest['Invitation state'] -eq 'PendingAcceptance' -or [int]$Guest['Days inactive'] -ge $Threshold -or [int]$Guest['Days inactive'] -eq -1
}

function Test-ProjectAddress { param([string]$Address) return (Get-TcAddressDomain $Address) -in $projectDomains }

function Test-AccountProtected { param([string]$Account) return (Test-TcProtectedAccount -Account ([string]$Account)) }

function Find-TargetGuest {
    param([string]$Address)
    return @(Invoke-TcGraph -Tenant 'Target' -Uri ("v1.0/users?`$filter=mail eq '{0}'&`$select=id" -f [uri]::EscapeDataString($Address.Replace("'", "''"))) -All)
}

$sourceProperties = [System.Collections.Generic.List[object]]::new()
$targetProperty = [System.Collections.Generic.List[object]]::new()
foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t 'Guests read'
    $guests = @(Get-TcRemediationTable $t 'users' | Where-Object { $_['Type'] -eq 'Guest' })
    if (-not $guests.Count -and -not (Test-TcCollectionComplete $t 'users')) { Write-TcRemediationAlert "${t}: user collection incomplete, the absence of guests proves nothing"; continue }
    $stale = @($guests | Where-Object { Test-Stale $_ })
    $crossOnes = @($guests | Where-Object { Test-ProjectAddress $_['Email'] })
    if ($t -eq 'Target') {
        $toReview = @($stale | Where-Object { -not (Test-ProjectAddress $_['Email']) -and -not (Test-AccountProtected $_['UPN']) })
        Write-TcRemediationNote ("Of {0} guests, {1} stale; {2} to review, not counting cross-tenant guests (CMP-CROSS-GUESTS)" -f $guests.Count, $stale.Count, $toReview.Count)
        foreach ($u in $toReview) {
            $targetProperty.Add([ordered]@{ Account = $u['UPN']; Id = $u['Id']; Name = $u['Name']; Email = $u['Email']; 'Invitation state' = $u['Invitation state']; 'Days inactive' = $u['Days inactive']; Proposal = '' })
        }
        continue
    }
    $useful = @($guests | Where-Object { -not (Test-Stale $_) -and -not (Test-ProjectAddress $_['Email']) })
    Write-TcRemediationNote ("Of {0} guests, {1} stale and {2} people from a tenant in scope; {3} to confirm" -f $guests.Count, $stale.Count, $crossOnes.Count, $useful.Count)
    if ($stale.Count) { Export-TcRemediationReport -Name "stale-guests-$t" -Rows @($stale | ForEach-Object { [ordered]@{ Account = $_['UPN']; Name = $_['Name']; Email = $_['Email']; 'Invitation state' = $_['Invitation state']; 'Days inactive' = $_['Days inactive'] } }) | Out-Null }
    foreach ($u in $useful) {
        $sourceProperties.Add([ordered]@{ Tenant = $t; Email = $u['Email']; Name = $u['Name']; 'Days inactive' = $u['Days inactive']; Company = $u['Company']; Proposal = 'Reinvite' })
    }
}

$sources = @($c.Tenants | Where-Object { $_ -ne 'Target' })
$sourceDecisions = @(Get-TcDecision -Name 'ENT-GUESTS' -Proposal @($sourceProperties) -Key 'Tenant', 'Email' -Choice 'Reinvite', 'Ignore')
$targetDecision = @(Get-TcDecision -Name 'ENT-GUESTS-TARGET' -Proposal @($targetProperty) -Key 'Account' -Choice 'Delete', 'Keep')
$copies = @(Get-TcDecision -Name 'ENT-GUESTS-COPY-DONE' -Proposal @($sources | ForEach-Object { [ordered]@{ Tenant = $_; Object = 'Copy of teams and sites to the target'; Proposal = '' } }) -Key 'Tenant' -Choice 'Done' | ForEach-Object { (Get-TcMember $_ 'Tenant') })

foreach ($t in $sources) {
    $toInvite = @($sourceDecisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Reinvite' })
    if (-not $toInvite.Count) { continue }
    if ($t -notin $copies) { Write-TcRemediationNote ("{0}: {1} reinvitation(s) decided, waiting for the copy to finish (ENT-GUESTS-COPY-DONE.csv)" -f $t, $toInvite.Count); continue }
    foreach ($d in $toInvite) {
        if (Test-ProjectAddress (Get-TcMember $d 'Email')) { Write-TcRemediationAlert ("{0}: {1} belongs to a domain in scope, it will not be reinvited" -f $t, (Get-TcMember $d 'Email')); continue }
        Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Email') -Description ("Reinvite {0} in the target (guest of {1})" -f (Get-TcMember $d 'Email'), $t) -Data @{ Address = (Get-TcMember $d 'Email'); Name = (Get-TcMember $d 'Name') } `
            -Test { param($a) [bool](Get-TcCount (Find-TargetGuest $a.data.Address)) } `
            -Block {
                param($a)
                $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri 'v1.0/invitations' -Body @{ invitedUserEmailAddress = $a.data.Address; invitedUserDisplayName = $a.data.Name; inviteRedirectUrl = $InvitationReturn; sendInvitationMessage = $true }
                $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = ("v1.0/users/{0}" -f (Get-TcMember (Get-TcMember $r 'invitedUser') 'id')) }
            }
    }
    $f = Export-TcRemediationReport -Name "guests-access-to-restore-$t" -Rows $toInvite
    Add-TcManualAction -Tenant 'Target' -Object "Guest access of $t" -Who 'Owners' -Description ("Restore the access of {0} guest(s) of {1}: teams, sites and groups" -f $toInvite.Count, $t) `
        -Instruction @("List, with the access noted in the Note column: $f", 'Each guest must accept the new invitation message. The owner then restores their access and confirms.')
}

foreach ($d in $targetDecision | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Delete' }) {
    if (Test-ProjectAddress (Get-TcMember $d 'Email')) { Write-TcRemediationAlert ("{0} falls under CMP-CROSS-GUESTS: decision ignored" -f (Get-TcMember $d 'Account')); continue }
    Add-TcGraphAction -Tenant 'Target' -Method DELETE -Uri "v1.0/users/$((Get-TcMember $d 'Id'))" -Object (Get-TcMember $d 'Account') -Description ("Delete the stale guest {0}" -f (Get-TcMember $d 'Email')) `
        -Test { param($a) -not @(Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users?`$filter=id eq '{0}'&`$select=id" -f ($a.uri -replace '^.*/', '')) -All).Count } `
        -Undo @{ type = 'Graph'; tenant = 'Target'; method = 'POST'; uri = "v1.0/directory/deletedItems/$((Get-TcMember $d 'Id'))/restore" }
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    if ($t -eq 'Target' -or $t -notin $copies) { return }
    $expectedItems = @($sourceDecisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Reinvite' -and -not (Test-ProjectAddress (Get-TcMember $_ 'Email')) })
    $missing = @($expectedItems | Where-Object { -not (Get-TcCount (Find-TargetGuest (Get-TcMember $_ 'Email'))) })
    if ($missing.Count) { New-TcRemediationResult Warning ("{0} confirmed guest(s) missing from the target" -f $missing.Count) } else { New-TcRemediationResult OK ("{0} confirmed guest(s) present in the target" -f $expectedItems.Count) }
}
Close-TcRemediation