List Every SharePoint Site with Owner, Storage and Last Activity
This time around, I am going to demonstrate listing all sites from the tenant together with their owner, storage, and last activity date, and why it is more difficult than one can think to list the owners. Script below.
Tested on a tenant with 37 sites using 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. These are 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. They are ArchiveStatus, RestrictedAccessControl, RestrictContentOrgWideSearch, EnableAutoExpirationVersionTrim and ExpireVersionsAfterDays. Print the lot with this.
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. There are no exceptions to this. And the logic is pretty clear when one thinks about it. A Teams site or an M365 group site is owned by the group and not by the site collection administrator, so there is nothing to put into this field by SharePoint.
What does this mean? Quite simply, a “find sites with no owner” script using Owner field showed 22 ownerless sites on my tenant. The truth is 7. 15 out of these were group sites with perfectly legitimate group owners.
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 point to be made here is about the cost. This is an individual request per group, and the measurements came to 1557 ms each. 15 groups take 23 seconds. 500 groups will take 13 minutes in addition to all that, hence the option of making it optional.
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 Admin. This does not imply that I am a site collection admin for each and every site, nor does SharePoint suggest that. It requires that I be an administrator for a particular site to read the administrators of the site, which in itself is a change to the site, in a report which I assumed to be read-only.
Instead, execute it in the admin connection and see what you get.
Title LoginName
----- ---------
Company Administrator c:0t.c|tenant|90a53517-cc9a-405c-a4cb-...
These administrators are those of the administration center and not of the particular site itself.
There is also no cost-effective way of obtaining an administrator for a site that you don’t have administrative permissions for. Group owners are the best solution for group sites, while the Owner is the most suitable for the others.
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 will say (group connected – please re-run with -ResolveGroupOwners) instead of putting a blank there. Having nothing in that spot means “there is no owner on this site,” and in my tenant, that would have been incorrect 15 out of 22 times.
One Comment
Comments are closed.