Private/Remediation/Remediation.Data.ps1

function Get-TcRemediationTable {
    <#
    .SYNOPSIS
    The rows of a table of a tenant, from the snapshot. A table that was not fully read raises an alert once.
    #>

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

    $run = $script:TcRemediationRun
    $dictionary = Get-TcTableDictionary
    if (-not $dictionary.Contains($Table)) { throw "Unknown table: $Table." }
    $state = Get-TcTableState -Snapshot $run['Snapshot'] -Role $Tenant -Table $Table
    if ($state.State -in 'NotCollected', 'NotFullyRead' -and -not $run['UnreadSaid'].ContainsKey("$Tenant|$Table")) {
        $run['UnreadSaid']["$Tenant|$Table"] = $true
        Write-TcRemediationAlert ('{0}: table {1} was not fully read ({2}). An empty result proves nothing here.' -f $Tenant, $Table, $state.Status)
    }
    @(Get-TcProperty (Get-TcProperty (Get-TcProperty $run['Snapshot']['tenants'] $Tenant) 'tables') $Table @() | Where-Object { $null -ne $_ })
}

function Get-TcRemediationValue {
    <#
    .SYNOPSIS
    A value from a Setting and Value table such as securitySettings or exchangeSettings.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Tenant,
        [Parameter(Mandatory, Position = 1)][string]$Table,
        [Parameter(Mandatory, Position = 2)][string]$Parameter,
        [Parameter(Position = 3)]$Default = $null
    )

    $row = Get-TcRemediationTable -Tenant $Tenant -Table $Table | Where-Object { $_['Setting'] -eq $Parameter } | Select-Object -First 1
    if ($row) { return $row['Value'] }
    $Default
}

function Test-TcCollectionComplete {
    <#
    .SYNOPSIS
    False when the collector of a table did not finish: an empty table then proves nothing.
    #>

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

    (Get-TcTableState -Snapshot $script:TcRemediationRun['Snapshot'] -Role $Tenant -Table $Table).State -in 'Collected', 'NotUsed'
}

function Get-TcRemediationPlanAction {
    <#
    .SYNOPSIS
    The actions of the plan for a procedure, with their items.
    #>

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

    @(Get-TcProperty $script:TcRemediationRun['PlanData'] 'actions' @()) |
        Where-Object { ($_['procedure'] -eq $Procedure -or $_['ruleId'] -eq $Procedure) -and (-not $Tenant -or $_['role'] -eq $Tenant -or $_['role'] -eq 'All') } |
        ForEach-Object {
            $copy = [ordered]@{}
            foreach ($key in @($_.Keys)) { $copy[$key] = $_[$key] }
            $copy['tenant'] = $_['role']
            $copy
        }
}

function Get-TcRemediationPlanBlocker {
    <#
    .SYNOPSIS
    The blocking actions of the plan for a tenant.
    #>

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

    @(Get-TcProperty $script:TcRemediationRun['PlanData'] 'actions' @()) | Where-Object { $_['blocking'] -and ($_['role'] -eq $Tenant -or $_['role'] -eq 'All') }
}

function Get-TcRemediationPlanRole {
    <#
    .SYNOPSIS
    The tenants the plan names for some rules. All covers every tenant of the project.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][System.Collections.IDictionary]$Plan,
        [AllowEmptyCollection()][string[]]$RuleId = @(),
        [string[]]$Known
    )

    if (-not $Plan) { $Plan = $script:TcRemediationRun['PlanData']; $Known = $script:TcRemediationRun['Known'] }
    $roles = @(@(Get-TcProperty $Plan 'actions' @()) | Where-Object { $_['procedure'] -in $RuleId -or $_['ruleId'] -in $RuleId } | ForEach-Object { $_['role'] } | Select-Object -Unique)
    if ('All' -in $roles) { return $Known }
    @($Known | Where-Object { $_ -in $roles })
}

function Get-TcRemediationTenant {
    <#
    .SYNOPSIS
    What a remediation knows about a tenant: name, identifier, domains, and the accounts to keep.
    #>

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

    $run = $script:TcRemediationRun
    $node = if ($Tenant -eq 'Target') { Get-TcProperty $run['Project'] 'target' } else { @(Get-TcProperty $run['Project'] 'sources' @()) | Where-Object { (Get-TcProperty $_ 'role') -eq $Tenant } | Select-Object -First 1 }
    if ($null -eq $node) { throw "Unknown tenant: $Tenant." }
    $snapshotNode = Get-TcProperty $run['Snapshot']['tenants'] $Tenant
    $domains = @(Get-TcProperty (Get-TcProperty $snapshotNode 'tables') 'domains' @())
    $initial = $domains | Where-Object { Test-TcTrue (Get-TcProperty $_ 'Initial') } | Select-Object -First 1
    $custom = @($domains | Where-Object { [string](Get-TcProperty $_ 'Domain') -notmatch '\.onmicrosoft\.com$' } | ForEach-Object { [string](Get-TcProperty $_ 'Domain') })
    $primary = [string](Get-TcProperty $node 'primaryDomain')
    if (-not $custom.Count -and $primary) { $custom = @($primary) }
    @{
        Code = $Tenant; Name = [string](Get-TcProperty $node 'displayName' $Tenant); TenantId = Get-TcProperty $node 'tenantId' (Get-TcProperty $snapshotNode 'tenantId'); PrimaryDomain = $primary
        InitialDomain = [string](Get-TcProperty $initial 'Domain' $primary); Domains = $custom; Prefix = $Tenant.ToLowerInvariant()
        KeptAccounts = @(Get-TcProperty (Get-TcRemediationOption 'KeptAccounts') $Tenant @())
    }
}

