<# .SYNOPSIS SharePoint Production Toolkit - Script 11 - Orphan User Report .DESCRIPTION READ-ONLY. Scans a SharePoint web application or a single site collection and exports a report of orphan-user candidates and identity review signals. This script does NOT remove users. It is intended to identify accounts that may require validation before migration. .PARAMETER WebAppUrl Target SharePoint Web Application URL. .PARAMETER OutputCsv Full path to the output CSV report. .PARAMETER SiteCollectionUrl Optional. Limit the scan to a single site collection. .PARAMETER NoPrompt Optional. Skips the confirmation prompt. .PARAMETER Remediate Included for toolkit consistency only. This script is READ-ONLY and does not change users or permissions. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$WebAppUrl, [Parameter(Mandatory = $true)] [string]$OutputCsv, [string]$SiteCollectionUrl, [switch]$NoPrompt, [switch]$Remediate ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" Write-Host "" Write-Host "SCRIPT 11 - ORPHAN USER REPORT" -ForegroundColor Cyan Write-Host "READ-ONLY. NO CHANGES ARE MADE." -ForegroundColor Green Write-Host "" # ------------------------------------------------------------ # Load SharePoint PowerShell snap-in # ------------------------------------------------------------ try { if (-not (Get-PSSnapin -Name "Microsoft.SharePoint.PowerShell" -ErrorAction SilentlyContinue)) { Add-PSSnapin "Microsoft.SharePoint.PowerShell" } } catch { throw "Unable to load SharePoint snap-in. Run in SharePoint Management Shell. Error: $($_.Exception.Message)" } # ------------------------------------------------------------ # Output setup # ------------------------------------------------------------ $outDir = Split-Path -Path $OutputCsv -Parent if ([string]::IsNullOrWhiteSpace($outDir)) { throw "OutputCsv must be a full path. Example: C:\Temp\Orphan_User_Report.csv" } if (-not (Test-Path -Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } $timestamp = (Get-Date).ToString("yyyyMMdd_HHmmss") $baseName = [System.IO.Path]::GetFileNameWithoutExtension($OutputCsv) $summaryPath = Join-Path $outDir ("{0}_{1}_Summary_ByUserStatus.csv" -f $baseName, $timestamp) $logPath = Join-Path $outDir ("{0}_{1}_RunLog.txt" -f $baseName, $timestamp) $errorPath = Join-Path $outDir ("{0}_{1}_Errors.csv" -f $baseName, $timestamp) # ------------------------------------------------------------ # Error / log helpers # ------------------------------------------------------------ $errors = New-Object System.Collections.Generic.List[object] function Add-ErrorRecord { param( [string]$Stage, [string]$Scope, [string]$ObjectTitle, [string]$Message ) $errors.Add([pscustomobject]@{ Timestamp = Get-Date Stage = $Stage Scope = $Scope ObjectTitle = $ObjectTitle Message = $Message }) | Out-Null } $runMessages = New-Object System.Collections.Generic.List[string] function Log-Message { param([string]$Message) $stamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $line = "[{0}] {1}" -f $stamp, $Message $runMessages.Add($line) | Out-Null Write-Host $line } if ($Remediate) { Write-Host "Note: -Remediate is included for toolkit consistency only. This script remains READ-ONLY." -ForegroundColor Yellow } if (-not $NoPrompt) { Write-Host "This script scans SharePoint users and writes reports only." -ForegroundColor Yellow Write-Host "It does not remove users, change permissions, or modify configuration." -ForegroundColor Yellow $resp = Read-Host "Type YES to continue" if ($resp -ne "YES") { Write-Host "Cancelled by user." -ForegroundColor Yellow return } } # ------------------------------------------------------------ # Resolve target sites # ------------------------------------------------------------ function Resolve-SitesToScan { param( [string]$WebAppUrl, [string]$SiteCollectionUrl ) if (-not [string]::IsNullOrWhiteSpace($SiteCollectionUrl)) { return @(Get-SPSite -Identity $SiteCollectionUrl) } return @(Get-SPSite -WebApplication $WebAppUrl -Limit All) } # ------------------------------------------------------------ # Identity analysis helpers # ------------------------------------------------------------ function Get-NormalizedLoginName { param([string]$LoginName) if ([string]::IsNullOrWhiteSpace($LoginName)) { return "" } # Handle common claims prefixes like: # i:0#.w|domain\user # i:0#.f|membership|user@domain.com # c:0+.w|... if ($LoginName -match '^[ic]:.+?\|(.+)$') { return $matches[1] } return $LoginName } function Test-WindowsIdentityResolvable { param([string]$NormalizedLoginName) if ([string]::IsNullOrWhiteSpace($NormalizedLoginName)) { return $false } try { $nt = New-Object System.Security.Principal.NTAccount($NormalizedLoginName) $null = $nt.Translate([System.Security.Principal.SecurityIdentifier]) return $true } catch { return $false } } function Get-UserStatus { param($UserObject) $loginName = "" $displayName = "" $email = "" $isSiteAdmin = $false $isDomainGroup = $false try { $loginName = [string]$UserObject.LoginName } catch { } try { $displayName = [string]$UserObject.Name } catch { } try { $email = [string]$UserObject.Email } catch { } try { $isSiteAdmin = [bool]$UserObject.IsSiteAdmin } catch { } try { $isDomainGroup = [bool]$UserObject.IsDomainGroup } catch { } if ($loginName -match 'System Account|SHAREPOINT\\system') { return "SystemAccount" } if ($isDomainGroup) { return "DomainGroup" } $normalized = Get-NormalizedLoginName -LoginName $loginName # Claims users should generally be reviewed separately if ($loginName -match '^[ic]:') { # Windows claims with domain\user can still be resolvable if ($normalized -match '^[^@]+\\[^@]+$') { if (Test-WindowsIdentityResolvable -NormalizedLoginName $normalized) { return "ResolvableWindowsIdentity" } return "UnresolvedWindowsIdentity" } return "ClaimsIdentity" } # Classic Windows login if ($normalized -match '^[^@]+\\[^@]+$') { if (Test-WindowsIdentityResolvable -NormalizedLoginName $normalized) { return "ResolvableWindowsIdentity" } return "UnresolvedWindowsIdentity" } # UPN style or other formats — cannot prove orphan state from SharePoint alone if ($normalized -match '@') { return "NeedsManualReview" } return "NeedsManualReview" } function Get-RiskLevel { param( [string]$UserStatus, [bool]$IsSiteAdmin ) if ($UserStatus -eq "UnresolvedWindowsIdentity" -and $IsSiteAdmin) { return "High" } if ($UserStatus -eq "UnresolvedWindowsIdentity") { return "High" } if ($UserStatus -eq "NeedsManualReview" -and $IsSiteAdmin) { return "Medium" } if ($UserStatus -eq "ClaimsIdentity") { return "Medium" } if ($UserStatus -eq "NeedsManualReview") { return "Medium" } if ($UserStatus -eq "DomainGroup") { return "Low" } if ($UserStatus -eq "ResolvableWindowsIdentity") { return "Low" } return "Low" } function Get-Score { param([string]$RiskLevel) switch ($RiskLevel) { "High" { return 30 } "Medium" { return 60 } "Low" { return 90 } default { return 50 } } } function Get-Recommendation { param( [string]$UserStatus, [bool]$IsSiteAdmin, [bool]$IsDomainGroup ) switch ($UserStatus) { "SystemAccount" { return "Keep for reference only. This is not an end-user identity." } "DomainGroup" { return "Validate group membership and confirm group-based access is still appropriate." } "ResolvableWindowsIdentity" { return "Identity appears resolvable. Keep in baseline access inventory and validate migration mapping." } "UnresolvedWindowsIdentity" { if ($IsSiteAdmin) { return "High priority review. This unresolved Windows identity has elevated access and should be validated before migration." } return "Investigate this unresolved Windows identity before migration. It may be a legacy or orphaned account." } "ClaimsIdentity" { return "Review claims-based identity mapping and validate the target authentication model before migration." } "NeedsManualReview" { if ($IsSiteAdmin) { return "Review manually. This identity has elevated access and should be validated before migration." } return "Review manually. SharePoint alone cannot confirm whether this identity is active or orphaned." } default { return "Review account status and confirm whether access is still required." } } } # ------------------------------------------------------------ # Main scan # ------------------------------------------------------------ $results = New-Object System.Collections.Generic.List[object] $seen = @{} try { $sitesToScan = Resolve-SitesToScan -WebAppUrl $WebAppUrl -SiteCollectionUrl $SiteCollectionUrl Log-Message ("Resolved {0} site collection(s) for orphan user analysis." -f $sitesToScan.Count) } catch { Add-ErrorRecord -Stage "ResolveSites" -Scope $WebAppUrl -ObjectTitle "Resolve-SitesToScan" -Message $_.Exception.Message throw } foreach ($site in $sitesToScan) { try { Log-Message ("Scanning site collection: {0}" -f $site.Url) foreach ($web in $site.AllWebs) { try { foreach ($user in $web.SiteUsers) { try { $loginName = "" try { $loginName = [string]$user.LoginName } catch { } if ([string]::IsNullOrWhiteSpace($loginName)) { continue } # Deduplicate by site collection + login name $key = "{0}|{1}" -f $site.Url, $loginName if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $displayName = "" $email = "" $isSiteAdmin = $false $isDomainGroup = $false $principalType = "" try { $displayName = [string]$user.Name } catch { } try { $email = [string]$user.Email } catch { } try { $isSiteAdmin = [bool]$user.IsSiteAdmin } catch { } try { $isDomainGroup = [bool]$user.IsDomainGroup } catch { } try { $principalType = [string]$user.PrincipalType } catch { } $userStatus = Get-UserStatus -UserObject $user if ($userStatus -eq "SystemAccount") { continue } $riskLevel = Get-RiskLevel -UserStatus $userStatus -IsSiteAdmin $isSiteAdmin $results.Add([pscustomobject]@{ SiteCollectionUrl = $site.Url WebUrl = $web.Url ObjectName = $displayName UserDisplayName = $displayName LoginName = $loginName NormalizedLoginName = Get-NormalizedLoginName -LoginName $loginName UserEmail = $email PrincipalType = $principalType IsDomainGroup = $isDomainGroup IsSiteAdmin = $isSiteAdmin UserStatus = $userStatus RiskLevel = $riskLevel Score = Get-Score -RiskLevel $riskLevel Category = "OrphanUserReview" ActionRecommendation = Get-Recommendation -UserStatus $userStatus -IsSiteAdmin $isSiteAdmin -IsDomainGroup $isDomainGroup }) | Out-Null } catch { Add-ErrorRecord -Stage "CollectUser" -Scope $web.Url -ObjectTitle "User" -Message $_.Exception.Message } } } catch { Add-ErrorRecord -Stage "CollectWebUsers" -Scope $site.Url -ObjectTitle $web.Url -Message $_.Exception.Message } finally { try { $web.Dispose() } catch { } } } try { $site.Dispose() } catch { } } catch { Add-ErrorRecord -Stage "CollectSiteUsers" -Scope $WebAppUrl -ObjectTitle $site.Url -Message $_.Exception.Message } } # ------------------------------------------------------------ # Export detail report # ------------------------------------------------------------ try { $results | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 } catch { throw "Failed to export detail report. $($_.Exception.Message)" } # ------------------------------------------------------------ # Export summary report # ------------------------------------------------------------ try { $results | Group-Object UserStatus | ForEach-Object { [pscustomobject]@{ UserStatus = $_.Name Count = $_.Count } } | Export-Csv -Path $summaryPath -NoTypeInformation -Encoding UTF8 } catch { Add-ErrorRecord -Stage "ExportSummary" -Scope $summaryPath -ObjectTitle "SummaryExport" -Message $_.Exception.Message } # ------------------------------------------------------------ # Export run log # ------------------------------------------------------------ try { $runMessages | Set-Content -Path $logPath -Encoding UTF8 } catch { Add-ErrorRecord -Stage "ExportRunLog" -Scope $logPath -ObjectTitle "RunLog" -Message $_.Exception.Message } # ------------------------------------------------------------ # Export errors if needed # ------------------------------------------------------------ try { if ($errors.Count -gt 0) { $errors | Export-Csv -Path $errorPath -NoTypeInformation -Encoding UTF8 Write-Host ("ERROR REPORT: {0}" -f $errorPath) -ForegroundColor Yellow } } catch { Write-Host "Failed to export error report." -ForegroundColor Red } Write-Host "" Write-Host ("DETAIL REPORT: {0}" -f $OutputCsv) -ForegroundColor Green Write-Host ("SUMMARY REPORT: {0}" -f $summaryPath) -ForegroundColor Green Write-Host ("RUN LOG: {0}" -f $logPath) -ForegroundColor Green Write-Host "" Write-Host "Complete." -ForegroundColor Green