Remediation/EntraID/ENT-MFA.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
    Creates in report-only the policy that requires MFA for all, then enables it on decision (procedure ENT-MFA).
 
.DESCRIPTION
    Data read: the conditional access policies, security settings, accounts and roles read.
    The policy is created only if the customer decides so, in the target as in a source: with no decision, nothing is planned.
    The proposal is Report only in the target, a mode that touches no sign-in, and Do not apply in a source.
    Scope: all users, all resources, authentication strength Multifactor authentication.
    Exclusions: emergency accounts and the sync accounts role, which covers all Entra Connect accounts.
 
    decisions\ENT-MFA.csv: one row per tenant, Do not apply, Report only or Enable.
    The proposal is never Enable: this step stays a customer choice, policy by policy.
 
    Guards of the activation, whatever the decisions:
      - the policy is in the snapshot, and reads on the tenant, in report-only;
      - the ENT-MFA-REGISTRATION campaign is handled, in each tenant: each account read has a valid decision in
        ENT-MFA-REGISTRATION.csv, and the collection of registrations is complete;
      - the emergency accounts exist, are enabled, have a FIDO2 key in the target and are excluded; otherwise the script refuses;
      - the policy has not changed for at least three days;
      - security defaults are off.
    The exclusion is read back after the activation: if it is missing, the policy goes back to report-only.
 
    Limits: analyzing the sign-ins affected during report-only stays a human read of the logs. An external authentication method is
    incompatible with the authentication strength: in that case, use the control Require multifactor authentication.
#>

$c = Open-TcRemediation

$PolicyName = (Get-TcObjectName ' - MFA for all')
$ReportOnlyState = $script:TcReportOnlyState
$MfaStrengthId = '00000000-0000-0000-0000-000000000002'      # built-in strength Multifactor authentication





function Get-SimilarPolicy {
    <# Policies that already aim at all users and all resources with an MFA requirement: to compare before enabling ours. #>
    param([string]$Tenant)
    return @(Get-TcRemediationTable $Tenant 'conditionalAccess' | Where-Object { $_['Users'] -eq 'All' -and $_['Applications'] -eq 'All' -and $_['Controls'] -match 'mfa|Multifactor' -and $_['Name'] -ne $PolicyName })
}

$proposals = foreach ($t in $c.Tenants) {
    Write-TcRemediationTenant $t 'MFA requirement read'
    $caPolicies = @(Get-TcRemediationTable $t 'conditionalAccess')
    if (-not $caPolicies.Count -and -not (Test-TcCollectionComplete $t 'conditionalAccess')) { Write-TcRemediationAlert 'Conditional access: incomplete collection, an empty table proves nothing.' }
    $activePolicies = @($caPolicies | Where-Object { ConvertTo-TcBoolean $_['MFA for all'] })
    $report = @($caPolicies | Where-Object { $_['State'] -eq $ReportOnlyState })
    $similar = @(Get-SimilarPolicy $t)
    $noMethod = @(Get-TcRemediationPlanAction -Procedure 'ENT-MFA-REGISTRATION' -Tenant $t | ForEach-Object { (Get-TcMember $_ 'items') } | Where-Object { $_ }).Count
    $sync = @(Get-TcRemediationTable $t 'adminRoles' | Where-Object { $_['Role'] -eq 'Directory Synchronization Accounts' }).Count
    Write-TcRemediationNote ("{0} policy(ies) read, {1} report-only, {2} require(s) MFA for all in active mode" -f $caPolicies.Count, $report.Count, $activePolicies.Count)
    Write-TcRemediationNote ("{0} active account(s) with no MFA method; {1} sync account(s) covered by the role exclusion" -f $noMethod, $sync)
    foreach ($s in $similar) { Write-TcRemediationNote ("Similar, to compare: {0} ({1})" -f $s['Name'], $s['State']) }
    $existingItem = $caPolicies | Where-Object { $_['Name'] -eq $PolicyName } | Select-Object -First 1
    [ordered]@{ Tenant = $t; Policy = $PolicyName; 'State read' = $(if ($existingItem) { $existingItem['State'] } else { 'missing' }); 'Accounts with no method' = $noMethod
                'Similar policies' = (@($similar | ForEach-Object { '{0} ({1})' -f $_['Name'], $_['State'] }) -join ' ; ')
                Proposal = $(if ($t -eq 'Target') { 'Report only' } else { 'Do not apply' }) }
}
$decisions = @(Get-TcDecision -Name 'ENT-MFA' -Proposal @($proposals | Where-Object { $_ }) -Key 'Tenant' -Choice 'Do not apply', 'Report only', 'Enable')

