scripts/local/sharepoint-admin.ps1

<#
    Raidiness — SharePoint Online (local, read-only)

    Why local: the SharePoint Online Management Shell app-only requires
    Sites.FullControl.All. That is write access to every site in the tenant,
    and Raidiness never asks for it (CLAUDE.md #1, docs/10-readonly-policy.md
    §3). A SharePoint Administrator runs this script interactively; it uses
    the same rights the administrator already has and changes nothing.

    Role: SharePoint Administrator.
    Writes: nothing. Only Get-* cmdlets and a JSON file on your own disk.
    Output: JSON you upload in Raidiness under the "SharePoint Online" module.

    Usage:
      .\sharepoint-admin.ps1 -TenantName contoso -RunId <run> -OutputPath .\spo.json
      .\sharepoint-admin.ps1 -TenantName contoso -RunId <run> -SampleSize 25
#>


[CmdletBinding()]
param(
    # The name before -admin.sharepoint.com, so "contoso" for contoso-admin.sharepoint.com.
    [Parameter(Mandatory = $true)][string] $TenantName,
    [Parameter(Mandatory = $true)][string] $RunId,
    [string] $OutputPath = ".\raidiness-sharepoint.json",
    # Reading unique permissions takes time per site; a sample by default.
    [int] $SampleSize = 15,
    [switch] $HashUpns
)

$ErrorActionPreference = "Stop"

function Protect-Upn {
    param([string] $Value)
    if (-not $HashUpns -or [string]::IsNullOrWhiteSpace($Value)) { return $Value }
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value.ToLowerInvariant())
    $sha = [System.Security.Cryptography.SHA256]::Create()
    return (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 16)
}

Write-Host "Raidiness — SharePoint Online, read-only. Role: SharePoint Administrator." -ForegroundColor Cyan
Write-Host "Nothing will be changed in the tenant." -ForegroundColor Cyan

Connect-SPOService -Url "https://$TenantName-admin.sharepoint.com"

$tenant = Get-SPOTenant | Select-Object `
    @{ Name = 'SharingCapability'; Expression = { [string]$_.SharingCapability } },
    @{ Name = 'DefaultSharingLinkType'; Expression = { [string]$_.DefaultSharingLinkType } },
    @{ Name = 'DefaultLinkPermission'; Expression = { [string]$_.DefaultLinkPermission } },
    @{ Name = 'ConditionalAccessPolicy'; Expression = { [string]$_.ConditionalAccessPolicy } },
    @{ Name = 'LimitedAccessFileType'; Expression = { [string]$_.LimitedAccessFileType } },
    RequireAnonymousLinksExpireInDays, ExternalUserExpirationRequired,
    IsCollabMeetingNotesFluidEnabled

$searchMode = $null
try {
    $searchMode = Get-SPOTenantRestrictedSearchMode | Select-Object RestrictedSearchMode
} catch {
    Write-Host "Restricted SharePoint Search is not available in this tenant." -ForegroundColor Yellow
}

$sites = Get-SPOSite -Limit All | Select-Object `
    Url, Title, Owner, StorageUsageCurrent, LastContentModifiedDate,
    SharingCapability, RestrictedContentDiscovery, SensitivityLabel

$restricted = $sites | Where-Object { $_.RestrictedContentDiscovery -eq $true }

foreach ($site in $sites) {
    $site.Owner = Protect-Upn $site.Owner
}

# Unique permissions are expensive to fetch per site; a sample of the largest
# sites is enough to show whether the pattern occurs.
$uniquePermissions = @()
if (Get-Module -ListAvailable -Name PnP.PowerShell) {
    Import-Module PnP.PowerShell
    $sample = $sites | Sort-Object StorageUsageCurrent -Descending | Select-Object -First $SampleSize

    foreach ($site in $sample) {
        try {
            Connect-PnPOnline -Url $site.Url -Interactive
            $lists = Get-PnPList -Includes HasUniqueRoleAssignments |
                Where-Object { $_.HasUniqueRoleAssignments -eq $true }

            $uniquePermissions += [ordered]@{
                url        = $site.Url
                listsWithUniquePermissions = @($lists).Count
                lists      = @($lists | Select-Object -First 20 | ForEach-Object { $_.Title })
            }
        } catch {
            Write-Host "Skipped (no access): $($site.Url)" -ForegroundColor Yellow
        }
    }
} else {
    Write-Host "PnP.PowerShell is missing; unique permissions will be skipped." -ForegroundColor Yellow
}

$payload = [ordered]@{
    raidiness = [ordered]@{
        version     = "1.0"
        module      = "sharepoint-admin"
        tenantId    = $TenantName
        runId       = $RunId
        generatedAt = (Get-Date).ToUniversalTime().ToString("o")
        upnsHashed  = [bool] $HashUpns
    }
    results = [ordered]@{
        "spo.tenant"                     = $tenant
        "spo.tenantRestrictedSearchMode" = $searchMode
        "spo.sites"                      = $sites
        "spo.restrictedContentDiscovery" = @($restricted | Select-Object Url, Title)
        "pnp.uniquePermissionsSample"    = $uniquePermissions
    }
}

$payload | ConvertTo-Json -Depth 8 | Out-File -FilePath $OutputPath -Encoding utf8
Write-Host "Done. Upload $OutputPath in Raidiness under the SharePoint Online module." -ForegroundColor Green