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 } } } |