Reporting

List Every SharePoint Site with Owner, Storage and Last Activity

In this post, I am going to show you how to list every site in your tenant with its owner, storage and last activity – and why the owner column is harder than it looks. Full script at the end.

Run against a real tenant of 37 sites on PnP.PowerShell 2.12.0.

The basic call

Connect-PnPOnline -Url "https://contoso-admin.sharepoint.com" -ClientId "1111...-5555" -Interactive
Get-PnPTenantSite
Sites returned : 37
Took           : 1.5s

Note the -admin URL. Tenant-wide cmdlets want the admin centre, not a site.

Two things that are not obvious

OneDrive is excluded. Personal sites are site collections too, but you only get them if you ask:

Without -IncludeOneDriveSites : 37
With    -IncludeOneDriveSites : 42

Five here. In a real organisation it is one per person, so a 200 site tenant becomes 5,000. Know which number you want before you run a report on it.

-Detailed did nothing. I compared the property sets:

Get-PnPTenantSite            1.5s, 91 properties
Get-PnPTenantSite -Detailed  0.7s, 91 properties - no difference

Not one extra property. Plenty of guides tell you to reach for -Detailed; on this version there is nothing to reach for. Check it on your own version before you pay for it.

91 properties

Far more than the four everyone shows. The ones I use:

Url  Title  Owner  Template  GroupId
StorageUsageCurrent  StorageQuota  LastContentModifiedDate
SharingCapability  LockState  SensitivityLabel  IsHubSite  WebsCount

There are newer ones worth knowing about too – ArchiveStatus, RestrictedAccessControl, RestrictContentOrgWideSearch, EnableAutoExpirationVersionTrim, ExpireVersionsAfterDays. Print the lot with:

Get-PnPTenantSite | Select-Object -First 1 | Get-Member -MemberType Properties

StorageUsageCurrent and StorageQuota are both in MB. A quota of 1048576 is 1TB.

The owner problem

This is the part worth reading. There are four owner-looking properties. Here is how many of my 37 sites had each one populated:

Owner          : 15
OwnerName      : 0
OwnerEmail     : 0
OwnerLoginName : 0

Three of them are empty on every single site. Not sparse – zero. If you build a report on OwnerEmail you get a column of blanks and nothing tells you it did not work.

And the one that does work is only populated on 15 of 37. Grouped by template, the blanks look like this:

Count Template
----- --------
   15 GROUP#0
    1 EHS#1
    1 POINTPUBLISHINGHUB#0
    1 RedirectSite#0
    ...

Every group-connected site has a blank Owner. No exceptions. Which makes sense once you think about it – a Teams or Microsoft 365 group site is owned by the group, not by a site collection administrator, so there is nothing for SharePoint to put in that field.

The consequence is worth being blunt about. A “find sites with no owner” script that checks Owner reported 22 ownerless sites on my tenant. The real answer is 7. Fifteen of them were group sites with perfectly good owners in the group.

Getting the real owner

For group-connected sites, ask the group. The site object gives you the GroupId:

$owners = Get-PnPMicrosoft365GroupOwner -Identity $site.GroupId
($owners | ForEach-Object { $_.UserPrincipalName }) -join '; '
allcompany : admin@contoso.onmicrosoft.com
cd         : admin@contoso.onmicrosoft.com
demohr     : admin@contoso.onmicrosoft.com

The important thing to note is the cost. That is a separate call per group, and I measured 1557ms each. Fifteen groups is 23 seconds. Five hundred groups is thirteen minutes on top of everything else, so make it optional rather than something your report always does.

What does not work

The obvious alternative is to ask each site for its administrators:

Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/Example" -ClientId "..." -Interactive
Get-PnPSiteCollectionAdmin
Attempted to perform an unauthorized operation.

I am a tenant administrator. That is not the same as being a site collection administrator on every site, and SharePoint does not pretend otherwise. To read a site’s administrators you have to be one, which means adding yourself first – and that is a change to the site, on a report you thought was read-only.

Run it from the admin connection instead and it answers a different question entirely:

Title                 LoginName
-----                 ---------
Company Administrator c:0t.c|tenant|90a53517-cc9a-405c-a4cb-...

That is the admin centre’s own administrators, not the site’s.

So there is no cheap way to get a site collection administrator for a site you do not already administer. Group owners are the practical answer for group sites, and Owner is the practical answer for the rest.

The whole script

