Public/Remediate/Repair-SPCMismatchUser.ps1
|
# Thin PS wrappers for PnP if (Get-Command -Name 'Remove-PnPUser' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpRemoveUser = Get-Command 'Remove-PnPUser' -Module PnP.PowerShell function Remove-PnPUser { [CmdletBinding()] param([string]$LoginName, [object]$Connection, [switch]$Confirm, [switch]$Force) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } if ($params.ContainsKey('LoginName')) { $params['Identity'] = $params['LoginName']; $params.Remove('LoginName') | Out-Null } $params.Remove('Confirm') | Out-Null $params['Force'] = $true & $__pnpRemoveUser @params } } function Get-PnPRoleAssignment { [CmdletBinding()] param([string] $LoginName, [Parameter()] [object] $Connection) try { $u = Get-PnPUser -LoginName $LoginName -Connection $Connection -ErrorAction SilentlyContinue if (-not $u) { return @() } $res = @() $webPerms = Get-PnPWebPermission -PrincipalId $u.Id -Connection $Connection -ErrorAction SilentlyContinue if ($webPerms) { foreach ($wp in $webPerms) { $res += [PSCustomObject]@{ Scope = 'Web'; ScopeUrl = ''; RoleDefinitionId = $wp.Name } } } $lists = Get-PnPList -Connection $Connection -Includes HasUniqueRoleAssignments, RootFolder.ServerRelativeUrl | Where-Object HasUniqueRoleAssignments foreach ($l in $lists) { $roleAssignments = Get-PnPProperty -ClientObject $l -Property RoleAssignments -Connection $Connection foreach ($ra in $roleAssignments) { $Connection.Context.Load($ra.Member) $Connection.Context.Load($ra.RoleDefinitionBindings) $Connection.Context.ExecuteQuery() if ($ra.Member.Id -eq $u.Id) { foreach ($rd in $ra.RoleDefinitionBindings) { $res += [PSCustomObject]@{ Scope = 'List'; ScopeUrl = $l.RootFolder.ServerRelativeUrl; RoleDefinitionId = $rd.Name; ListId = $l.Id } } } } } return $res } catch { throw } } if (-not (Get-Command -Name 'Add-PnPRoleAssignment' -ErrorAction SilentlyContinue)) { function Add-PnPRoleAssignment { [CmdletBinding()] param( [string] $LoginName, [string] $RoleDefinitionName, [int] $RoleDefinitionId, [Parameter()] [object] $Connection ) if (-not [string]::IsNullOrWhiteSpace($RoleDefinitionName)) { Set-PnPWebPermission -User $LoginName -AddRole $RoleDefinitionName -Connection $Connection -ErrorAction Stop } else { $rd = Get-PnPRoleDefinition -Identity $RoleDefinitionId -Connection $Connection -ErrorAction Stop Set-PnPWebPermission -User $LoginName -AddRole $rd.Name -Connection $Connection -ErrorAction Stop } } } if (Get-Command -Name 'Add-PnPGroupMember' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpAddGroupMember = Get-Command 'Add-PnPGroupMember' -Module PnP.PowerShell function Add-PnPGroupMember { [CmdletBinding()] param([string]$LoginName, [string]$Group, [object]$Connection) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } & $__pnpAddGroupMember @params } } function Repair-SPCMismatchUser { <# .SYNOPSIS Remediates SharePoint Online and OneDrive User ID Mismatches by safely removing stale UIL records and restoring permissions to active Entra ID identities. .DESCRIPTION PROBLEM: In fast-moving Microsoft 365 environments, employee re-hires, tenant migrations, account provisioning resets, and B2B guest re-invitations constantly trigger identity desynchronization. When an account is re-created with the same User Principal Name (UPN) or email address, Entra ID assigns a brand-new Immutable ID (ObjectId). However, SharePoint Online retains the historical, obsolete ObjectId in its hidden site collection User Information List (UIL). This fatal "Site User ID Mismatch" leads to phantom "Access Denied" errors for legitimate active employees, broken collaboration pipelines, inaccessible OneDrive libraries, and ghost permission footprints that fail compliance audits and breach zero-trust governance policies. AGITATION: Remediating User ID mismatches manually across hundreds or thousands of SharePoint Online sites and OneDrive accounts is an administrative nightmare. Site collection administrators and M365 engineers are forced to manually trace broken permissions, run fragile custom scripts, or risk permanent data loss by deleting user entries without a backup. A single mistaken deletion can irrevocably destroy complex broken permission inheritances, unique list/item-level access control lists (ACLs), and group memberships—triggering severe business disruption, urgent helpdesk escalations, and painful security audit failures under ISO 27001, SOC 2, and GDPR. SOLUTION: Repair-SPCMismatchUser delivers an enterprise-grade, automated remediation engine designed to resolve Site User ID Mismatches with zero administrative friction and absolute safety. Following Microsoft's official best-practice remediation lifecycle, the cmdlet: 1. Pre-validates active Entra ID identities via Microsoft Graph to prevent accidental orphaned deletions. 2. Automatically snapshots existing direct permissions (web, list, folder levels) and SharePoint group memberships. 3. Safely flushes the obsolete, mismatched UIL entry from the SharePoint site collection. 4. Seamlessly re-binds the active Entra ID identity to the UIL and accurately re-establishes all direct permissions and group memberships in a single operation. 5. Provides built-in -WhatIf simulation, Safety Rollback Snapshots, and detailed structured output for auditable reporting. Whether remediating a single high-priority executive account or batch-processing tenant-wide identity drift from Get-SPCMismatchUser, Repair-SPCMismatchUser transforms complex identity restoration into a dependable, one-line command. .PARAMETER InputObject Specifies one or more [SPC.MismatchUser] or custom objects containing identity mismatch data. Typically received via pipeline from Get-SPCMismatchUser. Required properties on each object: - SiteUrl: Target SharePoint Online or OneDrive site collection URL. - UPN: User Principal Name of the target user. - LoginName: Claims-based SharePoint login name (e.g., 'i:0#.f|membership|user@contoso.com'). - DisplayName: User's display name. - EntraObjectId: Active Entra ID Object ID. - Status: Identity status (items with 'GuestMismatch', 'Healthy', or 'Unknown' are automatically safely skipped or warned). .PARAMETER Mode Specifies the remediation execution mode. Valid options: - ReportOnly: (Default) Simulates the remediation process and writes informational preview messages without making changes. - Clean: Removes the stale UIL entry from the site collection to resolve identity conflicts, without re-granting permissions. - CleanAndRestore: Removes the stale UIL entry and immediately re-grants all captured direct permissions and group memberships using the active Entra ID identity. .PARAMETER CreateSnapshot When specified, captures and exports a complete JSON backup of the user's direct permissions and SharePoint group memberships BEFORE any remediation occurs. Enables safety rollbacks and disaster recovery. .PARAMETER SnapshotPath Specifies the custom file system directory where permission snapshots will be saved. If omitted when -CreateSnapshot is enabled, defaults to '.\SPClean_Snapshots\<timestamp>\'. .PARAMETER Force Suppresses interactive confirmation prompts ($PSCmdlet.ShouldProcess) when performing High impact changes in 'Clean' or 'CleanAndRestore' modes. Recommended for unattended automation workflows. .PARAMETER WhatIf Shows what would happen if the cmdlet runs. The cmdlet is not run. .PARAMETER Confirm Prompts you for confirmation before running the cmdlet. .EXAMPLE Get-SPCMismatchUser -SiteUrl 'https://contoso.sharepoint.com/sites/Finance' | Where-Object Status -eq 'StaleIdentity' | Repair-SPCMismatchUser -Mode ReportOnly Scans the Finance site collection for stale identities and simulates the remediation workflow in ReportOnly mode, previewing which user records would be updated without altering permissions or UIL entries. .EXAMPLE Get-SPCMismatchUser -SiteUrl 'https://contoso.sharepoint.com/sites/Executive' -User 'ceo@contoso.com' | Repair-SPCMismatchUser -Mode CleanAndRestore -CreateSnapshot -SnapshotPath 'C:\SPClean\Snapshots' Remediates a User ID Mismatch for a re-hired executive on the Executive site collection. Creates a pre-remediation safety snapshot in 'C:\SPClean\Snapshots', purges the stale UIL record, and re-applies all site and list-level permissions to the new Entra ID account. .EXAMPLE $mismatches = Get-SPCMismatchUser -AllSites | Where-Object Status -eq 'StaleIdentity' $mismatches | Repair-SPCMismatchUser -Mode CleanAndRestore -CreateSnapshot -Force | Export-Csv -Path 'C:\Audits\MismatchRemediation_Report.csv' -NoTypeInformation Performs an unattended, automated enterprise-wide remediation across all tenant site collections for stale identities. Creates safety backup snapshots, suppresses confirmation prompts with -Force, and exports the structured remediation results to CSV for compliance auditing. .OUTPUTS [PSCustomObject] with type name 'SPC.MismatchRepairResult'. Contains properties: - SiteUrl: The target SharePoint Online or OneDrive site collection URL. - UPN: User Principal Name of the remediated identity. - RemovedFromUIL: Boolean indicating if the stale UIL entry was successfully removed. - PermissionsRestored: Integer count of direct role assignments and group memberships re-applied. - Status: 'Success' or 'Failed'. - ErrorMessage: Exception details if an error occurred during remediation; otherwise $null. - RemediatedAt: UTC timestamp of the remediation operation. .NOTES Module: SPClean Author: SPClean Team Requires: Active connection established via Connect-SPCTenant. Licensing: Pro/Enterprise license required for 'Clean' and 'CleanAndRestore' execution modes and '-CreateSnapshot'. Permissions: Requires SharePoint Administrator or Global Administrator privileges with appropriate PnP context. .LINK Connect-SPCTenant .LINK Get-SPCMismatchUser .LINK Restore-SPCOrphanedUser .LINK Get-SPCOrphanedUser .LINK Remove-SPCOrphanedUser #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ValueFromPipeline)] [PSCustomObject[]] $InputObject, [Parameter()] [ValidateSet('ReportOnly', 'Clean', 'CleanAndRestore')] [string] $Mode = 'ReportOnly', [Parameter()] [switch] $CreateSnapshot, [Parameter()] [string] $SnapshotPath, [Parameter()] [switch] $Force ) begin { Test-SPCConnection if ($Mode -in @('Clean', 'CleanAndRestore') -and -not $WhatIfPreference) { Assert-SPCProLicense -Feature 'MismatchRemediation' } if ($CreateSnapshot -and -not $WhatIfPreference) { Assert-SPCProLicense -Feature 'SnapshotBackup' } $collected = [System.Collections.Generic.List[PSCustomObject]]::new() } process { foreach ($item in $InputObject) { $collected.Add($item) } } end { $connectToSite = { param([string] $Url, [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 $Url -AccessToken $token -ReturnConnection } 'AppOnly' { if ($Ctx._CertificatePath) { if ($null -ne $Ctx._CertificatePassword) { Connect-PnPOnline -Url $Url -ClientId $Ctx._ClientId -Tenant $tenantId -CertificatePath $Ctx._CertificatePath -CertificatePassword $Ctx._CertificatePassword -ReturnConnection } else { Connect-PnPOnline -Url $Url -ClientId $Ctx._ClientId -Tenant $tenantId -CertificatePath $Ctx._CertificatePath -ReturnConnection } } elseif ($Ctx._CertificateThumbprint) { Connect-PnPOnline -Url $Url -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 $Url -ClientId $Ctx._ClientId -ClientSecret $plain -ReturnConnection } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } } } if ($CreateSnapshot -and [string]::IsNullOrWhiteSpace($SnapshotPath)) { $ts = (Get-Date).ToString('yyyyMMddHHmmss') $SnapshotPath = ".\SPClean_Snapshots\$ts" } $ctx = $script:SPCContext $siteCache = @{} foreach ($item in $collected) { if ($Mode -eq 'ReportOnly' -or $WhatIfPreference) { Write-Information "WhatIf: Would run mode $Mode for user $($item.DisplayName) ($($item.UPN)) on site $($item.SiteUrl)." -InformationAction Continue continue } if (-not $Force -and -not $PSCmdlet.ShouldProcess("$($item.UPN) at $($item.SiteUrl)", "Repair-SPCMismatchUser ($Mode)")) { continue } if ($item.Status -eq 'GuestMismatch') { Write-Warning "Repair-SPCMismatchUser: Skipping Guest user $($item.UPN) at $($item.SiteUrl). Remediation for guests is not supported." continue } if ($item.Status -in @('Healthy', 'Unknown')) { Write-Warning "Repair-SPCMismatchUser: Skipping user $($item.UPN) with status $($item.Status)." continue } if (-not $siteCache.ContainsKey($item.SiteUrl)) { try { $siteCache[$item.SiteUrl] = & $connectToSite -Url $item.SiteUrl -Ctx $ctx } catch { Write-Error "Repair-SPCMismatchUser: Cannot connect to '$($item.SiteUrl)'. $_" continue } } $siteConn = $siteCache[$item.SiteUrl] $removed = $false $restoredCount = 0 $snapPerms = @() $snapGroups = @() try { if ($Mode -in @('Clean', 'CleanAndRestore')) { # 1. Pre-validation: ensure UPN is valid and active in Entra ID if (-not $item.EntraObjectId) { throw "Validation Failed: No valid EntraObjectId found for UPN $($item.UPN)." } # 2. Extract permissions $directPerms = Get-PnPRoleAssignment -LoginName $item.LoginName -Connection $siteConn -ErrorAction SilentlyContinue $userGroups = if ($item.GroupMemberships) { $item.GroupMemberships } else { @() } $snapPermList = [System.Collections.Generic.List[hashtable]]::new() foreach ($ra in $directPerms) { if ($ra.Scope -eq 'List') { $snapPermList.Add(@{ scope = $ra.ScopeUrl; scopeType = 'List'; listId = $ra.ListId; permissionLevel = [string]$ra.RoleDefinitionId; inheritanceStatus = 'Direct' }) } else { $snapPermList.Add(@{ scope = $item.SiteUrl; scopeType = 'Web'; permissionLevel = [string]$ra.RoleDefinitionId; inheritanceStatus = 'Direct' }) } } $snapGroupList = [System.Collections.Generic.List[hashtable]]::new() foreach ($g in $userGroups) { $snapGroupList.Add(@{ groupId = 0; groupName = [string]$g }) } $snapPerms = @($snapPermList) $snapGroups = @($snapGroupList) # 3. Create snapshot if ($CreateSnapshot) { Save-SPCPermissionSnapshot -UserLoginName $item.LoginName -UserDisplayName $item.DisplayName -UserUPN $item.UPN ` -TenantName $ctx.TenantName -SiteUrl $item.SiteUrl -Permissions $snapPerms -GroupMemberships $snapGroups -SnapshotPath $SnapshotPath | Out-Null } # 4. Remove stale identity Remove-PnPUser -LoginName $item.LoginName -Connection $siteConn -Confirm:$false -Force -ErrorAction Stop $removed = $true Write-Verbose "Repair-SPCMismatchUser: Removed stale UIL entry for $($item.UPN)" } if ($Mode -eq 'CleanAndRestore' -and $removed) { # 5. Re-grant permissions (PnP will automatically re-resolve UPN to new ObjectId) # We pass the UPN directly as LoginName. SharePoint converts it to i:0#.f|membership|upn $newLoginName = "i:0#.f|membership|$($item.UPN)" foreach ($p in $snapPerms) { try { $lvl = $p.permissionLevel if ($p.scopeType -eq 'List') { if ($lvl -match '^\d+$') { Set-PnPListPermission -Identity $p.listId -User $newLoginName -AddRole ([int]$lvl) -Connection $siteConn -ErrorAction Stop } else { Set-PnPListPermission -Identity $p.listId -User $newLoginName -AddRole $lvl -Connection $siteConn -ErrorAction Stop } } else { if ($lvl -match '^\d+$') { Add-PnPRoleAssignment -LoginName $newLoginName -RoleDefinitionId ([int]$lvl) -Connection $siteConn -ErrorAction Stop } else { Add-PnPRoleAssignment -LoginName $newLoginName -RoleDefinitionName $lvl -Connection $siteConn -ErrorAction Stop } } $restoredCount++ } catch { Write-Warning "Failed to restore role $($p.permissionLevel) for $($item.UPN): $_" } } foreach ($g in $snapGroups) { try { Add-PnPGroupMember -LoginName $newLoginName -Group $g.groupName -Connection $siteConn -ErrorAction Stop $restoredCount++ } catch { Write-Warning "Failed to restore group $($g.groupName) for $($item.UPN): $_" } } Write-Verbose "Repair-SPCMismatchUser: Restored $restoredCount permissions/groups for $($item.UPN) using new identity." } # Emit result $res = [PSCustomObject][ordered]@{ SiteUrl = $item.SiteUrl UPN = $item.UPN RemovedFromUIL = $removed PermissionsRestored = $restoredCount Status = 'Success' ErrorMessage = $null RemediatedAt = (Get-Date).ToUniversalTime() } $res.PSObject.TypeNames.Insert(0, 'SPC.MismatchRepairResult') $res } catch { Write-Error "Repair-SPCMismatchUser: Failed to process $($item.UPN) at $($item.SiteUrl). $_" $res = [PSCustomObject][ordered]@{ SiteUrl = $item.SiteUrl UPN = $item.UPN RemovedFromUIL = $removed PermissionsRestored = $restoredCount Status = 'Failed' ErrorMessage = $_.Exception.Message RemediatedAt = (Get-Date).ToUniversalTime() } $res.PSObject.TypeNames.Insert(0, 'SPC.MismatchRepairResult') $res } } } } |