Public/Entra/Object/Get-MgUserProvisioningError.ps1

<#
    .SYNOPSIS
    Lists the users carrying provisioning errors in Microsoft Entra ID.
 
    .DESCRIPTION
    Scans the tenant (or a single user) for the two kinds of provisioning errors exposed
    on the user object by Microsoft Graph:
 
    - serviceProvisioningErrors: errors raised by a downstream Microsoft 365 service
      (typically Exchange Online) when provisioning the user. The error detail is an XML
      payload, parsed to surface the service name, the error code and the description.
    - onPremisesProvisioningErrors: synchronization errors coming from Entra Connect,
      typically an attribute conflict (AttributeValueMustBeUnique on proxyAddresses or
      userPrincipalName).
 
    Microsoft Graph does not support server-side filtering on these properties, so the
    whole user list is retrieved and filtered client-side. One result row per error: a
    user carrying several errors produces several rows.
 
    Resolved service provisioning errors are excluded by default (Graph keeps them with
    isResolved = true); use -IncludeResolved to see them.
 
    .PARAMETER Identity
    (Optional) UserPrincipalName or object id of a single user to check. When omitted,
    the whole tenant is scanned.
 
    .PARAMETER ErrorSource
    (Optional) Restricts the results to one error source. Valid values: All,
    ServiceProvisioning, OnPremisesSync. Default is All.
 
    .PARAMETER IncludeResolved
    (Optional) Also returns the service provisioning errors flagged as resolved by
    Microsoft Graph. Ignored for on-premises errors, which Graph only keeps while they
    are active.
 
    .PARAMETER ForceNewToken
    Switch parameter to force getting a new token from Microsoft Graph.
 
    .PARAMETER ExportToExcel
    (Optional) If specified, exports the results to an Excel file in the user's profile directory.
 
    .EXAMPLE
    Get-MgUserProvisioningError
 
    Returns every active provisioning error of the tenant, one row per error.
 
    .EXAMPLE
    Get-MgUserProvisioningError -Identity 'user@contoso.com'
 
    Returns the provisioning errors of a single user.
 
    .EXAMPLE
    Get-MgUserProvisioningError -ErrorSource OnPremisesSync
 
    Returns only the Entra Connect synchronization errors (attribute conflicts).
 
    .EXAMPLE
    Get-MgUserProvisioningError -IncludeResolved
 
    Also returns the service provisioning errors already resolved.
 
    .EXAMPLE
    Get-MgUserProvisioningError -ExportToExcel
 
    Exports the provisioning errors report to an Excel file in the user's profile directory.
 
    .OUTPUTS
    System.Collections.Generic.List[PSCustomObject]
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MgUserProvisioningError
 
    .NOTES
    OUTPUT PROPERTIES
    UserPrincipalName : the user carrying the error
    DisplayName : display name of the user
    ErrorSource : ServiceProvisioning (downstream service) or OnPremisesSync (Entra Connect)
    Service : service instance for service errors (e.g. exchange), empty for sync errors
    Category : error code for service errors, Graph category for sync errors
                            (PropertyConflict for AttributeValueMustBeUnique)
    Property : attribute causing a sync error (e.g. ProxyAddresses), empty for service errors
    Description : parsed error description for service errors, conflicting value for sync errors
    IsResolved : True/False for service errors, empty for sync errors
    OccurredDateTime : when the error was recorded
    OnPremisesSyncEnabled : whether the user is synchronized from on-premises
    Id : object id of the user
 
    Required Microsoft Graph permissions:
        - User.Read.All
 
    Version history:
        1.0 - Creation. Covers serviceProvisioningErrors (errorDetail XML parsed) and
              onPremisesProvisioningErrors, client-side filtering (no server-side filter
              support on these properties), one row per error.
#>

