Public/Remediate/Restore-SPCOrphanedUser.ps1
|
# PnP 3.x removed Add-PnPRoleAssignment; wrap Set-PnPWebPermission -AddRole. 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 } } } 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. For disaster recovery only. .PARAMETER SnapshotPath Path to the JSON snapshot file. The siteUrl and user identity are read from the file. .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 ) begin { Test-SPCConnection Assert-SPCProLicense -Feature 'RestoreSnapshot' $resolvedSnap = $ExecutionContext.SessionState.Path.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 format." } $loginName = $snap.user.loginName $displayName = $snap.user.displayName $upn = $snap.user.upn $siteUrl = $snap.siteUrl # PS 5.1: ConvertFrom-Json converts empty JSON array [] to $null; @($null) creates a # 1-element null array that would count as 1 failed permission. Filter nulls explicitly. $permissions = @($snap.permissions | Where-Object { $null -ne $_ }) if ($permissions.Count -eq 0) { # Nothing recorded in snapshot → vacuously successful (no permissions to restore) Write-Verbose "Restore-SPCOrphanedUser: Snapshot for '$upn' contains no recorded permissions — 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) for $upn at $siteUrl" # WhatIf — report intent, emit a result, stop if ($WhatIfPreference) { Write-Information "WhatIf: Would restore $($permissions.Count) permission(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 } # Per-site connection (same pattern as other cmdlets) $ctx = $script:SPCContext $connectToSite = { param([string] $Url, [PSCustomObject] $Ctx) $tenantId = if ($Ctx.TenantName -match '\.') { $Ctx.TenantName } else { "$($Ctx.TenantName).onmicrosoft.com" } switch ($Ctx.AuthMethod) { 'Interactive' { $pnpArgs = @{ Url = $Url; Interactive = $true; ReturnConnection = $true } if (-not [string]::IsNullOrEmpty($Ctx._ClientId)) { $pnpArgs['ClientId'] = $Ctx._ClientId } Connect-PnPOnline @pnpArgs } 'AppOnly' { if ($Ctx._CertificatePath) { Connect-PnPOnline -Url $Url -ClientId $Ctx._ClientId ` -Tenant $tenantId ` -CertificatePath $Ctx._CertificatePath ` -CertificatePassword $Ctx._CertificatePassword -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) $plain = $null } } } } } try { $siteConn = & $connectToSite -Url $siteUrl -Ctx $ctx } catch { throw "Restore-SPCOrphanedUser: Cannot connect to '$siteUrl'. $_" } $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)) { # Blank permissionLevel = sentinel or group-derived entry; not a real failure Write-Verbose "Restore-SPCOrphanedUser: Skipping blank permissionLevel entry for $upn" continue } try { # Use numeric ID path or name path based on stored value 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 — $_" } } $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 } } |