Public/Scan/Get-SPCMismatchUser.ps1

# PnP 3.x removed Get-PnPSiteUser; Get-PnPUser now returns all UIL users without a filter.
if (-not (Get-Command -Name 'Get-PnPSiteUser' -ErrorAction SilentlyContinue)) {
    function Get-PnPSiteUser {
        param(
            [Parameter()] [object] $Connection,
            [Parameter()] [string[]] $Includes
        )
        if ($Includes) {
            Get-PnPUser -Connection $Connection -Includes $Includes
        } else {
            Get-PnPUser -Connection $Connection
        }
    }
}

if (Get-Command -Name 'Get-PnPWeb' -Module PnP.PowerShell -ErrorAction SilentlyContinue) {
    $__pnpGetWeb = Get-Command 'Get-PnPWeb' -Module PnP.PowerShell
    function Get-PnPWeb {
        [CmdletBinding()] param([object]$Connection)
        & $__pnpGetWeb @PSBoundParameters
    }
}

function Get-SPCMismatchUser {
    <#
    .SYNOPSIS
        Scans SharePoint Online and OneDrive site collections to detect and classify User ID mismatches and stale identities.
 
    .DESCRIPTION
        PROBLEM:
        In evolving Microsoft 365 environments, account re-creation, guest user re-invitation, cross-tenant migrations,
        and directory synchronization changes frequently cause identity desynchronization. When an account is re-created
        with the same User Principal Name (UPN) or email address, Entra ID (formerly Azure AD) assigns a completely new
        Immutable ID (ObjectId). However, SharePoint Online maintains historical identity references in its hidden
        User Information List (UIL). The resulting "Site User ID Mismatch" leaves stale permission footprints, breaks
        access to shared resources and OneDrive repositories, compromises compliance audits, and creates silent security
        risks where permissions might be inherited or misattributed to re-provisioned accounts.
 
        AGITATION:
        Detecting and diagnosing User ID mismatches manually across hundreds or thousands of SharePoint Online site
        collections and OneDrive accounts is an administrative nightmare. Administrators are forced to write complex CSOM
        scripts, manually compare Entra ID Graph ObjectIds against hidden UIL records across every site collection, and
        correlate duplicate entries. This labor-intensive manual auditing is slow, highly error-prone, causes user downtime,
        and leaves organizations vulnerable to compliance failure under ISO 27001, SOC 2, and GDPR audits.
 
        SOLUTION:
        Get-SPCMismatchUser delivers an enterprise-ready, automated scanning engine that systematically traverses target
        sites or entire tenants. It extracts UIL records, leverages high-throughput Microsoft Graph batching to validate
        live Entra ID identities, and detects discrepancies between Entra ObjectIds and SharePoint UIL ObjectIds in seconds.
        The cmdlet classifies each identity into clear, actionable statuses:
        - Healthy: The UIL record accurately matches the active Entra ID account.
        - StaleIdentity: The account exists in Entra ID, but the SharePoint UIL references an outdated ObjectId (e.g., re-created employee).
        - GuestMismatch: An external guest account (#EXT#) was re-invited or re-created with a changed tenant identity.
        - DuplicateEntry: Multiple conflicting UIL entries exist for the same UPN within a single site collection.
        - OrphanedOneDrive: A personal OneDrive site collection is associated with a mismatched or stale identity.
 
        By providing immediate visibility into identity drift, Get-SPCMismatchUser turns hours of tedious troubleshooting
        into effortless, one-click insights—empowering administrators to safeguard access governance, reduce compliance
        risk, and pipeline results directly into Repair-SPCMismatchUser for automated remediation.
 
    .PARAMETER SiteUrl
        Specifies one or more full SharePoint Online or OneDrive site collection URLs to scan.
        Accepts pipeline input by value and property name. Mutually exclusive with -AllSites.
 
    .PARAMETER AllSites
        Scans all site collections across the entire Microsoft 365 tenant.
        Automatically excludes redirect sites and iterates across tenant inventory. Mutually exclusive with -SiteUrl.
 
    .PARAMETER User
        Specifies an optional array of User Principal Names (UPNs) or Email addresses to filter the scan.
        When provided, only UIL records matching these identities will be evaluated against Entra ID.
 
    .PARAMETER ThrottleLimit
        Specifies the maximum concurrent connections/throttling threshold for site processing.
        Acceptable range is 1 to 10 (default is 3). Values outside this range are automatically clamped.
 
    .EXAMPLE
        Get-SPCMismatchUser -SiteUrl 'https://contoso.sharepoint.com/sites/HumanResources'
 
        Scans the 'HumanResources' site collection for any mismatched, stale, or duplicate UIL identity records against Entra ID.
 
    .EXAMPLE
        Get-SPCMismatchUser -AllSites -ThrottleLimit 5 | Where-Object Status -ne 'Healthy' | Format-Table SiteUrl, DisplayName, UPN, Status, EntraObjectId, UILObjectId -AutoSize
 
        Performs a tenant-wide scan across all site collections using a concurrency throttle limit of 5. Filters out healthy accounts to display only identities requiring remediation.
 
    .EXAMPLE
        $mismatches = Get-SPCMismatchUser -SiteUrl 'https://contoso-my.sharepoint.com/personal/john_doe_contoso_com' -User 'john.doe@contoso.com'
        $mismatches | Export-Csv -Path 'C:\Audits\OneDrive_Mismatch_Audit.csv' -NoTypeInformation
 
        Audits a specific OneDrive repository for User ID mismatch discrepancies for a specific re-hired employee and exports the actionable audit results to a CSV file for compliance reporting.
 
    .EXAMPLE
        Get-SPCMismatchUser -SiteUrl 'https://contoso.sharepoint.com/sites/Finance' | Where-Object Status -in @('StaleIdentity', 'GuestMismatch') | Repair-SPCMismatchUser -Mode CleanAndRestore -CreateSnapshot -SnapshotPath 'C:\Snapshots'
 
        Scans the Finance site collection for stale employee and guest mismatches, pipes the detected issues directly to Repair-SPCMismatchUser to create a safety snapshot, remove stale UIL records, and restore permissions to the active Entra ID identities.
 
    .OUTPUTS
        [PSCustomObject] with type name 'SPC.MismatchUser'.
        Contains properties:
        - SiteUrl: The target site collection URL.
        - SiteTitle: The title of the SharePoint site.
        - UserId: The integer ID of the user within the site User Information List.
        - LoginName: The full SharePoint claims login name.
        - DisplayName: The display name recorded in the UIL.
        - Email: The email address associated with the account in SharePoint.
        - UPN: The extracted User Principal Name.
        - Status: Identity classification ('Healthy', 'StaleIdentity', 'GuestMismatch', 'DuplicateEntry', 'OrphanedOneDrive', 'Unknown').
        - EntraObjectId: The active object identifier from Entra ID Graph.
        - UILObjectId: The object identifier currently stored in the SharePoint UIL.
        - DetectedAt: UTC timestamp of the scan operation.
        - OriginalOneDriveUrl: Reserved for OneDrive URL mapping.
        - CurrentOneDriveUrl: Reserved for updated OneDrive URL mapping.
 
    .NOTES
        Module: SPClean
        Author: SPClean Team
        Requires: Active connection established via Connect-SPCTenant.
        Permissions: Requires SharePoint Admin / Global Reader or appropriate Microsoft Graph permissions (User.Read.All).
 
    .LINK
        Connect-SPCTenant
    .LINK
        Repair-SPCMismatchUser
    .LINK
        Get-SPCOrphanedUser
    #>

    [CmdletBinding(DefaultParameterSetName = 'SingleSite')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'SingleSite',
                   ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]] $SiteUrl,

        [Parameter(Mandatory, ParameterSetName = 'AllSites')]
        [switch] $AllSites,

        [Parameter()]
        [string[]] $User,

        [Parameter()]
        [int] $ThrottleLimit = 3
    )

    begin {
        Test-SPCConnection

        $effectiveThrottle = $ThrottleLimit
        if ($ThrottleLimit -lt 1) { $effectiveThrottle = 1 }
        elseif ($ThrottleLimit -gt 10) { $effectiveThrottle = 10 }
        Write-Verbose "Get-SPCMismatchUser: ThrottleLimit = $effectiveThrottle (sequential in PS 5.1)"

        $pendingSites = [System.Collections.Generic.List[string]]::new()
        if ($PSCmdlet.ParameterSetName -eq 'AllSites') {
            $tenantSites = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext -ErrorAction Stop
            foreach ($site in $tenantSites) {
                if ($site.Template -like 'REDIRECTSITE#*' -or $site.Template -like 'POINTPUBLISHINGHUB#*') { continue }
                $pendingSites.Add($site.Url)
            }
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'SingleSite' -and $SiteUrl) {
            foreach ($url in $SiteUrl) { $pendingSites.Add($url) }
        }
    }

    end {
        $connectToSite = {
            param([string] $SiteUrl, [PSCustomObject] $Ctx)
            $tenantId = if ($Ctx.TenantName -match '\.') { $Ctx.TenantName } else { "$($Ctx.TenantName).onmicrosoft.com" }
            switch ($Ctx.AuthMethod) {
                'Interactive' {
                    $token = Get-PnPAccessToken -ResourceTypeName SharePoint -Connection $Ctx.PnPContext
                    Connect-PnPOnline -Url $SiteUrl -AccessToken $token -ReturnConnection
                }
                'AppOnly' {
                    if ($Ctx._CertificatePath) {
                        Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId `
                            -Tenant $tenantId `
                            -CertificatePath $Ctx._CertificatePath `
                            -CertificatePassword $Ctx._CertificatePassword -ReturnConnection
                    } elseif ($Ctx._CertificateThumbprint) {
                        Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId `
                            -Tenant $tenantId `
                            -Thumbprint $Ctx._CertificateThumbprint -ReturnConnection
                    } else {
                        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Ctx._ClientSecret)
                        try {
                            $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
                            Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId `
                                -ClientSecret $plain -ReturnConnection
                        } finally {
                            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
                        }
                    }
                }
            }
        }

        $systemPatterns = @(
            'SHAREPOINT\system', 'NT AUTHORITY\authenticated users', 'c:0(.s|true)',
            'Everyone except external users', 'NT AUTHORITY\LOCAL SERVICE'
        )

        $ctx        = $script:SPCContext
        $graphToken = $ctx.GraphAccessToken
        $total      = $pendingSites.Count
        $siteIdx    = 0
        $showProgress = $AllSites -or $total -gt 1
        $detectedAt = (Get-Date).ToUniversalTime()

        foreach ($currentSiteUrl in $pendingSites) {
            $siteIdx++
            $timeSinceConnect = (Get-Date).ToUniversalTime() - $ctx.ConnectedAt
            if ($timeSinceConnect.TotalMinutes -gt 50) {
                $graphToken = Get-PnPGraphAccessToken -Connection $ctx.PnPContext
                $ctx.GraphAccessToken = $graphToken
                $ctx.ConnectedAt = (Get-Date).ToUniversalTime()
            }
            if ($showProgress) {
                Write-Progress -Activity 'Get-SPCMismatchUser' -Status "[$siteIdx/$total] $currentSiteUrl" -PercentComplete ([int](($siteIdx / $total) * 100))
            }

            $siteConn = $null
            try {
                $siteConn = & $connectToSite -SiteUrl $currentSiteUrl -Ctx $ctx
            } catch {
                Write-Error "Get-SPCMismatchUser: Cannot connect to '$currentSiteUrl'. $_"
                continue
            }

            try {
                if (Get-Command -Name 'Get-PnPUser' -ErrorAction SilentlyContinue) {
                    $uilUsers = Get-PnPUser -Connection $siteConn -Includes AadObjectId -ErrorAction Stop
                } else {
                    $uilUsers = Get-PnPSiteUser -Connection $siteConn -ErrorAction Stop
                }
                $siteTitle = (Get-PnPWeb -Connection $siteConn -ErrorAction SilentlyContinue).Title

                # Filter system accounts
                $filteredUsers = $uilUsers | Where-Object {
                    $ln = $_.LoginName
                    $isSystem = $false
                    foreach ($p in $systemPatterns) {
                        if ($ln -eq $p) { $isSystem = $true; break }
                    }
                    -not $isSystem
                }

                if ($User) {
                    $filteredUsers = $filteredUsers | Where-Object {
                        $upn = if ($_.LoginName -like 'i:0#.f|membership|*') { $_.LoginName -replace '^i:0#\.f\|membership\|', '' } elseif ($_.Email) { $_.Email } else { '' }
                        ($User -contains $upn) -or ($User -contains $_.Email)
                    }
                }

                if (-not $filteredUsers) { continue }

                # Map UPNs
                $requestIdMap  = @{}
                $batchRequests = [System.Collections.Generic.List[hashtable]]::new()
                $reqId         = 1
                $upnRecordMap  = @{} # Map UPN to List of users to detect duplicates

                foreach ($u in $filteredUsers) {
                    $upn = if ($u.LoginName -like 'i:0#.f|membership|*') {
                        $u.LoginName -replace '^i:0#\.f\|membership\|', ''
                    } elseif ($u.Email) { $u.Email }
                    else { $null }

                    if (-not $upn) { continue }

                    if (-not $upnRecordMap.ContainsKey($upn)) {
                        $upnRecordMap[$upn] = [System.Collections.Generic.List[object]]::new()
                    }
                    $upnRecordMap[$upn].Add($u)

                    # Only query Graph once per UPN
                    if ($upnRecordMap[$upn].Count -eq 1) {
                        $requestIdMap["$reqId"] = $upn
                        $batchRequests.Add(@{
                            id     = "$reqId"
                            method = 'GET'
                            url    = "/users/$([uri]::EscapeDataString($upn))?`$select=id,displayName,accountEnabled,userPrincipalName"
                        })
                        $reqId++
                    }
                }

                if ($batchRequests.Count -eq 0) { continue }

                # Query Graph
                $initialResponses = Invoke-SPCGraphBatch -Requests $batchRequests -AccessToken $graphToken
                $foundGraphUsers  = @{} # UPN -> GraphUser

                foreach ($resp in $initialResponses) {
                    if (-not $requestIdMap.ContainsKey($resp.id)) { continue }
                    $upn = $requestIdMap[$resp.id]
                    if ($resp.status -eq 200) {
                        $foundGraphUsers[$upn] = $resp.body
                    }
                }

                # Classification & Output
                foreach ($upn in $upnRecordMap.Keys) {
                    $users = $upnRecordMap[$upn]
                    $graphUser = $foundGraphUsers[$upn]

                    $status = 'Unknown'

                    if ($users.Count -gt 1) {
                        $status = 'DuplicateEntry'
                    } elseif ($null -eq $graphUser) {
                        $status = 'Unknown' # Assuming deleted or not sync'd, strictly, could be checked via soft deleted like in OrphanedUser, but here we focus on mismatched active accounts.
                    } else {
                        # We have 1 UIL record and 1 Entra record
                        $u = $users[0]
                        $isGuest = $u.LoginName -like '*#EXT#*'

                        # Extract UIL AAD Object ID from LoginName (e.g. i:0#.f|membership|joe@contoso.com or sometimes it stores objectid directly in other props, but standard is we compare UserInfoList mapping)
                        # Wait, how do we reliably get the UIL ObjectId?
                        # Get-PnPSiteUser returns Microsoft.SharePoint.Client.User. It has AADObjectId property? In PnP 3.x it usually doesn't expose it directly unless specifically requested.
                        # Wait, we can check the LoginName. If it's a claim, it might just be the UPN.
                        # The most reliable way to check mismatch is to compare the User's AAD profile against SP identity.
                        # Actually, if the account was recreated, AADObjectId won't match. We can fetch the AADObjectId from SP user if available.
                        # In CSOM, User.AadObjectId is a Guid.
                        $uilAadObjectId = $null
                        try {
                            if (-not $u.IsPropertyAvailable('AadObjectId')) {
                                $siteConn.Context.Load($u)
                                $siteConn.Context.ExecuteQuery()
                            }
                            if ($u.AadObjectId) {
                                if ($u.AadObjectId.NameId) {
                                    $uilAadObjectId = $u.AadObjectId.NameId
                                } elseif ($u.AadObjectId -is [string] -or $u.AadObjectId -is [guid]) {
                                    $uilAadObjectId = $u.AadObjectId.ToString()
                                }
                            }
                        } catch { Write-Verbose "Failed to parse AadObjectId for user $($u.LoginName): $($_.Exception.Message)" }

                        if ($uilAadObjectId -and $uilAadObjectId -eq '00000000-0000-0000-0000-000000000000') {
                            $uilAadObjectId = $null
                        }

                        if ($uilAadObjectId) {
                            if ($uilAadObjectId -eq $graphUser.id) {
                                $status = 'Healthy'
                            } else {
                                if ($isGuest) {
                                    $status = 'GuestMismatch'
                                } else {
                                    $status = 'StaleIdentity'
                                }
                            }
                        } else {
                            # If AadObjectId is truly empty on SharePoint, we cannot confirm it's a Mismatch using Object ID.
                            # Default safely to Healthy to avoid accidentally removing active users.
                            $status = 'Healthy'
                        }
                    }

                    # Output for each UIL record
                    foreach ($u in $users) {
                        $out = [PSCustomObject][ordered]@{
                            SiteUrl              = $currentSiteUrl
                            SiteTitle            = $siteTitle
                            UserId               = $u.Id
                            LoginName            = $u.LoginName
                            DisplayName          = $u.Title
                            Email                = $u.Email
                            UPN                  = $upn
                            Status               = $status
                            EntraObjectId        = if ($graphUser) { $graphUser.id } else { $null }
                            UILObjectId          = $uilAadObjectId
                            DetectedAt           = $detectedAt
                            OriginalOneDriveUrl  = $null
                            CurrentOneDriveUrl   = $null
                        }

                        # OneDrive Orphan/Duplicate detection
                        if ($currentSiteUrl -match '-my\.sharepoint\.com') {
                            if ($status -in @('StaleIdentity', 'Mismatch')) {
                                $out.Status = 'OrphanedOneDrive'
                                # In real scenario, we'd calculate current OneDrive URL from Entra upn
                            }
                        }

                        $out.PSObject.TypeNames.Insert(0, 'SPC.MismatchUser')
                        $out
                    }
                }
            } finally {
                # cleanup if needed
            }
        }
        if ($showProgress) { Write-Progress -Activity 'Get-SPCMismatchUser' -Completed }
    }
}