Public/Get-MsecEntraMfaRegistrationStats.ps1

function Get-MsecEntraMfaRegistrationStats {
    <#
    .SYNOPSIS
        MFA registration coverage in a single summary row - overall, for admins, and by
        method - for a posture report or snapshot.

    .DESCRIPTION
        Internally calls Get-MsecEntraMfaRegistration and aggregates the per-user rows.
        Returns one PSCustomObject covering:

          - Population: TotalUsers, Members, Guests
          - Coverage: MfaRegistered / MfaCapable (+ percentages), NotMfaCapable
          - Admins: AdminTotal, AdminMfaCapable (+ percentage), AdminsNotMfaCapable
                         and AdminsNotMfaCapableUpn - the actual account names, because
                         "3 admins without MFA" is not actionable but a list is
          - Strength: PasswordlessCapable, PhoneOnlyMfaCapable
          - Recovery: SsprCapable (+ percentage)
          - ByMethod: count of users per registered method

        **AdminsNotMfaCapable is the headline number.** A privileged account that cannot
        perform MFA is the single most exploitable identity condition in a tenant, and it
        is invisible to Conditional Access reporting - CA shows MFA being demanded, not
        whether the account can satisfy it.

        Coverage uses IsMfaCapable, not IsMfaRegistered: a method registered but disabled
        by the tenant's authentication-methods policy will not work, so counting it would
        overstate coverage. See Get-MsecEntraMfaRegistration for the distinction.

        PhoneOnlyMfaCapable counts MFA-capable users whose registered methods are ALL
        phone-based (SMS / voice). Those are the phishable and SIM-swappable ones, so a
        tenant can be at 100% coverage and still be materially weak. The phone-method list
        is a best-effort match on Graph's method names (see .NOTES); MethodsRegistered on
        the per-user rows lets you reclassify if Microsoft renames them.

        Permission and licensing requirements are inherited from
        Get-MsecEntraMfaRegistration (AuditLog.Read.All, plus Entra ID P1/P2 - the report
        is premium-gated).

    .EXAMPLE
        Get-MsecEntraMfaRegistrationStats | Format-List

    .EXAMPLE
        # The finding you want to walk into a posture meeting with:
        $m = Get-MsecEntraMfaRegistrationStats
        if ($m.AdminsNotMfaCapable) {
            "$($m.AdminsNotMfaCapable) privileged account(s) cannot do MFA: " +
            ($m.AdminsNotMfaCapableUpn -join ', ')
        }

    .EXAMPLE
        # Slot it into the posture snapshot next to the other domains.
        $snapshot = [pscustomobject]@{
            CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('u')
            MfaRegistration = Get-MsecEntraMfaRegistrationStats
            CaStats = Get-MsecEntraConditionalAccessStats -Days 7
        }

    .OUTPUTS
        One PSCustomObject. Percentages are rounded to 2 decimals and are $null when the
        relevant population is zero (never 0, which would read as "nobody is covered").

    .NOTES
        Phone-based methods, for PhoneOnlyMfaCapable: mobilePhone, alternateMobilePhone,
        officePhone, voiceCall, sms. Everything else - microsoftAuthenticatorPush,
        softwareOneTimePasscode, fido2SecurityKey, windowsHelloForBusiness,
        passKeyDeviceBound, certificate*, temporaryAccessPass - counts as stronger.
        Graph has renamed these before; the classification is deliberately kept in one
        place here so it is easy to adjust.
    #>

    [CmdletBinding()]
    param()

    Assert-MsecSession

    $users = @(Get-MsecEntraMfaRegistration)

    # Guard every percentage: an empty population must yield $null, not 0, which would
    # read as "measured, and nobody is covered".
    $pct = { param([int] $Part, [int] $Whole)
             if ($Whole -gt 0) { [math]::Round(100.0 * $Part / $Whole, 2) } else { $null } }

    $phoneMethods = @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'voiceCall', 'sms')

    $members = @($users | Where-Object UserType -eq 'member')
    $guests  = @($users | Where-Object UserType -eq 'guest')

    $mfaRegistered = @($users | Where-Object IsMfaRegistered)
    $mfaCapable    = @($users | Where-Object IsMfaCapable)

    $admins            = @($users  | Where-Object IsAdmin)
    $adminsCapable     = @($admins | Where-Object IsMfaCapable)
    $adminsNotCapable  = @($admins | Where-Object { -not $_.IsMfaCapable })

    # MFA-capable users with at least one method, all of which are phone-based. The
    # "at least one" guard matters: a user with an empty MethodsRegistered array would
    # otherwise satisfy "all methods are phone" vacuously.
    $phoneOnly = @($mfaCapable | Where-Object {
        $m = @($_.MethodsRegistered)
        $m.Count -gt 0 -and -not @($m | Where-Object { $_ -notin $phoneMethods }).Count
    })

    # Method -> number of users who registered it. Users appear under several methods.
    $byMethod = [ordered]@{}
    foreach ($g in ($users.MethodsRegistered | Group-Object | Sort-Object Count -Descending)) {
        $byMethod[$g.Name] = $g.Count
    }

    [PSCustomObject]@{
        # Population
        TotalUsers                = $users.Count
        Members                   = $members.Count
        Guests                    = $guests.Count

        # Coverage (IsMfaCapable is the honest measure - see .DESCRIPTION)
        MfaRegistered             = $mfaRegistered.Count
        MfaRegisteredPercent      = & $pct $mfaRegistered.Count $users.Count
        MfaCapable                = $mfaCapable.Count
        MfaCapablePercent         = & $pct $mfaCapable.Count $users.Count
        NotMfaCapable             = $users.Count - $mfaCapable.Count

        # Admins - the headline
        AdminTotal                = $admins.Count
        AdminMfaCapable           = $adminsCapable.Count
        AdminMfaCapablePercent    = & $pct $adminsCapable.Count $admins.Count
        AdminsNotMfaCapable       = $adminsNotCapable.Count
        AdminsNotMfaCapableUpn    = @($adminsNotCapable.UserPrincipalName | Sort-Object)

        # Guests
        GuestsMfaCapable          = @($guests | Where-Object IsMfaCapable).Count

        # Strength of what is registered
        PasswordlessCapable       = @($users | Where-Object IsPasswordlessCapable).Count
        PasswordlessCapablePercent = & $pct @($users | Where-Object IsPasswordlessCapable).Count $users.Count
        PhoneOnlyMfaCapable       = $phoneOnly.Count
        PhoneOnlyMfaCapablePercent = & $pct $phoneOnly.Count $mfaCapable.Count

        # Self-service password reset
        SsprCapable               = @($users | Where-Object IsSsprCapable).Count
        SsprCapablePercent        = & $pct @($users | Where-Object IsSsprCapable).Count $users.Count

        ByMethod                  = $byMethod
    }
}