<# .SYN Toolkit - Script 04 - Site Users Report.SYNOPSIS .DESCRIPTION READ-ONLY. Scans a SharePoint web application or a single site collection and exports a site user inventory report. Captures: - Site collection URL - Web URL - User display name - Login name - Email - Site admin flag - Domain group flag - Principal type when available .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, groups, 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 04 - SITE USERS 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\Site_Users_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_BySite.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 modify users, groups, permissions, or 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) } # ------------------------------------------------------------ # Helper functions # ------------------------------------------------------------ function Get-RiskLevel { param( [bool]$IsSiteAdmin, [bool]$IsDomainGroup, [string]$Email ) if ($IsSiteAdmin) { return "High" } if ($IsDomainGroup) { return "Medium" } if ([string]::IsNullOrWhiteSpace($Email)) { return "Medium" } 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( [bool]$IsSiteAdmin, [bool]$IsDomainGroup, [string]$Email ) if ($IsSiteAdmin) { return "Review elevated user access and confirm this site admin assignment is still required." } if ($IsDomainGroup) { return "Validate group-based access and confirm the domain group is still the correct access model." } if ([string]::IsNullOrWhiteSpace($Email)) { return "Review this user record and validate identity mapping before migration or governance cleanup." } return "Keep in baseline user inventory and validate access with site owners if needed." } # ------------------------------------------------------------ # Main scan # ------------------------------------------------------------ $results = New-Object System.Collections.Generic.List[object] $seenKeys = @{} try { $sitesToScan = Resolve-SitesToScan -WebAppUrl $WebAppUrl -SiteCollectionUrl $SiteCollectionUrl Log-Message ("Resolved {0} site collection(s) for site user inventory." -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 ($seenKeys.ContainsKey($key)) { continue } $seenKeys[$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 { } $riskLevel = Get-RiskLevel -IsSiteAdmin $isSiteAdmin -IsDomainGroup $isDomainGroup -Email $email $results.Add([pscustomobject]@{ SiteCollectionUrl = $site.Url WebUrl = $web.Url UserName = $displayName LoginName = $loginName Email = $email IsSiteAdmin = $isSiteAdmin IsDomainGroup = $isDomainGroup PrincipalType = $principalType RiskLevel = $riskLevel Score = Get-Score -RiskLevel $riskLevel Category = "SiteUsers" ActionRecommendation = Get-Recommendation -IsSiteAdmin $isSiteAdmin -IsDomainGroup $isDomainGroup -Email $email }) | 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 { } } } } catch { Add-ErrorRecord -Stage "CollectSiteUsers" -Scope $WebAppUrl -ObjectTitle $site.Url -Message $_.Exception.Message } finally { try { $site.Dispose() } catch { } } } # ------------------------------------------------------------ # 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 SiteCollectionUrl | ForEach-Object { [pscustomobject]@{ SiteCollectionUrl = $_.Name UserCount = $_.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