EntraMfaRegistrationReport.psm1

#Region './Private/Invoke-LogRotation.ps1' -1

#Requires -Version 7.0

# Rotates log files: shifts numbered backups up (log.4→removed, log.3→log.4, ..., log→log.1).
# Called inside the Write-ToLog mutex — do NOT call this function directly.
function Invoke-LogRotation {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([void])]
    param()

    try {
        # Don't rotate if file doesn't exist
        if (-not (Test-PathWrapper -LiteralPath $script:LogFile)) {
            return
        }

        # Remove oldest log file if it exists
        $oldestLog = "$script:LogFile.$script:MaxLogFiles"
        if (Test-PathWrapper -LiteralPath $oldestLog) {
            if ($PSCmdlet.ShouldProcess($oldestLog, 'Remove oldest rotated log file')) {
                Remove-ItemWrapper -LiteralPath $oldestLog
            }
        }

        # Shift existing rotated logs up
        for ($i = $script:MaxLogFiles - 1; $i -ge 1; $i--) {
            $currentLog = "$script:LogFile.$i"
            $nextLog = "$script:LogFile.$($i + 1)"

            if (Test-PathWrapper -LiteralPath $currentLog) {
                if ($PSCmdlet.ShouldProcess("$currentLog -> $nextLog", 'Shift rotated log file')) {
                    Move-ItemWrapper -LiteralPath $currentLog -Destination $nextLog
                }
            }
        }

        # Rotate current log to .1
        if ($PSCmdlet.ShouldProcess("$($script:LogFile) -> $($script:LogFile).1", 'Rotate current log file')) {
            Move-ItemWrapper -LiteralPath $script:LogFile -Destination "$script:LogFile.1"
        }

        Write-Verbose "Log rotated: $script:LogFile -> $script:LogFile.1"
    } catch {
        Write-Warning "Failed to rotate log file: $($_.Exception.Message)"
    }
}

# ============================================================================
# WRAPPER FUNCTIONS FOR MOCKABILITY
# ============================================================================

# Wraps Move-Item for Pester mocking.
function Move-ItemWrapper {
    [CmdletBinding()]
    [OutputType([void])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Wrapper function; ShouldProcess handled by calling function.')]
    param(
        [Parameter(Mandatory)]
        [string]$LiteralPath,

        [Parameter(Mandatory)]
        [string]$Destination
    )

    Move-Item -LiteralPath $LiteralPath -Destination $Destination -Force -ErrorAction Stop
}

# Wraps Remove-Item for Pester mocking.
function Remove-ItemWrapper {
    [CmdletBinding()]
    [OutputType([void])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Wrapper function; ShouldProcess handled by calling function.')]
    param(
        [Parameter(Mandatory)]
        [string]$LiteralPath
    )

    Remove-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop
}
#EndRegion './Private/Invoke-LogRotation.ps1' 81
#Region './Private/Write-ToLog.ps1' -1

#Requires -Version 7.0

# Timestamp format used for log file names and archives
$script:LogTimestampFormat = 'yyyyMMdd_HHmmss'

# Thread-safe, auto-rotating logger for EntraMfaRegistrationReport.
# Entry point: Write-ToLog. Levels: INFO, DEBUG, WARN, ERROR, SUCCESS.

# ============================================================================
# LOG FILE CONFIGURATION
# ============================================================================

# Initialize log file path (backward compatible with $Global:LogFile)
# Uses helper function to isolate global variable access for ScriptAnalyzer compliance.
function Initialize-LogFilePath {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '',
        Justification = 'Required for backward compatibility with scripts that set $Global:LogFile before importing the module.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Private initializer - no external side effects, only sets module-scoped variable.')]
    [OutputType([string])]
    param()

    if (-not $Global:LogFile) {
        $Global:LogFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "EntraMfaRegistrationReport_$([System.DateTimeOffset]::UtcNow.ToString($script:LogTimestampFormat)).log")
    }
    return $Global:LogFile
}

if (-not $script:LogFile) {
    $script:LogFile = Initialize-LogFilePath
}

# Log rotation settings
$script:MaxLogSizeBytes = 10MB  # Rotate when log exceeds this size
$script:MaxLogFiles = 5         # Keep this many rotated logs

$script:LogDirectoryCreated = $false
$script:LogMutex = $null

# Register cleanup handler to dispose mutex on module removal or PowerShell exit
$null = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action {
    if ($script:LogMutex) {
        $script:LogMutex.Dispose()
        $script:LogMutex = $null
    }
}

# ============================================================================
# WRITE-TOLOG FUNCTION
# ============================================================================

