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 |