Collaborate, Innovate, Automate

Get-SPOOrphanedSiteRisk

This PnP PowerShell script audits every site in a SharePoint Online tenant for ownership risk — sites with zero owners, and sites with exactly one owner who could leave the organisation with nobody left to manage access. It also counts lists and libraries with broken permission inheritance on each site as a secondary sprawl indicator. Results are exported to a CSV, ranked by risk level, so the sites needing attention first are at the top. Background on why this matters is covered in The SharePoint Sites Nobody Owns.

Purpose

Owner accounts get disabled during routine offboarding without anyone checking whether that person was the last active owner of a site. This script surfaces that risk at tenant scale by:

Scope

Prerequisites

PowerShell Script

# ============================================================
# Get-SPOOrphanedSiteRisk.ps1
# Tenant-wide audit for SharePoint Online sites at risk of
# "orphaned" ownership: sites with zero or one owner. Also
# reports a broken-inheritance count per site as a secondary
# governance signal.
# ============================================================

<#
.SYNOPSIS
    Tenant-wide audit for SharePoint Online sites at risk of "orphaned" ownership:
    sites with zero or one owner. Also reports a broken-inheritance count per site
    as a secondary governance signal.

.NOTES
    Requires: PnP.PowerShell 3.x
    Author:   Cameron Griffiths
    Tenant:   tenantName.onmicrosoft.com (adjust -AdminUrl below for other tenants)
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$AdminUrl,

    [Parameter(Mandatory = $false)]
    [string]$ClientId = "",

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = ".\OrphanedSiteRisk_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv",

    [Parameter(Mandatory = $false)]
    [int]$ThrottleDelayMs = 300
)

# --- Known system-managed site templates to skip ---
# These don't expose a standard AssociatedOwnerGroup and aren't meaningful
# for an ownership-risk audit.
$systemTemplates = @(
    "APPCATALOG#0",   # App Catalog
    "SRCHCEN#0",      # Search Center
    "SPSMSITEHOST#0", # MySite Host
    "SPSTOC#0",       # Content Type Hub / Compliance Policy Center variants
    "TENANTADMIN#0"   # Tenant admin site itself
)

# --- Connect to the SPO admin site ---
Write-Host "Connecting to $AdminUrl ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId

# --- Get all sites (excluding OneDrive personal sites) ---
Write-Host "Retrieving tenant site list ..." -ForegroundColor Cyan
$sites = Get-PnPTenantSite -Detailed | Where-Object {
    $_.Template -notlike "SPSPERS*" -and $_.Url -notlike "*-my.sharepoint.com*"
}

Write-Host "Found $($sites.Count) sites to audit." -ForegroundColor Cyan

$report = New-Object System.Collections.Generic.List[Object]
$counter = 0

foreach ($site in $sites) {
    $counter++
    Write-Progress -Activity "Auditing sites" -Status $site.Url -PercentComplete (($counter / $sites.Count) * 100)

    # --- Skip known system-managed sites ---
    if ($systemTemplates -contains $site.Template) {
        $report.Add([PSCustomObject]@{
            SiteUrl              = $site.Url
            Title                = $site.Title
            TotalOwnersListed    = "-"
            OwnerNames           = "-"
            ListsWithUniquePerms = "-"
            RiskLevel            = "Skipped - System site"
            LastContentModified  = $site.LastContentModifiedDate
        })
        continue
    }

    try {
        Connect-PnPOnline -Url $site.Url -Interactive -ClientId $ClientId -WarningAction SilentlyContinue

        # --- Owner group membership ---
        $ownerGroup = Get-PnPGroup -AssociatedOwnerGroup -ErrorAction Stop
        $owners     = Get-PnPGroupMember -Group $ownerGroup -ErrorAction SilentlyContinue |
                      Where-Object { $_.PrincipalType -eq "User" -and $_.LoginName -notlike "*spo-grid-all-users*" }

        $ownerCount = $owners.Count

        # --- Broken inheritance tally across lists/libraries ---
        $lists = Get-PnPList -Includes HasUniqueRoleAssignments -ErrorAction SilentlyContinue
        $uniquePermCount = ($lists | Where-Object { $_.HasUniqueRoleAssignments -eq $true -and -not $_.Hidden }).Count

        # --- Risk classification ---
        $risk = "OK"
        if ($ownerCount -eq 0) {
            $risk = "CRITICAL - No owners"
        }
        elseif ($ownerCount -eq 1) {
            $risk = "HIGH - Single owner"
        }

        $report.Add([PSCustomObject]@{
            SiteUrl              = $site.Url
            Title                = $site.Title
            TotalOwnersListed    = $ownerCount
            OwnerNames           = ($owners.Title -join "; ")
            ListsWithUniquePerms = $uniquePermCount
            RiskLevel            = $risk
            LastContentModified  = $site.LastContentModifiedDate
        })
    }
    catch {
        $report.Add([PSCustomObject]@{
            SiteUrl              = $site.Url
            Title                = $site.Title
            TotalOwnersListed    = "ERROR"
            OwnerNames           = ""
            ListsWithUniquePerms = "ERROR"
            RiskLevel            = "UNKNOWN - Could not audit ($($_.Exception.Message))"
            LastContentModified  = $site.LastContentModifiedDate
        })
    }

    Start-Sleep -Milliseconds $ThrottleDelayMs
}

Write-Progress -Activity "Auditing sites" -Completed

# --- Output ---
$report | Sort-Object RiskLevel -Descending | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

Write-Host "`nAudit complete. Report written to $OutputPath" -ForegroundColor Green

$summary = $report | Group-Object RiskLevel | Select-Object Name, Count
Write-Host "`nRisk summary:" -ForegroundColor Yellow
$summary | Format-Table -AutoSize

Usage Notes

Related