Inventory/Get-SharePointInventory.ps1
|
<#
.SYNOPSIS Generates a per-site SharePoint inventory with storage usage and activity. .DESCRIPTION Retrieves SharePoint site data using the direct Graph Sites API (preferred) for real site names and owner information, falling back to the Reports API if the required permissions are not available. Primary path (Sites.Read.All): Uses /v1.0/sites/getAllSites with per-site drive calls for storage quota. Returns real owner names and site URLs regardless of tenant privacy settings. Fallback path (Reports.Read.All): Uses the Reports API usage report. Note that the Reports API anonymizes user-identifiable information by default. To see real owner names and site URLs with this path, a tenant admin must disable the privacy setting at: Microsoft 365 Admin Center > Settings > Org Settings > Reports > "Display concealed user, group, and site names in all reports". Requires Microsoft.Graph.Authentication module and an active Graph connection. Preferred permission: Sites.Read.All. Fallback permission: Reports.Read.All. .PARAMETER OutputPath Optional path to export results as CSV. If not specified, results are returned to the pipeline. .EXAMPLE PS> . .\Common\Connect-Service.ps1 PS> Connect-Service -Service Graph -Scopes 'Sites.Read.All' PS> .\Inventory\Get-SharePointInventory.ps1 Returns per-site SharePoint inventory using the direct Sites API. .EXAMPLE PS> .\Inventory\Get-SharePointInventory.ps1 -OutputPath '.\sharepoint-inventory.csv' Exports the SharePoint site inventory to CSV. .NOTES M365 Assess — M&A Inventory #> [CmdletBinding()] param( [Parameter()] [ValidateNotNullOrEmpty()] [string]$OutputPath ) $ErrorActionPreference = 'Stop' # Verify Graph connection if (-not (Assert-GraphConnection)) { return } # ------------------------------------------------------------------ # Phase 1: Try direct Graph Sites API (returns real data) # ------------------------------------------------------------------ $usedDirectApi = $false $results = $null try { Write-Verbose "Attempting direct Sites API (getAllSites)..." $allSites = [System.Collections.Generic.List[object]]::new() $uri = "/v1.0/sites/getAllSites?`$select=id,displayName,webUrl,createdDateTime,lastModifiedDateTime,isPersonalSite&`$top=999" do { $response = Invoke-MgGraphRequest -Method GET -Uri $uri if ($response.value) { foreach ($site in $response.value) { if (-not $site.isPersonalSite) { $allSites.Add($site) } } } $uri = $response.'@odata.nextLink' } while ($uri) if ($allSites.Count -eq 0) { Write-Verbose "No SharePoint sites found via direct API" return } Write-Verbose "Found $($allSites.Count) SharePoint sites. Retrieving storage details..." $resultList = [System.Collections.Generic.List[PSCustomObject]]::new() $counter = 0 foreach ($site in $allSites) { $counter++ if ($counter % 10 -eq 0 -or $counter -eq 1) { Write-Verbose "[$counter/$($allSites.Count)] Getting storage for $($site.webUrl)" } $storageUsedMB = $null $storageAllocatedMB = $null $ownerDisplayName = $null $ownerPrincipalName = $null try { $driveInfo = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/sites/$($site.id)/drive?`$select=quota,owner" if ($driveInfo.quota) { if ($null -ne $driveInfo.quota.used) { $storageUsedMB = [math]::Round([long]$driveInfo.quota.used / 1MB, 2) } if ($null -ne $driveInfo.quota.total) { $storageAllocatedMB = [math]::Round([long]$driveInfo.quota.total / 1MB, 2) } } if ($driveInfo.owner.user) { $ownerDisplayName = $driveInfo.owner.user.displayName $ownerPrincipalName = $driveInfo.owner.user.email } } catch { Write-Verbose "Could not retrieve drive info for $($site.webUrl): $($_.Exception.Message)" } $resultList.Add([PSCustomObject]@{ SiteUrl = $site.webUrl SiteId = $site.id OwnerDisplayName = $ownerDisplayName OwnerPrincipalName = $ownerPrincipalName IsDeleted = $false StorageUsedMB = $storageUsedMB StorageAllocatedMB = $storageAllocatedMB FileCount = $null ActiveFileCount = $null PageViewCount = $null LastActivityDate = $site.lastModifiedDateTime SiteType = $null }) } $results = @($resultList) | Sort-Object -Property SiteUrl $usedDirectApi = $true Write-Verbose "Direct API inventory complete: $($results.Count) SharePoint sites" } catch { $directApiError = $_ if ($directApiError.Exception.Message -match '401|403|Unauthorized|Forbidden|insufficient') { Write-Warning ("Direct Sites API unavailable (missing Sites.Read.All permission). " + "Falling back to Reports API. Data may be obfuscated if tenant privacy settings are enabled.") } else { Write-Warning "Direct Sites API failed: $($directApiError.Exception.Message). Falling back to Reports API." } } # ------------------------------------------------------------------ # Phase 2: Fallback to Reports API if direct API was not used # ------------------------------------------------------------------ if (-not $usedDirectApi) { $reportUri = "/v1.0/reports/getSharePointSiteUsageDetail(period='D7')" Write-Verbose "Downloading SharePoint site usage report from Graph Reports API..." $tempFile = [System.IO.Path]::GetTempFileName() try { Invoke-MgGraphRequest -Method GET -Uri $reportUri -OutputFilePath $tempFile $reportData = @(Import-Csv -Path $tempFile) } catch { Write-Error "Failed to retrieve SharePoint site usage report: $_" return } finally { Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue } if ($reportData.Count -eq 0) { Write-Verbose "No SharePoint sites found in the usage report" return } # Phase 3: Detect obfuscated data $sampleOwner = $reportData[0].'Owner Display Name' $sampleUrl = $reportData[0].'Site URL' $hexPattern = '^[0-9A-Fa-f]{16,}$' if (([string]::IsNullOrWhiteSpace($sampleUrl)) -or ($sampleOwner -match $hexPattern)) { Write-Warning ("Reports API data appears obfuscated (tenant privacy settings are enabled). " + "Site URLs, owner names, and site IDs are anonymized. To see real data, " + "a tenant admin must disable the privacy setting at: " + "Microsoft 365 Admin Center > Settings > Org Settings > Reports > " + "'Display concealed user, group, and site names in all reports'. " + "Alternatively, grant Sites.Read.All permission for the direct API path.") } Write-Verbose "Processing $($reportData.Count) SharePoint sites from Reports API..." $results = foreach ($row in $reportData) { $storageUsedMB = $null if ($row.'Storage Used (Byte)') { $storageUsedMB = [math]::Round([long]$row.'Storage Used (Byte)' / 1MB, 2) } $storageAllocatedMB = $null if ($row.'Storage Allocated (Byte)') { $storageAllocatedMB = [math]::Round([long]$row.'Storage Allocated (Byte)' / 1MB, 2) } [PSCustomObject]@{ SiteUrl = $row.'Site URL' SiteId = $row.'Site Id' OwnerDisplayName = $row.'Owner Display Name' OwnerPrincipalName = $row.'Owner Principal Name' IsDeleted = $row.'Is Deleted' StorageUsedMB = $storageUsedMB StorageAllocatedMB = $storageAllocatedMB FileCount = $row.'File Count' ActiveFileCount = $row.'Active File Count' PageViewCount = $row.'Page View Count' LastActivityDate = $row.'Last Activity Date' SiteType = $row.'Root Web Template' } } $results = @($results) | Sort-Object -Property SiteUrl Write-Verbose "Reports API inventory complete: $($results.Count) SharePoint sites" } # ------------------------------------------------------------------ # Output # ------------------------------------------------------------------ if ($OutputPath) { $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Output "Exported SharePoint inventory ($($results.Count) sites) to $OutputPath" } else { Write-Output $results } |