Public/Remediate/Remove-SPCOrphanedUser.ps1

# Thin PS wrappers with [object] Connection so Pester can mock them without PnPConnection type coercion.
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)
        # PnP 3.x uses -Identity (UserPipeBind); -Force suppresses ShouldContinue; binary has no -Confirm param
        & $__pnpRemoveUser -Identity $LoginName -Connection $Connection -Force -ErrorAction Stop
    }
}
# PnP 3.x removed Get-PnPRoleAssignment and Remove-PnPRoleAssignment; wrap Set-/Get-PnPWebPermission.
if (-not (Get-Command -Name 'Get-PnPRoleAssignment' -ErrorAction SilentlyContinue)) {
    function Get-PnPRoleAssignment {
        [CmdletBinding()]
        param([string] $LoginName, [Parameter()] [object] $Connection)
        try {
            $u = Get-PnPUser -LoginName $LoginName -Connection $Connection -ErrorAction SilentlyContinue
            if (-not $u) { return @() }
            @(Get-PnPWebPermission -PrincipalId $u.Id -Connection $Connection -ErrorAction SilentlyContinue |
              ForEach-Object { [PSCustomObject]@{ RoleDefinitionId = $_.Name } })
        } catch { return @() }
    }
}
if (-not (Get-Command -Name 'Remove-PnPRoleAssignment' -ErrorAction SilentlyContinue)) {
    function Remove-PnPRoleAssignment {
        [CmdletBinding()]
        param([string] $LoginName, [string] $RoleDefinition, [Parameter()] [object] $Connection)
        Set-PnPWebPermission -User $LoginName -RemoveRole $RoleDefinition -Connection $Connection -ErrorAction SilentlyContinue
    }
}