[CmdletBinding()]
param(
    [Parameter(Mandatory)] [string] $TenantAdminUrl,
    [Parameter(Mandatory)] [string] $ClientId,
    [string] $OutputCsv,
    [switch] $ResolveGroupOwners,
    [switch] $IncludeOneDrive,
    [int]    $StaleDays = 90
)

$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $TenantAdminUrl -ClientId $ClientId -Interactive

Write-Host "Enumerating sites..." -ForegroundColor Cyan
$sites = if ($IncludeOneDrive) { Get-PnPTenantSite -IncludeOneDriveSites } else { Get-PnPTenantSite }
Write-Host "  $($sites.Count) site(s)" -ForegroundColor Green

$cutoff  = (Get-Date).AddDays(-$StaleDays)
$results = [System.Collections.Generic.List[object]]::new()
$number  = 0

foreach ($site in $sites) {
    $number++
    Write-Progress -Activity 'Building site inventory' `
                   -Status "$number of $($sites.Count): $($site.Url)" `
                   -PercentComplete (($number / $sites.Count) * 100)

    $isGroupSite = $site.GroupId -and $site.GroupId -ne [guid]::Empty

    $owner = if ($site.Owner) {
        $site.Owner
    }
    elseif ($isGroupSite -and $ResolveGroupOwners) {
        try {
            $groupOwners = Get-PnPMicrosoft365GroupOwner -Identity $site.GroupId -ErrorAction Stop
            ($groupOwners | ForEach-Object { $_.UserPrincipalName }) -join '; '
        }
        catch { "(group lookup failed: $($_.Exception.Message))" }
    }
    elseif ($isGroupSite) {
        '(group-connected - rerun with -ResolveGroupOwners)'
    }
    else {
        '(none recorded)'
    }

    $results.Add([pscustomobject]@{
        Url             = $site.Url
        Title           = $site.Title
        Owner           = $owner
        GroupConnected  = $isGroupSite
        Template        = $site.Template
        StorageUsedMB   = [math]::Round($site.StorageUsageCurrent, 1)
        StorageQuotaMB  = $site.StorageQuota
        PercentUsed     = if ($site.StorageQuota -gt 0) {
                              [math]::Round(($site.StorageUsageCurrent / $site.StorageQuota) * 100, 1)
                          } else { 0 }
        LastModified    = $site.LastContentModifiedDate
        Stale           = $site.LastContentModifiedDate -lt $cutoff
        Sharing         = $site.SharingCapability
        SensitivityLabel= $site.SensitivityLabel
        LockState       = $site.LockState
    })
}

Write-Progress -Activity 'Building site inventory' -Completed

$stale   = @($results | Where-Object Stale)
$noOwner = @($results | Where-Object { $_.Owner -like '(none*' })
$totalGB = [math]::Round(($results | Measure-Object StorageUsedMB -Sum).Sum / 1024, 2)

Write-Host ""
Write-Host "Sites            : $($results.Count)"
Write-Host "Group-connected  : $(@($results | Where-Object GroupConnected).Count)"
Write-Host "Storage used     : $totalGB GB"
Write-Host "Not modified $StaleDays d : $($stale.Count)"
Write-Host "No owner recorded: $($noOwner.Count)"

if (-not $ResolveGroupOwners -and @($results | Where-Object GroupConnected).Count -gt 0) {
    Write-Warning "Group-connected sites have no owner shown. Rerun with -ResolveGroupOwners (about 1.5s each)."
}

if ($OutputCsv) {
    $results | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
    Write-Host "`nWritten to $OutputCsv" -ForegroundColor Green
} else {
    $results | Sort-Object StorageUsedMB -Descending |
        Format-Table Url, Owner, StorageUsedMB, LastModified, Stale -AutoSize
}

Disconnect-PnPOnline

Save it as Get-SiteInventory.ps1 and run it:

.\Get-SiteInventory.ps1 `
    -TenantAdminUrl "https://contoso-admin.sharepoint.com" `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -ResolveGroupOwners `
    -OutputCsv .\sites.csv
Enumerating sites...
  37 site(s)

Sites             : 37
Group-connected   : 15
Storage used      : 1.06 GB
Not modified 90 d : 1
No owner recorded : 7

Written to .\sites.csv

Without -ResolveGroupOwners it says (group-connected – rerun with -ResolveGroupOwners) in the owner column rather than leaving it blank. A blank there reads as “this site has no owner”, and on my tenant that would have been wrong fifteen times out of twenty-two 🙂