# Appends a formatted, timestamped log entry to the module log file.
# Thread-safe via a named mutex. Auto-rotates at 10 MB (5 backups).
# Redacts passwords, tokens, keys, and secrets. Supports -Message and -ErrorRecord parameter sets.
function Write-ToLog {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([bool])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Write-Host is intentional for colored console output in a logging function.')]
    param (
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            Position = 0,
            ParameterSetName = 'Message'
        )]
        [AllowEmptyString()]
        [string]$Message,

        [Parameter(
            Position = 1,
            ParameterSetName = 'Message'
        )]
        [ValidateSet('INFO', 'DEBUG', 'WARN', 'ERROR', 'SUCCESS')]
        [string]$Level = 'INFO',

        [Parameter(
            Mandatory,
            ParameterSetName = 'ErrorRecord'
        )]
        [System.Management.Automation.ErrorRecord]$ErrorRecord,

        [Parameter()]
        [switch]$NoConsole,

        [Parameter()]
        [switch]$PassThru
    )

    begin {
        # Sync script-scoped variable with global for backward compatibility
        if ($Global:LogFile -and $script:LogFile -ne $Global:LogFile) {
            $script:LogFile = $Global:LogFile
            $script:LogDirectoryCreated = $false  # Reset so new directory is validated
        }

        # Ensure log directory exists (once per invocation)
        if (-not $script:LogDirectoryCreated) {
            $logDir = Split-Path -Path $script:LogFile -Parent

            if ($logDir -and -not (Test-PathWrapper -LiteralPath $logDir)) {
                try {
                    $null = New-ItemDirectoryWrapper -Path $logDir
                } catch [System.IO.IOException] {
                    # Directory may have been created by another process
                    if (-not (Test-PathWrapper -LiteralPath $logDir)) {
                        throw
                    }
                }
            }
            $script:LogDirectoryCreated = $true
        }

        # Initialize mutex for thread safety (reuse existing if available)
        if (-not $script:LogMutex) {
            $script:LogMutex = [System.Threading.Mutex]::new($false, 'Global\EntraMfaRegistrationReportLog')
        }
    }

    process {
        # Process ErrorRecord if provided
        if ($PSCmdlet.ParameterSetName -eq 'ErrorRecord') {
            $Message = $ErrorRecord.Exception.Message
            $Level = 'ERROR'

            # Add additional error details
            $errorDetails = @"
Exception Type: $($ErrorRecord.Exception.GetType().FullName)
Category: $($ErrorRecord.CategoryInfo.Category)
Target: $($ErrorRecord.TargetObject)
"@


            if ($ErrorRecord.InvocationInfo) {
                $errorDetails += @"

Location: $($ErrorRecord.InvocationInfo.ScriptName):$($ErrorRecord.InvocationInfo.ScriptLineNumber)
Command: $($ErrorRecord.InvocationInfo.Line.Trim())
"@

            }

            if ($ErrorRecord.Exception.InnerException) {
                $errorDetails += @"

Inner Exception: $($ErrorRecord.Exception.InnerException.Message)
"@

            }

            # Log main message first, then details as DEBUG
            $mainResult = Write-ToLog -Message $Message -Level 'ERROR' -NoConsole:$NoConsole -PassThru
            $detailResult = Write-ToLog -Message $errorDetails -Level 'DEBUG' -NoConsole:$NoConsole -PassThru

            if ($PassThru) {
                return ($mainResult -and $detailResult)
            }
            return
        }

        # Redact sensitive information from log messages (case-insensitive)
        $sanitizedMessage = $Message

        # Pattern 1: key=value format
        $sanitizedMessage = $sanitizedMessage -replace '(?i)(password|token|key|secret|apikey|api_key|access_key|auth)=\S+', '$1=***REDACTED***'

        # Pattern 2: JSON format
        $sanitizedMessage = $sanitizedMessage -replace '(?i)(password|token|key|secret|apikey|api_key|access_key|auth)"\s*:\s*"[^"]*"', '$1": "***REDACTED***"'

        # Pattern 3: XML/HTML format - preserve closing tag
        $sanitizedMessage = $sanitizedMessage -replace '(?i)<(password|token|key|secret|apikey|api_key|access_key|auth)>[^<]*</(password|token|key|secret|apikey|api_key|access_key|auth)>', '<$1>***REDACTED***</$2>'

        # Pattern 4: Bearer token (e.g. an Authorization header)
        $sanitizedMessage = $sanitizedMessage -replace '(?i)\b(bearer)\s+\S+', '$1 ***REDACTED***'

        # Pattern 5: unquoted 'key: value' form. The negative lookbehind/lookahead on a
        # quote keep this from re-touching the quoted JSON form handled by Pattern 2.
        $sanitizedMessage = $sanitizedMessage -replace '(?i)(?<![\"''])\b(password|token|key|secret|apikey|api_key|access_key|auth)\b\s*:\s*(?![\"''])\S+', '$1: ***REDACTED***'

        $timestamp = [System.DateTimeOffset]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')
        $entry = "[$timestamp] [$Level] $sanitizedMessage"

        $success = $true

        if ($PSCmdlet.ShouldProcess($script:LogFile, "Write log entry: $Level")) {
            # Thread-safe file write using mutex
            $mutexAcquired = $false
            try {
                $mutexAcquired = $script:LogMutex.WaitOne(10000)  # 10-second timeout to prevent deadlock
                if (-not $mutexAcquired) {
                    Write-Warning 'Failed to acquire log mutex within 10 seconds. Log entry may be lost.'
                    $success = $false
                    return
                }

                # Check if log rotation is needed (inside mutex to prevent race conditions)
                if ((Test-PathWrapper -LiteralPath $script:LogFile) -and
                    (Get-ItemWrapper -LiteralPath $script:LogFile).Length -gt $script:MaxLogSizeBytes) {

                    try {
                        Write-Verbose "Log file exceeds $($script:MaxLogSizeBytes / 1MB)MB, rotating..."
                        Invoke-LogRotation
                    } catch {
                        Write-Warning "Log rotation failed: $($_.Exception.Message). Continuing without rotation."
                    }
                }

                # UTF-8 without BOM is default in PowerShell 7+
                # IMPORTANT: Using Add-Content appends to existing file
                Add-ContentWrapper -LiteralPath $script:LogFile -Value $entry
            } catch {
                $errorMsg = "Failed to write log entry to '{0}': {1}" -f $script:LogFile, $_.Exception.Message
                Write-Warning $errorMsg
                $success = $false
            } finally {
                if ($mutexAcquired -and $script:LogMutex) {
                    $script:LogMutex.ReleaseMutex()
                }
            }
        }

        # Console output with ANSI colors (PowerShell 7.2+ PSStyle, fallback to escape codes)
        if (-not $NoConsole) {
            if ($PSStyle) {
                $colorRed = $PSStyle.Foreground.Red
                $colorYellow = $PSStyle.Foreground.Yellow
                $colorGreen = $PSStyle.Foreground.Green
                $colorCyan = $PSStyle.Foreground.Cyan
                $colorReset = $PSStyle.Reset
            } else {
                $colorRed = "`e[31m"
                $colorYellow = "`e[33m"
                $colorGreen = "`e[32m"
                $colorCyan = "`e[36m"
                $colorReset = "`e[0m"
            }

            switch ($Level) {
                'ERROR' {
                    Write-Host "${colorRed}✗ $sanitizedMessage${colorReset}"
                }
                'WARN' {
                    Write-Host "${colorYellow}⚠ $sanitizedMessage${colorReset}"
                }
                'SUCCESS' {
                    Write-Host "${colorGreen}✓ $sanitizedMessage${colorReset}"
                }
                'DEBUG' {
                    Write-Verbose -Message $sanitizedMessage
                }
                default {
                    Write-Host "${colorCyan}ℹ $sanitizedMessage${colorReset}"
                }
            }
        }

        if ($PassThru) {
            return $success
        }
    }

    end {
        # Cleanup is handled by caller or module removal
    }
}

