Public/Scan/Get-SPCPrivilegedUser.ps1
|
function Get-SPCPrivilegedUser { <# .SYNOPSIS Gets the top 20 privileged users across the SharePoint tenant (Focus on Depth of Access). .DESCRIPTION This cmdlet scans all site collections to identify users who are Site Collection Administrators, members of the default Owners group, or have direct 'Full Control' assignments. It aggregates this data by UserPrincipalName and returns the top 20 users with the most privileged access. Concept: Privileged Users focus on "Depth of Access". Risk: These users have the highest level of control over specific resources, posing a severe risk of system compromise, configuration changes, or "admin theft" if their accounts are compromised. .EXAMPLE Get-SPCPrivilegedUser #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string]$SiteUrl, [Parameter()] [switch]$AddTempSiteCollectionAdmin ) begin { Write-Verbose "Validating connection..." if (Get-Command Test-SPCConnection -ErrorAction SilentlyContinue) { Test-SPCConnection } $tempResults = [System.Collections.Generic.List[PSCustomObject]]::new() } process { try { $sitesToScan = @() if ($SiteUrl) { $sitesToScan += $SiteUrl } else { Write-Verbose "Fetching all tenant sites..." $rawSites = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext $sitesToScan = @($rawSites | Where-Object { $null -eq $_.Template -or ($_.Template -notlike 'REDIRECTSITE#*' -and $_.Template -notlike 'POINTPUBLISHINGHUB#*') } | Select-Object -ExpandProperty Url) } $totalSites = $sitesToScan.Count $counter = 0 foreach ($url in $sitesToScan) { $counter++ Write-Progress -Activity "Scanning Sites for Privileged Users" -Status "Processing site $url ($counter / $totalSites)" -PercentComplete (($counter / $totalSites) * 100) Write-Verbose "Scanning site: $url" $removeSca = $false $myUPN = $null $siteConnection = $null try { $retryCount = 0 $maxRetries = 5 $success = $false while (-not $success -and $retryCount -lt $maxRetries) { try { $siteConnection = Connect-SPCSiteInternal -SiteUrl $url -Context $script:SPCContext # 1. SCAs - Use Get-PnPSiteCollectionAdmin $scas = Get-PnPSiteCollectionAdmin -Connection $siteConnection -ErrorAction Stop if ($scas) { foreach ($sca in $scas) { if (-not [string]::IsNullOrEmpty($sca.LoginName) -and $sca.LoginName -match "\|membership\|") { $upn = $sca.LoginName.Split("|")[-1] $tempResults.Add([PSCustomObject]@{ UPN = $upn SiteUrl = $url PermissionSource = "SCA" }) } } } # 2. Owners Group $ownersGroup = $null try { $ownersGroup = Get-PnPGroup -AssociatedOwnerGroup -Connection $siteConnection -ErrorAction SilentlyContinue } catch {} if (-not $ownersGroup) { try { $web = Get-PnPWeb -Connection $siteConnection -ErrorAction SilentlyContinue if ($web -and $web.AssociatedOwnerGroup) { $ownersGroup = $web.AssociatedOwnerGroup } } catch {} } if ($ownersGroup) { $ownerMembers = Get-PnPGroupMember -Group $ownersGroup -Connection $siteConnection -ErrorAction SilentlyContinue if ($ownerMembers) { foreach ($member in $ownerMembers) { if (-not [string]::IsNullOrEmpty($member.LoginName) -and $member.LoginName -match "\|membership\|") { $upn = $member.LoginName.Split("|")[-1] $tempResults.Add([PSCustomObject]@{ UPN = $upn SiteUrl = $url PermissionSource = "Owner" }) } } } } # 3. Direct Assignments (Full Control) $roleAssignments = (Invoke-PnPSPRestMethod -Method Get -Url "/_api/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -Connection $siteConnection -ErrorAction SilentlyContinue).value if ($roleAssignments) { foreach ($ra in $roleAssignments) { $isFullControl = $ra.RoleDefinitionBindings | Where-Object { $_.Name -eq 'Full Control' } if ($isFullControl) { if ($ra.Member.PrincipalType -in 'User', 1 -and $ra.Member.LoginName -match "\|membership\|") { $upn = $ra.Member.LoginName.Split("|")[-1] $tempResults.Add([PSCustomObject]@{ UPN = $upn SiteUrl = $url PermissionSource = "Direct" }) } } } } $success = $true } catch { $ex = $_.Exception if ($ex.Message -match '429|503' -or $_.FullyQualifiedErrorId -match '429|503') { $retryCount++ $retryAfter = $null if ($ex.Response -and $ex.Response.Headers -and $ex.Response.Headers["Retry-After"]) { $retryAfter = $ex.Response.Headers["Retry-After"] } $waitTime = if ($retryAfter) { [int]$retryAfter } else { [Math]::Pow(2, $retryCount) } Write-Verbose "Throttled (429/503). Retrying in $waitTime seconds (Attempt $retryCount of $maxRetries)..." Start-Sleep -Seconds $waitTime if ($retryCount -eq $maxRetries) { Write-Error "[ERR-GPU-001] $(Get-Date -Format 'o'): Failed to process site collection '$url' after $maxRetries attempts due to throttling. Resource: $url." -ErrorAction Continue } } elseif ($ex.Message -match "401|403|Access.*denied|Unauthorized|E_ACCESSDENIED|forbidden" -or $_.FullyQualifiedErrorId -match "401|403|Unauthorized|Access.*Denied") { if ($AddTempSiteCollectionAdmin) { $authMethod = if ($script:SPCContext.AuthMethod) { $script:SPCContext.AuthMethod } else { $script:SPCContext.AuthMode } if ($authMethod -ne 'Interactive') { Write-Error "[ERR-GPU-004] $(Get-Date -Format 'o'): Access Denied on '$url'. -AddTempSiteCollectionAdmin is only supported for Interactive auth. Resource: $url." -ErrorAction Continue $success = $true continue } Write-Verbose "Get-SPCPrivilegedUser: Access Denied on '$url'. Attempting temporary elevation." $elevationRecord = Invoke-SPCTempElevationInternal -SiteUrl $url -Context $script:SPCContext if ($elevationRecord.Success) { $siteConnection = Connect-SPCSiteInternal -SiteUrl $url -Context $script:SPCContext # Retry the scan in next while iteration } else { Write-Warning "[WARN-GPU-005] $(Get-Date -Format 'o'): Access Denied on site collection '$url'. Skipping restricted site. Details: $($elevationRecord.ErrorMessage)" $success = $true } } else { Write-Error "[ERR-GPU-002] $(Get-Date -Format 'o'): Error processing site collection '$url'. Resource: $url. Details: $($ex.Message)" -ErrorAction Continue $success = $true } } else { Write-Error "[ERR-GPU-002] $(Get-Date -Format 'o'): Error processing site collection '$url'. Resource: $url. Details: $($ex.Message)" -ErrorAction Continue $success = $true # break out of retry loop for non-throttling errors } } } } finally { if ($elevationRecord -and $elevationRecord.Success) { Undo-SPCTempElevationInternal -ElevationRecord $elevationRecord -SiteConnection $siteConnection -Context $script:SPCContext } } } Write-Verbose "Aggregating results..." $foundCount = 0 if ($tempResults.Count -gt 0) { $grouped = $tempResults | Group-Object -Property UPN $finalResults = foreach ($g in $grouped) { $obj = [PSCustomObject]@{ UPN = $g.Name SiteCount = $g.Count Sites = $g.Group.SiteUrl | Select-Object -Unique PermissionSources = $g.Group.PermissionSource | Select-Object -Unique } $obj.PSObject.TypeNames.Insert(0, 'SPC.PrivilegedUser') $obj } $top20 = @($finalResults | Sort-Object -Property SiteCount -Descending) if ($top20.Count -gt 20) { $top20 = $top20[0..19] } $foundCount = $top20.Count Write-Output $top20 } else { Write-Verbose "No privileged users found." Write-Output @() } } catch { $errCode = "ERR-AUTH-001" $exMsg = "[$errCode] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ssZ'): Terminating error occurred. $($_.Exception.Message)" Write-Error -Message $exMsg -Exception $_.Exception -ErrorId $errCode -ErrorAction Stop } } end { if ($null -eq $counter) { $counter = 0 } if ($null -eq $foundCount) { $foundCount = 0 } Write-Information -MessageData "Completed Get-SPCPrivilegedUser scan. Scanned $counter sites, found $foundCount privileged users." -InformationAction Continue } } |