Public/Scan/Get-SPCOverPermissionedUser.ps1

function Get-SPCOverPermissionedUser {
    <#
    .SYNOPSIS
        Identifies over-permissioned users based on an Excessive Access Score (EAS).
     
    .DESCRIPTION
        Scans site collections to determine user access levels (Full Control, Edit, Read).
        Calculates the Excessive Access Score (EAS) using the formula:
        EAS = (Full Control * 3) + (Edit * 2) + (Read * 1)
        Flags users with an EAS greater than 100 with a Red Alert.
     
    .EXAMPLE
        Get-SPCOverPermissionedUser
    #>

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

    begin {
        Write-Verbose "Validating connection..."
        if (Get-Command Test-SPCConnection -ErrorAction SilentlyContinue) {
            Test-SPCConnection
        }
        $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
                        }
                    }
                }
            }
        }
        $tempResults = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    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 Over-Permissioned 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
                            
                            $roleAssignments = (Invoke-PnPSPRestMethod -Method Get -Url "/_api/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -Connection $siteConnection -ErrorAction SilentlyContinue).value
                            foreach ($ra in $roleAssignments) {
                                if ($ra.Member.PrincipalType -in 'User', 1 -and $ra.Member.LoginName -match "\|membership\|") {
                                    $upn = $ra.Member.LoginName.Split("|")[-1]
                                    
                                    $roles = $ra.RoleDefinitionBindings.Name
                                    $accessLevel = "None"
                                    if ($roles -contains "Full Control") {
                                        $accessLevel = "Full Control"
                                    } elseif ($roles -contains "Edit" -or $roles -contains "Contribute") {
                                        $accessLevel = "Edit"
                                    } elseif ($roles -contains "Read" -or $roles -contains "View Only") {
                                        $accessLevel = "Read"
                                    }
                                    
                                    if ($accessLevel -ne "None") {
                                        $tempResults.Add([PSCustomObject]@{
                                            UPN = $upn
                                            AccessLevel = $accessLevel
                                        })
                                    }
                                }
                            }
                            $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-GOPU-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-GOPU-002] $(Get-Date -Format 'o'): Error processing site collection '$url'. Resource: $url. Details: $($ex.Message)" -ErrorAction Continue
                                $success = $true
                            }
                        }
                    }
                } catch {
                    Write-Error "[ERR-GOPU-003] $(Get-Date -Format 'o'): Error processing site collection '$url'. Resource: $url. Details: $($_.Exception.Message)" -ErrorAction Continue
                }
            }

            Write-Verbose "Calculating EAS..."
            $foundCount = 0
            if ($tempResults.Count -gt 0) {
                $grouped = $tempResults | Group-Object -Property UPN
                $finalResults = foreach ($g in $grouped) {
                    $fullControlCount = ($g.Group | Where-Object { $_.AccessLevel -eq 'Full Control' }).Count
                    $editCount = ($g.Group | Where-Object { $_.AccessLevel -eq 'Edit' }).Count
                    $readCount = ($g.Group | Where-Object { $_.AccessLevel -eq 'Read' }).Count
                    
                    $eas = ($fullControlCount * 3) + ($editCount * 2) + ($readCount * 1)
                    $isRedAlert = $eas -gt 100
                    
                    $obj = [PSCustomObject]@{
                        UPN = $g.Name
                        FullControlCount = $fullControlCount
                        EditCount = $editCount
                        ReadCount = $readCount
                        EAS = $eas
                        IsRedAlert = $isRedAlert
                    }
                    $obj.PSObject.TypeNames.Insert(0, 'SPC.OverPermissionedUser')
                    $obj
                }
                
                $sorted = $finalResults | Sort-Object -Property EAS -Descending
                $foundCount = $sorted.Count
                Write-Output $sorted
            } else {
                Write-Verbose "No 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-SPCOverPermissionedUser scan. Scanned $counter sites, found $foundCount users." -InformationAction Continue
    }
}