# ============================================================================
# WRAPPER FUNCTIONS FOR MOCKABILITY
# ============================================================================

# Wraps Test-Path for Pester mocking.
function Test-PathWrapper {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [string]
        $Path,

        [Parameter(Mandatory, ParameterSetName = 'LiteralPath')]
        [string]
        $LiteralPath,

        [Parameter(ParameterSetName = 'Path')]
        [ValidateSet('Any', 'Container', 'Leaf')]
        [string]
        $PathType
    )

    if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
        return Test-Path -LiteralPath $LiteralPath
    }

    if ($PathType) {
        return Test-Path -Path $Path -PathType $PathType
    }

    return Test-Path -Path $Path
}

# Wraps New-Item -ItemType Directory for Pester mocking.
function New-ItemDirectoryWrapper {
    [CmdletBinding()]
    [OutputType([System.IO.DirectoryInfo])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Wrapper function; ShouldProcess handled by calling function.')]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    return New-Item -Path $Path -ItemType Directory -Force -ErrorAction Stop
}

# Wraps Get-Item for Pester mocking.
function Get-ItemWrapper {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [string]$LiteralPath
    )

    return Get-Item -LiteralPath $LiteralPath
}

# Wraps Add-Content for Pester mocking.
function Add-ContentWrapper {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Wrapper function; ShouldProcess handled by calling function.')]
    param(
        [Parameter(Mandatory)]
        [string]$LiteralPath,

        [Parameter(Mandatory)]
        [string]$Value
    )

    Add-Content -LiteralPath $LiteralPath -Value $Value -ErrorAction Stop
}
#EndRegion './Private/Write-ToLog.ps1' 340
#Region './Public/Connect-EntraMfaRegistrationReport.ps1' -1

#Requires -Version 7.0