function Get-MgUserProvisioningError {
    [CmdletBinding()]
    [OutputType([System.Collections.Generic.List[PSCustomObject]])]
    param (
        [Parameter(Mandatory = $false, Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Identity,

        [Parameter(Mandatory = $false)]
        [ValidateSet('All', 'ServiceProvisioning', 'OnPremisesSync')]
        [string]$ErrorSource = 'All',

        [Parameter(Mandatory = $false)]
        [switch]$IncludeResolved,

        [Parameter(Mandatory = $false)]
        [switch]$ForceNewToken,

        [Parameter(Mandatory = $false)]
        [switch]$ExportToExcel
    )

    begin {
        [System.Collections.Generic.List[PSCustomObject]]$provisioningErrorsArray = @()

        $permissionsNeeded = @('User.Read.All')

        $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue)
        if ($ForceNewToken.IsPresent) {
            $null = Disconnect-MgGraph -ErrorAction SilentlyContinue
            $isConnected = $false
        }
        if (-not $isConnected) {
            $null = Connect-MgGraph -Scopes $permissionsNeeded -NoWelcome
        }

        if (-not (Test-MgGraphPermission -RequiredScopes $permissionsNeeded -CallerName $MyInvocation.MyCommand.Name)) {
            return
        }

        $selectClause = 'id,userPrincipalName,displayName,onPremisesSyncEnabled,serviceProvisioningErrors,onPremisesProvisioningErrors'

        # errorDetail is an XML payload (serviceProvisioningXmlError). Parsed to surface
        # the readable description instead of the raw XML string.
        function ConvertFrom-ServiceErrorDetail {
            param ([string]$ErrorDetail)

            $parsed = [PSCustomObject]@{
                Service     = ''
                Category    = ''
                Description = $ErrorDetail
            }

            if ([string]::IsNullOrWhiteSpace($ErrorDetail)) {
                return $parsed
            }

            try {
                $xml = [xml]$ErrorDetail
                $serviceInstance = $xml.ServiceInstance

                if ($serviceInstance) {
                    $parsed.Service = [string]$serviceInstance.Name
                    $errorRecord = $serviceInstance.ObjectErrors.ErrorRecord

                    if ($errorRecord) {
                        $parsed.Category = ($errorRecord | ForEach-Object { [string]$_.ErrorCode } | Where-Object { $_ }) -join '; '
                        $description = ($errorRecord | ForEach-Object { [string]$_.ErrorDescription } | Where-Object { $_ }) -join '; '

                        if ($description) {
                            $parsed.Description = $description
                        }
                    }
                }
            }
            catch {
                # Not XML, or an unexpected shape: the raw string set above is kept
                Write-Verbose "Could not parse errorDetail as XML: $_"
            }

            return $parsed
        }

        function Add-UserProvisioningError {
            param ($User)

            if ($ErrorSource -ne 'OnPremisesSync') {
                foreach ($serviceError in @($User.serviceProvisioningErrors)) {
                    if ($null -eq $serviceError) {
                        continue
                    }

                    $isResolved = [bool]$serviceError.isResolved

                    if ($isResolved -and -not $IncludeResolved.IsPresent) {
                        continue
                    }

                    $parsed = ConvertFrom-ServiceErrorDetail -ErrorDetail ([string]$serviceError.errorDetail)

                    $provisioningErrorsArray.Add([PSCustomObject][ordered]@{
                            UserPrincipalName     = $User.userPrincipalName
                            DisplayName           = $User.displayName
                            ErrorSource           = 'ServiceProvisioning'
                            Service               = $parsed.Service
                            Category              = $parsed.Category
                            Property              = ''
                            Description           = $parsed.Description
                            IsResolved            = $isResolved
                            OccurredDateTime      = $serviceError.createdDateTime
                            OnPremisesSyncEnabled = $User.onPremisesSyncEnabled
                            Id                    = $User.id
                        })
                }
            }

            if ($ErrorSource -ne 'ServiceProvisioning') {
                foreach ($syncError in @($User.onPremisesProvisioningErrors)) {
                    if ($null -eq $syncError) {
                        continue
                    }

                    $provisioningErrorsArray.Add([PSCustomObject][ordered]@{
                            UserPrincipalName     = $User.userPrincipalName
                            DisplayName           = $User.displayName
                            ErrorSource           = 'OnPremisesSync'
                            Service               = ''
                            Category              = $syncError.category
                            Property              = $syncError.propertyCausingError
                            Description           = $syncError.value
                            IsResolved            = ''
                            OccurredDateTime      = $syncError.occurredDateTime
                            OnPremisesSyncEnabled = $User.onPremisesSyncEnabled
                            Id                    = $User.id
                        })
                }
            }
        }
    }

    process {
        if ($PSBoundParameters.ContainsKey('Identity')) {
            try {
                $singleUserUri = "https://graph.microsoft.com/v1.0/users/$Identity`?`$select=$selectClause"
                $user = Invoke-MgGraphRequest -Method GET -Uri $singleUserUri -ErrorAction Stop

                Add-UserProvisioningError -User $user
            }
            catch {
                Write-Warning "Unable to retrieve user '$Identity': $($_.Exception.Message)"
            }
        }
    }

    end {
        if (-not $PSBoundParameters.ContainsKey('Identity')) {
            Write-Host -ForegroundColor Cyan 'Retrieving all users with their provisioning error properties (client-side filtering, Graph does not support filtering on them)...'

            # Invoke-MgGraphRequest rather than Get-MgUser: the SDK model of older
            # Microsoft.Graph versions does not deserialize serviceProvisioningErrors
            $uri = "https://graph.microsoft.com/v1.0/users?`$select=$selectClause&`$top=999"
            $userCount = 0

            try {
                do {
                    $response = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
                    $userCount = $userCount + @($response.value).Count

                    foreach ($user in @($response.value)) {
                        Add-UserProvisioningError -User $user
                    }

                    $uri = $response.'@odata.nextLink'
                } while ($uri)
            }
            catch {
                Write-Warning "Unable to retrieve the users: $($_.Exception.Message)"
                return
            }

            Write-Host -ForegroundColor Cyan "$userCount user(s) scanned."
        }

        $usersInError = @($provisioningErrorsArray | Select-Object -ExpandProperty UserPrincipalName -Unique)

        if ($provisioningErrorsArray.Count -gt 0) {
            Write-Host -ForegroundColor Yellow "$($provisioningErrorsArray.Count) provisioning error(s) found on $($usersInError.Count) user(s)."
        }
        else {
            Write-Host -ForegroundColor Green 'No provisioning error found.'
        }

        if ($ExportToExcel.IsPresent) {
            $now = Get-Date -Format 'yyyy-MM-dd_HHmmss'
            $excelFilePath = "$($env:userprofile)\$now-MgUserProvisioningError.xlsx"
            Write-Host -ForegroundColor Cyan "Exporting to Excel file: $excelFilePath"
            $provisioningErrorsArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -TableStyle Light9 -WorksheetName 'Entra-ProvisioningErrors'
            Write-Host -ForegroundColor Green 'Export completed successfully!'
        }
        else {
            return $provisioningErrorsArray
        }
    }
}