Private/Remediation/Remediation.ConditionalAccess.ps1

function Resolve-TcAccountId {
    <#
    .SYNOPSIS
    On the tenant, when it acts: the identifiers of the accounts present and enabled, and the list of the others.
    #>

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

    $ids = [System.Collections.Generic.List[string]]::new()
    $missing = [System.Collections.Generic.List[string]]::new()
    foreach ($name in @($Account | Where-Object { $_ })) {
        $found = @(Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/users?`$filter=userPrincipalName eq '{0}'&`$select=id,accountEnabled" -f [uri]::EscapeDataString($name)) -All)
        if ($found.Count -and (Get-TcProperty $found[0] 'accountEnabled')) { $ids.Add($found[0]['id']) } else { $missing.Add($name) }
    }
    @{ Ids = @($ids); Missing = @($missing) }
}

function Get-TcConditionalAccessPolicy {
    <#
    .SYNOPSIS
    A conditional access policy of a tenant, by name.
    #>

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

    @(Invoke-TcGraph -Tenant $Tenant -Uri $script:TcPolicyUri -All | Where-Object { (Get-TcProperty $_ 'displayName') -eq $Name }) | Select-Object -First 1
}

function Get-TcPolicyObservationDay {
    <#
    .SYNOPSIS
    The days since a policy was created or changed: going back to report-only restarts the observation.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)]$Policy
    )

    $dates = @(@((Get-TcProperty $Policy 'createdDateTime'), (Get-TcProperty $Policy 'modifiedDateTime')) | Where-Object { $_ } | ForEach-Object { ([datetime]$_).ToUniversalTime() } | Sort-Object)
    if (-not $dates.Count) { throw 'Activation refused: the policy has no date, so the observation time cannot be proved.' }
    ((Get-TcRemediationNow).ToUniversalTime() - $dates[-1]).TotalDays
}

function Assert-TcActivationGuard {
    <#
    .SYNOPSIS
    Replayed on the tenant just before the write. A refusal is an error, never a way through.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)]$Policy
    )

    $state = Get-TcProperty $Policy 'state'
    if ($state -ne $script:TcReportOnlyState) { throw ('Activation refused: the policy is in state {0} on the tenant, not report-only.' -f $state) }
    if (Get-TcProperty (Invoke-TcGraph -Tenant $Tenant -Uri 'v1.0/policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled') { throw 'Activation refused: security defaults are on.' }
    $age = Get-TcPolicyObservationDay -Policy $Policy
    if ($age -lt $script:TcPolicyObservationDays) { throw ('Activation refused: the policy has been report-only, unchanged, for {0:N1} day(s), minimum {1}.' -f $age, $script:TcPolicyObservationDays) }
    $protected = @(Get-TcProtectedAccount $Tenant)
    $accounts = Resolve-TcAccountId -Tenant $Tenant -Account $protected
    if (-not $protected.Count -or $accounts['Missing'].Count) { throw ('Activation refused: emergency accounts missing or disabled: {0}.' -f $(if ($accounts['Missing'].Count) { $accounts['Missing'] -join ', ' } else { 'no account known' })) }
    # The target gets the permission of EMERGENCY-ACCESS. The sources do not have this check.
    if ($Tenant -eq 'Target') {
        $noKey = @($accounts['Ids'] | Where-Object { -not @(Invoke-TcGraph -Tenant $Tenant -Uri ('v1.0/users/{0}/authentication/fido2Methods' -f $_) -All).Count })
        if ($noKey.Count) { throw ('Activation refused: {0} emergency account(s) with no FIDO2 key: {1}.' -f $noKey.Count, ($noKey -join ', ')) }
    }
    $accounts
}

