SharePoint Online is built for collaboration, but storage growth is rarely set it and forget it. In most tenants, the storage curve is driven by everyday activity like co-authoring, AutoSave, and long-running project sites that never quite get cleaned up. Microsoft’s guidance is to treat storage as an operational concern: monitor trends, configure version history and retention intentionally, and take action on low-value content before it becomes a cost problem.
In this post, we will cover the most common storage drivers we see in the field (version history and file duplication), along with a practical, low-risk approach to reduce storage use without disrupting end users. We will also give a high-level overview of an AdaptivEdge PowerShell script that enables automatic version trimming and performs a one-time cleanup of older file versions.
Why storage grows faster than expected
- Version history sprawl
Version history is a core SharePoint feature, and it is essential for recovery and collaboration. The challenge is that modern editing behaviors (co-authoring and frequent saves) can create a large number of versions over time, especially for active files. The out-of-box version limits many libraries inherit can also contribute to growth.
- Duplicate files, duplicate storage
File duplication usually shows up in a few patterns: multiple copies created via Save As, parallel drafts stored in different folders, or content duplicated across sites during reorganizations and migrations. Each copy becomes its own lifecycle, with its own version history, retention outcomes, and long-term storage impact.
- Governance gaps that compound over time
If site ownership, content lifecycle, and library design are unclear, storage optimization becomes reactive. This is one reason many SharePoint experts push information architecture first (sites and libraries that reflect business boundaries, plus clear ownership) as a foundational best practice.
Common SharePoint Storage Challenges
Even with version trimming and duplicate cleanup in place, several hidden or systemic factors can continue driving storage growth. Here are three of the most common challenges:
Microsoft 365 Archive offers a lower-cost storage tier for inactive SharePoint sites. It is ideal for content that must be retained for compliance but is rarely accessed. However, it only supports full-site archiving (not individual files), and retrieval times may be slower. Organizations often overlook this option until storage costs spike.
Preservation hold libraries are hidden containers created by compliance policies. They retain original versions of files, even after deletion or modification, making them essential for legal holds but problematic for storage optimization. These libraries are not affected by version trimming and can silently accumulate large amounts of data.
Inactive sites, abandoned project spaces, and old team workspaces can consume significant storage. SharePoint Advanced Management allows administrators to configure policies that detect inactivity, notify site owners, and trigger archiving or deletion workflows. Without these policies, inactive sites often persist indefinitely.
Reducing storage footprint is a smart financial move, but it comes with tradeoffs. Aggressive version trimming or deletion may limit your ability to restore files from far-back edits or recover deleted content. The key is aligning version retention depth with business needs—keeping deep history where it matters and trimming aggressively in high-churn libraries.
|
Priority Scenario |
Recommended Strategy |
|
Need instant access and collaboration |
Standard Storage |
|
Need to retain content affordably |
Microsoft 365 Archive |
|
Need to clean up abandoned content |
Inactive Site Policies |
|
Need to reduce costs without losing control |
Inactive Site Policies |
|
Need to preserve content for compliance |
Microsoft 365 Archive |
Understanding SharePoint Storage Costs
Before implementing cleanup strategies, it is important to understand how SharePoint storage is allocated and priced:
-
Each Microsoft 365 tenant receives 1 TB of SharePoint storage, plus 10 GB per licensed user.
-
Additional storage costs $0.20 per GB per month.
-
Microsoft 365 Archive offers cold storage at approximately $0.05 per GB, ideal for rarely accessed content.
-
Deleted items in the recycle bin still count toward your quota until permanently removed.
-
Most organizations can reduce storage costs by 40 to 60 percent through cleanup, archiving, and governance.
Microsoft-recommended best practices for SharePoint storage
Below are the storage management moves Microsoft highlights consistently, plus how we typically apply them during engagements:
- Monitor storage trends and identify what is driving growth. Use admin center reporting to understand which sites are growing and why, then prioritize cleanup where it matters most.
- Set version history limits and review retention policies. Configure version history limits and align retention policies with business and compliance needs.
- Trim low-value versions to reclaim space. Trimming existing low-value versions can reduce storage footprint, especially in high-churn libraries.
- Archive inactive content rather than keeping everything hot. For content that must be retained but is rarely accessed, consider archive approaches to reduce active storage costs.
- Design sites and libraries to avoid one giant repository. Structuring content into multiple sites and libraries affects your ability to govern versioning, retention, and ownership at a reasonable scope.
High-level overview: what the AdaptivEdge version cleanup script does
The attached script is designed to do two things across SharePoint Online document libraries:
- Enable ongoing automatic version trimming
- Perform a one-time cleanup that keeps only the most recent N versions per file
**CRITICAL: Version Trimming is Permanent and Irreversible**
Versions deleted by this script are permanently deleted and bypass the recycle bin. Deleted versions cannot be recovered through normal recovery workflows. Microsoft strongly recommends:
- Running impact analysis (Dry Run mode) before committing to deletions
- Verifying versions to be deleted don't conflict with retention requirements
- Ensuring stakeholders understand the recoverability implications
The following is the non-technical breakdown.
Step 1: Connects to SharePoint securely
The script supports two authentication approaches using PnP PowerShell:
- Delegated (interactive) sign-in, useful for an admin running it manually from an Entra-joined machine
- App-only certificate authentication, useful for controlled automation
It intentionally avoids client secrets and uses certificate-based options when running app-only.
Step 2: Choose which sites to process
You can run it in two modes:
- Tenant-wide, targeting sites created on or before a cutoff date
- Single site, targeting one site URL for a controlled run
This makes it easier to phase cleanup, start with a pilot, or focus on known storage hotspots.
Step 3: Ensures access so cleanup does not fail mid-run
Version cleanup can fail if the executing identity does not have sufficient rights across every site and library. The script addresses this by temporarily ensuring site collection admin access on the target sites. It can also remove that elevated access after completion if you enable the cleanup option.
Step 4: Turns on automatic version trimming at the library level
For each non-system document library within the target sites, the script enables Automatic mode using Set-PnPList -EnableAutoExpirationVersionTrim. This applies Microsoft's intelligent version trimming algorithm to each library individually, allowing ongoing automatic cleanup going forward.
Step 5: Performs a one-time pruning of older versions
After enabling automatic trimming, it does a one-time pass through each file in each library and removes versions older than your configured threshold (for example, keep the latest 10 or 50 versions). It supports a Dry Run mode so you can estimate impact without making changes.Step 6: Produces logs and (optionally) a summary report
The script writes to a CSV log file and can also generate an aggregated summary report showing how many files were processed and how many versions were deleted. This is helpful for change records and for communicating outcomes to stakeholders.
How we recommend using this approach
If you are planning version cleanup at scale, a safe rollout usually looks like this:
- Start with reporting. Identify the top sites and libraries by storage and activity
- Run a pilot in Dry Run mode on a single site. Validate scope, timing, and expected reductions.
- Align version retention with business reality. Not every library needs the same depth of recoverability.
- Enable automatic trimming broadly, then prune selectively. Automatic trimming keeps future growth under control, and pruning reduces existing bloat.
- Revisit duplicate content drivers. Cleanup works best when paired with user guidance on collaboration patterns and document ownership.
Closing
Version history is valuable, and it is also one of the most common reasons SharePoint Online storage grows faster than expected. Microsoft’s guidance is clear: set sensible limits, review retention policies, monitor trends, and reduce low-value content where possible.
The AdaptivEdge script enables automatic trimming to prevent future sprawl, and it provides a controlled way to clean up existing versions at scale, with Dry Run support and audit-friendly reporting.
Note: The script includes an AS IS disclaimer and is intended to be validated in a test or pilot scope before broad rollout.
Sources
Plan for SharePoint storage (Microsoft Learn): https://learn.microsoft.com/en-us/sharepoint/sharepoint-storage-planning
October 2025 Microsoft 365 Announcement Highlights (AdaptivEdge): https://info.adaptivedge.com/blog/october-2025-microsoft-365-announcement-highlights
Technical Details: Version History Cleanup Script
This PowerShell script, Pnp-VersionHistoryPurge_SetAutomaticTrim.ps1, enables automatic version trimming on SharePoint Online document libraries and performs a one-time prune to retain only the most recent N versions. It supports both Delegated (interactive) and AppOnly (certificate-based) authentication modes using PnP.PowerShell. The script can target all sites created before a cutoff date or a single specified site, and includes detailed logging, summary reporting, and optional cleanup of elevated admin access.
- Supports Delegated and AppOnly authentication via PnP.PowerShell
- Enables automatic version trimming using Set-PnPList -EnableAutoExpirationVersionTrim
- Performs one-time cleanup using Get-PnPFileVersion and Remove-PnPFileVersion
- Logs errors and actions to C:\Temp\ErrorLog_<MM-dd-yyyy>.csv
- Generates summary report to C:\Temp\VersionTrimSummary_<yyyyMMdd_HHmmss>.csv
- Supports DryRun mode for safe testing
- Can remove Site Collection Admin access after execution with -CleanupAdminAccess
File Version Trim Script (ZIP)
A ready-to-use set of scripts to remove old file versions and keep storage tidy. Ideal for admins who want fast, repeatable cleanup with minimal risk.
- Curated for SharePoint Online version bloat scenarios
- Packed as a ZIP for quick download and sharing
- Includes sample usage and comments
- Includes script for creating a self-signed script for application authentication
Source Code
<#
.SYNOPSIS
Enable automatic version trimming on document libraries and perform a one-time prune
(keeping only the most recent N versions) across:
- All SharePoint sites created on or before a cutoff date, OR
- A single specified site (using -SingleSiteUrl).
.DESCRIPTION
- Supports Delegated (interactive) and AppOnly (certificate-based) authentication with PnP.PowerShell.
- Delegated uses: Connect-PnPOnline -Interactive -ClientId.
- AppOnly uses: certificate thumbprint or PFX file (preferred over client secret).
- Grants Site Collection Admin on target sites by default to avoid unauthorized errors:
- Delegated: ensures the current user is owner via tenant admin cmdlet.
- AppOnly: ensures a specified human UPN via -EnsureOwnerUpn (recommended).
- Elevation uses Set-PnPTenantSite -Owners from the admin connection for efficiency.
- Enables Enhanced Version Controls (Auto Expiration Version Trim) on each document library:
Set-PnPList -EnableAutoExpirationVersionTrim $true
- Performs a one-time cleanup to keep only the latest N versions per file using:
Get-PnPFileVersion / Remove-PnPFileVersion.
- Logs to C:\Temp\ErrorLog_<MM-dd-yyyy>.csv using your preferred Write-Log function.
- Supports detailed (per-library & per-file) logging via -DetailedLog.
- Generates an aggregated summary CSV when -GenerateSummary is specified.
.PARAMETER TenantName
Tenant short name (e.g., contoso).
.PARAMETER ClientId
Azure AD App (public or confidential client) ID.
.PARAMETER AuthMode
Authentication mode: Delegated (default) or AppOnly.
.PARAMETER CertificateThumbprint
Thumbprint for certificate in CurrentUser\My or LocalMachine\My (AppOnly).
.PARAMETER CertificatePath
Path to PFX certificate file (AppOnly).
.PARAMETER CertificatePassword
SecureString password for the PFX (AppOnly).
.PARAMETER EnsureOwnerUpn
In AppOnly mode, human UPN to ensure as Site Collection Admin on target sites.
.PARAMETER CutoffDate
Process all sites created on or before this date (ignored if -SingleSiteUrl is provided).
.PARAMETER KeepLatestVersions
Number of most recent versions to keep during one-time prune (default: 10).
.PARAMETER DryRun
Simulate actions without making any changes (still elevates admin to ensure access, by design).
.PARAMETER SingleSiteUrl
Optional. If provided, the script runs only against this site and skips tenant-wide enumeration.
.PARAMETER DetailedLog
Optional. If provided, logs detailed per-library and per-file actions.
.PARAMETER GenerateSummary
Optional. If provided, writes an aggregated CSV report:
C:\Temp\VersionTrimSummary_<yyyyMMdd_HHmmss>.csv
.PARAMETER CleanupAdminAccess
Optional. If provided, removes Site Collection Admin for the identity elevated by this run.
.EXAMPLES
# Example 1: DELEGATED - All sites created on/before 8/26/2025 (Dry Run, summary)
.\Pnp-VersionHistoryPurge_SetAutomaticTrim.ps1 `
-TenantName "contoso" `
-ClientId "00000000-0000-0000-0000-000000000000" `
-CutoffDate '2025-08-26' `
-KeepLatestVersions 10 `
-DryRun `
-GenerateSummary
# Example 2: DELEGATED - Single site, detailed logging
.\Pnp-VersionHistoryPurge_SetAutomaticTrim.ps1 `
-TenantName "contoso" `
-ClientId "00000000-0000-0000-0000-000000000000" `
-SingleSiteUrl "https://contoso.sharepoint.com/sites/Finance `
-KeepLatestVersions 10 `
-DetailedLog `
-GenerateSummary
# Example 3: APPONLY - Thumbprint-based certificate
.\Pnp-VersionHistoryPurge_SetAutomaticTrim.ps1 `
-TenantName "contoso" `
-ClientId "00000000-0000-0000-0000-000000000000" `
-AuthMode AppOnly `
-CertificateThumbprint "ABCD1234EF567890ABCD1234EF567890ABCD1234" `
-SingleSiteUrl "https://contoso.sharepoint.com/sites/Finance `
-KeepLatestVersions 10 `
-DetailedLog `
-GenerateSummary
# Example 4: APPONLY - PFX file
$certPwd = Read-Host -AsSecureString "Enter PFX password"
.\Pnp-VersionHistoryPurge_SetAutomaticTrim.ps1 `
-TenantName "contoso" `
-ClientId "00000000-0000-0000-0000-000000000000" `
-AuthMode AppOnly `
-CertificatePath "C:\Certs\ContosoAppCert.pfx" `
-CertificatePassword $certPwd `
-CutoffDate '2025-08-26' `
-KeepLatestVersions 10 `
-GenerateSummary
*** Disclamer ***
The sample scripts are not supported under any AdaptivEdge standard support program or service. The sample
scripts are provided AS IS without warranty of any kind. AdptivEdge further disclaims all implied warranties
including, without limitation, any implied warranties of merchantability or of fitness for a particular
purpose. The entire risk arising out of the use or performance of the sample scripts and documentation
remains with you. In no event shall AdaptivEdge, its authors, or anyone else involved in the creation,
production, or delivery of the scripts be liable for any damages whatsoever (including, without limitation,
damages for loss of business profits, business interruption, loss of business information, or other
pecuniary loss) arising out of the use of or inability to use the sample scripts or documentation, even
if AdaptivEdge has advised of the possibility of such damages.
Original script by: AdaptivEdge LLC | Alexander Silva | asilva@adaptivedge.com
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$TenantName,
[Parameter(Mandatory=$true)][string]$ClientId,
[ValidateSet('Delegated','AppOnly')]
[string]$AuthMode = 'Delegated',
# App-only certificate options
[string]$CertificateThumbprint,
[string]$CertificatePath,
[SecureString]$CertificatePassword,
# Human owner to ensure in AppOnly mode (optional if app already has full access)
[string]$EnsureOwnerUpn,
[datetime]$CutoffDate = [datetime]'2025-08-26',
[int]$KeepLatestVersions = 500,
[switch]$DryRun,
[string]$SingleSiteUrl,
[switch]$DetailedLog,
[switch]$GenerateSummary,
[switch]$CleanupAdminAccess
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Ensure log folder exists
if (-not (Test-Path 'C:\Temp')) { New-Item -Path 'C:\Temp' -ItemType Directory -Force | Out-Null }
# Logging to C:\Temp\ErrorLog_<MM-dd-yyyy>.csv
$GlobalErrorLogPath = "C:\Temp\ErrorLog_{0}.csv" -f (Get-Date -Format 'MM-dd-yyyy')
Function Write-Log {
param (
[string]$SiteUrl,
[string]$Action,
[string]$Status,
[string]$ErrorMessage = ""
)
# Ensure log folder exists
if (-not (Test-Path 'C:\Temp')) {
New-Item -Path 'C:\Temp' -ItemType Directory -Force | Out-Null
}
# Initialize CSV with headers if it doesn't exist
if (-not (Test-Path $GlobalErrorLogPath)) {
$null = [pscustomobject]@{
Timestamp = "Timestamp"
SiteUrl = "SiteUrl"
Action = "Action"
Status = "Status"
Details = "Details"
} | Export-Csv -Path $GlobalErrorLogPath -NoTypeInformation -Encoding UTF8
}
# Prepare log entry
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$cleanError = if ($null -ne $ErrorMessage) { ($ErrorMessage -replace '[\r\n]+',' ') } else { "" }
$logEntry = [pscustomobject]@{
Timestamp = $timestamp
SiteUrl = $SiteUrl
Action = $Action
Status = $Status
Details = $cleanError
}
# Append to CSV
$logEntry | Export-Csv -Path $GlobalErrorLogPath -NoTypeInformation -Encoding UTF8 -Append
# Console output
if ($Status -eq "Success") {
Write-Host "$timestamp [$Action] - $SiteUrl Success" -ForegroundColor Green
} else {
Write-Host "$timestamp [$Action] - $SiteUrl Error - $cleanError" -ForegroundColor Red
}
}
# Summary output
if ($GenerateSummary) {
$script:SummaryRows = New-Object System.Collections.Generic.List[object]
$script:SummaryPath = "C:\Temp\VersionTrimSummary_{0}.csv" -f (Get-Date -Format 'yyyyMMdd_HHmmss')
}
# Tracking for cleanup
$script:SitesWeElevated = New-Object System.Collections.Generic.List[string]
$script:EnsuredOwnersBySite = @{}
# Derived values
$TenantDomain = "$TenantName.onmicrosoft.com"
$AdminUrl = "https://$TenantName-admin.sharepoint.com"
# ===== Authentication (no client secret paths) =====
function Connect-PnPAdmin {
try {
# Safely disconnect any existing session
try {
$null = Get-PnPConnection
Disconnect-PnPOnline -ErrorAction SilentlyContinue | Out-Null
} catch { }
# Connect based on auth mode
if ($AuthMode -eq 'Delegated') {
Connect-PnPOnline -Url $AdminUrl -ClientId $ClientId -Interactive | Out-Null
} else {
if ($CertificateThumbprint) {
Connect-PnPOnline -Url $AdminUrl -ClientId $ClientId -Tenant $TenantDomain -Thumbprint $CertificateThumbprint | Out-Null
} elseif ($CertificatePath) {
if ($CertificatePassword) {
Connect-PnPOnline -Url $AdminUrl -ClientId $ClientId -Tenant $TenantDomain -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword | Out-Null
} else {
Connect-PnPOnline -Url $AdminUrl -ClientId $ClientId -Tenant $TenantDomain -CertificatePath $CertificatePath | Out-Null
}
} else {
throw "AppOnly selected but no certificate provided. Supply -CertificateThumbprint or -CertificatePath (+ -CertificatePassword)."
}
}
Write-Log -SiteUrl "$AdminUrl" -Action "Connect-PnPAdmin ($AuthMode)" -Status "Success"
} catch {
Write-Log -SiteUrl "$AdminUrl" -Action "Connect-PnPAdmin ($AuthMode)" -Status "Error" -ErrorMessage $_.Exception.Message
throw
}
}
function Connect-PnPSite {
param([string]$SiteUrl)
try {
if ($AuthMode -eq 'Delegated') {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive | Out-Null
} else {
if ($CertificateThumbprint) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $TenantDomain -Thumbprint $CertificateThumbprint | Out-Null
} elseif ($CertificatePath) {
if ($CertificatePassword) {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $TenantDomain -CertificatePath $CertificatePath -CertificatePassword $CertificatePassword | Out-Null
} else {
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $TenantDomain -CertificatePath $CertificatePath | Out-Null
}
} else {
throw "AppOnly selected but no certificate provided. Supply -CertificateThumbprint or -CertificatePath (+ -CertificatePassword)."
}
}
Write-Log -SiteUrl "$SiteUrl" -Action "Connect-PnPSite ($AuthMode)" -Status "Success"
} catch {
Write-Log -SiteUrl "$SiteUrl" -Action "Connect-PnPSite ($AuthMode)" -Status "Error" -ErrorMessage $_.Exception.Message
throw
}
}
# ===== Target Sites =====
function Get-TargetSites {
try {
$sites = Get-PnPTenantSite -IncludeOneDriveSites:$false | Where-Object { $_.Template -ne "REDIRECTSITE#0" }
$filtered = $sites | Where-Object {
$created = $_.CreationTime; if (-not $created) { $created = $_.Created }
$isSystem = $_.Template -in @('APPCATALOG#0','SPSMSITEHOST#0')
($created -le $CutoffDate) -and (-not $isSystem)
}
Write-Log -SiteUrl "$AdminUrl" -Action "Get-TargetSites" -Status "Success" -ErrorMessage ("Count=" + ($filtered | Measure-Object).Count)
return $filtered
} catch {
Write-Log -SiteUrl "$AdminUrl" -Action "Get-TargetSites" -Status "Error" -ErrorMessage $_.Exception.Message
throw
}
}
# ===== Elevation Helpers =====
function Resolve-OwnerToEnsure {
if ($AuthMode -eq 'Delegated') {
try {
$meLogin = (Get-PnPWeb -Includes CurrentUser).CurrentUser.LoginName
if (-not $meLogin) { throw "Unable to resolve current user login on admin site." }
Write-Log -SiteUrl "$AdminUrl" -Action "CurrentUser" -Status "Success" -ErrorMessage $meLogin
return $meLogin
} catch {
Write-Log -SiteUrl "$AdminUrl" -Action "CurrentUser" -Status "Error" -ErrorMessage $_.Exception.Message
return $null
}
} else {
if ($EnsureOwnerUpn) {
Write-Log -SiteUrl "$AdminUrl" -Action "EnsureOwnerUpn" -Status "Success" -ErrorMessage $EnsureOwnerUpn
return $EnsureOwnerUpn
} else {
# With Sites.FullControl.All, elevation can be skipped
Write-Log -SiteUrl "$AdminUrl" -Action "EnsureOwnerUpn" -Status "Error" -ErrorMessage "AppOnly: no -EnsureOwnerUpn provided. Skipping elevation."
return $null
}
}
}
function Ensure-AdminAccessForSites {
param([string[]]$SiteUrls)
$ownerToEnsure = Resolve-OwnerToEnsure
foreach ($url in $SiteUrls) {
try {
if ($null -eq $ownerToEnsure) {
Write-Log -SiteUrl "$url" -Action "AddSiteCollectionAdmin" -Status "Success" -ErrorMessage "Skipped (no owner to ensure in $AuthMode)"
continue
}
# Ensure via admin context
Set-PnPTenantSite -Identity $url -Owners $ownerToEnsure
[void]$script:SitesWeElevated.Add($url)
$script:EnsuredOwnersBySite[$url] = $ownerToEnsure
Write-Log -SiteUrl "$url" -Action "AddSiteCollectionAdmin" -Status "Success" -ErrorMessage "Ensured '$ownerToEnsure' via Set-PnPTenantSite -Owners"
} catch {
Write-Log -SiteUrl "$url" -Action "AddSiteCollectionAdmin" -Status "Error" -ErrorMessage $_.Exception.Message
}
}
}
function Cleanup-AdminAccessForSites {
param([string[]]$SiteUrls)
if (-not $CleanupAdminAccess) { return }
foreach ($url in $SiteUrls) {
if ($script:SitesWeElevated -notcontains $url) {
Write-Log -SiteUrl "$url" -Action "RemoveSiteCollectionAdmin" -Status "Success" -ErrorMessage "Skipped removal (not elevated by this run)"
continue
}
$ownerToRemove = $null
if ($script:EnsuredOwnersBySite.ContainsKey($url)) {
$ownerToRemove = $script:EnsuredOwnersBySite[$url]
} elseif ($AuthMode -eq 'Delegated') {
try { $ownerToRemove = (Get-PnPWeb -Includes CurrentUser).CurrentUser.LoginName } catch {}
} else {
$ownerToRemove = $EnsureOwnerUpn
}
if (-not $ownerToRemove) {
Write-Log -SiteUrl "$url" -Action "RemoveSiteCollectionAdmin" -Status "Error" -ErrorMessage "Unknown owner to remove in $AuthMode"
continue
}
try {
# Switch to site context to remove
Connect-PnPSite -SiteUrl $url
Remove-PnPSiteCollectionAdmin -Owners $ownerToRemove
Write-Log -SiteUrl "$url" -Action "RemoveSiteCollectionAdmin" -Status "Success" -ErrorMessage "Removed $ownerToRemove from Site Collection Admins"
# Switch back to admin for safety if more tenant ops follow
Connect-PnPAdmin
} catch {
Write-Log -SiteUrl "$url" -Action "RemoveSiteCollectionAdmin" -Status "Error" -ErrorMessage $_.Exception.Message
}
}
}
# ===== Library & Versioning =====
function Set-LibraryAutoExpiration {
param([Microsoft.SharePoint.Client.List]$List, [string]$SiteUrl)
try {
if ($DryRun) {
if ($DetailedLog) {
Write-Log -SiteUrl "$SiteUrl" -Action ("Set AutoExpiration -> " + $List.Title) -Status "Success" -ErrorMessage "[DryRun] Set-PnPList -EnableAutoExpirationVersionTrim $true"
}
} else {
Set-PnPList -Identity $List -EnableAutoExpirationVersionTrim $true
if ($DetailedLog) {
Write-Log -SiteUrl "$SiteUrl" -Action ("Set AutoExpiration -> " + $List.Title) -Status "Success"
}
}
} catch {
Write-Log -SiteUrl "$SiteUrl" -Action ("Set AutoExpiration -> " + $List.Title) -Status "Error" -ErrorMessage $_.Exception.Message
}
}
function Trim-FileVersionsInList {
param([Microsoft.SharePoint.Client.List]$List, [string]$SiteUrl, [int]$Keep = 10)
$filesProcessed = 0
$versionsDeleted = 0
try {
$items = Get-PnPListItem -List $List -PageSize 2000 -Fields FileLeafRef,FileRef,FSObjType |
Where-Object { $_.FileSystemObjectType -eq "File" }
foreach ($it in $items) {
$fileUrl = $it.FieldValues.FileRef
$filesProcessed++
try {
$versions = Get-PnPFileVersion -Url $fileUrl
if (-not $versions) { continue }
$toDelete = $versions | Sort-Object -Property Created -Descending | Select-Object -Skip $Keep
$countToDelete = ($toDelete | Measure-Object).Count
if ($countToDelete -gt 0) {
if ($DryRun) {
if ($DetailedLog) {
Write-Log -SiteUrl "$SiteUrl" -Action ("DryRun DeleteVersions -> " + $fileUrl) -Status "Success" -ErrorMessage ("Would remove " + $countToDelete + " older versions")
}
} else {
foreach ($v in $toDelete) {
Remove-PnPFileVersion -Url $fileUrl -Identity $v.Id -Force
}
if ($DetailedLog) {
Write-Log -SiteUrl "$SiteUrl" -Action ("DeletedVersions -> " + $fileUrl) -Status "Success" -ErrorMessage ("Removed " + $countToDelete + " older versions")
}
}
$versionsDeleted += $countToDelete
}
} catch {
Write-Log -SiteUrl "$SiteUrl" -Action ("TrimError -> " + $fileUrl) -Status "Error" -ErrorMessage $_.Exception.Message
}
}
} catch {
Write-Log -SiteUrl "$SiteUrl" -Action ("Prune Error -> " + $List.Title) -Status "Error" -ErrorMessage $_.Exception.Message
}
return [pscustomobject]@{
SiteUrl = $SiteUrl
LibraryName = $List.Title
FilesProcessed = $filesProcessed
VersionsDeleted = $versionsDeleted
Mode = $(if ($DryRun) { 'DryRun' } else { 'Actual' })
}
}
function Process-Site {
param([string]$SiteUrl)
try {
Connect-PnPSite -SiteUrl $SiteUrl
} catch {
# Connection already logged
return
}
$excluded = @(
"Form Templates","Preservation Hold Library","Site Assets","Style Library",
"Site Pages","Images","Site Collection Documents","Site Collection Images"
)
try {
$libs = Get-PnPList -ErrorAction Stop | Where-Object {
$_.BaseType -eq "DocumentLibrary" -and $_.Hidden -eq $false -and $_.Title -notin $excluded
}
} catch {
Write-Log -SiteUrl "$SiteUrl" -Action "Get-PnPList" -Status "Error" -ErrorMessage "Unauthorized or failed to enumerate libraries. Skipping site."
return
}
Write-Log -SiteUrl "$SiteUrl" -Action "ProcessSite" -Status "Success" -ErrorMessage ("Libraries=" + $libs.Count)
foreach ($lib in $libs) {
Set-LibraryAutoExpiration -List $lib -SiteUrl $SiteUrl
$row = Trim-FileVersionsInList -List $lib -SiteUrl $SiteUrl -Keep $KeepLatestVersions
if ($GenerateSummary -and $null -ne $row) { [void]$script:SummaryRows.Add($row) }
}
}
# ===== Main =====
Write-Log -SiteUrl "$AdminUrl" -Action "Start" -Status "Success"
Connect-PnPAdmin
# Determine target sites
[string[]]$targetSiteUrls = @()
if ($SingleSiteUrl) {
$targetSiteUrls = @($SingleSiteUrl)
Write-Log -SiteUrl "$SingleSiteUrl" -Action "Mode" -Status "Success" -ErrorMessage "Single site"
} else {
$sites = Get-TargetSites
$targetSiteUrls = $sites.Url
}
# Ensure admin access (always, even during DryRun)
Ensure-AdminAccessForSites -SiteUrls $targetSiteUrls
# Process each site
if ($SingleSiteUrl) {
try { Process-Site -SiteUrl $SingleSiteUrl } catch { Write-Log -SiteUrl "$SingleSiteUrl" -Action "Process-Site" -Status "Error" -ErrorMessage $_.Exception.Message }
} else {
foreach ($u in $targetSiteUrls) {
try { Process-Site -SiteUrl $u } catch { Write-Log -SiteUrl "$u" -Action "Process-Site" -Status "Error" -ErrorMessage $_.Exception.Message }
}
}
# Optional summary report
if ($GenerateSummary) {
try {
$script:SummaryRows |
Sort-Object SiteUrl, LibraryName |
Export-Csv -Path $script:SummaryPath -NoTypeInformation -Encoding UTF8
Write-Log -SiteUrl "$AdminUrl" -Action "SummaryReport" -Status "Success" -ErrorMessage $script:SummaryPath
} catch {
Write-Log -SiteUrl "$AdminUrl" -Action "SummaryReport" -Status "Error" -ErrorMessage $_.Exception.Message
}
}
# Optional cleanup (only for sites elevated by this run)
Cleanup-AdminAccessForSites -SiteUrls $targetSiteUrls

