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 User ID Mismatches by removing the stale UIL entry and optionally restoring permissions. .DESCRIPTION Implements Microsoft's recommended workflow for resolving Site User ID Mismatches. Never modifies the UIL ObjectId directly. .PARAMETER InputObject One or more [SPC.MismatchUser] objects. .PARAMETER Mode ReportOnly: Preview actions. Clean: Remove stale UIL entry. CleanAndRestore: Remove stale UIL entry and re-grant permissions to new Entra identity. .PARAMETER CreateSnapshot Export a JSON permission snapshot BEFORE removal. .PARAMETER SnapshotPath Directory for snapshot files. #> [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 } } } } |