function Get-TcRemediationIdentity {
    <#
    .SYNOPSIS
    The draft identity matrix of a source, as the plan computes it, or the validated one when it exists.
    #>

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

    $matrix = Import-TcSourceMatrix -Source $Tenant
    $rows = @($matrix['Rows'])
    $synced = @{}
    foreach ($user in @(Get-TcProperty (Get-TcProperty (Get-TcProperty $script:TcRemediationRun['Snapshot']['tenants'] 'Target') 'tables') 'users' @())) {
        if (Test-TcTrue (Get-TcProperty $user 'Synced')) { $synced[([string](Get-TcProperty $user 'UPN')).ToLowerInvariant()] = $true }
    }
    foreach ($row in $rows) { $row['Synced'] = $synced.ContainsKey(([string](Get-TcProperty $row 'Target UPN')).ToLowerInvariant()) }
    @{ Rows = $rows; State = $matrix['State']; Bookings = @($rows | Where-Object { [string](Get-TcProperty $_ 'Notes') -match 'Bookings' }).Count }
}

function Import-TcSourceMatrix {
    <#
    .SYNOPSIS
    The validated matrix of a source if it exists, in <output>\decisions\identity-matrix-<role>.csv, or the draft.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][string]$Source
    )

    $run = $script:TcRemediationRun
    $file = Read-TcDecisionFile -Name "identity-matrix-$Source"
    if ($file['State'] -eq 'Read') {
        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) }
        $rows = @($file['Rows'] | ForEach-Object { $copy = [ordered]@{}; foreach ($property in $_.PSObject.Properties) { $copy[$property.Name] = $property.Value }; $copy })
        return @{ State = 'Validated'; Rows = $rows }
    }
    if ($Source -notin $run['Sources'] -or -not $run['Snapshot']['tenants'].Contains($Source)) { return @{ State = 'Missing'; Rows = @() } }
    @{ State = 'Draft'; Rows = @(ConvertTo-TcIdentityMatrix -Snapshot $run['Snapshot'] -SourceRole $Source) }
}

function Find-TcMatrixRow {
    <#
    .SYNOPSIS
    The row of an account in the matrix of a source.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)][string]$Tenant,
        [Parameter(Mandatory, Position = 1)][AllowEmptyString()][string]$Account
    )

    (Get-TcRemediationIdentity -Tenant $Tenant)['Rows'] | Where-Object { $_['Source UPN'] -eq $Account } | Select-Object -First 1
}

function ConvertTo-TcBoolean {
    <#
    .SYNOPSIS
    One way to read a value that may be a boolean or a text.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Position = 0)][AllowNull()]$Value
    )

    if ($Value -is [bool]) { return $Value }
    ([string]$Value).Trim() -in 'Yes', 'True', 'true', 'yes', '1', 'Enabled', 'enabled', 'Active', 'active', 'On'
}

function Copy-TcObject {
    <#
    .SYNOPSIS
    A full copy of an object, as dictionaries and arrays.
    #>

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

    ConvertTo-TcHashtable -InputObject ($InputObject | ConvertTo-Json -Depth 50 | ConvertFrom-Json -AsHashtable -Depth 50)
}

function Get-TcRowIndex {
    <#
    .SYNOPSIS
    A dictionary of rows by one column, whatever the case.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Position = 0)][AllowEmptyCollection()][object[]]$Rows,
        [Parameter(Mandatory, Position = 1)][string]$Key
    )

    $index = @{}
    foreach ($item in @($Rows)) {
        $value = ([string](Get-TcProperty $item $Key)).ToLowerInvariant()
        if ($value) { $index[$value] = $item }
    }
    $index
}

function Get-TcRandomPassword {
    <#
    .SYNOPSIS
    A random password with every character class. It never goes in a log.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'Generates a secret.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [int]$Length = 20
    )

    $sets = @('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnopqrstuvwxyz', '23456789', '!#$%&*+-=?@')
    $characters = [System.Collections.Generic.List[char]]::new()
    foreach ($set in $sets) { $characters.Add($set[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($set.Length)]) }
    $all = -join $sets
    foreach ($i in 1..($Length - 4)) { $characters.Add($all[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($all.Length)]) }
    -join ($characters | Sort-Object { [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(1000) })
}

function Get-TcRemediationLicense {
    <#
    .SYNOPSIS
    The license migrated accounts get, from the LicenseSku setting. A script that needs it stops without one.
    #>

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

    $sku = ([string](Get-TcRemediationOption 'LicenseSku')).Trim()
    if (-not $sku) { throw 'This script needs the license migrated accounts get: set LicenseSku in the remediation settings, with -Setting or in decisions\settings.json. Nothing was changed.' }
    $sku
}

function Save-TcRemediationSecret {
    <#
    .SYNOPSIS
    Saves a secret with DPAPI: only this Windows account on this computer can read it. Returns the file path.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The value comes from the service, and only exists as plain text at this point.')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$Value
    )

    $folder = Join-Path $script:TcRemediationRun['Folder'] 'secrets'
    New-Item -ItemType Directory -Force -Path $folder | Out-Null
    $path = Join-Path $folder "$Name.xml"
    ConvertTo-SecureString $Value -AsPlainText -Force | Export-Clixml -LiteralPath $path
    $path
}

function Test-TcPostponed {
    <#
    .SYNOPSIS
    In a -Test block: the action does not run, the run ends with code 1, and a rerun picks it up.
    #>

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

    $run = $script:TcRemediationRun
    if (-not $run['Postponed'].ContainsKey($Reason)) {
        $run['Postponed'][$Reason] = $true
        Write-TcRemediationAlert ('Postponed: {0}' -f $Reason)
    }
    $Action['postponed'] = $true
    $Action['description'] = 'Postponed, {0}: {1}' -f $Reason, $Action['description']
    $true
}