###################################################################################
###################################################################################
# ===============================================================
# 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
Post a Comment