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 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]$OutputPath,

        [string]$ClientId,

        [string]$Tenant,

        [string]$CertificatePath,

        [securestring]$CertificatePassword,

        [string]$Thumbprint,

        [switch]$Interactive,

        [switch]$PassThru
    )

    if ($CertificatePath -and -not $Tenant) {
        throw 'Bei Zertifikats-Authentifizierung wird -Tenant benoetigt (z. B. 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 gefunden."
    }
    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))
        }
        catch {
            if ($Interactive -and $_.Exception.Message -match '(?i)unauthorized') {
                $hint = "Zugriff verweigert fuer '$url'. Fuer -Interactive (delegierter Login) braucht die App-Registrierung " +
                "die delegierte SharePoint-Berechtigung 'AllSites.FullControl' (Register-SPPermissionApp legt sie ab v0.1.2 mit an; " +
                "bei bestehenden Apps im Entra-Portal ergaenzen und Admin-Consent erteilen). " +
                "Alternativ Zertifikats-Authentifizierung verwenden: -CertificatePath/-Thumbprint zusammen mit -Tenant. " +
                "Urspruenglicher Fehler: $($_.Exception.Message)"
                if ($PSCmdlet.ParameterSetName -eq 'Tenant') { Write-Warning $hint } else { throw $hint }
            }
            elseif ($PSCmdlet.ParameterSetName -eq 'Tenant') {
                Write-Warning "Site '$url' konnte nicht gescannt werden: $($_.Exception.Message)"
            }
            else {
                throw
            }
        }
    }
    Write-Progress -Id 1 -Activity 'SPPermissionMatrix-Scan' -Completed

    if ($sites.Count -eq 0) {
        throw 'Keine Site konnte gescannt werden.'
    }

    $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 gespeichert: $OutputPath"

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