function Assert-TcExclusionKept {
    <#
    .SYNOPSIS
    After the write: reads the policy back. With no proof that each identifier stays excluded, it goes back to report-only.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)][string]$Uri,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Id
    )

    $missing = @($Id)
    for ($attempt = 1; $attempt -le 3 -and $missing.Count; $attempt++) {
        if ($attempt -gt 1) { Wait-TcRemediation -Seconds 5 }
        try {
            $read = Invoke-TcGraph -Tenant $Tenant -Uri $Uri
            $excluded = @(Get-TcProperty (Get-TcProperty (Get-TcProperty $read 'conditions') 'users') 'excludeUsers' @())
            $missing = @($Id | Where-Object { $_ -notin $excluded })
        }
        catch { $missing = @($Id) }
    }
    if (-not $missing.Count) { return }
    try { [void](Invoke-TcGraph -Tenant $Tenant -Method PATCH -Uri $Uri -Body @{ state = $script:TcReportOnlyState }) }
    catch { throw ('URGENT: {0} emergency account(s) not excluded from an active policy, and the return to report-only failed. Set {1} back to report-only by hand ({2}).' -f $missing.Count, $Uri, (Get-TcErrorText -ErrorInput $_)) }
    throw ('Activation undone: {0} emergency account(s) not excluded after the write, the policy went back to report-only: {1}.' -f $missing.Count, ($missing -join ', '))
}

function Get-TcExclusionResult {
    <#
    .SYNOPSIS
    The final check: each protected account exists and stays excluded. On an active policy, no proof is a block.
    #>

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

    $protected = @(Get-TcProtectedAccount $Tenant)
    $accounts = Resolve-TcAccountId -Tenant $Tenant -Account $protected
    $excluded = @(Get-TcProperty (Get-TcProperty (Get-TcProperty $Policy 'conditions') 'users') 'excludeUsers' @())
    $notExcluded = @($accounts['Ids'] | Where-Object { $_ -notin $excluded })
    if ($protected.Count -and -not $accounts['Missing'].Count -and -not $notExcluded.Count) { return (New-TcRemediationResult OK 'emergency accounts excluded') }
    $text = if ($protected.Count) { '{0} emergency account(s) missing, {1} not excluded' -f $accounts['Missing'].Count, $notExcluded.Count } else { 'no emergency account known for this tenant' }
    New-TcRemediationResult $(if ((Get-TcProperty $Policy 'state') -eq 'enabled') { 'Blocking' } else { 'Warning' }) $text
}

function Add-TcPolicyActivation {
    <#
    .SYNOPSIS
    Plans the activation of a policy already observed. -Critical stops the plan if it is refused.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)][string]$Name,
        [AllowEmptyCollection()][string[]]$Temporary = @(),
        [switch]$Critical
    )

    Add-TcBlockAction -Tenant $Tenant -Service Graph -Object $Name -Description ('Enable the policy {0}' -f $Name) -Data @{ Name = $Name; Temporary = @($Temporary) } -Critical:$Critical `
        -Test { param($a) $p = Get-TcConditionalAccessPolicy $a.tenant $a.data.Name; [bool]($p -and (Get-TcProperty $p 'state') -eq 'enabled') } `
        -Block {
        param($a)
        $p = Get-TcConditionalAccessPolicy $a.tenant $a.data.Name
        if (-not $p) { throw "Policy missing from the tenant: $($a.data.Name)." }
        $accounts = Assert-TcActivationGuard -Tenant $a.tenant -Policy $p
        $others = Resolve-TcAccountId -Tenant $a.tenant -Account @($a.data.Temporary)
        foreach ($name in $others['Missing']) { Write-TcRemediationAlert ('Temporary exclusion not found, or account disabled: {0}' -f $name) }
        # A full copy: the PATCH replaces what it receives, so the guest settings must go with the rest.
        $users = Copy-TcObject (Get-TcProperty (Get-TcProperty $p 'conditions') 'users')
        $users['excludeUsers'] = @(@($users['excludeUsers']) + @($accounts['Ids']) + @($others['Ids']) | Where-Object { $_ } | Select-Object -Unique)
        $users['excludeRoles'] = @(@($users['excludeRoles']) + @($script:TcSyncRoleId) | Where-Object { $_ } | Select-Object -Unique)
        $uri = "$($script:TcPolicyUri)/$($p['id'])"
        $a['undo'] = @{ type = 'Graph'; tenant = $a.tenant; method = 'PATCH'; uri = $uri; body = @{ state = $script:TcReportOnlyState } }
        [void](Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri $uri -Body @{ state = 'enabled'; conditions = @{ users = $users } })
        Assert-TcExclusionKept -Tenant $a.tenant -Uri $uri -Id @($accounts['Ids'])
    }
}