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
            }
        }
    }
}