Public/Remediate/Restore-SPCOrphanedUser.ps1
|
function Restore-SPCOrphanedUser { <# .SYNOPSIS Restores a previously removed user's permissions from a JSON snapshot per SRS 3.4.2. .DESCRIPTION Reads a snapshot file created by Remove-SPCOrphanedUser -CreateSnapshot and re-applies all recorded permission assignments to the target site. Supports both v1.0 and v1.1 snapshots. .PARAMETER SnapshotPath Path to the JSON snapshot file. The siteUrl and user identity are read from the file. .PARAMETER AddTempSiteCollectionAdmin Temporarily add executor as Site Collection Administrator if access is denied. .EXAMPLE Restore-SPCOrphanedUser -SnapshotPath C:\Snapshots\jdoe_20260622T120000Z.json .EXAMPLE Restore-SPCOrphanedUser -SnapshotPath C:\Snapshots\jdoe.json -WhatIf .OUTPUTS SPC.RestoreResult #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string] $SnapshotPath, [Parameter()] [switch] $AddTempSiteCollectionAdmin ) begin { Test-SPCConnection Assert-SPCProLicense -Feature 'RestoreSnapshot' $resolvedSnap = $SnapshotPath if ($ExecutionContext -and $ExecutionContext.SessionState -and ($p = $ExecutionContext.SessionState.Path)) { $resolvedSnap = $p.GetUnresolvedProviderPathFromPSPath($SnapshotPath) } if (-not (Test-Path -Path $resolvedSnap -PathType Leaf)) { throw "Restore-SPCOrphanedUser: Snapshot file not found: '$resolvedSnap'" } } process { # Parse snapshot $snapRaw = Get-Content -Path $resolvedSnap -Encoding UTF8 -Raw -ErrorAction Stop $snap = $snapRaw | ConvertFrom-Json if ($null -eq $snap.user -or [string]::IsNullOrWhiteSpace($snap.siteUrl)) { throw "Restore-SPCOrphanedUser: Snapshot '$resolvedSnap' is missing required fields (user, siteUrl). Expected snapshotVersion 1.0 or 1.1 format." } $loginName = $snap.user.loginName $displayName = $snap.user.displayName $upn = $snap.user.upn $siteUrl = $snap.siteUrl # Support both v1.0 (__empty sentinel) and v1.1 (empty array / isEmptyPermissionSet) $permissions = @($snap.permissions | Where-Object { $null -ne $_ -and $_.inheritanceStatus -ne '__empty' -and -not [string]::IsNullOrWhiteSpace($_.permissionLevel) }) $groupMemberships = if ($null -ne $snap.groupMemberships) { @($snap.groupMemberships | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace($_.groupName) }) } else { @() } if ($permissions.Count -eq 0 -and $groupMemberships.Count -eq 0) { # Nothing recorded in snapshot → vacuously successful (no permissions to restore) Write-Verbose "Restore-SPCOrphanedUser: Snapshot for '$upn' contains no recorded permissions or groups — reporting Success." $emptyResult = [PSCustomObject][ordered]@{ SiteUrl = $siteUrl UPN = $upn DisplayName = $displayName PermissionsRestored = 0 PermissionsFailed = 0 Status = 'Success' ErrorMessage = $null RestoredAt = (Get-Date).ToUniversalTime() } $emptyResult.PSObject.TypeNames.Insert(0, 'SPC.RestoreResult') $emptyResult return } Write-Verbose "Restore-SPCOrphanedUser: Snapshot '$resolvedSnap' — $($permissions.Count) permission(s) and $($groupMemberships.Count) group(s) for $upn at $siteUrl" # WhatIf — report intent, emit a result, stop if ($WhatIfPreference) { Write-Information "WhatIf: Would restore $($permissions.Count) permission(s) and $($groupMemberships.Count) group(s) for $displayName ($upn) at site $siteUrl." -InformationAction Continue $preview = [PSCustomObject][ordered]@{ SiteUrl = $siteUrl UPN = $upn DisplayName = $displayName PermissionsRestored = 0 PermissionsFailed = 0 Status = 'WhatIf' ErrorMessage = $null RestoredAt = $null } $preview.PSObject.TypeNames.Insert(0, 'SPC.RestoreResult') $preview return } if (-not $PSCmdlet.ShouldProcess("$upn at $siteUrl", 'Restore-SPCOrphanedUser')) { return } $ctx = $script:SPCContext $removeSca = $false $myUPN = $null try { try { $siteConn = Connect-SPCSiteInternal -SiteUrl $siteUrl -Context $ctx try { Get-PnPWeb -Connection $siteConn -ErrorAction Stop | Out-Null } catch [System.UnauthorizedAccessException], [System.Exception] { if ($_.Exception.Message -match "401|403|Access.*denied|Unauthorized|E_ACCESSDENIED|forbidden" -or $_.FullyQualifiedErrorId -match "401|403|Unauthorized|Access.*Denied") { if ($AddTempSiteCollectionAdmin) { if ($ctx.AuthMethod -ne 'Interactive') { Write-Warning "Restore-SPCOrphanedUser: Access Denied on $siteUrl. -AddTempSiteCollectionAdmin is only supported for Interactive auth." throw "Access Denied" } Write-Verbose "Restore-SPCOrphanedUser: Access Denied. Attempting to add temporary Site Collection Admin rights." $me = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me" -Headers @{ Authorization = "Bearer $($ctx.GraphAccessToken)" } -ErrorAction Stop $myUPN = $me.userPrincipalName Set-PnPTenantSite -Connection $ctx.PnPContext -Url $siteUrl -Owners $myUPN -ErrorAction Stop $removeSca = $true Start-Sleep -Seconds 5 $siteConn = Connect-SPCSiteInternal -SiteUrl $siteUrl -Context $ctx } else { Write-Warning "Restore-SPCOrphanedUser: Access Denied on $siteUrl. You must be a Site Collection Administrator or use -AddTempSiteCollectionAdmin." throw "Access Denied" } } else { throw $_ } } } catch { throw "Restore-SPCOrphanedUser: Cannot connect to '$siteUrl' or access denied. $_" } $restoredCount = 0 $failedCount = 0 $errMsgs = [System.Collections.Generic.List[string]]::new() foreach ($perm in $permissions) { $level = if ($null -ne $perm.permissionLevel) { [string]$perm.permissionLevel } else { '' } if ([string]::IsNullOrWhiteSpace($level)) { Write-Verbose "Restore-SPCOrphanedUser: Skipping blank permissionLevel entry for $upn" continue } try { if ($level -match '^\d+$') { Add-PnPRoleAssignment -LoginName $loginName -RoleDefinitionId ([int]$level) ` -Connection $siteConn -ErrorAction Stop } else { Add-PnPRoleAssignment -LoginName $loginName -RoleDefinitionName $level ` -Connection $siteConn -ErrorAction Stop } $restoredCount++ Write-Verbose "Restore-SPCOrphanedUser: Restored permission '$level' for $upn" } catch { $failedCount++ $errMsgs.Add("Permission '$level': $($_.Exception.Message)") Write-Verbose "Restore-SPCOrphanedUser: Failed to restore '$level' for $upn — $_" } } foreach ($grp in $groupMemberships) { $grpName = $grp.groupName if ([string]::IsNullOrWhiteSpace($grpName)) { continue } try { Add-PnPGroupMember -LoginName $loginName -Group $grpName -Connection $siteConn -ErrorAction Stop $restoredCount++ Write-Verbose "Restore-SPCOrphanedUser: Restored group membership '$grpName' for $upn" } catch { $failedCount++ $errMsgs.Add("Group '$grpName': $($_.Exception.Message)") Write-Verbose "Restore-SPCOrphanedUser: Failed to restore group '$grpName' for $upn — $_" } } $status = if ($failedCount -eq 0) { 'Success' } elseif ($restoredCount -gt 0) { 'PartialSuccess' } else { 'Failed' } $errorMsg = if ($errMsgs.Count -gt 0) { $errMsgs -join '; ' } else { $null } $result = [PSCustomObject][ordered]@{ SiteUrl = $siteUrl UPN = $upn DisplayName = $displayName PermissionsRestored = $restoredCount PermissionsFailed = $failedCount Status = $status ErrorMessage = $errorMsg RestoredAt = (Get-Date).ToUniversalTime() } $result.PSObject.TypeNames.Insert(0, 'SPC.RestoreResult') $result } finally { if ($removeSca -and $myUPN) { Write-Verbose "Restore-SPCOrphanedUser: Removing temporary Site Collection Admin rights for $myUPN on $siteUrl" Remove-PnPSiteCollectionAdmin -Connection $siteConn -Owners $myUPN -ErrorAction SilentlyContinue } } } } |