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,

        [Parameter()]
        [switch] $AddTempSiteCollectionAdmin
    )

    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 $_ })
        $groupMemberships = if ($null -ne $snap.groupMemberships) { @($snap.groupMemberships | Where-Object { $null -ne $_ }) } 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
            }
            $out.PSObject.TypeNames.Insert(0, 'SPC.RestoreResult')
            $out
            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' {
                    $token = Get-PnPAccessToken -ResourceTypeName SharePoint -Connection $Ctx.PnPContext
                    Connect-PnPOnline -Url $Url -AccessToken $token -ReturnConnection
                }
                'AppOnly' {
                    if ($Ctx._CertificatePath) {
                        Connect-PnPOnline -Url $Url -ClientId $Ctx._ClientId `
                            -Tenant $tenantId `
                            -CertificatePath $Ctx._CertificatePath `
                            -CertificatePassword $Ctx._CertificatePassword -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)
                            $plain = $null
                        }
                    }
                }
            }
        }

        $removeSca = $false
        $myUPN = $null

        try {
            try {
                $siteConn = & $connectToSite -Url $siteUrl -Ctx $ctx
                try {
                    Get-PnPWeb -Connection $siteConn -ErrorAction Stop | Out-Null
                } catch [System.UnauthorizedAccessException], [System.Exception] {
                    if ($_.Exception.Message -match "401" -or $_.Exception.Message -match "403" -or $_.Exception.Message -match "Access denied" -or $_.Exception.Message -match "Unauthorized") {
                        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 = & $connectToSite -Url $siteUrl -Ctx $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)) {
                # 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 — $_"
            }
        }

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