PnP-PowerShell script to get SharePoint online list items permissions report including Inherited permissions and Unique Permissions using Graph API app permissions for all site collections in the tenant level

This PnP-PowerShell script to get SharePoint online list items permissions report including inherited permissions and unique permissions using Graph API app permissions for all site collections in the tenant level.

This script includes exception handling also.

Required Azure AD App permissions (Application + Admin Consent)

PermissionSource
Sites.FullControl.All    Microsoft Graph
Files.Read.All    Microsoft Graph
User.Read.All    Microsoft Graph
GroupMember.Read.All    Microsoft Graph
Sites.FullControl.All    SharePoint

 

# Full tenant scan with all options

.\Get-TenantWideListItemPermissionsReport.ps1 `

  -TenantId     "contoso.onmicrosoft.com" `

  -ClientId     "your-app-client-id" `

  -ClientSecret "your-secret" `

  -TenantName   "contoso" `

  -SkipSystemLists `

  -ExpandGroupMembers `

  -IncludeListLevelPermissions `

  -MaxRetryAttempts 5 `

  -ThrottleDelayMs  200


# Targeted sites only

.\Get-TenantWideListItemPermissionsReport.ps1 `

  -TenantId "contoso.onmicrosoft.com" `

  -ClientId "your-app-client-id" `

  -ClientSecret "your-secret" `

  -TenantName "contoso" `

  -IncludeSiteUrls @("https://contoso.sharepoint.com/sites/HR")

###################################################################################

#Create File with Get-TenantWideListItemPermissionsReport.ps1

###################################################################################


# ===============================================================
# Get-TenantWideListItemPermissionsReport.ps1
#
# Tenant-Wide SharePoint Online List Item Permissions Report
# - Covers ALL site collections, lists/libraries, and items
# - Reports INHERITED and UNIQUE permissions
# - Full exception handling, retry logic, and structured logging
# - Streams output to CSV (memory-safe for large tenants)
#
# Data Sources:
#   Microsoft Graph API  (App-Only / Client Credentials)
#   PnP.PowerShell       (App-Only) for HasUniqueRoleAssignments
#
# Outputs (in timestamped folder):
#   00_Master_Permissions.csv   - every permission row
#   00_Summary_By_Site_List.csv - aggregated stats per site/list
#   00_Errors.csv               - all errors encountered
#   00_RunLog.txt               - full execution log
# ===============================================================

# ---------------------------------------------------------------
# PREREQUISITES - Azure AD App Registration
# ---------------------------------------------------------------
# Microsoft Graph (Application permissions - Admin Consent):
#   Sites.Read.All
#   Sites.FullControl.All
#   Files.Read.All
#   User.Read.All
#   GroupMember.Read.All
#
# SharePoint (Application permissions - Admin Consent):
#   Sites.FullControl.All
#
# Install required module (run once as admin):
#   Install-Module PnP.PowerShell -Scope CurrentUser -Force
# ---------------------------------------------------------------

[CmdletBinding(SupportsShouldProcess)]
param (
    # ── Azure AD / App credentials ────────────────────────────
    [Parameter(Mandatory = $true, HelpMessage = "Azure AD Tenant ID or domain")]
    [ValidateNotNullOrEmpty()]
    [string]$TenantId,

    [Parameter(Mandatory = $true, HelpMessage = "App Registration Client ID")]
    [ValidateNotNullOrEmpty()]
    [string]$ClientId,

    [Parameter(Mandatory = $true, HelpMessage = "App Registration Client Secret")]
    [ValidateNotNullOrEmpty()]
    [string]$ClientSecret,

    [Parameter(Mandatory = $true, HelpMessage = "Tenant name e.g. 'contoso' for contoso.sharepoint.com")]
    [ValidateNotNullOrEmpty()]
    [string]$TenantName,

    # ── Scope filters ──────────────────────────────────────────
    [Parameter(Mandatory = $false, HelpMessage = "Only process these site URLs (leave empty = all sites)")]
    [string[]]$IncludeSiteUrls = @(),

    [Parameter(Mandatory = $false, HelpMessage = "Skip these site URLs entirely")]
    [string[]]$ExcludeSiteUrls = @(),

    [Parameter(Mandatory = $false, HelpMessage = "Skip lists matching these Graph template IDs")]
    [string[]]$ExcludeListTemplates = @("850","851","544","2100","1100","120","119","300","301","302"),
    # 850=Pages, 851=Images, 544=MicroFeed, 2100=Workflow, 1100=Issues,
    # 120=Discussion, 119=Survey, 300-302=Report/Dashboard

    [Parameter(Mandatory = $false)]
    [switch]$SkipSystemLists,
    # Skip hidden lists

    [Parameter(Mandatory = $false)]
    [switch]$ExpandGroupMembers,
    # Resolve individual members of AAD group permissions

    [Parameter(Mandatory = $false)]
    [switch]$IncludeListLevelPermissions,
    # Include list-level permission rows in master CSV

    # ── Performance ────────────────────────────────────────────
    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 10000)]
    [int]$MaxItemsPerList = 0,
    # 0 = all items; positive = cap per list

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 5000)]
    [int]$ThrottleDelayMs = 150,
    # Base delay between Graph calls (ms)

    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 10)]
    [int]$MaxRetryAttempts = 5,
    # Max retries on throttle / transient errors

    [Parameter(Mandatory = $false)]
    [ValidateRange(10, 500)]
    [int]$CsvFlushBatchSize = 100,
    # Flush rows to CSV every N items

    # ── Output ─────────────────────────────────────────────────
    [Parameter(Mandatory = $false)]
    [string]$OutputFolder = ".\SPO_TenantPermissions_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
)

#Requires -Modules PnP.PowerShell

Set-StrictMode -Version Latest
$ErrorActionPreference = "Continue"
$ProgressPreference    = "Continue"

# ===============================================================
# region: OUTPUT SETUP & LOGGING
# ===============================================================

try {
    if (-not (Test-Path $OutputFolder)) {
        New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
    }
} catch {
    Write-Error "FATAL: Cannot create output folder '$OutputFolder': $_"
    exit 1
}

$MasterCsvPath  = Join-Path $OutputFolder "00_Master_Permissions.csv"
$SummaryCsvPath = Join-Path $OutputFolder "00_Summary_By_Site_List.csv"
$ErrorCsvPath   = Join-Path $OutputFolder "00_Errors.csv"
$LogPath        = Join-Path $OutputFolder "00_RunLog.txt"

