PnpAlertsExport.psm1
<#
.SYNOPSIS Export SharePoint Online alerts to a CSV file using app‑only authentication. .DESCRIPTION Retrieves alert subscriptions across all site collections or a single site (requires -SiteUrl) and their subsites, exporting them to a CSV file. - Interactive console wizard guides you step by step—no need to memorize long parameter strings. - Choose to auto‑register a new Entra AD app or supply an existing AppId/Tenant/PFX. - Process all sites or just one, scan all document libraries or one specific. - Parallel mode can dramatically speed up very large‑tenant scans by processing multiple runspaces simultaneously. - Supports PowerShell 7+ and PnP.PowerShell v3.1.0. .PARAMETER AdminCenterUrl The URL of the SharePoint Admin Center (e.g., https://contoso-admin.sharepoint.com). .PARAMETER OutputCsvPath Full path where the resulting CSV file will be saved. .PARAMETER Mode Scope of sites to process: AllSites or SingleSite. .PARAMETER SiteUrl The URL of the site to process when Mode is SingleSite. .PARAMETER ListMode Which libraries to scan: AllLibraries or SpecificLibrary. .PARAMETER ListTitle Title of the library to scan when ListMode is SpecificLibrary. .PARAMETER UseParallel Switch to enable parallel processing for faster scans of large tenants. .PARAMETER NoSubWebs Switch to skip subsites entirely. .PARAMETER AppId Azure AD application ID for an existing app (required when using existing). .PARAMETER Tenant Your tenant domain or ID (e.g., contoso.onmicrosoft.com). .PARAMETER CertificatePath Path to the .pfx certificate file for app‑only authentication. .PARAMETER CertificatePassword SecureString password to unlock the .pfx file. .PARAMETER AutoRegister Switch to auto‑register a new Entra AD application. .PARAMETER AppName Display name for the new Entra AD application when using AutoRegister. **Required**. .PARAMETER CertOutPath Folder path for the generated .cer/.pfx files in AutoRegister mode. Default: ".\Certs". .EXAMPLE PS C:\> Export-PnpAlertsAlerts # Launches the interactive wizard (preferred). .EXAMPLE PS C:\> Export-PnpAlertsAlerts ` -AdminCenterUrl https://contoso-admin.sharepoint.com ` -OutputCsvPath C:\alerts.csv ` -AutoRegister ` -AppName "AlertsExportApp" # Auto‑registers a new Entra AD app and scans all sites. .EXAMPLE PS C:\> Export-PnpAlertsAlerts -AdminCenterUrl https://contoso-admin.sharepoint.com ` -OutputCsvPath C:\alerts.csv ` -AppId 12345678-90ab-cdef-1234-567890abcdef ` -Tenant contoso.onmicrosoft.com ` -CertificatePath C:\Certs\mycert.pfx ` -CertificatePassword (ConvertTo-SecureString "P@ssw0rd" -AsPlainText -Force) # Uses an existing app to scan all sites (non‑parallel). .EXAMPLE PS C:\> Export-PnpAlertsAlerts -AdminCenterUrl https://contoso-admin.sharepoint.com ` -OutputCsvPath C:\alerts.csv ` -Mode SingleSite ` -SiteUrl https://contoso.sharepoint.com/sites/Marketing ` -AppId 12345678-90ab-cdef-1234-567890abcdef ` -Tenant contoso.onmicrosoft.com ` -CertificatePath C:\Certs\mycert.pfx ` -CertificatePassword (ConvertTo-SecureString "P@ssw0rd" -AsPlainText -Force) ` -ListMode SpecificLibrary ` -ListTitle "Shared Documents" ` -UseParallel # Scans a single site’s "Shared Documents" library in parallel using an existing app. .NOTES Author: Sam Larson Last Updated: April 20, 2025 #> function Export-Alerts { [CmdletBinding()] param( [string] $AdminCenterUrl, [string] $OutputCsvPath, [ValidateSet('AllSites','SingleSite')] [string] $Mode = 'AllSites', [string] $SiteUrl, [ValidateSet('AllLibraries','SpecificLibrary')] [string] $ListMode = 'AllLibraries', [string] $ListTitle, [switch] $UseParallel, [switch] $NoSubWebs, [string] $AppId, [string] $Tenant, [string] $CertificatePath, [SecureString]$CertificatePassword, [switch] $AutoRegister, [string] $AppName, [string] $CertOutPath = '.\Certs' ) # ────────────────────────────────────────────────── # 🎛️ Interactive wizard: operation, auth, scope, library & parallel # ────────────────────────────────────────────────── Write-Host "Select an operation:" -ForegroundColor Cyan Write-Host " 1) AllSites + Auto‑register new app" Write-Host " 2) AllSites + Use existing app" Write-Host " 3) SingleSite + Auto‑register new app" Write-Host " 4) SingleSite + Use existing app" $choice = Read-Host "Enter 1, 2, 3 or 4" switch ($choice) { '1' { $Mode='AllSites'; $AutoRegister=$true } '2' { $Mode='AllSites'; $AutoRegister=$false } '3' { $Mode='SingleSite'; $AutoRegister=$true } '4' { $Mode='SingleSite'; $AutoRegister=$false } default { Throw "Invalid choice: $choice" } } # 📝 Prompt for core inputs if (-not $AdminCenterUrl) { $AdminCenterUrl = (Read-Host "Enter Admin Center URL (e.g. https://contoso-admin.sharepoint.com)").Trim() } if (-not $OutputCsvPath) { $OutputCsvPath = (Read-Host "Enter path to save CSV (e.g. C:\temp\alerts.csv)").Trim() } # 🔐 Auth details if ($AutoRegister) { if (-not $Tenant) { $Tenant = (Read-Host "Enter your tenant (e.g. contoso.onmicrosoft.com)").Trim() } if (-not $AppName) { $AppName = (Read-Host "Enter display name for the new Entra AD application").Trim() } } else { if (-not $AppId) { $AppId = (Read-Host "Enter your existing App ID (GUID)").Trim() } if (-not $Tenant) { $Tenant = (Read-Host "Enter your tenant (e.g. contoso.onmicrosoft.com)").Trim() } if (-not $CertificatePath) { $CertificatePath = (Read-Host "Enter path to PFX certificate (e.g. C:\Windows\Documents\Certs\ExportAlerts.pfx)").Trim() } if (-not $CertificatePassword) { $CertificatePassword = Read-Host "Enter PFX password (leave blank if none)" -AsSecureString } } # 🌐 Site scope for SingleSite mode if ($Mode -eq 'SingleSite') { if (-not $SiteUrl) { $SiteUrl = (Read-Host "Enter the Site URL (e.g. https://contoso.sharepoint.com/sites/Marketing)").Trim() } } # 📚 Library mode Write-Host "Select library mode:" -ForegroundColor Cyan Write-Host " 1) All libraries" Write-Host " 2) One specific library" $libChoice = Read-Host "Enter 1 or 2" switch ($libChoice) { '1' { $ListMode='AllLibraries'; $ListTitle=$null } '2' { $ListMode='SpecificLibrary' do { $ListTitle = (Read-Host "Enter the EXACT library title (case‑sensitive)").Trim() } while ([string]::IsNullOrWhiteSpace($ListTitle)) } default { Throw "Invalid library choice: $libChoice" } } # ⚡️ Parallel mode? Write-Host "Enable parallel processing for large tenants? (y/n)" -ForegroundColor Cyan $p = Read-Host "Parallel mode" $UseParallel = $p -match '^[Yy]' # PowerShell 7+ check if ($PSVersionTable.PSVersion.Major -lt 7 -or $PSISE) { Write-Host "❌ Requires PowerShell 7+ (no ISE)." -ForegroundColor Red return } # Ensure PnP.PowerShell 3.1.0 Write-Host '🔍 Checking for PnP.PowerShell v3.1.0…' -ForegroundColor Cyan if (-not (Get-Module -ListAvailable -Name PnP.PowerShell | Where-Object Version -eq '3.1.0')) { Write-Host 'Installing PnP.PowerShell v3.1.0…' -ForegroundColor Yellow Install-Module PnP.PowerShell -RequiredVersion 3.1.0 -Scope CurrentUser -Force } Import-Module PnP.PowerShell -RequiredVersion 3.1.0 -ErrorAction Stop # Initialize logging helper function Log-Message { param($m,$l='INFO') Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$l] $m" } # AutoRegister flow # if ($AutoRegister) { # 2.1 Consent prompt if (-not $Tenant) { throw '-Tenant is required for -AutoRegister.' } $consent = Read-Host -Prompt "AutoRegister will create a new Entra app in your tenant. Proceed? (Y/N)" if ($consent -notin 'Y','y') { Write-Warning "App registration skipped by user." break } # 2.2 Prepare output folder Write-Host '🔧 Launching Entra app registration in a new window…' -ForegroundColor Cyan $outPath = (Resolve-Path $CertOutPath -ErrorAction SilentlyContinue).ProviderPath if (-not $outPath) { $outPath = Join-Path $PWD $CertOutPath if (-not (Test-Path $outPath)) { New-Item -ItemType Directory -Path $outPath | Out-Null } } # 2.3 Ensure PnP.PowerShell v2.4.0 is installed for helper window if (-not (Get-Module -ListAvailable -Name 'PnP.PowerShell' | Where-Object { $_.Version -eq [Version]'2.4.0' })) { Write-Host '🔄 Installing PnP.PowerShell v2.4.0 for helper window...' -ForegroundColor Cyan Install-Module -Name PnP.PowerShell -RequiredVersion 2.4.0 -Scope CurrentUser -Force } # 2.4 Build and launch the one‑liner registration command $regCmd = "Import-Module PnP.PowerShell -RequiredVersion 2.4.0 -Force; Register-PnPEntraIDApp -ApplicationName `"$AppName`" -Tenant `"$Tenant`" -DeviceLogin -SharePointApplicationPermissions @('Sites.FullControl.All') -OutPath `"$outPath`" -Verbose | Out-Host" Start-Process pwsh -ArgumentList @( '-ExecutionPolicy','Bypass', '-NoExit', '-Command', $regCmd ) # 2.5 Wait for the user to finish in the helper window Read-Host -Prompt "✔️ Helper window launched. Complete app registration there, then press Enter to continue in this window" # 2.6 Gather the newly created values $AppId = Read-Host "Enter the newly registered ClientId" $CertificatePath = Read-Host "Enter the path to the .pfx file" if (-not (Test-Path $CertificatePath)) { throw "Cannot find .pfx at $CertificatePath" } Write-Host "ℹ️ Optionally check to ensure you have granted consent in the Entra portal: go to https://entra.microsoft.com, search for '$AppName', select API permissions, and verify the required scopes show a green check mark." -ForegroundColor Yellow Read-Host "Press Enter to continue…" # Newly created certs come without a password $CertificatePassword = $null } ############################################################################ # Authenticate to the SharePoint Admin Center # ############################################################################ Write-Host '🔒 Connecting to SharePoint Admin Center…' -ForegroundColor Cyan try { $connection = Connect-PnPOnline ` -Url $AdminCenterUrl ` -ClientId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ReturnConnection ` -ErrorAction Stop Log-Message "Connected to $AdminCenterUrl" } catch { $msg = $_.Exception.Message if ($msg -match 'AADSTS700027') { Write-Host "⚠️ Certificate not found on your app registration." -ForegroundColor Yellow Write-Host " • In Azure portal, navigate to Azure AD → App registrations → '$AppName' → Certificates & secrets" -ForegroundColor Yellow Write-Host " • Upload the public (.cer) part of your certificate so MSAL can validate the assertion" -ForegroundColor Yellow Write-Host " • See https://aka.ms/msal-net-invalid-client for more info" -ForegroundColor Yellow } Write-Error "Failed to connect: $_" return } # Track runtime and memory $start = Get-Date $initialMB = [math]::Round(([GC]::GetTotalMemory($false)/1MB),2) ############################################################################ # Helper: retryable PnP connection # ############################################################################ function Test-PnPConnection { param($Conn,$Url) try { Get-PnPWeb -Connection $Conn -ErrorAction Stop | Out-Null return $Conn } catch { for ($i = 1; $i -le 3; $i++) { Start-Sleep -Seconds (5 * $i) try { return Connect-PnPOnline ` -Url $Url ` -ClientId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ReturnConnection -ErrorAction Stop } catch {} } throw "Cannot reconnect to $Url" } } ############################################################################ # Function: Get-WebAlerts — fetches alerts from one site # ############################################################################ function Get-WebAlerts { param($Url) $alerts = @() # Connect with backoff for ($r = 0; $r -lt 3; $r++) { try { $conn = Connect-PnPOnline ` -Url $Url ` -ClientId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ReturnConnection -ErrorAction Stop break } catch { Start-Sleep -Seconds (5 * ($r + 1)) if ($r -eq 2) { throw "Cannot connect to $Url" } } } # Validate or re‑connect if needed $conn = Test-PnPConnection -Conn $conn -Url $Url # Retrieve lists per ListMode if ($ListMode -eq 'SpecificLibrary' -and $ListTitle) { $lists = @(Get-PnPList -Identity $ListTitle -Connection $conn -ErrorAction Stop) } else { $lists = Get-PnPList -Connection $conn -ErrorAction Stop | Where-Object { $_.BaseTemplate -eq 101 -and -not $_.Hidden -and -not $_.IsCatalog -and -not $_.IsSiteAssetsLibrary } } # Gather alerts foreach ($l in $lists) { $la = Get-PnPAlert -List $l.Title -AllUsers -Connection $conn -ErrorAction SilentlyContinue foreach ($a in $la) { $u = Get-PnPProperty -ClientObject $a -Property User -Connection $conn $alerts += [pscustomobject]@{ SiteUrl = $Url AlertTitle = $a.Title User = $u.LoginName Frequency = $a.AlertFrequency EventType = $a.EventType ListTitle = $l.Title ListUrl = $l.DefaultViewUrl } } } return $alerts } ############################################################################ # Fetch site collections or a single site # ############################################################################ if ($Mode -eq 'AllSites') { $sites = Get-PnPTenantSite -IncludeOneDriveSites:$false -Connection $connection -ErrorAction Stop } else { $sites = @([pscustomobject]@{ Url = $SiteUrl }) } ############################################################################ # Process each site (parallel if requested) – ensure helper functions load ############################################################################ # capture module folder $scriptRoot = $PSScriptRoot # helper for parallel mode (needs using:) $parallelBlock = { param($site) # bind the pipeline input into $site $site = $_ # load helper functions (Log-Message, Get-WebAlerts, etc.) . "$using:scriptRoot\GetWebAlertsFunctions.ps1" # authenticate inside this runspace $conn = Connect-PnPOnline ` -Url $site.Url ` -ClientId $using:AppId ` -Tenant $using:Tenant ` -CertificatePath $using:CertificatePath ` -CertificatePassword $using:CertificatePassword ` -ReturnConnection Log-Message "Processing $($site.Url)" # root‑site alerts $out = Get-WebAlerts ` -Url $site.Url ` -AppId $using:AppId ` -Tenant $using:Tenant ` -CertificatePath $using:CertificatePath ` -CertificatePassword $using:CertificatePassword ` -ListMode $using:ListMode ` -ListTitle $using:ListTitle # subsites if requested if (-not $using:NoSubWebs) { $subsites = Get-PnPSubWeb -Recurse -Connection $conn foreach ($w in $subsites) { $out += Get-WebAlerts ` -Url $w.Url ` -AppId $using:AppId ` -Tenant $using:Tenant ` -CertificatePath $using:CertificatePath ` -CertificatePassword $using:CertificatePassword ` -ListMode $using:ListMode ` -ListTitle $using:ListTitle } } return $out } # helper for non‑parallel mode (no using:) $localBlock = { param($site) . "$scriptRoot\GetWebAlertsFunctions.ps1" $conn = Connect-PnPOnline ` -Url $site.Url ` -ClientId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ReturnConnection Log-Message "Processing $($site.Url)" $out = Get-WebAlerts ` -Url $site.Url ` -AppId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ListMode $ListMode ` -ListTitle $ListTitle if (-not $NoSubWebs) { $subsites = Get-PnPSubWeb -Recurse -Connection $conn foreach ($w in $subsites) { $out += Get-WebAlerts ` -Url $w.Url ` -AppId $AppId ` -Tenant $Tenant ` -CertificatePath $CertificatePath ` -CertificatePassword $CertificatePassword ` -ListMode $ListMode ` -ListTitle $ListTitle } } return $out } if ($UseParallel) { $allAlerts = $sites | ForEach-Object -Parallel $parallelBlock -ThrottleLimit 8 } else { $allAlerts = foreach ($s in $sites) { & $localBlock $s } } ############################################################################ # Export results to CSV # ############################################################################ try { $count = $allAlerts.Count $allAlerts | Export-Csv -Path $OutputCsvPath -NoTypeInformation -Encoding UTF8 Write-Host "✅ Exported $count alerts → $OutputCsvPath" -ForegroundColor Green } catch { Write-Error "Export failed: $_" } # Final stats $end = Get-Date $endMB = [math]::Round(([GC]::GetTotalMemory($false)/1MB),2) Log-Message "Start: $start | End: $end | Runtime: $($end - $start) | Memory: ${initialMB} → ${endMB}MB" } Export-ModuleMember -Function Export-Alerts |