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 for User ID mismatches. .DESCRIPTION Detects UIL records whose ObjectIds do not match the corresponding Entra ID account. This occurs primarily during account recreation, guest re-invitation, or directory sync changes. .PARAMETER SiteUrl One or more full site collection URLs. Mutually exclusive with -AllSites. .PARAMETER AllSites Scan all site collections in the tenant. .PARAMETER User Optional array of UPNs or Emails to filter the scan. .PARAMETER ThrottleLimit Maximum concurrent site connections. Default 3. .EXAMPLE Get-SPCMismatchUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' .OUTPUTS SPC.MismatchUser #> [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 } $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#*') { 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 {} 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 { # Nếu AadObjectId thực sự rỗng trên SharePoint, ta không thể xác nhận đây là Mismatch bằng Object ID. # Mặc định an toàn là Healthy để tránh xóa nhầm user đang hoạt động. $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 } } } |