# ── Logging ────────────────────────────────────────────────────
function Write-Log {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [ValidateSet("INFO","OK","WARN","ERROR","FATAL","DEBUG","SECTION")]
        [string]$Level = "INFO"
    )
    $ts   = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
    $icon = switch ($Level) {
        "OK"      { "[OK]"    }  "WARN"    { "[WARN]"  }
        "ERROR"   { "[ERR]"   }  "FATAL"   { "[FATAL]" }
        "DEBUG"   { "[DBG]"   }  "SECTION" { "[===]"   }
        default   { "[INFO]"  }
    }
    $line = "[$ts]$icon $Message"
    try { Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue } catch {}
    $color = switch ($Level) {
        "OK"      { "Green"   }  "WARN"    { "Yellow"  }
        "ERROR"   { "Red"     }  "FATAL"   { "Magenta" }
        "DEBUG"   { "DarkGray"}  "SECTION" { "Cyan"    }
        default   { "Gray"    }
    }
    Write-Host $line -ForegroundColor $color
}

# ── Error record list ──────────────────────────────────────────
$script:ErrorLog = [System.Collections.Generic.List[PSCustomObject]]::new()

function Register-Error {
    param(
        [string]$Scope,
        [string]$SiteUrl   = "",
        [string]$ListName  = "",
        [string]$ItemId    = "",
        [string]$Operation = "",
        [string]$ErrorMsg
    )
    $script:ErrorLog.Add([PSCustomObject]@{
        Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
        Scope     = $Scope
        SiteUrl   = $SiteUrl
        ListName  = $ListName
        ItemId    = $ItemId
        Operation = $Operation
        Error     = $ErrorMsg
    })
    Write-Log "$Scope | $Operation | $ErrorMsg" "ERROR"
}

# ── CSV streaming writer ───────────────────────────────────────
$script:MasterHeadersWritten = $false

function Write-ToCsv {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.Generic.List[PSCustomObject]]$Rows,
        [string]$Path
    )
    if ($Rows.Count -eq 0) { return }
    try {
        if (-not $script:MasterHeadersWritten) {
            $Rows | Export-Csv -Path $Path -NoTypeInformation -Encoding UTF8 -Force
            $script:MasterHeadersWritten = $true
        } else {
            $Rows | Export-Csv -Path $Path -NoTypeInformation -Encoding UTF8 -Append
        }
        $Rows.Clear()
    } catch {
        Write-Log "CSV write failed to '$Path': $_" "WARN"
    }
}

# ===============================================================
# endregion
# ===============================================================


# ===============================================================
# region: TOKEN MANAGEMENT
# ===============================================================

$script:AccessToken      = $null
$script:TokenAcquiredAt  = [datetime]::MinValue
$script:TokenExpiresIn   = 3599

function Get-GraphAccessToken {
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string]$ClientSecret
    )
    $body = @{
        grant_type    = "client_credentials"
        client_id     = $ClientId
        client_secret = $ClientSecret
        scope         = "https://graph.microsoft.com/.default"
    }
    $attempt = 0
    while ($attempt -lt $MaxRetryAttempts) {
        $attempt++
        try {
            $resp = Invoke-RestMethod `
                -Uri         "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
                -Method      POST `
                -Body        $body `
                -ContentType "application/x-www-form-urlencoded" `
                -ErrorAction Stop
            $script:TokenExpiresIn = if ($resp.expires_in) { [int]$resp.expires_in } else { 3599 }
            return $resp.access_token
        } catch {
            $err = $_.ToString()
            if ($attempt -ge $MaxRetryAttempts) {
                Write-Log "Token acquisition failed after $MaxRetryAttempts attempts: $err" "FATAL"
                throw "Cannot acquire Graph access token: $err"
            }
            $wait = [math]::Pow(2, $attempt) * 2
            Write-Log "Token attempt $attempt failed. Retrying in ${wait}s: $err" "WARN"
            Start-Sleep -Seconds $wait
        }
    }
}

function Get-ValidToken {
    $elapsed          = (Get-Date) - $script:TokenAcquiredAt
    $refreshThreshold = [math]::Max(60, $script:TokenExpiresIn - 120)
    if ($null -eq $script:AccessToken -or $elapsed.TotalSeconds -ge $refreshThreshold) {
        Write-Log "Refreshing Graph access token..." "DEBUG"
        try {
            $script:AccessToken     = Get-GraphAccessToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
            $script:TokenAcquiredAt = Get-Date
            Write-Log "Token refreshed. Valid ~$($script:TokenExpiresIn)s." "DEBUG"
        } catch {
            Register-Error -Scope "Token" -Operation "Refresh" -ErrorMsg $_.ToString()
            throw
        }
    }
    return $script:AccessToken
}

# ===============================================================
# endregion
# ===============================================================


# ===============================================================
# region: GRAPH API HELPERS WITH FULL RETRY + EXCEPTION HANDLING
# ===============================================================

