Private/Remediation/Remediation.Helpers.ps1

function Get-TcMember {
    <#
    .SYNOPSIS
    Reads a member that may be missing, from a dictionary, an object, a list of them, or $null.
 
    .DESCRIPTION
    The scripts read what Graph and the service cmdlets return, and strict mode stops on a dotted read of a
    missing member. On a list it reads each item, as PowerShell does for a dotted read. A missing member
    gives nothing, so a script must not read nothing as a value.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)][AllowNull()]$InputObject,
        [Parameter(Mandatory, Position = 1)][string]$Name
    )

    if ($null -eq $InputObject) { return }
    if ($InputObject -is [System.Collections.IDictionary]) {
        if ($InputObject.Contains($Name)) { return $InputObject[$Name] }
        return
    }
    if ($InputObject -isnot [string] -and $InputObject -is [System.Collections.IEnumerable]) {
        foreach ($item in $InputObject) { Get-TcMember -InputObject $item -Name $Name }
        return
    }
    $property = $InputObject.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
}

function Get-TcText {
    <#
    .SYNOPSIS
    Reads a member as text: an empty string when it is missing, never $null.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Position = 0)][AllowNull()]$InputObject,
        [Parameter(Mandatory, Position = 1)][string]$Name
    )

    # A variable that holds nothing casts to $null, unlike $null itself.
    $value = Get-TcMember -InputObject $InputObject -Name $Name
    if ($null -eq $value) { return '' }
    [string]$value
}

function Get-TcSourceGroup {
    <#
    .SYNOPSIS
    The groups a project plans to create for a source, from the SourceGroups setting: @{ S1 = @(@{ name = 'Finance' }) }.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Tenant
    )

    $groups = Get-TcRemediationOption 'SourceGroups'
    if ($groups -is [System.Collections.IDictionary] -and $groups.Contains($Tenant)) { @($groups[$Tenant]) }
}

function ConvertTo-TcSecret {
    <#
    .SYNOPSIS
    Turns text that a service returned into a secure string, for a file only this Windows account can read.
    #>

    [CmdletBinding()]
    [OutputType([securestring])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text
    )

    $secret = [securestring]::new()
    foreach ($character in $Text.ToCharArray()) { $secret.AppendChar($character) }
    $secret.MakeReadOnly()
    $secret
}

function Get-TcCount {
    <#
    .SYNOPSIS
    The number of items of a value that may be $null, one object, a list or a dictionary.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Position = 0)][AllowNull()]$InputObject
    )

    if ($null -eq $InputObject) { return 0 }
    $property = $InputObject.PSObject.Properties['Count']
    if ($property) { return [int]$property.Value }
    @($InputObject).Count
}

function Get-TcSourceRole {
    <#
    .SYNOPSIS
    The source roles of the project.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    @($script:TcRemediationRun['Sources'])
}

function Get-TcProjectRole {
    <#
    .SYNOPSIS
    The roles of the project: the target and each source.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    @($script:TcRemediationRun['Known'])
}

function Get-TcObjectName {
    <#
    .SYNOPSIS
    The name of an object a script creates: the ObjectPrefix setting, then a neutral text.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Text
    )

    "$(Get-TcRemediationOption 'ObjectPrefix')$Text"
}

function Get-TcRemediationOutput {
    <#
    .SYNOPSIS
    The output folder of the project.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    $script:TcRemediationRun['Output']
}

function Get-TcDecisionName {
    <#
    .SYNOPSIS
    A decisions file name without its folder and extension.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Name
    )

    [System.IO.Path]::GetFileName($Name) -replace '\.csv$', ''
}

function Test-TcDecisionFile {
    <#
    .SYNOPSIS
    True when a decisions file exists.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Name
    )

    Test-Path -LiteralPath (Join-Path (Get-TcDecisionFolder) ('{0}.csv' -f (Get-TcDecisionName $Name)))
}

function Read-TcDecisionRow {
    <#
    .SYNOPSIS
    The usable rows of a decisions file of this or of another remediation. A refused row raises an alert.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Name
    )

    $file = Read-TcDecisionFile -Name (Get-TcDecisionName $Name)
    foreach ($number in @($file['Refused'])) { Write-TcRemediationAlert ('{0}, row {1}: a cell starts with a formula sign. The row is not used.' -f $file['Path'], $number) }
    @($file['Rows'])
}

