EntraCaExclusionReport.psm1

#Region './Private/ConvertTo-CaExclusionStateLabel.ps1' -1

#Requires -Version 7.0

function ConvertTo-CaExclusionStateLabel {
    # Maps a raw Microsoft Graph Conditional Access policy state to the three permitted report
    # values. VERIFIED against the conditionalAccessPolicy resource: the documented states are
    # 'enabled', 'disabled', and 'enabledForReportingButNotEnforced'.
    #
    # Anything unrecognised is passed through verbatim with a warning rather than coerced into one
    # of the three, because a coerced state misrepresents the control.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$RawState
    )

    switch ($RawState) {
        'enabled' { return 'On' }
        'disabled' { return 'Off' }
        'enabledForReportingButNotEnforced' { return 'Report-Only' }
        default {
            Write-Warning "Unrecognised Conditional Access policy state '$RawState' - passing it through verbatim rather than guessing a mapping."
            return $RawState
        }
    }
}
#EndRegion './Private/ConvertTo-CaExclusionStateLabel.ps1' 28
#Region './Private/Get-CaExclusionRowForPolicy.ps1' -1

#Requires -Version 7.0

function Get-CaExclusionRowForPolicy {
    # Turns one Conditional Access policy's exclusion collections into report rows.
    #
    # A policy with no exclusions still yields exactly one row carrying the no-exclusion marker, so
    # every assessed policy appears in the output. Without that row, a policy omitted through a bug
    # would be indistinguishable from a policy that genuinely excludes nobody.
    #
    # VERIFIED against the conditionalAccessUsers resource: the exclusion collections are
    # ExcludeUsers, ExcludeGroups, ExcludeRoles, and ExcludeGuestsOrExternalUsers, nested under
    # Conditions.Users.
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [object]$Policy,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$MappedState,

        [Parameter(Mandatory)]
        [hashtable]$ReportState,

        [Parameter()]
        [switch]$IncludeExcludedRoles
    )

    # An unparseable policy is an explicit error, never zero rows: silently omitting a policy is
    # indistinguishable from that policy having no exclusions.
    if (-not $Policy.Conditions -or -not $Policy.Conditions.Users) {
        throw 'Policy has no parseable Conditions.Users block - cannot determine exclusions.'
    }

    $markers = $ReportState.Markers
    $caUsers = $Policy.Conditions.Users
    $rows = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($userId in @($caUsers.ExcludeUsers | Where-Object { $_ })) {
        # Reserved tokens are not directory objects: report verbatim, never look them up, and
        # never report them as a failed resolution.
        if ($userId -in $ReportState.ReservedTokens) {
            $rows.Add([PSCustomObject]@{
                    PolicyName        = $Policy.DisplayName
                    State             = $MappedState
                    ObjectID          = $userId
                    UserPrincipalName = $markers.NotApplicable
                    DisplayName       = $markers.ReservedToken
                    PrincipalType     = 'Token'
                })
            continue
        }

        $resolvedUser = Resolve-CaExclusionPrincipal -PrincipalType 'User' -ObjectId $userId `
            -PolicyDisplayName $Policy.DisplayName -ReportState $ReportState
        $rows.Add([PSCustomObject]@{
                PolicyName        = $Policy.DisplayName
                State             = $MappedState
                ObjectID          = $resolvedUser.ObjectId
                UserPrincipalName = $resolvedUser.UserPrincipalName
                DisplayName       = $resolvedUser.DisplayName
                PrincipalType     = 'User'
            })
    }

    foreach ($groupId in @($caUsers.ExcludeGroups | Where-Object { $_ })) {
        $resolvedGroup = Resolve-CaExclusionPrincipal -PrincipalType 'Group' -ObjectId $groupId `
            -PolicyDisplayName $Policy.DisplayName -ReportState $ReportState
        $rows.Add([PSCustomObject]@{
                PolicyName        = $Policy.DisplayName
                State             = $MappedState
                ObjectID          = $resolvedGroup.ObjectId
                UserPrincipalName = $resolvedGroup.UserPrincipalName
                DisplayName       = $resolvedGroup.DisplayName
                PrincipalType     = 'Group'
            })
    }

    if ($IncludeExcludedRoles) {
        foreach ($roleId in @($caUsers.ExcludeRoles | Where-Object { $_ })) {
            $resolvedRole = Resolve-CaExclusionPrincipal -PrincipalType 'Role' -ObjectId $roleId `
                -PolicyDisplayName $Policy.DisplayName -ReportState $ReportState
            $rows.Add([PSCustomObject]@{
                    PolicyName        = $Policy.DisplayName
                    State             = $MappedState
                    ObjectID          = $resolvedRole.ObjectId
                    UserPrincipalName = $resolvedRole.UserPrincipalName
                    DisplayName       = $resolvedRole.DisplayName
                    PrincipalType     = 'Role'
                })
        }

        if ($caUsers.ExcludeGuestsOrExternalUsers) {
            $rows.Add([PSCustomObject]@{
                    PolicyName        = $Policy.DisplayName
                    State             = $MappedState
                    ObjectID          = 'GuestsOrExternalUsers'
                    UserPrincipalName = $markers.NotApplicable
                    DisplayName       = $markers.ReservedToken
                    PrincipalType     = 'Token'
                })
        }
    }

    if ($rows.Count -eq 0) {
        $rows.Add([PSCustomObject]@{
                PolicyName        = $Policy.DisplayName
                State             = $MappedState
                ObjectID          = $markers.NoExclusions
                UserPrincipalName = $markers.NoExclusions
                DisplayName       = $markers.NoExclusions
                PrincipalType     = 'None'
            })
    }

    return $rows.ToArray()
}
#EndRegion './Private/Get-CaExclusionRowForPolicy.ps1' 119
#Region './Private/New-CaExclusionRunState.ps1' -1

#Requires -Version 7.0

function New-CaExclusionRunState {
    # Builds the single mutable state object for one report run: the markers, the reserved-token
    # list, the resolution cache, and the counters the summary reports.
    #
    # Returned as a hashtable because it is a reference type, so the other helpers can increment
    # counters and populate the cache in place. State is passed explicitly rather than held in
    # $script: variables so that each helper's data dependencies are visible in its signature and
    # each is independently testable.
    #
    # No ShouldProcess: this constructs an in-memory object and touches nothing outside it.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Creates an in-memory hashtable only. There is no system state to gate.')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    @{
        Markers                  = @{
            NoExclusions    = '[No exclusions]'
            NotApplicable   = 'N/A'
            Unresolved      = '[Unresolved]'
            ReservedToken   = '[Reserved token - not a directory object]'
            ProcessingError = '[Processing error]'
        }
        # ASSUMPTION - re-verify against current Microsoft Learn documentation. See README.md.
        ReservedTokens           = @('All', 'None', 'GuestsOrExternalUsers')
        ResolutionCache          = @{}
        UnresolvedCount          = 0
        FailedPolicyCount        = 0
        AssessedPolicyCount      = 0
        EstablishedOwnConnection = $false
        ResolvedTenantId         = $null
    }
}
#EndRegion './Private/New-CaExclusionRunState.ps1' 37
#Region './Private/Resolve-CaExclusionPrincipal.ps1' -1

#Requires -Version 7.0

function Resolve-CaExclusionPrincipal {
    # Resolves one excluded object ID to its directory attributes, caching by type+id in the run
    # state so an ID excluded from forty policies costs one lookup, not forty.
    #
    # A failed lookup is never fatal and never guessed: the raw ID is preserved, the display name
    # is marked unresolved, a warning names the policy and the ID, and the caller still gets a row.
    # A deleted object still sitting in an exclusion list is itself a finding, so dropping the row
    # would hide exactly what the report exists to surface.
    #
    # HARD CONSTRAINT: the 'Group' branch reads DisplayName only. It must never call
    # Get-MgGroupMember, Get-MgGroupTransitiveMember, or any membership or member-count endpoint.
    # See README.md - this is a requirement, not an optimisation to be revisited.
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('User', 'Group', 'Role')]
        [string]$PrincipalType,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ObjectId,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$PolicyDisplayName,

        [Parameter(Mandatory)]
        [hashtable]$ReportState
    )

    $cacheKey = "$PrincipalType|$ObjectId"
    if ($ReportState.ResolutionCache.ContainsKey($cacheKey)) {
        return $ReportState.ResolutionCache[$cacheKey]
    }

    $markers = $ReportState.Markers
    $resolved = $null

    try {
        switch ($PrincipalType) {
            'User' {
                $user = Get-MgUser -UserId $ObjectId -Property Id, UserPrincipalName, DisplayName -ErrorAction Stop
                $resolved = [PSCustomObject]@{
                    ObjectId          = $ObjectId
                    UserPrincipalName = $user.UserPrincipalName
                    DisplayName       = $user.DisplayName
                }
            }
            'Group' {
                # Display name only. No membership call is made here or anywhere else.
                $group = Get-MgGroup -GroupId $ObjectId -Property Id, DisplayName -ErrorAction Stop
                $resolved = [PSCustomObject]@{
                    ObjectId          = $ObjectId
                    UserPrincipalName = $markers.NotApplicable
                    DisplayName       = $group.DisplayName
                }
            }
            'Role' {
                $role = Get-MgDirectoryRoleTemplate -DirectoryRoleTemplateId $ObjectId -ErrorAction Stop
                $resolved = [PSCustomObject]@{
                    ObjectId          = $ObjectId
                    UserPrincipalName = $markers.NotApplicable
                    DisplayName       = $role.DisplayName
                }
            }
        }
    } catch {
        Write-Warning "Policy '$PolicyDisplayName': unable to resolve $PrincipalType '$ObjectId' - $($_.Exception.Message)"
        $ReportState.UnresolvedCount++
        $resolved = [PSCustomObject]@{
            ObjectId          = $ObjectId
            UserPrincipalName = if ($PrincipalType -eq 'User') { $markers.Unresolved } else { $markers.NotApplicable }
            DisplayName       = $markers.Unresolved
        }
    }

    $ReportState.ResolutionCache[$cacheKey] = $resolved
    return $resolved
}
#EndRegion './Private/Resolve-CaExclusionPrincipal.ps1' 83
#Region './Private/Resolve-CaExclusionReportPath.ps1' -1

#Requires -Version 7.0

function Resolve-CaExclusionReportPath {
    # Works out the final CSV path. An empty OutputPath, or one naming an existing directory, gets
    # a generated timestamped filename; an explicit file path is used exactly as given.
    #
    # The tenant label prefers the tenant ID the Graph session actually resolved to over whatever
    # the caller typed, so the filename identifies the tenant that was really assessed even when
    # -TenantId was omitted entirely or supplied as a verified domain rather than a GUID.
    #
    # Timestamps are ISO 8601 UTC, per the reporting convention for generated filenames.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [AllowEmptyString()]
        [string]$OutputPath,

        [Parameter()]
        [AllowNull()]
        [string]$ResolvedTenantId,

        [Parameter()]
        [AllowNull()]
        [string]$RequestedTenantId
    )

    $tenantLabel = if ($ResolvedTenantId) { $ResolvedTenantId }
    elseif ($RequestedTenantId) { $RequestedTenantId }
    else { 'UnknownTenant' }

    # A verified domain contains characters that are legal in a path but noisy in a filename.
    $tenantLabel = ($tenantLabel -replace '[^A-Za-z0-9._-]', '_')

    $timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
    $generatedFileName = "EntraCaExclusionReport_${tenantLabel}_${timestamp}.csv"

    if ([string]::IsNullOrWhiteSpace($OutputPath)) {
        return (Join-Path -Path (Get-Location).Path -ChildPath $generatedFileName)
    }
    if (Test-Path -Path $OutputPath -PathType Container) {
        return (Join-Path -Path $OutputPath -ChildPath $generatedFileName)
    }
    return $OutputPath
}
#EndRegion './Private/Resolve-CaExclusionReportPath.ps1' 46
#Region './Private/Write-CaExclusionSummary.ps1' -1

#Requires -Version 7.0

function Write-CaExclusionSummary {
    # Emits the end-of-run console summary.
    #
    # Written to the information stream with -InformationAction Continue rather than Write-Verbose,
    # because the summary is a required deliverable the operator must see on a default run, not
    # flow narration they have to opt into with -Verbose. Write-Host is not used: it cannot be
    # captured or redirected.
    #
    # Counts come from rows that still carry the PrincipalType discriminator. A role row and a
    # group row are identical once you only have the marker columns to go on - both carry 'N/A' in
    # UserPrincipalName and a real DisplayName - so inferring type from those would silently
    # inflate the excluded-group count whenever -IncludeExcludedRoles is used.
    #
    # Dates are DD/MM/YYYY with 24-hour time, per the convention for human-facing output.
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ReportState,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Row,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvPath
    )

    $policiesWithExclusions = @($Row |
            Where-Object { $_.PrincipalType -in @('User', 'Group', 'Role', 'Token') } |
                Select-Object -ExpandProperty PolicyName -Unique).Count
    $distinctUsers = @($Row | Where-Object { $_.PrincipalType -eq 'User' } |
            Select-Object -ExpandProperty ObjectID -Unique).Count
    $distinctGroups = @($Row | Where-Object { $_.PrincipalType -eq 'Group' } |
            Select-Object -ExpandProperty ObjectID -Unique).Count

    $summaryLines = @(
        '--- Get-EntraCaExclusionReport summary ---'
        "Run completed : $((Get-Date).ToString('dd/MM/yyyy HH:mm')) (local)"
        "Policies assessed : $($ReportState.AssessedPolicyCount)"
        "Policies with exclusions : $policiesWithExclusions"
        "Distinct excluded users : $distinctUsers"
        "Distinct excluded groups : $distinctGroups"
        "Unresolved principals : $($ReportState.UnresolvedCount)"
        "Policies failed : $($ReportState.FailedPolicyCount)"
        "CSV written to : $CsvPath"
    )

    foreach ($summaryLine in $summaryLines) {
        Write-Information -MessageData $summaryLine -InformationAction Continue
    }
}
#EndRegion './Private/Write-CaExclusionSummary.ps1' 56
#Region './Public/Get-EntraCaExclusionReport.ps1' -1

#Requires -Version 7.0

function Get-EntraCaExclusionReport {
    <#
    .SYNOPSIS
    Reports every user and group excluded from every Conditional Access policy in an Entra ID
    tenant, as a flat CSV suitable for audit evidence.

    .DESCRIPTION
    Get-EntraCaExclusionReport connects to Microsoft Graph using device code authentication,
    retrieves every Conditional Access policy in the tenant (optionally filtered by display name
    or state), and emits one row per principal excluded from each policy's user conditions:
    excluded users and excluded groups always; excluded directory roles and excluded guest/external
    user types only when -IncludeExcludedRoles is specified.

    Group exclusions are reported by ObjectID and DisplayName only - group membership is never
    enumerated, expanded, or resolved to member users, by design. Excluded object identifiers are
    resolved to their DisplayName (and, for users, UserPrincipalName) via Microsoft Graph, with each
    distinct identifier resolved at most once per run. Reserved exclusion tokens (for example 'All',
    'None', 'GuestsOrExternalUsers') are reported verbatim without a directory lookup. Principals
    that cannot be resolved are reported with their raw identifier preserved and a marked unresolved
    display name - never silently dropped.

    A policy with no exclusions still produces exactly one row, so the CSV is a complete assessment
    of every policy in scope, not a partial extract of only the policies that have exclusions.

    The CSV carries six columns, in this order: PolicyName, State, ObjectID, UserPrincipalName,
    DisplayName, PrincipalType. PrincipalType is one of 'User', 'Group', 'Role', 'Token', 'None',
    or 'Error', and exists so a consumer can filter or pivot by principal type directly instead of
    inferring it from the UserPrincipalName marker.

    The function is strictly read-only against the directory: it performs no create, update, or
    delete against any Entra ID object. Its only write is the CSV file it produces.

    .PARAMETER TenantId
    The target tenant's ID (GUID) or a verified domain (for example 'contoso.onmicrosoft.com'),
    passed through to Connect-MgGraph. When omitted, the signed-in operator's home tenant applies.
    No tenant identifier is hardcoded as a default.

    .PARAMETER Scopes
    The Microsoft Graph delegated scopes requested at connection time. Defaults to the minimum set
    this function needs: Policy.Read.All, User.Read.All, Group.Read.All. RoleManagement.Read.Directory
    is added automatically (not via this parameter's default) only when -IncludeExcludedRoles is
    specified. Override this if your tenant's consent policy requires a broader, pre-consented scope
    set instead.

    .PARAMETER OutputPath
    The CSV file path, or a directory in which a timestamped file is created. When omitted, the
    current location is used with a generated filename of the form
    'EntraCaExclusionReport_<TenantId>_<yyyyMMddTHHmmssZ>.csv'. Never a hardcoded absolute path.

    .PARAMETER PolicyName
    One or more Conditional Access policy display names or wildcard patterns to scope the
    assessment to a subset of policies. When omitted, every policy in the tenant is assessed.

    .PARAMETER PolicyState
    Restricts the report to policies in a single mapped state: 'On', 'Off', or 'Report-Only'. When
    omitted, policies in every state are included.

    .PARAMETER IncludeExcludedRoles
    When specified, additionally emits rows for directory roles excluded from a policy
    (excludeRoles) and for excluded guest/external user types (excludeGuestsOrExternalUsers). Off
    by default because the stated requirement is users and groups; this also adds
    RoleManagement.Read.Directory to the requested scopes for this run only.

    .PARAMETER UseExistingConnection
    Uses the current Get-MgContext session instead of initiating device code sign-in. Fails with a
    clear error if no session exists or the existing session lacks a required scope. Off by
    default, so the documented default path is device code sign-in.

    .PARAMETER PassThru
    Also returns the report objects to the pipeline in addition to writing the CSV, which is always
    written regardless of this switch.

    .PARAMETER Force
    Allows overwriting an existing file at the resolved output path. Without it, an existing file
    is a terminating error rather than a silent overwrite, because this file is audit evidence.

    .EXAMPLE
    Get-EntraCaExclusionReport

    Default run: signs in via device code flow to the caller's home tenant, requests the minimum
    scopes, assesses every Conditional Access policy, and writes a timestamped CSV to the current
    location.

    .EXAMPLE
    Get-EntraCaExclusionReport -TenantId 'contoso.onmicrosoft.com' -PolicyState 'On' -OutputPath 'C:\Evidence\ca-exclusions.csv' -Force

    Signs in to the named tenant, reports only enabled ('On') policies, and writes the CSV to a
    specific path, overwriting an existing file at that path if present.

    .EXAMPLE
    Get-EntraCaExclusionReport -UseExistingConnection -IncludeExcludedRoles -PassThru

    Reuses the caller's already-authenticated Graph session (failing early if it lacks a required
    scope), also reports excluded directory roles and excluded guest/external user types, and
    returns the report objects to the pipeline as well as writing the CSV.

    .INPUTS
    None. This function does not accept pipeline input.

    .OUTPUTS
    PSCustomObject
    When -PassThru is specified, returns one PSCustomObject per CSV row with properties PolicyName,
    State, ObjectID, UserPrincipalName, DisplayName, PrincipalType - the same six columns, in the
    same order, as the CSV.

    .NOTES
    Required Microsoft Graph scopes and why:
      - Policy.Read.All : read Conditional Access policy definitions (always required).
      - User.Read.All : resolve excluded user IDs to UPN/DisplayName (always required).
      - Group.Read.All : resolve excluded group IDs to DisplayName (always required).
                                         Membership is never read under this or any scope.
      - RoleManagement.Read.Directory : resolve excluded directory role IDs to role names, requested
                                         only when -IncludeExcludedRoles is specified.
    Consent implications: these are all read-only delegated scopes. In a tenant with user consent
    restricted, an administrator must consent to them for the Microsoft Graph PowerShell SDK
    enterprise application before this function can run for a standard user.

    Authentication: device code flow is the default and only authentication path implemented here,
    per the stated organisational preference, using Connect-MgGraph -UseDeviceCode with
    -ContextScope Process (so the caller's persisted Graph session, if any, is not mutated) and
    -TenantId when supplied. This is a delegated, interactive flow: the report reflects only what
    the signed-in operator can read, and this function cannot be scheduled unattended. App-only,
    certificate-based authentication is the appropriate path for unattended or scheduled execution
    of an equivalent report and is deliberately not implemented in this function - it would be a
    separate parameter set or a separate function.

    Group membership is deliberately never enumerated, expanded, or resolved to member users for an
    excluded group. This is a hard constraint, not an omission to be "improved" later - see
    README.md for the full rationale and the complete list of build assumptions.

    Output sensitivity: the resulting CSV names every user and group excluded from every
    Conditional Access policy in the tenant. Treat it as sensitive security documentation - store
    and transmit it accordingly, and do not attach it to a general-access ticket or channel.

    This function is strictly read-only against Entra ID. It has no ShouldProcess/-WhatIf/-Confirm
    support because it changes no directory state; its only write is the local CSV, gated by
    -Force instead.

    Structure: this is the module's only public function. The per-policy work is delegated to six
    private helpers under source/Private - see README.md for the map.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string]
        $TenantId,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $Scopes = @('Policy.Read.All', 'User.Read.All', 'Group.Read.All'),

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutputPath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $PolicyName,

        [Parameter()]
        [ValidateSet('On', 'Off', 'Report-Only')]
        [string]
        $PolicyState,

        [Parameter()]
        [switch]
        $IncludeExcludedRoles,

        [Parameter()]
        [switch]
        $UseExistingConnection,

        [Parameter()]
        [switch]
        $PassThru,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        $ErrorActionPreference = 'Stop'

        $reportState = New-CaExclusionRunState
        $reportRows = [System.Collections.Generic.List[PSCustomObject]]::new()

        try {
            Write-Verbose 'Verifying required Microsoft Graph SDK sub-modules are available.'
            $requiredModules = @('Microsoft.Graph.Authentication', 'Microsoft.Graph.Identity.SignIns', 'Microsoft.Graph.Users', 'Microsoft.Graph.Groups')
            if ($IncludeExcludedRoles) {
                $requiredModules += 'Microsoft.Graph.Identity.DirectoryManagement'
            }

            foreach ($moduleName in $requiredModules) {
                if (-not (Get-Module -Name $moduleName -ListAvailable)) {
                    throw "Required module '$moduleName' is not installed. Install it with: Install-Module -Name $moduleName -Scope CurrentUser"
                }
            }

            $effectiveScopes = [System.Collections.Generic.List[string]]::new()
            foreach ($scope in $Scopes) {
                if (-not $effectiveScopes.Contains($scope)) {
                    $effectiveScopes.Add($scope)
                }
            }
            if ($IncludeExcludedRoles -and -not $effectiveScopes.Contains('RoleManagement.Read.Directory')) {
                $effectiveScopes.Add('RoleManagement.Read.Directory')
            }

            if ($UseExistingConnection) {
                Write-Verbose 'Using the existing Microsoft Graph session (-UseExistingConnection specified).'
                $context = Get-MgContext
                if (-not $context) {
                    throw 'No existing Microsoft Graph session found. Connect first with Connect-MgGraph, or omit -UseExistingConnection to sign in via device code.'
                }
            } else {
                Write-Verbose 'Initiating Microsoft Graph device code sign-in.'
                $connectParameters = @{
                    Scopes        = $effectiveScopes.ToArray()
                    ContextScope  = 'Process'
                    UseDeviceCode = $true
                    ErrorAction   = 'Stop'
                }
                if ($TenantId) {
                    $connectParameters['TenantId'] = $TenantId
                }

                # The device code prompt travels through the success stream on affected SDK
                # versions, so it must be piped to Out-Host (renders immediately, returns nothing)
                # rather than captured or discarded. See README.md - this is a confirmed open SDK
                # defect, and '$null = ...' or '| Out-Null' here silently swallows the sign-in
                # prompt and the call appears to hang.
                Connect-MgGraph @connectParameters | Out-Host
                $reportState.EstablishedOwnConnection = $true

                $context = Get-MgContext
                if (-not $context) {
                    throw 'Connect-MgGraph completed but no Microsoft Graph context was established.'
                }
            }

            $reportState.ResolvedTenantId = $context.TenantId

            $grantedScopes = @($context.Scopes)
            $missingScopes = @($effectiveScopes | Where-Object { $_ -notin $grantedScopes })
            if ($missingScopes.Count -gt 0) {
                throw "The current Microsoft Graph session is missing required scope(s): $($missingScopes -join ', '). Re-connect with these scopes included."
            }

            Write-Verbose "Connected to tenant '$($context.TenantId)' as '$($context.Account)' with scopes: $($grantedScopes -join ', ')"
        } catch {
            # A terminating error in begin skips process and end entirely, so the end block's
            # finally never runs. Tear down a session this function opened before rethrowing,
            # otherwise a scope-verification failure would leave it connected.
            if ($reportState.EstablishedOwnConnection) {
                Write-Verbose 'Disconnecting the Microsoft Graph session established by this function before aborting.'
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
                $reportState.EstablishedOwnConnection = $false
            }
            Write-Error -Message "Failed to establish a usable Microsoft Graph session: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

    process {
        try {
            Write-Verbose 'Retrieving Conditional Access policies (paged with -All, no result-count assumption).'
            $policies = @(Get-MgIdentityConditionalAccessPolicy -All -ErrorAction Stop)

            if ($PolicyName) {
                $policies = @($policies | Where-Object {
                        $currentPolicy = $_
                        ($PolicyName | Where-Object { $currentPolicy.DisplayName -like $_ }).Count -gt 0
                    })
            }

            Write-Verbose "Retrieved $($policies.Count) Conditional Access polic$(if ($policies.Count -eq 1) { 'y' } else { 'ies' }) before state filtering."

            foreach ($policy in $policies) {
                $mappedState = $null
                try {
                    $mappedState = ConvertTo-CaExclusionStateLabel -RawState ([string]$policy.State)

                    if ($PolicyState -and $mappedState -ne $PolicyState) {
                        continue
                    }

                    # Counted explicitly rather than derived from distinct policy names in the
                    # output: Entra permits two policies to share a display name, and deriving
                    # the count would silently merge them.
                    $reportState.AssessedPolicyCount++

                    $policyRows = @(Get-CaExclusionRowForPolicy -Policy $policy -MappedState $mappedState `
                            -ReportState $reportState -IncludeExcludedRoles:$IncludeExcludedRoles)

                    foreach ($row in $policyRows) {
                        $reportRows.Add($row)
                    }
                } catch {
                    # Errors are isolated per policy: one failure is surfaced against that policy
                    # and never aborts the run or truncates the CSV.
                    $reportState.FailedPolicyCount++
                    Write-Error -Message "Failed to process Conditional Access policy '$($policy.DisplayName)' (Id '$($policy.Id)'): $($_.Exception.Message)" -ErrorAction Continue

                    # The State column must only ever carry a mapped state or a verbatim Graph
                    # passthrough, so the failure is reported in the principal columns instead.
                    # Writing 'Error' into State would let a consumer filtering on
                    # State in (On, Off, Report-Only) silently drop failed policies - precisely
                    # the false-assurance failure this report exists to prevent.
                    $errorMarker = $reportState.Markers.ProcessingError
                    $stateForErrorRow = if ($mappedState) { $mappedState } else { [string]$policy.State }
                    $reportRows.Add([PSCustomObject]@{
                            PolicyName        = $policy.DisplayName
                            State             = $stateForErrorRow
                            ObjectID          = $errorMarker
                            UserPrincipalName = $errorMarker
                            DisplayName       = "$errorMarker $($_.Exception.Message)"
                            PrincipalType     = 'Error'
                        })
                }
            }
        } catch {
            Write-Error -Message "Failed to retrieve Conditional Access policies: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

    end {
        try {
            # Deterministic ordering so a diff between two runs is meaningful. PrincipalType is
            # appended as a sixth column rather than inserted, so the five originally-specified
            # columns keep their exact names, order, and positions.
            $sortedRows = @($reportRows | Sort-Object -Property PolicyName, PrincipalType, DisplayName |
                    Select-Object -Property PolicyName, State, ObjectID, UserPrincipalName, DisplayName, PrincipalType)

            $resolvedOutputPath = Resolve-CaExclusionReportPath -OutputPath $OutputPath `
                -ResolvedTenantId $reportState.ResolvedTenantId -RequestedTenantId $TenantId

            if ((Test-Path -Path $resolvedOutputPath -PathType Leaf) -and -not $Force) {
                throw "Output file '$resolvedOutputPath' already exists. Specify -Force to overwrite this audit evidence file, or choose a different -OutputPath."
            }

            Write-Verbose "Writing $($sortedRows.Count) row(s) to '$resolvedOutputPath'."
            # Explicit UTF-8 without a byte order mark, passed as an encoding object rather than
            # the 'utf8' string alias so the encoding is unambiguous across PowerShell hosts.
            $csvEncoding = [System.Text.UTF8Encoding]::new($false)
            $sortedRows | Export-Csv -Path $resolvedOutputPath -NoTypeInformation -Encoding $csvEncoding -Force:$Force -ErrorAction Stop

            if (-not (Test-Path -Path $resolvedOutputPath -PathType Leaf)) {
                throw "CSV export reported success but no file was found at '$resolvedOutputPath'."
            }

            Write-CaExclusionSummary -ReportState $reportState -Row $sortedRows -CsvPath $resolvedOutputPath

            if ($PassThru) {
                # Emitted one row at a time rather than as the array, so the declared
                # OutputType (PSCustomObject) matches what actually reaches the pipeline.
                foreach ($sortedRow in $sortedRows) {
                    Write-Output -InputObject $sortedRow
                }
            }
        } catch {
            Write-Error -Message "Failed to write the CSV report: $($_.Exception.Message)" -ErrorAction Stop
        } finally {
            if ($reportState.EstablishedOwnConnection) {
                Write-Verbose 'Disconnecting the Microsoft Graph session established by this function.'
                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            }
        }
    }
}
#EndRegion './Public/Get-EntraCaExclusionReport.ps1' 378