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) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } if ($params.ContainsKey('LoginName')) { $params['Identity'] = $params['LoginName'] $params.Remove('LoginName') | Out-Null } $params.Remove('Confirm') | Out-Null $params['Force'] = $true & $__pnpRemoveUser @params } } # 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 Stop if (-not $u) { return @() } @(Get-PnPWebPermission -PrincipalId $u.Id -Connection $Connection -ErrorAction Stop | ForEach-Object { [PSCustomObject]@{ RoleDefinitionId = $_.Name } }) } catch { throw } } } 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 Stop } } 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 AddTempSiteCollectionAdmin Temporarily add executor as Site Collection Administrator if access is denied. .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] $AddTempSiteCollectionAdmin, [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' { $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 } } } } } # 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 $tempScaAdded = @{} # SiteUrl → myUPN 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 { $siteConn = & $connectToSite -Url $item.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 "Remove-SPCOrphanedUser: Access Denied on $($item.SiteUrl). -AddTempSiteCollectionAdmin is only supported for Interactive auth. Skipping site." throw "Access Denied" } Write-Verbose "Remove-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 $item.SiteUrl -Owners $myUPN -ErrorAction Stop $tempScaAdded[$item.SiteUrl] = $myUPN Start-Sleep -Seconds 5 $siteConn = & $connectToSite -Url $item.SiteUrl -Ctx $ctx } else { Write-Warning "Remove-SPCOrphanedUser: Access Denied on $($item.SiteUrl). You must be a Site Collection Administrator or use -AddTempSiteCollectionAdmin." throw "Access Denied" } } else { throw $_ } } $siteCache[$item.SiteUrl] = $siteConn } catch { Write-Error "Remove-SPCOrphanedUser: Cannot connect to '$($item.SiteUrl)' or access denied. $_" $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: write object to pipeline } # Cleanup Temporary SCA foreach ($site in $tempScaAdded.Keys) { $myUPN = $tempScaAdded[$site] $siteConn = $siteCache[$site] try { Write-Verbose "Remove-SPCOrphanedUser: Removing temporary Site Collection Admin rights for $myUPN on $site" Remove-PnPSiteCollectionAdmin -Connection $siteConn -Owners $myUPN -ErrorAction SilentlyContinue } catch {} } # SRS step 6: summary to information stream (suppressed in WhatIf — nothing was executed) if (-not $WhatIfPreference) { Write-Host "Removed $removedCount orphaned users across $($siteSet.Count) sites. Skipped $errorCount due to errors (see error stream)." } } } |