Public/Get-UserAccess.ps1

function Get-UserAccess {
    <#
    .SYNOPSIS
        Show what a user can access on one or more SharePoint sites, and HOW -
        grouped so the access they were never explicitly given stands out.

    .DESCRIPTION
        For each site, this reports the user's effective access and attributes it
        to a route: a SharePoint group, a direct grant, or an Everyone claim. Each
        route is classified Granted (they are a member / were given it) or
        Overshared (an Everyone claim or sharing link - access they were never
        explicitly given, which is exactly what Copilot will surface).

        This is an admin tool. Reading a site's permissions needs Full Control on
        that site - a Contributor cannot see this. Run it as a Site Owner / Site
        Collection Admin, or with an app that has the rights.

        Read-only. Pipe the results to Export-UserAccessReport or Export-Csv.

    .PARAMETER User
        The user to report on, as a UPN (jane@contoso.com).

    .PARAMETER SiteUrl
        A single site to check.

    .PARAMETER SiteUrls
        Several sites to check.

    .PARAMETER OversharedOnly
        Return only the Overshared routes - the access the user was never
        explicitly given (Everyone claims and sharing links).
        The old name -UnexpectedOnly still works as an alias.

    .PARAMETER Deep
        Walk below the site: subsites, then lists and libraries that have their
        own permissions. Add -IncludeItems to go all the way to individual files
        and items.

        Only objects that BREAK inheritance are examined - everything else
        already has its parent's answer - so the cost is proportional to how
        much the site has been shared, not to how big it is.

        Deep scans also find SHARING LINKS, which a normal scan cannot: an
        organisation-wide link grants no per-user permission at all, so it is
        invisible to "what can this user do here". It is found by asking what
        links an object carries and whether this user is in the audience.

    .PARAMETER IncludeItems
        With -Deep, descend to individual items and files. This is the expensive
        level: every list must be paged once to find which items broke
        inheritance. Measured between 4ms and 28ms per item, so a 100,000-item
        library is roughly 7 to 47 minutes. Use it on sites you care about, not
        across a whole tenant.

    .PARAMETER MaxItemsPerList
        Stop after this many items in any one list. A warning naming the list is
        emitted whenever this truncates, so partial results are never silent.
        0 (the default) means no cap.

    .PARAMETER ClientId
        Entra ID app registration client ID. Not needed with -UseExistingConnection.

    .EXAMPLE
        Get-UserAccess -User jane@contoso.com -SiteUrl "https://contoso.sharepoint.com/sites/Sales" -ClientId $id -Interactive

    .EXAMPLE
        Get-UserAccess -User jane@contoso.com -SiteUrls $sites -UseExistingConnection -OversharedOnly

    .EXAMPLE
        # Everything Jane can reach across the tenant, worst routes first
        Get-UserAccess -User jane@contoso.com -TenantWide `
            -TenantAdminUrl "https://contoso-admin.sharepoint.com" -ClientId $id -Interactive

    .EXAMPLE
        # All the way down - subsites, lists, and individual files - including
        # documents Jane can open through a sharing link she was never granted.
        Get-UserAccess -User jane@contoso.com -SiteUrl $site -ClientId $id -Interactive `
            -Deep -IncludeItems -OversharedOnly
    #>

    [CmdletBinding(DefaultParameterSetName = 'Site')]
    param(
        [Parameter(Mandatory)]
        [string]   $User,

        [Parameter(Mandatory, ParameterSetName = 'Site')]
        [string]   $SiteUrl,

        [Parameter(Mandatory, ParameterSetName = 'Sites')]
        [string[]] $SiteUrls,

        [Parameter(Mandatory, ParameterSetName = 'Tenant')]
        [switch]   $TenantWide,

        [Parameter(ParameterSetName = 'Tenant')]
        [string]   $TenantAdminUrl,

        [Alias('UnexpectedOnly')]
        [switch]   $OversharedOnly,

        [switch]   $Deep,
        [switch]   $IncludeItems,
        [int]      $MaxItemsPerList = 0,

        [string]       $ClientId,
        [string]       $Tenant,
        [string]       $CertificatePath,
        [securestring] $CertificatePassword,
        [string]       $Thumbprint,
        [switch]       $Interactive,
        [switch]       $ManagedIdentity,
        [switch]       $UseExistingConnection
    )

    $connectSplat = @{
        ClientId = $ClientId; Tenant = $Tenant
        CertificatePath = $CertificatePath; CertificatePassword = $CertificatePassword
        Thumbprint = $Thumbprint; Interactive = $Interactive
        ManagedIdentity = $ManagedIdentity; UseExistingConnection = $UseExistingConnection
    }

    $userLogin = ConvertTo-ClaimLogin -User $User

    if ($IncludeItems -and -not $Deep) {
        throw "-IncludeItems only means something with -Deep. Add -Deep, or drop -IncludeItems."
    }
    if ($Deep -and $UseExistingConnection) {
        # Each subweb is connected to in turn (PnP v2 dropped -Web), which an
        # existing connection cannot do - every subsite would be silently
        # skipped. Require real credentials so the whole tree is actually walked.
        throw "-Deep cannot be combined with -UseExistingConnection: subsites are connected to individually, so credentials (e.g. -ClientId -Interactive) are required."
    }
    if ($Deep -and $PSCmdlet.ParameterSetName -eq 'Tenant') {
        # Not blocked - an admin may genuinely want this - but they should know
        # what they are starting. Item level across a tenant can run for hours.
        Write-Warning "-Deep across an entire tenant walks every subsite and list of every site. This can run for a very long time. Consider -SiteUrls with the sites you actually care about."
    }

    $targets = switch ($PSCmdlet.ParameterSetName) {
        'Site'   { @($SiteUrl) }
        'Sites'  { $SiteUrls }
        'Tenant' {
            $null = $TenantWide
            if (-not $TenantAdminUrl) { throw "-TenantWide needs -TenantAdminUrl." }
            if ($UseExistingConnection) {
                throw "-UseExistingConnection cannot be combined with -TenantWide: each site is connected to in turn, so real credentials are required."
            }
            Connect-IfNeeded -Url $TenantAdminUrl @connectSplat
            Write-Verbose "Enumerating tenant sites..."
            (Invoke-WithRetry -Because 'Get-PnPTenantSite' -Action { Get-PnPTenantSite }) |
                Where-Object {
                    # Skip redirect stubs and the OneDrive/MySite host - the latter
                    # is not a content site and hangs on permission checks.
                    $_.Template -notlike 'REDIRECT*' -and
                    $_.Template -notlike 'SPSMSITEHOST*' -and
                    $_.Url -notlike '*-my.sharepoint.com*'
                } |
                Select-Object -ExpandProperty Url
        }
    }

    $total = @($targets).Count
    $n = 0
    $emitted = 0
    # Sites we could not READ (connect/throttle/permission failures). Tracked so an
    # incomplete scan can be told apart from a genuine "no access" - see below.
    $skipped = [System.Collections.Generic.List[string]]::new()

    # One membership cache for the whole run: a group seen on many sites/objects
    # is resolved against Graph once, not repeatedly.
    $membershipCache = @{}

    foreach ($site in $targets) {
        $n++
        Write-Progress -Activity "Checking $User's access" -Status "$n of $total : $site" `
                       -PercentComplete (($n / [Math]::Max($total,1)) * 100)
        try {
            Connect-IfNeeded -Url $site @connectSplat

            $rows =
                if ($Deep) {
                    Get-UserAccessDeep -SiteUrl $site -UserLogin $userLogin `
                        -ConnectSplat $connectSplat -IncludeItems:$IncludeItems `
                        -MaxItemsPerList $MaxItemsPerList -MembershipCache $membershipCache
                } else {
                    Get-UserAccessForSite -SiteUrl $site -UserLogin $userLogin `
                        -MembershipCache $membershipCache
                }

            $rows |
                Where-Object { -not $OversharedOnly -or $_.RouteType -eq 'Overshared' } |
                ForEach-Object { $emitted++; $_ }
        }
        catch {
            $skipped.Add($site)
            Write-Warning "Skipped $site : $($_.Exception.Message)"
        }
    }

    Write-Progress -Activity "Checking $User's access" -Completed

    # A skipped site returned nothing because it could not be READ, not because the
    # user has no access there. Surface that as a non-terminating error so a caller
    # can tell an incomplete scan from a genuine "no access" - a throttled site must
    # never read as a clean result. Rows from the sites that DID respond have already
    # gone down the pipeline, so callers still get whatever partial results exist.
    if ($skipped.Count -gt 0) {
        $msg = "Scan is INCOMPLETE: $($skipped.Count) of $total site(s) could not be read and were skipped ($($skipped -join ', ')). Results cover only the sites that responded."
        $PSCmdlet.WriteError(
            [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new($msg),
                'UserAccessExplorer.IncompleteScan',
                [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
                $User))
    }

    # Only claim "no access" when every site actually answered. If some were skipped,
    # the error above is the honest signal - saying "no access" here would be a lie.
    if ($emitted -eq 0 -and $skipped.Count -eq 0) {
        $what = if ($OversharedOnly) { 'overshared access' } else { 'access' }
        Write-Information "$User has no $what on the $total site(s) checked." -InformationAction Continue
    }
}