foreach ($t in $c.Tenants) {
    $d = $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t } | Select-Object -First 1
    $choice = if ($d) { (Get-TcMember $d 'Decision') } else { '' }
    if ($choice -notin 'Report only', 'Enable') { Write-TcRemediationNote ("{0}: no policy without a customer decision (migrated accounts will inherit those of the target)" -f $t); continue }
    if (ConvertTo-TcBoolean (Get-TcRemediationValue $t 'securitySettings' 'Security defaults enabled')) { Write-TcRemediationAlert ("{0}: security defaults are enabled: conditional access cannot be enabled." -f $t); continue }

    $missing = @(Get-TcMissingProtectedAccount $t)
    if ($missing.Count) { Write-TcRemediationAlert ("{0}: emergency accounts missing from the snapshot: {1}; enabling will stay refused (EMERGENCY-ACCESS procedure, SEC-02)." -f $t, ($missing -join ', ')) }
    $existingItem = Get-TcRemediationTable $t 'conditionalAccess' | Where-Object { $_['Name'] -eq $PolicyName } | Select-Object -First 1

    if (-not $existingItem) {
        Add-TcBlockAction -Tenant $t -Service Graph -Object $PolicyName -Description ("Create policy {0} in report-only mode" -f $PolicyName) `
            -Test { param($a) [bool](Get-TcConditionalAccessPolicy $a.tenant $PolicyName) } `
            -Block {
                param($a)
                if ((Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri 'v1.0/policies/identitySecurityDefaultsEnforcementPolicy') 'isEnabled')) { throw 'Security defaults are enabled: disable them first.' }
                $x = Resolve-TcAccountId $a.tenant @(Get-TcProtectedAccount $a.tenant)
                foreach ($u in (Get-TcMember $x 'Missing')) { Write-TcRemediationAlert ("{0} does not exist yet: to exclude before enabling, the script handles it at that time" -f $u) }
                $body = @{ displayName = $PolicyName; state = $ReportOnlyState
                            conditions = @{ users = @{ includeUsers = @('All'); excludeUsers = @((Get-TcMember $x 'Ids')); excludeRoles = @($script:TcSyncRoleId) }
                                            applications = @{ includeApplications = @('All') }; clientAppTypes = @('all') }
                            grantControls = @{ operator = 'OR'; authenticationStrength = @{ id = $MfaStrengthId } } }
                $r = Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri $script:TcPolicyUri -Body $body
                $a.undo = @{ type = 'Graph'; tenant = $a.tenant; method = 'DELETE'; uri = "$script:TcPolicyUri/$((Get-TcMember $r 'id'))" }
            }
    }
    elseif ($existingItem['State'] -eq 'enabled') { Write-TcRemediationNote ("{0}: policy already active" -f $t) }

    if ($choice -ne 'Enable') {
        if ($existingItem -and $existingItem['State'] -eq $ReportOnlyState) { Add-TcManualAction -Tenant $t -Object 'Observation' -Description 'Analyze the sign-ins affected in report-only mode, then decide on enabling' -Who 'Team' `
            -Instruction @('Entra ID > Monitoring & health > Sign-in logs, Conditional Access tab.', 'Handle the service accounts, the migration account and the devices affected.', 'After approval and announcement, enter Enable in decisions\ENT-MFA.csv.') }
        continue
    }
    if (-not $existingItem) { Write-TcRemediationAlert ("{0}: enabling decided, but the policy is not yet in the snapshot: create, observe, refresh the snapshot." -f $t); continue }
    if ($existingItem['State'] -ne $ReportOnlyState) { continue }
    if ($missing.Count -or -not @(Get-TcProtectedAccount $t).Count) { Write-TcRemediationAlert ("{0}: enabling blocked: emergency accounts missing." -f $t); continue }
    if (-not (Test-TcCollectionComplete $t 'mfaRegistrations')) { Write-TcRemediationAlert ("{0}: MFA registration collection incomplete: enabling blocked, run Invoke-TcAssessment again." -f $t); continue }
    $accounts = @(Get-TcRemediationPlanAction -Procedure 'ENT-MFA-REGISTRATION' -Tenant $t | ForEach-Object { (Get-TcMember $_ 'items') } | Where-Object { $_ } | ForEach-Object { @{ Tenant = $t; Account = $_ } })
    $pending = @(Get-TcUndecidedRow -Name 'ENT-MFA-REGISTRATION' -Key 'Tenant', 'Account' -Choice 'Register', 'Remove' -Expected $accounts -Tenant $t)
    if ($pending.Count) { Write-TcRemediationAlert ("{0}: {1} campaign account(s) with no valid decision: enabling blocked (run ENT-MFA-REGISTRATION -Tenant {0}, then decide)." -f $t, $pending.Count); continue }
    Add-TcPolicyActivation -Tenant $t -Name $PolicyName
}

Invoke-TcRemediationPlan -Verify {
    param($t)
    $p = Get-TcConditionalAccessPolicy $t $PolicyName
    if (-not $p) {
        if ($t -eq 'Target') { New-TcRemediationResult Blocking "policy missing: $PolicyName" } else { New-TcRemediationResult Info 'no policy created in this source' }
        return
    }
    if ((Get-TcMember $p 'state') -eq 'enabled') { New-TcRemediationResult OK 'MFA is required for all: the MFA check before cutover will recognize this policy' }
    else { New-TcRemediationResult Info ("policy in {0} mode: the MFA check before cutover does not recognize it yet" -f (Get-TcMember $p 'state')) }
    if ($script:TcSyncRoleId -notin @((Get-TcMember (Get-TcMember (Get-TcMember $p 'conditions') 'users') 'excludeRoles'))) { New-TcRemediationResult Blocking 'the role of the sync accounts is not excluded' }
    Get-TcExclusionResult -Tenant $t -Policy $p
}
Close-TcRemediation