Public/Get-NSPConditionalAccessReportOnlyImpact.ps1

function Get-NSPConditionalAccessReportOnlyImpact {
    <#
    .SYNOPSIS
        Read-only: summarizes what each report-only policy would have done, from recent sign-in logs.
    .DESCRIPTION
        Reads interactive sign-ins from the last -Days days and counts, per policy, the report-only
        results Entra recorded:
          - WouldBlock: reportOnlyFailure (the policy would have blocked the sign-in).
          - WouldInterrupt: reportOnlyInterrupted (the user would have been prompted, e.g. for MFA).
          - Satisfied: reportOnlySuccess (the sign-in already met the policy).
        UsersAffected counts distinct users with a WouldBlock or WouldInterrupt result.
 
        Reading stops after -MaxSignIns sign-ins (newest first); Truncated says so. Sign-in logs
        need Entra ID P1. Without it, or without AuditLog.Read.All, Available is false and Reason
        says why; nothing throws, so promotion planning can still show the other gates.
        -Save writes the result under .nsp-conditionalaccess\impact (Git-ignored).
    .EXAMPLE
        $impact = Get-NSPConditionalAccessReportOnlyImpact -Days 14 -Save
        $impact.Policies | Format-Table DisplayName, WouldBlock, WouldInterrupt, UsersAffected
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [ValidateRange(1, 30)][int]$Days = 7,
        [ValidateRange(100, 500000)][int]$MaxSignIns = 20000,
        [switch]$Save,
        [string]$ConfigRoot
    )

    Import-NSPM365Graph
    $context = Assert-NSPConditionalAccessSession -ConfigRoot $ConfigRoot
    function Get-Value { param($Object, [string]$Key) Get-NSPConditionalAccessMapValue $Object $Key }

    $since = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
    $result = [pscustomobject][ordered]@{
        SchemaVersion = 1
        TenantId = $context.TenantId
        CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        Days = $Days
        SinceUtc = $since
        Available = $true
        Reason = $null
        SignInsRead = 0
        Truncated = $false
        Policies = @()
        Path = $null
    }

    $stats = @{}
    $next = "auditLogs/signIns?`$filter=$([uri]::EscapeDataString("createdDateTime ge $since"))&`$top=999"
    try {
        while ($next -and $result.SignInsRead -lt $MaxSignIns) {
            Write-NSPConditionalAccessProgress -Activity Impact -Status "Last $Days day(s): $($result.SignInsRead) sign-ins read" -Step $result.SignInsRead -Of $MaxSignIns
            $page = Invoke-NSPGraphRequest -Uri $next
            foreach ($signIn in @(Get-Value $page 'value')) {
                if ($null -eq $signIn) { continue }
                if ($result.SignInsRead -ge $MaxSignIns) { $result.Truncated = $true; break }
                $result.SignInsRead++
                $userId = Get-Value $signIn 'userId'
                foreach ($applied in @(Get-Value $signIn 'appliedConditionalAccessPolicies')) {
                    $outcome = [string](Get-Value $applied 'result')
                    if ($outcome -notlike 'reportOnly*') { continue }
                    $policyId = Get-Value $applied 'id'
                    if (-not $stats.ContainsKey($policyId)) {
                        $stats[$policyId] = @{ DisplayName = Get-Value $applied 'displayName'; WouldBlock = 0; WouldInterrupt = 0; Satisfied = 0; NotApplied = 0; Users = @{} }
                    }
                    $entry = $stats[$policyId]
                    switch ($outcome) {
                        'reportOnlyFailure' { $entry.WouldBlock++; if ($userId) { $entry.Users[$userId] = $true } }
                        'reportOnlyInterrupted' { $entry.WouldInterrupt++; if ($userId) { $entry.Users[$userId] = $true } }
                        'reportOnlySuccess' { $entry.Satisfied++ }
                        default { $entry.NotApplied++ }
                    }
                }
            }
            $next = Get-Value $page '@odata.nextLink'
        }
        if ($next -and $result.SignInsRead -ge $MaxSignIns) { $result.Truncated = $true }
    } catch {
        $message = "$_"
        $result.Available = $false
        $result.Reason = if ($message -match 'RequestFromNonPremiumTenant|Premium') { 'Sign-in logs need Entra ID P1 in this tenant.' }
        elseif ($message -match 'Forbidden|403|Authorization_RequestDenied') { 'The session cannot read sign-in logs (AuditLog.Read.All, plus a Reports Reader, Security Reader, or Global Reader role).' }
        else { "Sign-in logs could not be read: $message" }
    }

    Write-NSPConditionalAccessProgress -Activity Impact -Completed
    $result.Policies = @($stats.Keys | ForEach-Object {
            $entry = $stats[$_]
            [pscustomobject][ordered]@{
                PolicyId = $_
                DisplayName = $entry.DisplayName
                WouldBlock = $entry.WouldBlock
                WouldInterrupt = $entry.WouldInterrupt
                Satisfied = $entry.Satisfied
                NotApplied = $entry.NotApplied
                UsersAffected = $entry.Users.Count
            }
        } | Sort-Object DisplayName)

    if ($Save -and $PSCmdlet.ShouldProcess($result.TenantId, 'Save report-only impact')) {
        $folder = Join-Path (Get-NSPConditionalAccessLocalPath -Kind Impact -ConfigRoot $ConfigRoot) $result.TenantId
        $result.Path = Join-Path $folder ((Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') + '.json')
        Save-NSPConditionalAccessJson -InputObject $result -Path $result.Path
    }
    $result
}