Public/Invoke-SPPermissionScan.ps1

function Invoke-SPPermissionScan {
    <#
    .SYNOPSIS
        Scans the permissions of a SharePoint site (or all sites of a tenant) into a JSON file.
    .DESCRIPTION
        Walks site -> document libraries -> folders down to -Depth. Per node only the cheap
        HasUniqueRoleAssignments flag is queried; role assignments are loaded only where
        inheritance is broken - the matrix still shows effective permissions on every row.
        Group members are resolved transitively (SharePoint groups and nested Entra ID
        groups via Graph, one resolution per group thanks to a scan-wide cache).
        HTTP 429/503 throttling is retried honoring Retry-After.
    .PARAMETER SiteUrl
        One or more site URLs to scan.
    .PARAMETER AllSites
        Scans all (non-system) sites of the tenant. Requires -TenantAdminUrl.
    .PARAMETER TenantAdminUrl
        URL of the SharePoint admin center, e.g. https://contoso-admin.sharepoint.com
    .PARAMETER Depth
        0 = site only, 1 = + document libraries, 2 = + first folder level, and so on. Default: 1.
    .PARAMETER IncludeLibrary
        Only scan libraries whose title matches one of these wildcard patterns.
    .PARAMETER ExcludeLibrary
        Skip libraries whose title matches one of these wildcard patterns.
    .PARAMETER OutputPath
        Path of the JSON output file. Default: .\SPPermissionScan_<timestamp>.json
    .PARAMETER ClientId
        Entra app registration to authenticate with (see Register-SPPermissionApp).
        Without -ClientId an existing Connect-PnPOnline connection is reused (single site only).
    .PARAMETER PassThru
        Returns the scan object instead of the JSON FileInfo.
    .EXAMPLE
        Invoke-SPPermissionScan -SiteUrl https://contoso.sharepoint.com/sites/hr -Depth 2 -ClientId $appId -Interactive
    .EXAMPLE
        Invoke-SPPermissionScan -AllSites -TenantAdminUrl https://contoso-admin.sharepoint.com -Depth 1 `
            -ClientId $appId -Tenant contoso.onmicrosoft.com -CertificatePath .\app.pfx
    #>

    [CmdletBinding(DefaultParameterSetName = 'Site')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Site', Position = 0)]
        [string[]]$SiteUrl,

        [Parameter(Mandatory, ParameterSetName = 'Tenant')]
        [switch]$AllSites,

        [Parameter(Mandatory, ParameterSetName = 'Tenant')]
        [string]$TenantAdminUrl,

        [ValidateRange(0, 10)]
        [int]$Depth = 1,

        [string[]]$IncludeLibrary = @(),

        [string[]]$ExcludeLibrary = @(),

        [string]$OutputPath,

        [string]$ClientId,

        [string]$Tenant,

        [string]$CertificatePath,

        [securestring]$CertificatePassword,

        [string]$Thumbprint,

        [switch]$Interactive,

        [switch]$PassThru
    )

    if ($CertificatePath -and -not $Tenant) {
        throw 'Certificate authentication requires -Tenant (e.g. contoso.onmicrosoft.com).'
    }

    $auth = @{
        ClientId            = $ClientId
        Tenant              = $Tenant
        CertificatePath     = $CertificatePath
        CertificatePassword = $CertificatePassword
        Thumbprint          = $Thumbprint
        Interactive         = $Interactive.IsPresent
    }

    $targetUrls = @()
    if ($PSCmdlet.ParameterSetName -eq 'Tenant') {
        Connect-SPMSite -Url $TenantAdminUrl -Auth $auth
        $excludedTemplates = '^(SRCHCEN|SPSMSITEHOST|APPCATALOG|POINTPUBLISHINGHUB|POINTPUBLISHINGTOPIC|EDISC|REDIRECTSITE|TEAMCHANNEL)'
        $targetUrls = @(Invoke-SPMWithRetry { Get-PnPTenantSite } |
                Where-Object { $_.Template -notmatch $excludedTemplates } |
                Select-Object -ExpandProperty Url)
        Write-Verbose "Tenant scan: $($targetUrls.Count) sites found."
    }
    else {
        $targetUrls = @($SiteUrl)
    }

    $groupCache = @{}
    $sites = [System.Collections.Generic.List[object]]::new()
    $index = 0

    foreach ($url in $targetUrls) {
        $index++
        $percent = [int](($index - 1) / [Math]::Max($targetUrls.Count, 1) * 100)
        Write-Progress -Id 1 -Activity 'SPPermissionMatrix scan' -Status "Site $index/$($targetUrls.Count): $url" -PercentComplete $percent
        try {
            Connect-SPMSite -Url $url -Auth $auth
            $sites.Add((Get-SPMSiteScan -SiteUrl $url -Depth $Depth -GroupCache $groupCache -IncludeLibrary $IncludeLibrary -ExcludeLibrary $ExcludeLibrary))
        }
        catch {
            if ($Interactive -and $_.Exception.Message -match '(?i)unauthorized') {
                $hint = "Access denied for '$url'. -Interactive (delegated login) requires the app registration to hold " +
                "the delegated SharePoint permission 'AllSites.FullControl' (Register-SPPermissionApp adds it since v0.1.2; " +
                "for existing apps add it in the Entra portal and grant admin consent). " +
                "Alternatively use certificate authentication: -CertificatePath/-Thumbprint together with -Tenant. " +
                "Original error: $($_.Exception.Message)"
                if ($PSCmdlet.ParameterSetName -eq 'Tenant') { Write-Warning $hint } else { throw $hint }
            }
            elseif ($PSCmdlet.ParameterSetName -eq 'Tenant') {
                Write-Warning "Site '$url' could not be scanned: $($_.Exception.Message)"
            }
            else {
                throw
            }
        }
    }
    Write-Progress -Id 1 -Activity 'SPPermissionMatrix scan' -Completed

    if ($sites.Count -eq 0) {
        throw 'No site could be scanned.'
    }

    $moduleVersion = (Get-Module -Name SPPermissionMatrix | Select-Object -First 1).Version
    $scan = [pscustomobject]@{
        schemaVersion = '1.0'
        generator     = "SPPermissionMatrix $moduleVersion"
        scanDate      = (Get-Date).ToUniversalTime().ToString('o')
        depth         = $Depth
        siteCount     = $sites.Count
        sites         = $sites.ToArray()
    }

    if (-not $OutputPath) {
        $OutputPath = Join-Path (Get-Location) ('SPPermissionScan_{0:yyyyMMdd_HHmmss}.json' -f (Get-Date))
    }
    $scan | ConvertTo-Json -Depth 64 | Set-Content -Path $OutputPath -Encoding utf8
    Write-Verbose "Scan saved: $OutputPath"

    if ($PassThru) {
        return $scan
    }
    return (Get-Item -Path $OutputPath)
}