Checks/SharePoint/ARG.SPO.010.ps1

Register-ArgusCheck @{
    Id          = 'ARG.SPO.010'
    Title       = 'Sites near their storage quota (more than {QuotaPercent}%)'
    Area        = 'SharePoint'
    Category    = 'Storage'
    Severity    = 'Low'
    Description = 'SharePoint sites whose used storage is more than **{QuotaPercent}%** of the allocated storage, from the site usage report (`Storage Used (Byte)` / `Storage Allocated (Byte)`, period D180). Deleted sites and sites without an allocation are ignored. With concealed report names, or when the report returns no rows, the result is Investigate. When the report leaves `Site URL` empty (a known issue of the usage reports), the URL is read from the `Site Id`: first from the PnP tenant site list when SharePoint is connected (it includes private group and channel sites), then with `GET /sites/<id>`, which in delegated mode only reads the sites the account can access.'
    Rationale   = 'A site that reaches its quota becomes read-only for new content, which interrupts work. Planning quota increases or clean-up in advance avoids that.'
    Remediation = @{
        Steps      = "1. Review large libraries, old versions and the recycle bin of each site.`n2. Clean up or archive content that is no longer needed, or reduce the number of kept versions.`n3. Increase the site storage limit if the content is needed and tenant storage allows it."
        PortalUrl  = 'https://admin.microsoft.com/sharepoint?page=siteManagement&modern=true'
    }
    References  = @(
        @{ Title = 'Manage site storage limits'; Url = 'https://learn.microsoft.com/en-us/sharepoint/manage-site-collection-storage-limits' }
        @{ Title = 'Microsoft 365 reports in the admin center - SharePoint site usage'; Url = 'https://learn.microsoft.com/en-us/microsoft-365/admin/activity-reports/sharepoint-site-usage-ww' }
    )
    Requires    = @{
        Services         = @('Graph')
        GraphScopes      = @('Reports.Read.All', 'Sites.Read.All')
        DataSets         = @('SharePointSiteUsage', 'ReportSettings')
        OptionalDataSets = @('SharePointSites')
    }
    Parameters  = @{ QuotaPercent = 90 }
    Tags        = @('SharePoint', 'Storage', 'Reports')
    Test        = {
        param($Context)
        $rows = @($Context.Data['SharePointSiteUsage'] | Where-Object { $_ -and (Get-ArgusProperty $_ 'Is Deleted' '') -ne 'True' })
        $issue = Get-ArgusReportIssue -ReportSettings $Context.Data['ReportSettings'] -Row $rows -ObjectName 'sites near their quota'
        if ($issue) { return New-ArgusResult -Status Investigate -Summary $issue }

        $limit = [double]$Context.Parameters.QuotaPercent
        $evaluated = @($rows | Where-Object { [double](Get-ArgusProperty $_ 'Storage Allocated (Byte)' 0) -gt 0 })
        $near = @($evaluated | Where-Object {
                100 * [double](Get-ArgusProperty $_ 'Storage Used (Byte)' 0) / [double](Get-ArgusProperty $_ 'Storage Allocated (Byte)' 1) -gt $limit
            })
        # The report can return an empty Site URL (advisory SP676147): resolve it from the Site Id.
        $missing = @($near | Where-Object { -not (Get-ArgusProperty $_ 'Site URL' '') } | ForEach-Object { [string](Get-ArgusProperty $_ 'Site Id' '') })
        $urls = if ($missing.Count -gt 0) { Resolve-ArgusSiteUrl -SiteId $missing -TenantSite $Context.Data['SharePointSites'] } else { @{} }
        $findings = @($near | ForEach-Object {
                $used = [double](Get-ArgusProperty $_ 'Storage Used (Byte)' 0)
                $allocated = [double](Get-ArgusProperty $_ 'Storage Allocated (Byte)' 0)
                $url = [string](Get-ArgusProperty $_ 'Site URL' '')
                $siteId = ([string](Get-ArgusProperty $_ 'Site Id' '')).ToLowerInvariant()
                if (-not $url -and $urls.ContainsKey($siteId)) { $url = $urls[$siteId] }
                $owner = [string](Get-ArgusProperty $_ 'Owner Principal Name' '')
                if (-not $owner) { $owner = [string](Get-ArgusProperty $_ 'Owner Display Name' '') }
                [pscustomobject]@{
                    SiteUrl     = $url
                    UsedGB      = [math]::Round($used / 1GB, 2)
                    AllocatedGB = [math]::Round($allocated / 1GB, 2)
                    UsedPercent = [math]::Round(100 * $used / $allocated, 1)
                    Owner       = if ($owner) { $owner } else { 'Not reported' }
                    SiteId      = $siteId
                }
            } | Sort-Object UsedPercent -Descending)
        $unresolved = @($findings | Where-Object { -not $_.SiteUrl }).Count
        $note = if ($unresolved -gt 0 -and -not $Context.Data['SharePointSites']) { " The URL of $unresolved site(s) could not be read: private group and channel sites need the SharePoint (PnP) connection." } else { '' }
        $status = if ($findings.Count -gt 0) { 'Failed' } else { 'Passed' }
        New-ArgusResult -Status $status -Findings $findings -EvaluatedCount $evaluated.Count `
            -Summary ('{AffectedCount} of {EvaluatedCount} SharePoint sites use more than {QuotaPercent}% of their storage quota.' + $note)
    }
}