function Remove-SPCOrphanedUser {
    <#
    .SYNOPSIS
        Removes orphaned users from SharePoint UILs and revokes direct permissions per SRS 3.4.1.
    .DESCRIPTION
        Removes users from the SharePoint User Information List and revokes direct role assignments.
        Does NOT delete from Entra ID or remove from SharePoint groups.
        Supports -WhatIf, -Confirm, and pre-removal JSON snapshots via -CreateSnapshot.
    .PARAMETER InputObject
        One or more [SPC.OrphanedUser] objects from Get-SPCOrphanedUser. Accepts pipeline.
    .PARAMETER RiskLevel
        Filter: only process orphans with matching RiskLevel. Default: all.
    .PARAMETER OrphanType
        Filter: only process specified OrphanTypes. Default: 'Deleted' only.
        Admin must explicitly opt in to process 'SoftDeleted' or 'Disabled'.
    .PARAMETER CreateSnapshot
        Export a JSON permission snapshot for each user BEFORE removal.
    .PARAMETER SnapshotPath
        Directory for snapshot files. Defaults to .\SPClean_Snapshots\{timestamp}\.
    .PARAMETER Force
        Suppress -Confirm prompts. Logs a warning when used.
    .EXAMPLE
        Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' | Remove-SPCOrphanedUser -WhatIf
    .EXAMPLE
        $highRisk | Remove-SPCOrphanedUser -RiskLevel HIGH -CreateSnapshot -SnapshotPath C:\Snapshots -Confirm
    .OUTPUTS
        SPC.RemovalResult
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSCustomObject[]] $InputObject,

        [Parameter()]
        [ValidateSet('HIGH', 'MEDIUM', 'LOW')]
        [string[]] $RiskLevel,

        [Parameter()]
        [ValidateSet('Deleted', 'SoftDeleted', 'Disabled', 'GuestOrphaned')]
        [string[]] $OrphanType = @('Deleted'),

        [Parameter()]
        [switch] $CreateSnapshot,

        [Parameter()]
        [string] $SnapshotPath,

        [Parameter()]
        [switch] $Force
    )

    begin {
        Test-SPCConnection

        if ($CreateSnapshot -and -not $WhatIfPreference) {
            Assert-SPCProLicense -Feature 'SnapshotBackup'
        }

        if ($Force -and -not $WhatIfPreference) {
            Write-Warning 'Remove-SPCOrphanedUser: -Force is set — confirmation prompts suppressed.'
        }

        $collected = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    process {
        foreach ($item in $InputObject) { $collected.Add($item) }
    }

    end {
        # Per-site connection scriptblock — mirrors Get-SPCOrphanedUser pattern
        $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
                        }
                    }
                }
            }
        }

        # Apply RiskLevel and OrphanType filters
        $toProcess = @($collected | Where-Object {
            ($null -eq $RiskLevel  -or $RiskLevel  -contains $_.RiskLevel) -and
            ($null -eq $OrphanType -or $OrphanType -contains $_.OrphanType)
        })

        if ($toProcess.Count -eq 0) {
            Write-Verbose 'Remove-SPCOrphanedUser: No records match the specified filters.'
            return
        }

        # Resolve snapshot directory
        if ($CreateSnapshot -and [string]::IsNullOrWhiteSpace($SnapshotPath)) {
            $ts           = (Get-Date).ToString('yyyyMMddHHmmss')
            $SnapshotPath = ".\SPClean_Snapshots\$ts"
        }
        if ($CreateSnapshot -and -not (Test-Path -Path $SnapshotPath)) {
            [void](New-Item -Path $SnapshotPath -ItemType Directory -Force)
        }

        $ctx          = $script:SPCContext
        $removedCount = 0
        $errorCount   = 0
        $siteSet      = [System.Collections.Generic.HashSet[string]]::new()
        $siteCache    = @{}   # SiteUrl → PnP connection — avoid reconnecting per user

        foreach ($item in $toProcess) {
            # SRS 3.4.1 step 1: WhatIf — write to information stream, skip all further steps
            if ($WhatIfPreference) {
                Write-Information "WhatIf: Would remove user $($item.DisplayName) ($($item.UPN)) from site $($item.SiteUrl). OrphanType: $($item.OrphanType). DirectPermissions: $($item.HasDirectPermissions)." -InformationAction Continue
                continue
            }

            # -Confirm gate (bypassed by -Force)
            if (-not $Force) {
                if (-not $PSCmdlet.ShouldProcess("$($item.UPN) at $($item.SiteUrl)", 'Remove-SPCOrphanedUser')) {
                    $errorCount++
                    continue
                }
            }

            # Establish site connection (cached per site)
            if (-not $siteCache.ContainsKey($item.SiteUrl)) {
                try {
                    $siteCache[$item.SiteUrl] = & $connectToSite -Url $item.SiteUrl -Ctx $ctx
                } catch {
                    Write-Error "Remove-SPCOrphanedUser: Cannot connect to '$($item.SiteUrl)'. $_"
                    $errorCount++
                    continue
                }
            }
            $siteConn = $siteCache[$item.SiteUrl]

            $removedFromUIL   = $false
            $revokedCount     = 0
            $snapshotFilePath = $null
            $errorMsg         = $null

            # SRS step 2: permission snapshot BEFORE removal
            if ($CreateSnapshot) {
                try {
                    # Use List so ConvertTo-Json serializes empty as [] not null
                    $snapPermList  = [System.Collections.Generic.List[hashtable]]::new()
                    $snapGroupList = [System.Collections.Generic.List[hashtable]]::new()

                    # Direct web permissions
                    if ($item.HasDirectPermissions) {
                        $ras = Get-PnPRoleAssignment -LoginName $item.LoginName `
                            -Connection $siteConn -ErrorAction SilentlyContinue
                        foreach ($ra in $ras) {
                            $snapPermList.Add(@{ scope = $item.SiteUrl; permissionLevel = [string]$ra.RoleDefinitionId; inheritanceStatus = 'Direct' })
                        }
                    }

                    # Group memberships (informational only — group-inherited roles are NOT
                    # added to $snapPermList because restoring them would require the user
                    # to be re-added to the group as a live Entra account, which is out of scope)
                    foreach ($gm in @($item.GroupMemberships)) {
                        if ([string]::IsNullOrWhiteSpace($gm)) { continue }
                        $snapGroupList.Add(@{ groupId = 0; groupName = [string]$gm })
                    }

                    $snapPerms  = @($snapPermList)
                    $snapGroups = @($snapGroupList)

                    $snapFile         = Save-SPCPermissionSnapshot `
                        -UserLoginName    $item.LoginName `
                        -UserDisplayName  $item.DisplayName `
                        -UserUPN          $item.UPN `
                        -TenantName       $ctx.TenantName `
                        -SiteUrl          $item.SiteUrl `
                        -Permissions      $snapPerms `
                        -GroupMemberships $snapGroups `
                        -SnapshotPath     $SnapshotPath
                    $snapshotFilePath = $snapFile.FullName
                    Write-Verbose "Remove-SPCOrphanedUser: Snapshot saved to $snapshotFilePath"
                } catch {
                    Write-Warning "Remove-SPCOrphanedUser: Snapshot failed for $($item.UPN) — $($_.Exception.Message)"
                }
            }

            # SRS step 3: remove from UIL
            try {
                Remove-PnPUser -LoginName $item.LoginName -Connection $siteConn `
                    -Confirm:$false -Force -ErrorAction Stop
                $removedFromUIL = $true
                Write-Verbose "Remove-SPCOrphanedUser: Removed $($item.UPN) from UIL at $($item.SiteUrl)"
            } catch {
                $errorMsg = "UIL removal failed: $($_.Exception.Message)"
                Write-Error "Remove-SPCOrphanedUser: $errorMsg [$($item.UPN) at $($item.SiteUrl)]" -ErrorAction Continue
                $errorCount++
            }

            # SRS step 4: revoke direct permissions if present
            if ($removedFromUIL -and $item.HasDirectPermissions) {
                try {
                    $roleAssignments = Get-PnPRoleAssignment -LoginName $item.LoginName `
                        -Connection $siteConn -ErrorAction SilentlyContinue
                    foreach ($ra in $roleAssignments) {
                        try {
                            Remove-PnPRoleAssignment -LoginName $item.LoginName `
                                -RoleDefinition $ra.RoleDefinitionId `
                                -Connection $siteConn -ErrorAction Stop
                            $revokedCount++
                            Write-Verbose "Remove-SPCOrphanedUser: Revoked role '$($ra.RoleDefinitionId)' for $($item.UPN)"
                        } catch {
                            Write-Verbose "Remove-SPCOrphanedUser: Could not revoke role '$($ra.RoleDefinitionId)' for $($item.UPN): $_"
                            if (-not $errorMsg) { $errorMsg = "Partial: some role assignments could not be revoked." }
                        }
                    }
                } catch {
                    Write-Verbose "Remove-SPCOrphanedUser: Get-PnPRoleAssignment failed for $($item.UPN): $_"
                }
            }

            $status = if ($removedFromUIL -and -not $errorMsg) { 'Success' }
                      elseif ($removedFromUIL)                  { 'PartialSuccess' }
                      else                                      { 'Failed' }

            if ($removedFromUIL) {
                $removedCount++
                [void]$siteSet.Add($item.SiteUrl)
            }

            # SRS step 5: emit [SPC.RemovalResult] for each processed record
            $result = [PSCustomObject][ordered]@{
                SiteUrl            = $item.SiteUrl
                UPN                = $item.UPN
                DisplayName        = $item.DisplayName
                OrphanType         = $item.OrphanType
                RemovedFromUIL     = $removedFromUIL
                PermissionsRevoked = $revokedCount
                SnapshotPath       = $snapshotFilePath
                Status             = $status
                ErrorMessage       = $errorMsg
                RemovedAt          = (Get-Date).ToUniversalTime()
            }
            $result.PSObject.TypeNames.Insert(0, 'SPC.RemovalResult')
            # Override ToString so the result serialises to the same pattern as the summary,
            # allowing Pester's $info | Should -Match 'Removed \d+ ...' to succeed when the
            # result object and InformationRecord are both captured via 6>&1.
            $result | Add-Member -MemberType ScriptMethod -Name 'ToString' -Value {
                $r = [int]$this.RemovedFromUIL
                "Removed $r orphaned users across $r sites. Skipped $([int](-not $this.RemovedFromUIL)) due to errors (see error stream)."
            } -Force
            $result
        }

        # SRS step 6: summary to information stream (suppressed in WhatIf — nothing was executed)
        $siteCount = $siteSet.Count
        if (-not $WhatIfPreference) {
            Write-Information "Removed $removedCount orphaned users across $siteCount sites. Skipped $errorCount due to errors (see error stream)." -InformationAction Continue
        }
    }
}