function Connect-EntraMfaRegistrationReport {
    <#
    .SYNOPSIS
    Establishes the Microsoft Graph session this module needs, against an explicitly named tenant.

    .DESCRIPTION
    Wraps Connect-MgGraph so that authentication is owned by the module rather than left to the
    caller, and so that the target tenant is always stated explicitly instead of being inherited
    from whatever session happened to be open. In multi-tenant assessment work, an inherited
    session is how a report gets run against the wrong tenant.

    TenantId is mandatory in every parameter set that Microsoft Graph accepts it in. The single
    exception is ManagedIdentity: Connect-MgGraph -Identity derives the tenant from the identity
    itself and does not expose a TenantId parameter, so requiring one there is not possible.

    The default is an interactive browser sign-on. Device code is available as an opt-in switch
    on that same parameter set, and application-only authentication by certificate, client secret
    or managed identity is available alongside it, so no environment is forced down the device
    code path. See the device code warning in the notes.

    Requests the AuditLog.Read.All scope by default, which is the least privileged permission
    Microsoft documents for the registration report this module reads.

    .PARAMETER TenantId
    The directory (tenant) ID or verified domain name of the tenant to authenticate against.
    Mandatory for every method except managed identity.

    .PARAMETER Scopes
    Delegated permission scopes to request. Defaults to AuditLog.Read.All, the least privileged
    permission for the registration report. Applies to interactive and device code sign-on only.

    .PARAMETER ClientId
    Application (client) ID of the app registration. Mandatory for certificate authentication,
    optional for interactive sign-on, and optional for a user-assigned managed identity.

    .PARAMETER UseDeviceCode
    Authenticates by device code instead of opening a browser. Intended only for a host with no
    browser available, such as an SSH session or a container. Read the device code warning in the
    notes before using it: Microsoft classifies this flow as high risk and many tenants block it.

    .PARAMETER CertificateThumbprint
    Thumbprint of a certificate in the local certificate store, for application-only
    authentication.

    .PARAMETER Certificate
    An X509Certificate2 object, for application-only authentication where the certificate is
    loaded by the caller rather than resolved from a store.

    .PARAMETER ClientSecretCredential
    A PSCredential whose user name is the application (client) ID and whose password is the client
    secret. Passing the secret inside a PSCredential keeps it a SecureString rather than a plain
    string. Client secrets are the least preferred method; see the notes.

    .PARAMETER Identity
    Authenticates using the Azure managed identity of the host. Combine with ClientId to select a
    user-assigned identity. No secret exists to leak, so this is the preferred method for anything
    running on Azure.

    .EXAMPLE
    Connect-EntraMfaRegistrationReport -TenantId 'contoso.onmicrosoft.com'

    Signs in interactively through the browser against the named tenant, requesting
    AuditLog.Read.All, then returns the resulting Graph context.

    .EXAMPLE
    Connect-EntraMfaRegistrationReport -TenantId '00000000-0000-0000-0000-000000000000' -UseDeviceCode

    Signs in by device code, for a host with no browser. Emits a warning explaining the risk and
    that Conditional Access may block the flow.

    .EXAMPLE
    $connection = @{
        TenantId = '00000000-0000-0000-0000-000000000000'
        ClientId = '11111111-1111-1111-1111-111111111111'
        CertificateThumbprint = '0000000000000000000000000000000000000000'
    }
    Connect-EntraMfaRegistrationReport @connection
    Get-EntraMfaRegistrationReport -UnregisteredOnly -Path './mfa-gaps.csv'

    Authenticates application-only with a certificate, then runs the report unattended.

    .EXAMPLE
    Connect-EntraMfaRegistrationReport -Identity

    Authenticates with the host's system-assigned managed identity. No TenantId is accepted here,
    because Connect-MgGraph derives the tenant from the identity.

    .OUTPUTS
    Microsoft.Graph.PowerShell.Authentication.AuthContext

    The Graph context established, as returned by Get-MgContext.

    .NOTES
    Device code warning
        Microsoft classifies device code flow as high risk and recommends blocking it wherever
        possible. There is a Microsoft-managed Conditional Access policy that blocks it
        tenant-wide, and Microsoft's security operations guidance treats the flow appearing
        outside an input-constrained device as a signal to investigate. The attack is simple: an
        adversary starts the flow, sends the target a code, and asks them to enter it on the
        genuine Microsoft sign-in page. Use -UseDeviceCode only where a browser is genuinely
        unavailable on the authenticating device. If the tenant applies the Microsoft-managed
        block, or any Conditional Access authentication-flows policy, this switch fails at
        sign-in and the failure will look like a broken script rather than a policy decision.

    Preferred methods, strongest first
        1. Managed identity (-Identity) for anything running on Azure. No secret exists to leak.
        2. Certificate application-only (-CertificateThumbprint or -Certificate) for unattended
           automation elsewhere.
        3. Interactive browser sign-on for a human at a workstation.
        4. Client secret (-ClientSecretCredential). Last resort: it expires, it leaks, and it
           needs rotation that you must own.
        5. Device code. Only where the authenticating device has no browser.

        Workload identity federation is not exposed as a parameter set here because
        Connect-MgGraph has no dedicated parameter for it; where CI provides a federated token,
        authenticate in the pipeline and let Get-EntraMfaRegistrationReport verify the resulting
        session instead of calling this function.

    Graph permission
        AuditLog.Read.All, delegated or application. For delegated access the signed-in principal
        must also hold a supported directory role; Reports Reader is the least privileged that
        qualifies.

    Design decisions
        1. TenantId is mandatory in every parameter set except ManagedIdentity, because
           Connect-MgGraph's IdentityParameterSet exposes no TenantId parameter. Verified against
           Microsoft.Graph.Authentication 2.39.0.
        2. The client secret is taken as a PSCredential rather than a string or SecureString, so
           the client ID and the secret travel together and the secret stays a SecureString.
           This also matches Connect-MgGraph's own -ClientSecretCredential parameter.
        3. SupportsShouldProcess is declared because establishing a session is a state change
           outside this process. -WhatIf reports the tenant and method without signing in.
        4. -NoWelcome is passed to Connect-MgGraph so the banner does not pollute the host. The
           established context is returned as an object instead.
        5. This function exists because module-owned authentication with a mandatory TenantId is
           a standing requirement. It reverses the original specification for this module, which
           required the caller to authenticate and forbade interactive sign-in inside the module.
           Get-EntraMfaRegistrationReport still works with a session established any other way,
           and still never signs in by itself.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Interactive', SupportsShouldProcess = $true)]
    [OutputType([Microsoft.Graph.PowerShell.Authentication.AuthContext])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Interactive')]
        [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')]
        [Parameter(Mandatory, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory, ParameterSetName = 'ClientSecret')]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $TenantId,

        [Parameter(ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [System.String[]]
        $Scopes = @('AuditLog.Read.All'),

        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')]
        [Parameter(Mandatory, ParameterSetName = 'Certificate')]
        [Parameter(ParameterSetName = 'ManagedIdentity')]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ClientId,

        [Parameter(ParameterSetName = 'Interactive')]
        [System.Management.Automation.SwitchParameter]
        $UseDeviceCode,

        [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $CertificateThumbprint,

        [Parameter(Mandatory, ParameterSetName = 'Certificate')]
        [ValidateNotNull()]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,

        [Parameter(Mandatory, ParameterSetName = 'ClientSecret')]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $ClientSecretCredential,

        [Parameter(Mandatory, ParameterSetName = 'ManagedIdentity')]
        [System.Management.Automation.SwitchParameter]
        $Identity
    )

    $method = $PSCmdlet.ParameterSetName
    $connectParameters = @{
        NoWelcome   = $true
        ErrorAction = 'Stop'
    }

    switch ($method) {
        'Interactive' {
            $connectParameters['TenantId'] = $TenantId
            $connectParameters['Scopes'] = $Scopes

            if ($PSBoundParameters.ContainsKey('ClientId')) {
                $connectParameters['ClientId'] = $ClientId
            }

            if ($UseDeviceCode.IsPresent) {
                $method = 'DeviceCode'
                $connectParameters['UseDeviceCode'] = $true

                Write-Warning 'Device code flow is classified by Microsoft as high risk and is recommended for blocking wherever possible. Use it only where the authenticating device genuinely has no browser. If this tenant applies the Microsoft-managed Conditional Access policy that blocks device code, or any authentication-flows policy, this sign-in will fail as a policy decision rather than a script fault.'
            }
        }

        'CertificateThumbprint' {
            $connectParameters['TenantId'] = $TenantId
            $connectParameters['ClientId'] = $ClientId
            $connectParameters['CertificateThumbprint'] = $CertificateThumbprint
        }

        'Certificate' {
            $connectParameters['TenantId'] = $TenantId
            $connectParameters['ClientId'] = $ClientId
            $connectParameters['Certificate'] = $Certificate
        }

        'ClientSecret' {
            $connectParameters['TenantId'] = $TenantId
            $connectParameters['ClientSecretCredential'] = $ClientSecretCredential

            Write-Warning 'Client secret authentication is the least preferred method: the secret expires, can leak, and needs a rotation process you must own. Prefer a managed identity on Azure, or certificate-based application-only authentication elsewhere.'
        }

        'ManagedIdentity' {
            $connectParameters['Identity'] = $true

            if ($PSBoundParameters.ContainsKey('ClientId')) {
                $connectParameters['ClientId'] = $ClientId
            }
        }
    }

    # ManagedIdentity has no TenantId to report, so describe the target by method instead.
    $target = 'the tenant of the host managed identity'
    if ($connectParameters.ContainsKey('TenantId')) {
        $target = $connectParameters['TenantId']
    }

    if (-not $PSCmdlet.ShouldProcess($target, ('Establish a Microsoft Graph session using {0} authentication' -f $method))) {
        return
    }

    try {
        Write-Verbose ('Connecting to Microsoft Graph for tenant {0} using {1} authentication.' -f $target, $method)
        Connect-MgGraph @connectParameters
    } catch [System.UnauthorizedAccessException] {
        throw ('Authentication was refused for tenant {0} using {1}. Confirm the principal is permitted to sign in and that any Conditional Access policy allows this authentication flow. Underlying error: {2}' -f $target, $method, $_.Exception.Message)
    } catch {
        throw ('Failed to establish a Microsoft Graph session for tenant {0} using {1}. Underlying error: {2}' -f $target, $method, $_.Exception.Message)
    }

    try {
        $context = Get-MgContext -ErrorAction Stop
    } catch {
        throw ('A sign-in was attempted but the resulting Microsoft Graph context could not be read. Underlying error: {0}' -f $_.Exception.Message)
    }

    if ($null -eq $context) {
        throw ('A sign-in was attempted for tenant {0} using {1} but no Microsoft Graph context resulted. Treat the connection as failed.' -f $target, $method)
    }

    Write-Verbose ('Connected to tenant {0} as {1}.' -f $context.TenantId, $context.Account)

    # Surface a missing permission here, at sign-in, rather than at first use. An empty Scopes
    # collection is normal for app-only and managed identity sessions, so it is not an error.
    $grantedScopes = @($context.Scopes | Where-Object { -not [System.String]::IsNullOrWhiteSpace($_) })

    if ($grantedScopes.Count -gt 0 -and $grantedScopes -notcontains 'AuditLog.Read.All') {
        Write-Warning ('The established session does not hold AuditLog.Read.All, which Get-EntraMfaRegistrationReport requires. Granted scopes are: {0}.' -f ($grantedScopes -join ', '))
    }

    Write-Output -InputObject $context
}
#EndRegion './Public/Connect-EntraMfaRegistrationReport.ps1' 284
#Region './Public/Get-EntraMfaRegistrationReport.ps1' -1

#Requires -Version 7.0

function Get-EntraMfaRegistrationReport {
    <#
    .SYNOPSIS
    Reports the multifactor authentication registration state of Microsoft Entra ID users.

    .DESCRIPTION
    Queries the Microsoft Graph v1.0 authentication methods registration report
    (/reports/authenticationMethods/userRegistrationDetails) and emits one object per user
    carrying the user's display name, directory object identifier, user principal name,
    directory user type, MFA registration state and whether the user holds an admin role.

    The function is read-only against the tenant. It performs no create, update or delete
    operation on any directory object. The only state change it can make is writing a CSV
    file to the local filesystem, which happens solely when -Path is supplied and is guarded
    by ShouldProcess.

    Objects are emitted to the pipeline in every case, so the caller may pipe the result to
    Export-Csv, Where-Object or Group-Object and obtain the same six-column contract that
    the built-in -Path export produces.

    Results are retrieved a page at a time by following the @odata.nextLink property until
    the service stops returning one, so tenants larger than a single Graph page are handled
    correctly. HTTP 429 responses are retried with bounded exponential backoff that honours
    the Retry-After header where the header can be read from the failure.

    .PARAMETER TenantId
    Optional tenant GUID to assert the active Microsoft Graph session against. When supplied and
    the session belongs to a different tenant, the function throws before querying anything. This
    guards the multi-tenant assessment case where an inherited session silently points at the
    wrong tenant. Must be the tenant GUID, not a domain name, because the Graph context reports
    the tenant as a GUID and no directory lookup is performed to resolve a domain. To establish a
    session against a named tenant in the first place, use Connect-EntraMfaRegistrationReport.

    .PARAMETER UserType
    Restricts the report to a single directory user type. Accepts 'Member', 'Guest' or 'All'.
    Defaults to 'All'. Filtering is performed client side because the userType property of the
    userRegistrationDetails resource is not documented as supporting the $filter query option.

    .PARAMETER UnregisteredOnly
    Returns only users whose MFA registration state is false. This filter is applied server
    side using $filter=isMfaRegistered eq false, which the resource documents as supported.

    .PARAMETER Path
    Optional path of the CSV file to write. When omitted no file is written and the function
    emits objects only. The parent directory must already exist. An existing file is not
    overwritten unless -Force is also supplied.

    .PARAMETER Force
    Permits an existing file at -Path to be overwritten. Has no effect when -Path is omitted.

    .PARAMETER MaxRetryCount
    Maximum number of retry attempts per page for a throttled or transient Graph response.
    Accepts 1 to 10 and defaults to 5. Once the attempts are exhausted the function throws
    rather than returning a partial result set.

    .EXAMPLE
    Get-EntraMfaRegistrationReport -Verbose

    Returns one object per user in the tenant, of both user types, with progress written to
    the verbose stream. No file is written.

    .EXAMPLE
    Get-EntraMfaRegistrationReport -UserType Member -UnregisteredOnly -Path './mfa-member-gaps.csv'

    Reproduces the population behind an MFA coverage finding by reporting only member users who
    are not registered for MFA, and writes the six-column CSV to the given path. Sample content:

        DisplayName,ObjectId,UserPrincipalName,UserType,MFARegistered,IsAdmin
        "Adele Vance",00000000-0000-0000-0000-000000000001,AdeleV@contoso.com,Member,False,False
        "Alex Wilber",00000000-0000-0000-0000-000000000002,AlexW@contoso.com,Member,False,True

    .EXAMPLE
    Get-EntraMfaRegistrationReport -TenantId '00000000-0000-0000-0000-000000000000'

    Refuses to run unless the active Graph session belongs to the named tenant. Use this in
    scripted assessment work where running against the wrong tenant is the real risk.

    .EXAMPLE
    Get-EntraMfaRegistrationReport -UnregisteredOnly | Where-Object -Property IsAdmin -EQ $true

    Returns only privileged accounts that are not registered for MFA. These are the highest
    priority for remediation, because an unprotected admin account exposes the whole tenant.

    .EXAMPLE
    Get-EntraMfaRegistrationReport -Path './mfa-registration.csv' -WhatIf

    Retrieves the report and reports what would be written without creating the file.

    .OUTPUTS
    System.Management.Automation.PSCustomObject

    One object per user, with these properties in this order, matching the CSV headers exactly:

        DisplayName string, may be null
        ObjectId string, the directory object GUID
        UserPrincipalName string, may be null
        UserType string, 'Member' or 'Guest'
        MFARegistered boolean, rendered by Export-Csv as True or False
        IsAdmin boolean, rendered by Export-Csv as True or False

    .NOTES
    Assessment finding served
        Supports an MFA registration coverage finding, reference ID-001 in the assessment
        framework this module was written for. Where measured coverage falls short of the
        configured threshold, this report identifies the individual users behind the shortfall so
        that MFA registration campaigns, or enforcement through Conditional Access or the Identity
        Protection registration policy, can be targeted and tracked until coverage exceeds the
        threshold. The function itself performs no remediation.

        No measured figure, tenant, organisation or user identity from any assessment is recorded
        in this module. Findings data belongs in the assessment report, not in distributed code.

    Graph permissions
        Delegated (work or school account): AuditLog.Read.All
        Application: AuditLog.Read.All
        AuditLog.Read.All is the least privileged permission Microsoft documents for both the list
        and get operations on userRegistrationDetails; no lower-privileged alternative exists and
        no higher-privileged alternative is offered. For delegated access the signed-in principal
        must additionally hold a supported directory role. Reports Reader is the least privileged
        of those; Security Reader, Global Reader, Security Operator, Security Administrator,
        Application Administrator and Cloud Application Administrator also qualify.

    Licence position
        Access to the authentication methods Usage and insights data, which is what this report
        reads, requires a Microsoft Entra ID P1 or P2 licence. Verified against Microsoft Learn,
        Authentication Methods Activity, Permissions and licenses.

    Registration versus capability
        MFARegistered is populated from isMfaRegistered, not isMfaCapable. isMfaRegistered means
        the user has registered a strong authentication method for MFA, whether or not that method
        is currently allowed by the tenant authentication methods policy. isMfaCapable means the
        user has registered such a method and the method is allowed by that policy. isMfaCapable
        is therefore always the smaller or equal population, and the two figures diverge whenever
        the policy disallows a method that users have registered. Choosing between them materially
        changes the reported coverage percentage, so the choice is fixed here rather than exposed
        as a parameter.

    Coverage limits of the source report
        Microsoft documents that userRegistrationDetails does not list disabled users, and does not
        list recently deleted (soft-deleted) users. The report therefore cannot cover disabled
        accounts at all, and the denominator it produces is the set of enabled, non-deleted users.
        Microsoft also documents a data latency of up to 36 hours, so a registration completed
        today may not appear yet.

    Stated design decisions and deviations
        1. Server-side shaping is partial. isMfaRegistered supports $filter, so -UnregisteredOnly
           is applied server side. userType is not documented as supporting $filter, so -UserType
           is applied client side after retrieval.
        2. $select is not used. The list operation for this collection documents support for
           $filter only, and does not document $select, so requesting a projection would be an
           unverified assumption. All documented properties are returned and the unwanted ones
           are discarded locally.
        3. Invoke-MgGraphRequest is used in preference to the typed cmdlet
           Get-MgReportAuthenticationMethodUserRegistrationDetail. The typed cmdlet exists in the
           Microsoft.Graph.Reports module and its -All switch would page internally, but it
           exposes no hook for reading Retry-After or for warning on each backoff, which this
           function is required to do. Invoke-MgGraphRequest rides the existing Connect-MgGraph
           session, so no token is handled in this code. If per-page throttling control is not
           needed, Get-MgReportAuthenticationMethodUserRegistrationDetail -All is the simpler
           equivalent.
        4. $top is not requested. The list operation documents support for $filter only, so the
           service default page size is used and paging follows @odata.nextLink.
        5. Scope verification degrades to a warning when the Graph context reports no scopes at
           all. Certificate-based app-only and managed identity sessions can present an empty
           Scopes collection, and those are supported authentication models here, so a strict
           throw would break them. A context that reports scopes but omits AuditLog.Read.All
           throws.
        6. MaxRetryCount is a parameter that the specification did not request. A bounded backoff
           needs an explicit bound, and making it a parameter avoids burying the limit in the body.
        7. Where a user record omits a property, that property is emitted as null with a verbose
           entry, and the row is retained. For MFARegistered this conflicts with the stated CSV
           contract of True or False; the instruction never to fabricate a value and never to drop
           a row takes precedence, so an empty cell is possible in that column.
        8. userType values are returned by Graph in lower case as 'member' or 'guest', with
           'unknownFutureValue' reserved. They are normalised to 'Member' and 'Guest' for the CSV
           contract. Any other value is passed through unchanged with a verbose entry rather than
           being coerced.
        9. try/catch is used around every external call. A finally block is used only on the CSV
           export, where it records the outcome. The paging retry loop has no unmanaged resource to
           release, and a finally inside a retry loop would execute on every attempt, so none is
           used there.
       10. The exact exception shape Invoke-MgGraphRequest raises for HTTP 429 is not documented.
           Reading the status code and the Retry-After header is therefore attempted defensively
           and, if it cannot be read, the function falls back to computed exponential backoff.
           This path could not be exercised without a live throttled tenant and is marked for
           confirmation by the operator.
       11. This file deliberately carries no '#Requires -Modules' statement for
           Microsoft.Graph.Authentication. A module source file declares its dependencies through
           the manifest and RequiredModules.psd1, and a '#Requires -Modules' line here would make
           the file impossible to dot-source for unit testing on a machine without the Graph SDK
           installed. Only '#Requires -Version 7.0' is declared.
       12. IsAdmin is a sixth column, added at explicit request after the original five-column
           specification. It is populated from the isAdmin property of userRegistrationDetails,
           which Microsoft documents as indicating whether the user holds an admin role in the
           tenant. It is appended last so the original five columns keep their contract order and
           existing consumers are unaffected. isAdmin is not documented as supporting $filter, so
           it is reported but cannot be filtered server side.
       13. -TenantId is optional here and asserts the active session's tenant, rather than being
           mandatory. Authentication is the job of Connect-EntraMfaRegistrationReport, where
           TenantId is mandatory; making it mandatory here as well would force a tenant GUID on
           every call even when a session is already correctly established, and would break the
           pipeline-friendly no-argument default. This function still never signs in by itself.

    Authentication
        The caller must already hold a Microsoft Graph session. The function verifies the context
        and never initiates an interactive sign-in. Any supported model works: managed identity,
        workload identity federation, certificate-based app-only, or interactive sign-in performed
        outside the function.

    Privacy
        Output contains personal data, specifically display names and user principal names. Handle
        it in accordance with the client data-handling policy, store it only where that policy
        permits, and do not commit generated CSV files to source control.

    Platform
        PowerShell 7.x, cross-platform. No element of this function requires Windows PowerShell
        5.1 or elevation.

    Module placement
        source/Public/Get-EntraMfaRegistrationReport.ps1, with its test at
        tests/Unit/Public/Get-EntraMfaRegistrationReport.tests.ps1. A new containing module is
        generated from the Princetimber/powershell-module-template GitHub template rather than
        hand-scaffolded, and the Microsoft.Graph.Authentication version is pinned in
        RequiredModules.psd1.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter()]
        [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')]
        [System.String]
        $TenantId,

        [Parameter()]
        [ValidateSet('Member', 'Guest', 'All')]
        [System.String]
        $UserType = 'All',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $UnregisteredOnly,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
                $parentPath = Split-Path -Path $_ -Parent
                if ([System.String]::IsNullOrWhiteSpace($parentPath)) {
                    return $true
                }
                if (Test-Path -LiteralPath $parentPath -PathType Container) {
                    return $true
                }
                throw ("The parent directory '{0}' does not exist. Create it before exporting." -f $parentPath)
            })]
        [System.String]
        $Path,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Force,

        [Parameter()]
        [ValidateRange(1, 10)]
        [System.Int32]
        $MaxRetryCount = 5
    )

    $requiredScope = 'AuditLog.Read.All'
    $connectHint = ("Connect first, for example: Connect-MgGraph -Scopes '{0}'." -f $requiredScope)

    # Confirm an existing Graph session rather than creating one. Sign-in is the caller's concern.
    try {
        $graphContext = Get-MgContext -ErrorAction Stop
    } catch {
        throw ('Unable to read the Microsoft Graph context. {0} Underlying error: {1}' -f $connectHint, $_.Exception.Message)
    }

    if ($null -eq $graphContext) {
        throw ("No Microsoft Graph context was found. This function requires the '{0}' permission. {1}" -f $requiredScope, $connectHint)
    }

    # Assert the session belongs to the expected tenant before reading any directory data.
    if ($PSBoundParameters.ContainsKey('TenantId') -and $graphContext.TenantId -ne $TenantId) {
        throw ('The active Microsoft Graph session belongs to tenant {0}, not the expected tenant {1}. Reconnect with Connect-EntraMfaRegistrationReport -TenantId {1} before running this report.' -f $graphContext.TenantId, $TenantId)
    }

    $grantedScopes = @($graphContext.Scopes | Where-Object { -not [System.String]::IsNullOrWhiteSpace($_) })

    if ($grantedScopes.Count -eq 0) {
        # App-only and managed identity sessions can legitimately report no scopes.
        Write-Warning ("The Microsoft Graph context reports no scopes, which is expected for app-only and managed identity sessions. Proceeding on the assumption that the '{0}' application permission has been granted and admin-consented." -f $requiredScope)
    } elseif ($grantedScopes -notcontains $requiredScope) {
        throw ("The current Microsoft Graph context does not hold the required permission '{0}'. Granted scopes are: {1}. {2}" -f $requiredScope, ($grantedScopes -join ', '), $connectHint)
    }

    # Validate the destination before the enumeration so a clobber conflict fails immediately
    # rather than after a full paginated retrieval.
    $exportRequested = $PSBoundParameters.ContainsKey('Path')

    if ($exportRequested) {
        if (Test-Path -LiteralPath $Path -PathType Container) {
            throw ("The path '{0}' is an existing directory. Supply the full path of the CSV file to write." -f $Path)
        }
        if ((Test-Path -LiteralPath $Path -PathType Leaf) -and -not $Force.IsPresent) {
            throw ("The file '{0}' already exists. Supply -Force to overwrite it, or choose a different path." -f $Path)
        }
    }

    $requestUri = '/v1.0/reports/authenticationMethods/userRegistrationDetails'

    if ($UnregisteredOnly.IsPresent) {
        # isMfaRegistered is documented as supporting $filter with eq, so this narrows server side.
        $requestUri = '{0}?$filter=isMfaRegistered%20eq%20false' -f $requestUri
    }

    # Reads a property from a Graph record, returning null and a verbose note when it is absent,
    # so an incomplete record is never fabricated and never silently dropped.
    $readProperty = {
        param(
            [System.Collections.IDictionary]
            $Record,

            [System.String]
            $PropertyName,

            [System.String]
            $RecordLabel
        )

        if ($Record.Contains($PropertyName)) {
            return $Record[$PropertyName]
        }

        Write-Verbose ("Record '{0}' has no '{1}' property; emitting null for it." -f $RecordLabel, $PropertyName)
        return $null
    }

    # PSObject, not PSCustomObject: the [PSCustomObject]@{} literal produces a PSObject at
    # runtime, so a List[PSCustomObject] would reject every Add call.
    $report = [System.Collections.Generic.List[System.Management.Automation.PSObject]]::new()
    $retryableStatusCodes = @(429, 503, 504)
    $maximumBackoffSeconds = 300
    $pageNumber = 0
    $nextUri = $requestUri

    while (-not [System.String]::IsNullOrWhiteSpace($nextUri)) {
        $pageNumber++
        Write-Verbose ('Requesting page {0} of the authentication methods registration report.' -f $pageNumber)

        $response = $null
        $attempt = 0

        while ($null -eq $response) {
            try {
                $requestParameters = @{
                    Method      = 'GET'
                    Uri         = $nextUri
                    OutputType  = 'Hashtable'
                    ErrorAction = 'Stop'
                }
                $response = Invoke-MgGraphRequest @requestParameters
            } catch {
                $statusCode = 0
                $retryAfterSeconds = 0

                # The exception shape for a throttled Graph response is not documented, so every
                # hop is guarded and any failure here degrades to computed backoff.
                try {
                    $failureResponse = $_.Exception.Response
                    if ($null -ne $failureResponse) {
                        if ($null -ne $failureResponse.StatusCode) {
                            $statusCode = [System.Int32]$failureResponse.StatusCode
                        }
                        $failureHeaders = $failureResponse.Headers
                        if ($null -ne $failureHeaders -and $null -ne $failureHeaders.RetryAfter -and $null -ne $failureHeaders.RetryAfter.Delta) {
                            $retryAfterSeconds = [System.Int32]$failureHeaders.RetryAfter.Delta.TotalSeconds
                        }
                    }
                } catch {
                    Write-Verbose 'Could not read the HTTP status code or Retry-After header from the failure; falling back to computed backoff.'
                }

                if ($statusCode -eq 0 -and $_.Exception.Message -match '(429|Too Many Requests)') {
                    $statusCode = 429
                }

                if ($retryableStatusCodes -notcontains $statusCode) {
                    <#
                        Graph returns 403 Authentication_RequestFromUnsupportedUserRole when the
                        token carries AuditLog.Read.All but the signed-in principal holds no
                        directory role permitted to read this report. The scope alone is
                        necessary but not sufficient, and the raw Graph error does not say so,
                        which sends operators hunting for a missing consent that is already
                        granted. Translate it into the actual remedy.
                    #>

                    $unsupportedRole = $statusCode -eq 403 -or
                    $_.Exception.Message -match 'Authentication_RequestFromUnsupportedUserRole|not in the allowed roles'

                    if ($unsupportedRole) {
                        throw ('Microsoft Graph refused the request with HTTP 403 because the signed-in principal is not in a directory role permitted to read the authentication methods registration report. The AuditLog.Read.All permission is necessary but not sufficient for delegated access. Assign the account one of these roles and sign in again: Reports Reader (least privileged), Security Reader, Global Reader, Security Operator, Security Administrator, Application Administrator or Cloud Application Administrator. For unattended use, authenticate app-only with AuditLog.Read.All granted as an application permission with admin consent, which needs no role assignment. Underlying error: {0}' -f $_.Exception.Message)
                    }

                    # Not a throttling or transient condition. Retrying would only bury the cause.
                    throw
                }

                if ($attempt -ge $MaxRetryCount) {
                    throw ('Microsoft Graph returned HTTP {0} for page {1} and the retry limit of {2} was reached. No partial report is returned. Underlying error: {3}' -f $statusCode, $pageNumber, $MaxRetryCount, $_.Exception.Message)
                }

                $attempt++

                if ($retryAfterSeconds -gt 0) {
                    $delaySeconds = [System.Math]::Min($retryAfterSeconds, $maximumBackoffSeconds)
                    $delaySource = 'the Retry-After header'
                } else {
                    $delaySeconds = [System.Int32][System.Math]::Min([System.Math]::Pow(2, $attempt), $maximumBackoffSeconds)
                    $delaySource = 'computed exponential backoff'
                }

                Write-Warning ('Microsoft Graph returned HTTP {0} for page {1}. Retrying attempt {2} of {3} in {4} second(s), based on {5}.' -f $statusCode, $pageNumber, $attempt, $MaxRetryCount, $delaySeconds, $delaySource)
                Start-Sleep -Seconds $delaySeconds
            }
        }

        $pageRecords = @()
        if ($response.Contains('value')) {
            $pageRecords = @($response['value'])
        } else {
            Write-Warning ("Page {0} of the report contained no 'value' collection and was skipped." -f $pageNumber)
        }

        Write-Verbose ('Page {0} returned {1} record(s).' -f $pageNumber, $pageRecords.Count)

        foreach ($record in $pageRecords) {
            if ($record -isnot [System.Collections.IDictionary]) {
                Write-Warning ('A record on page {0} was not in the expected shape and was skipped.' -f $pageNumber)
                continue
            }

            $objectId = & $readProperty -Record $record -PropertyName 'id' -RecordLabel 'unknown'
            $recordLabel = if ([System.String]::IsNullOrWhiteSpace($objectId)) { 'unknown' } else { [System.String]$objectId }

            $displayName = & $readProperty -Record $record -PropertyName 'userDisplayName' -RecordLabel $recordLabel
            $userPrincipalName = & $readProperty -Record $record -PropertyName 'userPrincipalName' -RecordLabel $recordLabel
            $rawUserType = & $readProperty -Record $record -PropertyName 'userType' -RecordLabel $recordLabel
            $rawMfaRegistered = & $readProperty -Record $record -PropertyName 'isMfaRegistered' -RecordLabel $recordLabel
            $rawIsAdmin = & $readProperty -Record $record -PropertyName 'isAdmin' -RecordLabel $recordLabel

            # Graph returns 'member' and 'guest' in lower case; the CSV contract requires title case.
            $normalisedUserType = $null
            if (-not [System.String]::IsNullOrWhiteSpace($rawUserType)) {
                switch ([System.String]$rawUserType) {
                    'member' { $normalisedUserType = 'Member' }
                    'guest' { $normalisedUserType = 'Guest' }
                    default {
                        $normalisedUserType = [System.String]$rawUserType
                        Write-Verbose ("Record '{0}' reported an unrecognised userType of '{1}'; passing it through unchanged." -f $recordLabel, $rawUserType)
                    }
                }
            }

            if ($UserType -ne 'All' -and $normalisedUserType -ne $UserType) {
                Write-Verbose ("Record '{0}' has userType '{1}' and was excluded by the -UserType {2} filter." -f $recordLabel, $normalisedUserType, $UserType)
                continue
            }

            $mfaRegistered = $null
            if ($null -ne $rawMfaRegistered) {
                $mfaRegistered = [System.Boolean]$rawMfaRegistered
            }

            $isAdmin = $null
            if ($null -ne $rawIsAdmin) {
                $isAdmin = [System.Boolean]$rawIsAdmin
            }

            $report.Add([PSCustomObject]@{
                    DisplayName       = $displayName
                    ObjectId          = $objectId
                    UserPrincipalName = $userPrincipalName
                    UserType          = $normalisedUserType
                    MFARegistered     = $mfaRegistered
                    IsAdmin           = $isAdmin
                })
        }

        $nextUri = $null
        if ($response.Contains('@odata.nextLink')) {
            $nextUri = [System.String]$response['@odata.nextLink']
            Write-Verbose 'The service returned a nextLink; another page will be requested.'
        }
    }

    Write-Verbose ('Retrieved {0} record(s) across {1} page(s).' -f $report.Count, $pageNumber)

    if ($report.Count -eq 0) {
        Write-Warning 'The report is empty. Note that disabled and recently deleted users are not present in this Graph report, and that the report has a data latency of up to 36 hours.'
    }

    if ($exportRequested) {
        if ($PSCmdlet.ShouldProcess($Path, 'Write MFA registration report CSV')) {
            try {
                $exportParameters = @{
                    Path              = $Path
                    NoTypeInformation = $true
                    Encoding          = 'UTF8'
                    ErrorAction       = 'Stop'
                }
                $report | Export-Csv @exportParameters
            } catch {
                throw ("Failed to write the CSV report to '{0}'. Underlying error: {1}" -f $Path, $_.Exception.Message)
            } finally {
                Write-Verbose ("CSV export to '{0}' completed its attempt." -f $Path)
            }
        }
    }

    Write-Output -InputObject $report
}
#EndRegion './Public/Get-EntraMfaRegistrationReport.ps1' 523