function Export-TcDecisionRow {
    <#
    .SYNOPSIS
    Writes rows to a file of the decisions folder, with every cell safe for Excel.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][AllowNull()]$InputObject,
        [Parameter(Mandatory, Position = 0)][string]$Name
    )

    begin { $rows = [System.Collections.Generic.List[object]]::new() }
    process { if ($null -ne $InputObject) { $rows.Add($InputObject) } }
    end {
        if (-not $rows.Count) { return }
        New-Item -ItemType Directory -Force -Path (Get-TcDecisionFolder) | Out-Null
        $columns = [System.Collections.Generic.List[string]]::new()
        foreach ($row in $rows) {
            $names = if ($row -is [System.Collections.IDictionary]) { @($row.Keys) } else { @($row.PSObject.Properties | ForEach-Object Name) }
            foreach ($column in $names) { if ($column -notin $columns) { $columns.Add($column) } }
        }
        Write-TcDecisionFile -Path (Join-Path (Get-TcDecisionFolder) ('{0}.csv' -f (Get-TcDecisionName $Name))) -Row $rows.ToArray() -Column $columns.ToArray()
    }
}

function Test-TcCutoverStep {
    <#
    .SYNOPSIS
    True when a person marked a cutover step Done for a tenant, in decisions\CUTOVER-STEPS.csv.
 
    .DESCRIPTION
    This module does not run a cutover. A remediation that must wait for one of its steps reads this file,
    with the columns Tenant, Step and Decision. With no file, no step is done.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)][string]$Step
    )

    $done = @(Read-TcDecisionRow 'CUTOVER-STEPS') | Where-Object { (Get-TcProperty $_ 'Tenant') -eq $Tenant -and (Get-TcProperty $_ 'Step') -eq $Step -and (Get-TcProperty $_ 'Decision') -ceq 'Done' }
    [bool]$done
}

function Import-TcMatrixDraft {
    <#
    .SYNOPSIS
    The rows of the matrix of a source: the validated one if it exists, otherwise the draft.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Source
    )

    $matrix = Import-TcSourceMatrix -Source $Source
    if ($matrix['State'] -eq 'Missing') { Write-TcRemediationAlert "$Source`: no draft matrix, the accounts of this source were not read." }
    @($matrix['Rows'])
}

function Get-TcProtectedMatrixRow {
    <#
    .SYNOPSIS
    The protected accounts that are in the matrix of a source.
 
    .DESCRIPTION
    When sign-ins are blocked in a source, protected accounts stay open, and any enabled account of the
    matrix then fails the check, whatever its decision: only removing the row works.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Source
    )

    $info = Get-TcRemediationTenant -Tenant $Source
    $protected = @(Get-TcProtectedAccount $Source)
    @(@((Import-TcSourceMatrix -Source $Source)['Rows']) | Where-Object { $_['Source UPN'] -in $protected -or (Test-TcProtectedAccount -Account ([string]$_['Source UPN']) -Info $info) } | ForEach-Object { $_['Source UPN'] })
}

function Get-TcDomainHolder {
    <#
    .SYNOPSIS
    The display names of the accounts of a source that hold a migrated domain and have no mailbox.
 
    .DESCRIPTION
    Exchange does not see these accounts, so releasing mail addresses leaves them on the domain, and the
    removal of the domain stops on them. The snapshot is what tells: this is a reading, not a proof.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Tenant
    )

    $info = Get-TcRemediationTenant -Tenant $Tenant
    $mailboxes = @{}
    foreach ($row in @(Get-TcRemediationTable $Tenant 'mailboxes')) { $mailboxes[([string]$row['UPN']).ToLowerInvariant()] = $true }
    @(Get-TcRemediationTable $Tenant 'users' | Where-Object { (Get-TcAddressDomain ([string]$_['UPN'])) -in $info['Domains'] -and -not $mailboxes.ContainsKey(([string]$_['UPN']).ToLowerInvariant()) } | ForEach-Object { [string]$_['Name'] } | Select-Object -Unique)
}

function Read-TcSourceAddress {
    <#
    .SYNOPSIS
    The addresses a cutover froze for a source, in <output>\decisions\source-addresses-<role>.json. Nothing when there is no such file.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Source
    )

    $path = Join-Path (Get-TcDecisionFolder) "source-addresses-$Source.json"
    if (-not (Test-Path -LiteralPath $path)) { return $null }
    $data = Read-TcJsonFile -Path $path
    $addresses = @{}
    foreach ($key in @($data.Keys)) { $addresses[([string]$key).ToLowerInvariant()] = @(@(Get-TcProperty $data[$key] 'addresses' @()) | ForEach-Object { ([string]$_).ToLowerInvariant() }) }
    $addresses
}