Public/Scan/Get-SPCPrivilegedUser.ps1

function Get-SPCPrivilegedUser {
    <#
    .SYNOPSIS
        Gets the top 20 privileged users across the SharePoint tenant.
     
    .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.
     
    .EXAMPLE
        Get-SPCPrivilegedUser
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SiteUrl
    )

    begin {
        Write-Verbose "Validating connection..."
        if (Get-Command Test-SPCConnection -ErrorAction SilentlyContinue) {
            Test-SPCConnection
        }
        $tempResults = [System.Collections.Generic.List[PSCustomObject]]::new()
        
        $connectToSite = {
            param([string] $SiteUrl, [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 $SiteUrl -AccessToken $token -ReturnConnection
                }
                'AppOnly' {
                    if ($Ctx._CertificatePath) {
                        Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId `
                            -Tenant $tenantId `
                            -CertificatePath $Ctx._CertificatePath `
                            -CertificatePassword $Ctx._CertificatePassword -ReturnConnection
                    } elseif ($Ctx._CertificateThumbprint) {
                        Connect-PnPOnline -Url $SiteUrl -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 $SiteUrl -ClientId $Ctx._ClientId `
                                -ClientSecret $plain -ReturnConnection
                        } finally {
                            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
                            $plain = $null
                        }
                    }
                }
            }
        }
    }

    process {
        try {
            $sitesToScan = @()
            if ($SiteUrl) {
                $sitesToScan += $SiteUrl
            } else {
                Write-Verbose "Fetching all tenant sites..."
                $sitesToScan = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext | 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"

                try {
                    $retryCount = 0
                    $maxRetries = 5
                    $success = $false
                    
                    while (-not $success -and $retryCount -lt $maxRetries) {
                        try {
                            $siteConnection = & $connectToSite -SiteUrl $url -Ctx $script:SPCContext
                            
                            # 1. SCAs - Use Get-PnPSiteCollectionAdmin to avoid large list thresholds
                            $scas = Get-PnPSiteCollectionAdmin -Connection $siteConnection -ErrorAction SilentlyContinue
                            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 = Get-PnPGroup -AssociatedOwnerGroup -Connection $siteConnection -ErrorAction SilentlyContinue
                            if ($ownersGroup) {
                                $ownerMembers = Get-PnPGroupMember -Group $ownersGroup.Title -Connection $siteConnection -ErrorAction SilentlyContinue
                                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)
                            # Use REST API to bypass missing Get-PnPRoleAssignment in PnP 3.x
                            $roleAssignments = (Invoke-PnPSPRestMethod -Method Get -Url "/_api/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -Connection $siteConnection -ErrorAction SilentlyContinue).value
                            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"]
                                }
                                
                                if ($retryAfter) {
                                    $waitTime = [int]$retryAfter
                                } else {
                                    $waitTime = [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
                                }
                            } 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
                            }
                        }
                    }
                } catch {
                    Write-Error "[ERR-GPU-003] $(Get-Date -Format 'o'): Error processing site collection '$url'. Resource: $url. Details: $($_.Exception.Message)" -ErrorAction Continue
                }
            }

            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
    }
}