function Invoke-GraphGet {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter(Mandatory = $false)]
        [string]$Context = ""
    )
    $results    = [System.Collections.Generic.List[object]]::new()
    $currentUri = $Uri

    do {
        $attempt = 0
        $success = $false

        while (-not $success -and $attempt -lt $MaxRetryAttempts) {
            $attempt++
            try {
                Start-Sleep -Milliseconds $ThrottleDelayMs
                $token   = Get-ValidToken
                $headers = @{
                    Authorization    = "Bearer $token"
                    ConsistencyLevel = "eventual"
                }
                $resp = Invoke-RestMethod `
                    -Uri         $currentUri `
                    -Headers     $headers `
                    -Method      GET `
                    -ErrorAction Stop

                if ($null -ne $resp.value) { $results.AddRange([object[]]$resp.value) }
                elseif ($null -ne $resp)   { $results.Add($resp) }

                $currentUri = $resp.'@odata.nextLink'
                $success    = $true

            } catch {
                $statusCode = 0
                try { $statusCode = [int]$_.Exception.Response.StatusCode.value__ } catch {}
                $errMsg = $_.ToString()

                switch ($statusCode) {
                    { $_ -in @(429, 503) } {
                        $retryAfter = 30
                        try { $retryAfter = [int]$_.Exception.Response.Headers["Retry-After"] } catch {}
                        $retryAfter = [math]::Max($retryAfter, 5)
                        Write-Log "Throttled HTTP $statusCode (attempt $attempt/$MaxRetryAttempts). Waiting ${retryAfter}s. [$Context]" "WARN"
                        Start-Sleep -Seconds $retryAfter
                    }
                    { $_ -in @(500, 502, 504) } {
                        $wait = [math]::Pow(2, $attempt) * 3
                        Write-Log "Server error HTTP $statusCode (attempt $attempt/$MaxRetryAttempts). Waiting ${wait}s. [$Context]" "WARN"
                        Start-Sleep -Seconds $wait
                    }
                    401 {
                        Write-Log "HTTP 401 Unauthorized. Forcing token refresh. [$Context]" "WARN"
                        $script:TokenAcquiredAt = [datetime]::MinValue
                        Start-Sleep -Seconds 2
                    }
                    403 {
                        Write-Log "HTTP 403 Forbidden (non-retryable): $currentUri [$Context]" "WARN"
                        return $results
                    }
                    404 {
                        Write-Log "HTTP 404 Not Found (non-retryable): $currentUri [$Context]" "DEBUG"
                        return $results
                    }
                    default {
                        $wait = [math]::Pow(2, $attempt) * 2
                        Write-Log "HTTP $statusCode (attempt $attempt/$MaxRetryAttempts). Waiting ${wait}s. [$Context]: $errMsg" "WARN"
                        Start-Sleep -Seconds $wait
                    }
                }

                if ($attempt -ge $MaxRetryAttempts) {
                    Register-Error -Scope "Graph" -Operation "GET $currentUri" `
                        -ErrorMsg "Max retries ($MaxRetryAttempts) exceeded. HTTP $statusCode: $errMsg"
                    return $results
                }
            }
        }

        if (-not $success) { break }

    } while ($currentUri)

    return $results
}

# ── Site Collections ───────────────────────────────────────────
function Get-AllSiteCollections {
    Write-Log "Enumerating tenant site collections via Graph..." "INFO"
    try {
        $sites = Invoke-GraphGet `
            -Uri     "https://graph.microsoft.com/v1.0/sites?search=*&`$select=id,displayName,webUrl,siteCollection,root&`$top=200" `
            -Context "GetAllSites"

        if ($sites.Count -eq 0) {
            Write-Log "WARNING: No sites returned. Verify Sites.Read.All permission and tenant name." "WARN"
        } else {
            Write-Log "Discovered $($sites.Count) site collection(s)." "OK"
        }
        return $sites
    } catch {
        Register-Error -Scope "Tenant" -Operation "EnumerateSites" -ErrorMsg $_.ToString()
        return @()
    }
}

# ── Lists ──────────────────────────────────────────────────────
function Get-SiteLists {
    param([string]$SiteId, [string]$SiteUrl)
    try {
        return Invoke-GraphGet `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$SiteId/lists?`$select=id,displayName,list,hidden&`$top=200" `
            -Context "GetLists:$SiteUrl"
    } catch {
        Register-Error -Scope "Site" -SiteUrl $SiteUrl -Operation "GetLists" -ErrorMsg $_.ToString()
        return @()
    }
}

# ── List items ─────────────────────────────────────────────────
function Get-AllListItems {
    param(
        [string]$SiteId,
        [string]$ListId,
        [string]$SiteUrl,
        [string]$ListName,
        [int]$MaxItems
    )
    $top = if ($MaxItems -gt 0 -and $MaxItems -lt 100) { $MaxItems } else { 100 }
    $uri = "https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items" +
           "?`$top=$top&`$select=id,webUrl,sharepointIds" +
           "&`$expand=fields(`$select=Title,FileLeafRef,ContentType)"

    $all     = [System.Collections.Generic.List[object]]::new()
    $headers = @{ Authorization = "Bearer $(Get-ValidToken)" }

    do {
        $attempt = 0
        $success = $false
        while (-not $success -and $attempt -lt $MaxRetryAttempts) {
            $attempt++
            try {
                Start-Sleep -Milliseconds $ThrottleDelayMs
                $headers.Authorization = "Bearer $(Get-ValidToken)"
                $resp    = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET -ErrorAction Stop
                $all.AddRange([object[]]$resp.value)
                $uri     = $resp.'@odata.nextLink'
                $success = $true
            } catch {
                $sc = 0
                try { $sc = [int]$_.Exception.Response.StatusCode.value__ } catch {}
                if ($sc -in @(429, 503)) {
                    $wait = 30
                    try { $wait = [int]$_.Exception.Response.Headers["Retry-After"] } catch {}
                    Write-Log "Throttled getting items for '$ListName'. Waiting ${wait}s (attempt $attempt)." "WARN"
                    Start-Sleep -Seconds $wait
                } elseif ($sc -in @(403, 404)) {
                    Register-Error -Scope "List" -SiteUrl $SiteUrl -ListName $ListName `
                        -Operation "GetItems" -ErrorMsg "HTTP $sc (non-retryable)"
                    return $all
                } elseif ($attempt -ge $MaxRetryAttempts) {
                    Register-Error -Scope "List" -SiteUrl $SiteUrl -ListName $ListName `
                        -Operation "GetItems" -ErrorMsg "Max retries: $_"
                    return $all
                } else {
                    Start-Sleep -Seconds ([math]::Pow(2, $attempt) * 2)
                }
            }
        }
        if (-not $success) { break }
        if ($MaxItems -gt 0 -and $all.Count -ge $MaxItems) {
            return $all | Select-Object -First $MaxItems
        }
    } while ($uri)

    return $all
}

# ── List-level permissions ─────────────────────────────────────
function Get-ListLevelPermissions {
    param([string]$SiteId, [string]$ListId, [string]$SiteUrl, [string]$ListName)
    try {
        return Invoke-GraphGet `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/drive/root/permissions" `
            -Context "ListPerms:$ListName"
    } catch {
        Write-Log "List-level permissions unavailable for '$ListName' (may not be a document library)." "DEBUG"
        return @()
    }
}

# ── Item-level permissions ─────────────────────────────────────
function Get-ItemPermissions {
    param(
        [string]$SiteId,
        [string]$ListId,
        [string]$ItemId,
        [string]$SiteUrl,
        [string]$ListName
    )
    try {
        return Invoke-GraphGet `
            -Uri     "https://graph.microsoft.com/v1.0/sites/$SiteId/lists/$ListId/items/$ItemId/driveItem/permissions" `
            -Context "ItemPerms:$ItemId@$ListName"
    } catch {
        Write-Log "driveItem permissions unavailable for item '$ItemId' in '$ListName'." "DEBUG"
        return @()
    }
}

# ── AAD group members ──────────────────────────────────────────
function Get-GroupMembers {
    param([string]$GroupId, [string]$GroupName)
    try {
        $members = Invoke-GraphGet `
            -Uri     "https://graph.microsoft.com/v1.0/groups/$GroupId/members?`$select=displayName,mail,userPrincipalName" `
            -Context "GroupMembers:$GroupName"
        return ($members | ForEach-Object {
            $nameStr  = if ($_.displayName)       { $_.displayName }       else { "Unknown" }
            $emailStr = if ($_.mail)               { $_.mail }               `
                        elseif ($_.userPrincipalName) { $_.userPrincipalName } `
                        else                       { "" }
            if ($emailStr) { "$nameStr <$emailStr>" } else { $nameStr }
        }) -join " | "
    } catch {
        Write-Log "Cannot resolve members of group '$GroupName': $_" "WARN"
        return "(member resolution failed)"
    }
}

# ===============================================================
# endregion
# ===============================================================


# ===============================================================
# region: PnP UNIQUE-PERMISSION FLAGS WITH EXCEPTION HANDLING
# ===============================================================

$script:PnPConnectedSite = ""

function Connect-PnPSite {
    param([string]$SiteUrl)
    if ($script:PnPConnectedSite -eq $SiteUrl) { return $true }

    # Disconnect any existing session
    try { Disconnect-PnPOnline -ErrorAction SilentlyContinue } catch {}

    $attempt = 0
    while ($attempt -lt $MaxRetryAttempts) {
        $attempt++
        try {
            Connect-PnPOnline `
                -Url           $SiteUrl `
                -ClientId      $ClientId `
                -ClientSecret  $ClientSecret `
                -Tenant        $TenantId `
                -WarningAction SilentlyContinue `
                -ErrorAction   Stop
            $script:PnPConnectedSite = $SiteUrl
            return $true
        } catch {
            $errMsg = $_.ToString()
            if ($attempt -ge $MaxRetryAttempts) {
                Register-Error -Scope "PnP" -SiteUrl $SiteUrl -Operation "Connect" `
                    -ErrorMsg "Max retries exceeded: $errMsg"
                return $false
            }
            $wait = [math]::Pow(2, $attempt) * 2
            Write-Log "PnP connect attempt $attempt failed for '$SiteUrl'. Retrying in ${wait}s: $errMsg" "WARN"
            Start-Sleep -Seconds $wait
        }
    }
    return $false
}

function Disconnect-PnPSite {
    try {
        Disconnect-PnPOnline -ErrorAction SilentlyContinue
        $script:PnPConnectedSite = ""
    } catch {}
}

function Get-UniquePermissionFlags {
    param(
        [string]$SiteUrl,
        [string]$ListName,
        [object[]]$Items
    )
    $flags = @{ "__LIST__" = $null }

    $connected = Connect-PnPSite -SiteUrl $SiteUrl
    if (-not $connected) {
        Write-Log "PnP unavailable for '$SiteUrl' — unique-perm flags will show Unknown." "WARN"
        return $flags
    }

    # List-level unique perms
    try {
        $list              = Get-PnPList -Identity $ListName -Includes HasUniqueRoleAssignments -ErrorAction Stop
        $flags["__LIST__"] = $list.HasUniqueRoleAssignments
    } catch {
        Register-Error -Scope "PnP" -SiteUrl $SiteUrl -ListName $ListName `
            -Operation "GetListUniquePerms" -ErrorMsg $_.ToString()
    }

    # Per-item unique perms
    foreach ($item in $Items) {
        $spId = $null
        try { $spId = $item.sharepointIds.listItemId } catch {}

        if (-not $spId) { $flags[$item.id] = $null; continue }

        try {
            $pnpItem         = Get-PnPListItem -List $ListName -Id $spId -ErrorAction Stop
            $flags[$item.id] = Get-PnPProperty -ClientObject $pnpItem -Property HasUniqueRoleAssignments
        } catch {
            $flags[$item.id] = $null
            Write-Log "Cannot get unique-perm flag for item $spId in '$ListName': $_" "DEBUG"
        }
    }

    return $flags
}

# ===============================================================
# endregion
# ===============================================================


# ===============================================================
# region: PERMISSION ROW BUILDER WITH FULL EXCEPTION HANDLING
# ===============================================================

function Build-PermissionRow {
    param(
        [string]$TenantName,
        [string]$SiteDisplayName,
        [string]$SiteUrl,
        [string]$ListName,
        [string]$ListTemplate,
        [string]$ItemId,
        [string]$ItemTitle,
        [string]$ItemUrl,
        [string]$ContentType,
        [object]$HasUnique,
        [string]$PermScope,
        [object]$Permission,
        [bool]$ExpandGroups
    )

    if ($null -eq $Permission) {
        Write-Log "Null permission object for item '$ItemId' in '$ListName'." "DEBUG"
        return $null
    }

    $permType  = "Unknown"
    $principal = ""
    $email     = ""
    $groupId   = ""
    $members   = ""

    # ── Resolve principal with guarded property access ──────────
    try {
        if ($null -ne $Permission.grantedToV2) {
            $g = $Permission.grantedToV2
            if ($null -ne $g.user) {
                $permType  = "User"
                $principal = if ($g.user.PSObject.Properties['displayName'] -and $g.user.displayName) { $g.user.displayName } else { "" }
                $email     = if ($g.user.PSObject.Properties['email']       -and $g.user.email)       { $g.user.email }       else { "" }
            } elseif ($null -ne $g.group) {
                $permType  = "AAD Group"
                $principal = if ($g.group.PSObject.Properties['displayName'] -and $g.group.displayName) { $g.group.displayName } else { "" }
                $email     = if ($g.group.PSObject.Properties['email']       -and $g.group.email)       { $g.group.email }       else { "" }
                $groupId   = if ($g.group.PSObject.Properties['id']          -and $g.group.id)          { $g.group.id }          else { "" }
            } elseif ($null -ne $g.siteUser) {
                $permType  = "SharePoint User"
                $principal = if ($g.siteUser.PSObject.Properties['displayName'] -and $g.siteUser.displayName) { $g.siteUser.displayName } else { "" }
                $email     = if ($g.siteUser.PSObject.Properties['email']       -and $g.siteUser.email)       { $g.siteUser.email }       else { "" }
            } elseif ($null -ne $g.application) {
                $permType  = "Application"
                $principal = if ($g.application.PSObject.Properties['displayName'] -and $g.application.displayName) { $g.application.displayName } else { "" }
            } elseif ($null -ne $g.device) {
                $permType  = "Device"
                $principal = if ($g.device.PSObject.Properties['displayName'] -and $g.device.displayName) { $g.device.displayName } else { "" }
            }
        } elseif ($null -ne $Permission.grantedToIdentitiesV2 -and
                  $Permission.grantedToIdentitiesV2.Count -gt 0) {
            try {
                $ids = $Permission.grantedToIdentitiesV2 | ForEach-Object {
                    if      ($null -ne $_.user)  { "$($_.user.displayName) <$($_.user.email)>" }
                    elseif  ($null -ne $_.group) { "Group: $($_.group.displayName)" }
                    else                         { "Unknown identity" }
                }
                $permType  = "Sharing Link (multiple identities)"
                $principal = $ids -join " | "
            } catch {
                Write-Log "Error parsing grantedToIdentitiesV2 for '$ItemId': $_" "DEBUG"
                $permType  = "Sharing Link (identities - parse error)"
            }
        } elseif ($null -ne $Permission.link) {
            $lType     = if ($Permission.link.PSObject.Properties['type']  -and $Permission.link.type)  { $Permission.link.type }  else { "unknown" }
            $lScope    = if ($Permission.link.PSObject.Properties['scope'] -and $Permission.link.scope) { $Permission.link.scope } else { "unknown" }
            $permType  = "Sharing Link ($lType/$lScope)"
            $principal = switch ($lScope) {
                "anonymous"    { "Anyone (anonymous)" }
                "organization" { "Organisation members" }
                default        { "Specific people" }
            }
        }
    } catch {
        Write-Log "Error resolving principal for permission '$($Permission.id)' on '$ItemId': $_" "WARN"
        $permType = "Parse Error"
    }

    # ── Group member expansion ──────────────────────────────────
    if ($ExpandGroups -and $groupId -ne "") {
        try {
            $members = Get-GroupMembers -GroupId $groupId -GroupName $principal
        } catch {
            $members = "(expansion failed)"
            Write-Log "Group member expansion failed for '$principal': $_" "WARN"
        }
    }

    # ── Inheritance details ─────────────────────────────────────
    $isInherited   = $false
    $inheritedPath = ""
    try {
        $isInherited   = ($null -ne $Permission.inheritedFrom)
        $inheritedPath = if ($isInherited -and $Permission.inheritedFrom.PSObject.Properties['path'] `
                            -and $Permission.inheritedFrom.path) {
                             $Permission.inheritedFrom.path
                         } else { "" }
    } catch {}

    # ── Sharing link details ────────────────────────────────────
    $linkWebUrl = ""; $linkType = ""; $linkScope = ""; $hasPassword = ""; $expiry = ""
    try {
        if ($null -ne $Permission.link) {
            $linkWebUrl = if ($Permission.link.PSObject.Properties['webUrl'] -and $Permission.link.webUrl) { $Permission.link.webUrl } else { "" }
            $linkType   = if ($Permission.link.PSObject.Properties['type']   -and $Permission.link.type)   { $Permission.link.type }   else { "" }
            $linkScope  = if ($Permission.link.PSObject.Properties['scope']  -and $Permission.link.scope)  { $Permission.link.scope }  else { "" }
        }
        $hasPassword = if ($Permission.PSObject.Properties['hasPassword'] -and $null -ne $Permission.hasPassword) {
                           $Permission.hasPassword.ToString() } else { "" }
        $expiry      = if ($Permission.PSObject.Properties['expirationDateTime'] -and $Permission.expirationDateTime) {
                           $Permission.expirationDateTime } else { "" }
    } catch {}

    return [PSCustomObject]@{
        TenantName           = $TenantName
        SiteDisplayName      = $SiteDisplayName
        SiteUrl              = $SiteUrl
        ListName             = $ListName
        ListTemplate         = $ListTemplate
        ItemId               = $ItemId
        ItemTitle            = $ItemTitle
        ItemUrl              = $ItemUrl
        ContentType          = $ContentType
        HasUniquePermissions = if ($null -eq $HasUnique) { "Unknown" } else { $HasUnique.ToString() }
        PermissionScope      = $PermScope
        PermissionId         = if ($Permission.PSObject.Properties['id'] -and $Permission.id) { $Permission.id } else { "" }
        PermissionType       = $permType
        Roles                = if ($Permission.PSObject.Properties['roles'] -and $Permission.roles) { ($Permission.roles -join ", ") } else { "" }
        PrincipalName        = $principal
        PrincipalEmail       = $email
        GroupMembers         = $members
        IsInherited          = $isInherited.ToString()
        InheritedFromPath    = $inheritedPath
        LinkWebUrl           = $linkWebUrl
        LinkType             = $linkType
        LinkScope            = $linkScope
        HasPassword          = $hasPassword
        ExpirationDateTime   = $expiry
        ReportedOn           = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
    }
}

# ===============================================================
# endregion
# ===============================================================


# ===============================================================
# MAIN EXECUTION
# ===============================================================

Write-Log "" "SECTION"
Write-Log "TENANT-WIDE SPO LIST ITEM PERMISSIONS REPORT" "SECTION"
Write-Log "Tenant     : $TenantName ($TenantId)" "SECTION"
Write-Log "Output     : $OutputFolder" "SECTION"
Write-Log "MaxItems   : $(if ($MaxItemsPerList -eq 0) {'All'} else {$MaxItemsPerList})" "SECTION"
Write-Log "Retries    : $MaxRetryAttempts  |  Delay: ${ThrottleDelayMs}ms" "SECTION"
Write-Log "" "SECTION"

# ── Validate PnP module ────────────────────────────────────────
try {
    $null = Get-Command Connect-PnPOnline -ErrorAction Stop
    Write-Log "PnP.PowerShell module confirmed." "OK"
} catch {
    Write-Log "PnP.PowerShell module not found. Run: Install-Module PnP.PowerShell -Scope CurrentUser -Force" "FATAL"
    exit 1
}

# ── Validate credentials are non-empty ────────────────────────
if ([string]::IsNullOrWhiteSpace($TenantId) -or
    [string]::IsNullOrWhiteSpace($ClientId) -or
    [string]::IsNullOrWhiteSpace($ClientSecret)) {
    Write-Log "TenantId, ClientId, and ClientSecret are all required." "FATAL"
    exit 1
}

# ── Acquire initial token ──────────────────────────────────────
Write-Log "Acquiring initial Graph access token..."
try {
    $null = Get-ValidToken
    Write-Log "Token acquired successfully." "OK"
} catch {
    Write-Log "Cannot continue without a valid token. Exiting." "FATAL"
    exit 1
}

# ── PHASE 1: Enumerate site collections ───────────────────────
Write-Log "" "INFO"
Write-Log "[PHASE 1] Enumerating site collections..." "INFO"

$allSites = Get-AllSiteCollections

if ($null -eq $allSites -or $allSites.Count -eq 0) {
    Write-Log "No sites found. Exiting." "FATAL"
    exit 1
}

# Apply scope filters
if ($IncludeSiteUrls.Count -gt 0) {
    $before   = $allSites.Count
    $allSites = @($allSites | Where-Object { $IncludeSiteUrls -contains $_.webUrl })
    Write-Log "IncludeSiteUrls applied: $before -> $($allSites.Count) site(s)." "INFO"
} elseif ($ExcludeSiteUrls.Count -gt 0) {
    $before   = $allSites.Count
    $allSites = @($allSites | Where-Object { $ExcludeSiteUrls -notcontains $_.webUrl })
    Write-Log "ExcludeSiteUrls applied: $before -> $($allSites.Count) site(s)." "INFO"
}

$totalSites = $allSites.Count
Write-Log "Finalised: $totalSites site(s) to process." "OK"

# ── Counters ───────────────────────────────────────────────────
$totalLists     = 0
$totalItems     = 0
$totalPermRows  = 0
$siteCounter    = 0
$SummaryRows    = [System.Collections.Generic.List[PSCustomObject]]::new()

# ── PHASE 2: Process sites / lists / items ────────────────────
Write-Log "" "INFO"
Write-Log "[PHASE 2] Processing sites, lists, and items..." "INFO"

foreach ($site in $allSites) {
    $siteCounter++
    $siteId = ""; $siteUrl = ""; $siteDisplayName = ""

    # Guard: validate site object fields
    try {
        if (-not $site -or -not $site.PSObject.Properties['id']) {
            Write-Log "Invalid site object at index $siteCounter — skipping." "WARN"
            continue
        }
        $siteId          = $site.id
        $siteUrl         = $site.webUrl
        $siteDisplayName = if ($site.PSObject.Properties['displayName'] -and $site.displayName) { $site.displayName } else { $siteUrl }
    } catch {
        Register-Error -Scope "Site" -Operation "ParseSiteObject" `
            -ErrorMsg "Site index $siteCounter parse error: $($_.ToString())"
        continue
    }

    Write-Log "" "INFO"
    Write-Log "+----- [$siteCounter/$totalSites] $siteDisplayName" "INFO"
    Write-Log "|      $siteUrl" "INFO"

    Write-Progress -Id 0 `
        -Activity        "Tenant Permissions Report" `
        -Status          "Site $siteCounter/$totalSites : $siteDisplayName" `
        -PercentComplete (($siteCounter / $totalSites) * 100)

    # ── Get lists ──────────────────────────────────────────────
    $lists = @()
    try {
        $lists = @(Get-SiteLists -SiteId $siteId -SiteUrl $siteUrl)
    } catch {
        Register-Error -Scope "Site" -SiteUrl $siteUrl `
            -Operation "GetLists" -ErrorMsg $_.ToString()
        Write-Log "+-- Site skipped (list retrieval failed)." "WARN"
        continue
    }

    # Apply list filters
    if ($SkipSystemLists.IsPresent) {
        $lists = @($lists | Where-Object { -not $_.hidden })
    }
    if ($ExcludeListTemplates.Count -gt 0) {
        $lists = @($lists | Where-Object {
            -not ($_.PSObject.Properties['list'] -and $_.list -and
                  $_.list.PSObject.Properties['template'] -and
                  $ExcludeListTemplates -contains $_.list.template)
        })
    }

    if ($lists.Count -eq 0) {
        Write-Log "|  No processable lists found on this site." "DEBUG"
        Write-Log "+-- Site done (no lists)." "OK"
        continue
    }

    Write-Log "|  Lists to process: $($lists.Count)" "INFO"
    $listCounter = 0

    foreach ($list in $lists) {
        $listCounter++
        $listId = ""; $listName = ""; $listTemplate = ""

        # Guard: validate list object
        try {
            if (-not $list -or -not $list.PSObject.Properties['id']) {
                Write-Log "|  Invalid list object at index $listCounter — skipping." "WARN"
                continue
            }
            $listId       = $list.id
            $listName     = if ($list.PSObject.Properties['displayName'] -and $list.displayName) { $list.displayName } else { "List-$listId" }
            $listTemplate = if ($list.PSObject.Properties['list'] -and $list.list -and
                               $list.list.PSObject.Properties['template'] -and $list.list.template) {
                                $list.list.template } else { "unknown" }
        } catch {
            Register-Error -Scope "List" -SiteUrl $siteUrl `
                -Operation "ParseListObject" -ErrorMsg $_.ToString()
            continue
        }

        Write-Log "|  +-- [$listCounter/$($lists.Count)] '$listName' (template: $listTemplate)" "INFO"

        $listPermRowCount = 0; $listItemCount = 0
        $uniqueCount      = 0; $inheritedCount = 0
        $batchRows        = [System.Collections.Generic.List[PSCustomObject]]::new()

        try {
            # ── List-level permissions ──────────────────────────
            if ($IncludeListLevelPermissions.IsPresent) {
                try {
                    $listPerms = @(Get-ListLevelPermissions -SiteId $siteId -ListId $listId -SiteUrl $siteUrl -ListName $listName)
                    foreach ($perm in $listPerms) {
                        try {
                            $row = Build-PermissionRow `
                                -TenantName      $TenantName `
                                -SiteDisplayName $siteDisplayName `
                                -SiteUrl         $siteUrl `
                                -ListName        $listName `
                                -ListTemplate    $listTemplate `
                                -ItemId          "__LIST__" `
                                -ItemTitle       "[LIST: $listName]" `
                                -ItemUrl         $siteUrl `
                                -ContentType     "List" `
                                -HasUnique       $null `
                                -PermScope       "List-level" `
                                -Permission      $perm `
                                -ExpandGroups    $ExpandGroupMembers.IsPresent
                            if ($null -ne $row) {
                                $batchRows.Add($row)
                                $listPermRowCount++
                            }
                        } catch {
                            Register-Error -Scope "List" -SiteUrl $siteUrl -ListName $listName `
                                -Operation "BuildListPermRow" -ErrorMsg $_.ToString()
                        }
                    }
                } catch {
                    Register-Error -Scope "List" -SiteUrl $siteUrl -ListName $listName `
                        -Operation "ListLevelPerms" -ErrorMsg $_.ToString()
                }
            }

            # ── Get list items ──────────────────────────────────
            $items = @()
            try {
                $items = @(Get-AllListItems `
                    -SiteId   $siteId  -ListId   $listId `
                    -SiteUrl  $siteUrl -ListName $listName `
                    -MaxItems $MaxItemsPerList)
            } catch {
                Register-Error -Scope "List" -SiteUrl $siteUrl -ListName $listName `
                    -Operation "GetItems" -ErrorMsg $_.ToString()
            }

            if ($items.Count -eq 0) {
                Write-Log "|  |  No items found in '$listName'." "DEBUG"
            } else {
                Write-Log "|  |  $($items.Count) item(s). Checking unique-perm flags via PnP..." "INFO"

                # ── PnP unique-perm flags ───────────────────────
                $flags         = @{ "__LIST__" = $null }
                $listHasUnique = $null
                try {
                    $flags         = Get-UniquePermissionFlags -SiteUrl $siteUrl -ListName $listName -Items $items
                    $listHasUnique = $flags["__LIST__"]
                    Write-Log "|  |  List unique perms: $(if ($null -eq $listHasUnique) {'Unknown'} else {$listHasUnique})" "DEBUG"
                } catch {
                    Register-Error -Scope "PnP" -SiteUrl $siteUrl -ListName $listName `
                        -Operation "UniquePermFlags" -ErrorMsg $_.ToString()
                }

                $itemCounter = 0

                foreach ($item in $items) {
                    $itemCounter++
                    $itemId = ""; $itemTitle = ""; $itemUrl = ""; $contentType = ""

                    # Guard: parse item object
                    try {
                        if (-not $item -or -not $item.PSObject.Properties['id']) {
                            Write-Log "|  |  Invalid item object at index $itemCounter — skipping." "WARN"
                            continue
                        }
                        $itemId = $item.id

                        $itemTitle = if     ($item.PSObject.Properties['fields'] -and $item.fields -and
                                             $item.fields.PSObject.Properties['Title'] -and $item.fields.Title) {
                                         $item.fields.Title
                                     } elseif ($item.PSObject.Properties['fields'] -and $item.fields -and
                                               $item.fields.PSObject.Properties['FileLeafRef'] -and $item.fields.FileLeafRef) {
                                         $item.fields.FileLeafRef
                                     } else { "Item-$itemId" }

                        $itemUrl     = if ($item.PSObject.Properties['webUrl'] -and $item.webUrl) { $item.webUrl } else { $siteUrl }
                        $contentType = if ($item.PSObject.Properties['fields'] -and $item.fields -and
                                          $item.fields.PSObject.Properties['ContentType'] -and $item.fields.ContentType) {
                                           $item.fields.ContentType } else { "" }
                    } catch {
                        Register-Error -Scope "Item" -SiteUrl $siteUrl -ListName $listName `
                            -ItemId "index-$itemCounter" -Operation "ParseItem" -ErrorMsg $_.ToString()
                        continue
                    }

                    Write-Progress -Id 1 -ParentId 0 `
                        -Activity        "$listName ($siteDisplayName)" `
                        -Status          "Item $itemCounter/$($items.Count): $itemTitle" `
                        -PercentComplete (($itemCounter / $items.Count) * 100)

                    # Determine scope label
                    $hasUnique  = if ($flags.ContainsKey($itemId)) { $flags[$itemId] } else { $null }
                    $scopeLabel = if     ($null -eq $hasUnique)        { "Unknown" }
                                  elseif ($hasUnique -eq $true)        { "Item-level (Unique)" }
                                  elseif ($listHasUnique -eq $true)    { "Inherited from List" }
                                  else                                 { "Inherited from Site" }

                    if ($hasUnique -eq $true) { $uniqueCount++ } else { $inheritedCount++ }

                    # ── Item-level permissions ──────────────────
                    $perms = @()
                    try {
                        $perms = @(Get-ItemPermissions `
                            -SiteId   $siteId  -ListId   $listId `
                            -ItemId   $itemId  -SiteUrl  $siteUrl `
                            -ListName $listName)
                    } catch {
                        Register-Error -Scope "Item" -SiteUrl $siteUrl -ListName $listName `
                            -ItemId $itemId -Operation "GetPermissions" -ErrorMsg $_.ToString()
                    }

                    if ($perms.Count -eq 0) {
                        # Record item with no driveItem permissions (plain list rows)
                        $batchRows.Add([PSCustomObject]@{
                            TenantName           = $TenantName
                            SiteDisplayName      = $siteDisplayName
                            SiteUrl              = $siteUrl
                            ListName             = $listName
                            ListTemplate         = $listTemplate
                            ItemId               = $itemId
                            ItemTitle            = $itemTitle
                            ItemUrl              = $itemUrl
                            ContentType          = $contentType
                            HasUniquePermissions = if ($null -eq $hasUnique) { "Unknown" } else { $hasUnique.ToString() }
                            PermissionScope      = $scopeLabel
                            PermissionId         = ""
                            PermissionType       = "No driveItem permissions (generic list row)"
                            Roles                = ""
                            PrincipalName        = ""
                            PrincipalEmail       = ""
                            GroupMembers         = ""
                            IsInherited          = (-not ($hasUnique -eq $true)).ToString()
                            InheritedFromPath    = ""
                            LinkWebUrl           = ""; LinkType = ""; LinkScope = ""
                            HasPassword          = ""; ExpirationDateTime = ""
                            ReportedOn           = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
                        })
                    } else {
                        foreach ($perm in $perms) {
                            try {
                                $row = Build-PermissionRow `
                                    -TenantName      $TenantName `
                                    -SiteDisplayName $siteDisplayName `
                                    -SiteUrl         $siteUrl `
                                    -ListName        $listName `
                                    -ListTemplate    $listTemplate `
                                    -ItemId          $itemId `
                                    -ItemTitle       $itemTitle `
                                    -ItemUrl         $itemUrl `
                                    -ContentType     $contentType `
                                    -HasUnique       $hasUnique `
                                    -PermScope       $scopeLabel `
                                    -Permission      $perm `
                                    -ExpandGroups    $ExpandGroupMembers.IsPresent
                                if ($null -ne $row) { $batchRows.Add($row) }
                            } catch {
                                Register-Error -Scope "Item" -SiteUrl $siteUrl -ListName $listName `
                                    -ItemId $itemId -Operation "BuildPermRow" -ErrorMsg $_.ToString()
                            }
                        }
                    }

                    $listItemCount++
                    $totalItems++

                    # Flush batch to CSV
                    if ($batchRows.Count -ge $CsvFlushBatchSize) {
                        Write-ToCsv -Rows $batchRows -Path $MasterCsvPath
                        $totalPermRows += $batchRows.Count
                    }
                }

                Write-Progress -Id 1 -Activity "Items" -Completed
            }

            # Flush remaining batch
            if ($batchRows.Count -gt 0) {
                Write-ToCsv -Rows $batchRows -Path $MasterCsvPath
                $totalPermRows += $batchRows.Count
            }

        } catch {
            Register-Error -Scope "List" -SiteUrl $siteUrl -ListName $listName `
                -Operation "ProcessList (outer)" -ErrorMsg $_.ToString()
        }

        # ── Per-list summary row ────────────────────────────────
        $listErrors = ($script:ErrorLog | Where-Object {
            $_.SiteUrl -eq $siteUrl -and $_.ListName -eq $listName
        }).Count

        $SummaryRows.Add([PSCustomObject]@{
            TenantName         = $TenantName
            SiteDisplayName    = $siteDisplayName
            SiteUrl            = $siteUrl
            ListName           = $listName
            ListTemplate       = $listTemplate
            TotalItems         = $listItemCount
            UniquePermItems    = $uniqueCount
            InheritedPermItems = $inheritedCount
            ListLevelPermRows  = $listPermRowCount
            ErrorCount         = $listErrors
            ReportedOn         = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
        })

        $totalLists++
        Write-Log "|  +-- Done '$listName' — Items: $listItemCount | Unique: $uniqueCount | Inherited: $inheritedCount | Errors: $listErrors" "OK"
    }

    # Disconnect PnP session before next site
    Disconnect-PnPSite

    Write-Log "+----- Site done — $listCounter list(s). Running totals — Items: $totalItems | Perm rows: $totalPermRows" "OK"
}

Write-Progress -Id 0 -Activity "Tenant Permissions Report" -Completed

# ── PHASE 3: Write summary and error CSVs ─────────────────────
Write-Log "" "INFO"
Write-Log "[PHASE 3] Finalising output files..." "INFO"

try {
    if ($SummaryRows.Count -gt 0) {
        $SummaryRows | Export-Csv -Path $SummaryCsvPath -NoTypeInformation -Encoding UTF8 -Force
        Write-Log "Summary CSV: $SummaryCsvPath" "OK"
    } else {
        Write-Log "No summary rows to write." "WARN"
    }
} catch {
    Write-Log "Failed to write summary CSV: $_" "ERROR"
}

try {
    if ($script:ErrorLog.Count -gt 0) {
        $script:ErrorLog | Export-Csv -Path $ErrorCsvPath -NoTypeInformation -Encoding UTF8 -Force
        Write-Log "$($script:ErrorLog.Count) error(s) written to: $ErrorCsvPath" "WARN"
    } else {
        Write-Log "Zero errors encountered — no error CSV needed." "OK"
    }
} catch {
    Write-Log "Failed to write error CSV: $_" "ERROR"
}

# ── FINAL SUMMARY ──────────────────────────────────────────────
Write-Log "" "SECTION"
Write-Log "REPORT COMPLETE" "SECTION"
Write-Log "  Site Collections : $totalSites" "SECTION"
Write-Log "  Lists Processed  : $totalLists" "SECTION"
Write-Log "  Items Processed  : $totalItems" "SECTION"
Write-Log "  Permission Rows  : $totalPermRows" "SECTION"
Write-Log "  Total Errors     : $($script:ErrorLog.Count)" "SECTION"
Write-Log "  Master CSV       : $MasterCsvPath" "SECTION"
Write-Log "  Summary CSV      : $SummaryCsvPath" "SECTION"
Write-Log "  Log File         : $LogPath" "SECTION"
if ($script:ErrorLog.Count -gt 0) {
    Write-Log "  Error CSV        : $ErrorCsvPath" "SECTION"
}
Write-Log "" "SECTION"

# Console breakdown table
if ($SummaryRows.Count -gt 0) {
    Write-Host "`nTop 25 sites by item count:" -ForegroundColor Cyan
    $SummaryRows |
        Group-Object SiteUrl |
        ForEach-Object {
            [PSCustomObject]@{
                SitePath  = ($_.Name -replace "https://[^/]*/", "/")
                Lists     = $_.Count
                Items     = ($_.Group | Measure-Object TotalItems         -Sum).Sum
                Unique    = ($_.Group | Measure-Object UniquePermItems    -Sum).Sum
                Inherited = ($_.Group | Measure-Object InheritedPermItems -Sum).Sum
                Errors    = ($_.Group | Measure-Object ErrorCount         -Sum).Sum
            }
        } |
        Sort-Object Items -Descending |
        Select-Object -First 25 |
        Format-Table -AutoSize

    # Error breakdown by scope
    if ($script:ErrorLog.Count -gt 0) {
        Write-Host "`nError breakdown by scope:" -ForegroundColor Yellow
        $script:ErrorLog |
            Group-Object Scope |
            Sort-Object Count -Descending |
            Format-Table Name, Count -AutoSize
    }
}

Comments