Private/Get-UserAccessForSite.ps1

function Get-UserAccessForSite {
    <#
        The engine, for one already-connected site. Answers two questions and joins
        them: CAN the user access this site and at what level (effective
        permissions), and HOW - attributed to each route, classified Granted
        (member / direct grant) or Overshared (an Everyone claim they were never
        explicitly given).

        Assumes Connect-PnPOnline has already run for this site.
    #>

    param(
        [Parameter(Mandatory)] [string] $SiteUrl,
        [Parameter(Mandatory)] [string] $UserLogin,
        # Per-scan (user,group) -> membership cache, shared across sites.
        [hashtable] $MembershipCache = @{}
    )

    # Clean UPN for display and for the Graph membership check, not the
    # i:0#.f|membership| claims login.
    $userDisplay = ($UserLogin -split '\|')[-1]

    $web = Invoke-WithRetry -Because 'Get-PnPWeb' -Action { Get-PnPWeb }
    $ctx = Get-PnPContext

    # 1. definitive: what can they actually do here?
    $perms = $web.GetUserEffectivePermissions($UserLogin)
    Invoke-WithRetry -Because 'effective permissions' -Action { $ctx.ExecuteQuery() }

    $level =
        if     ($perms.Value.Has('FullMask') -or $perms.Value.Has('ManagePermissions')) { 'Full Control' }
        elseif ($perms.Value.Has('EditListItems'))  { 'Edit' }
        elseif ($perms.Value.Has('ViewListItems'))  { 'Read' }
        else                                        { 'None' }

    if ($level -eq 'None') { return }   # no effective access here - nothing to report

    # 2. how - attribute the access to routes
    $assignments = Invoke-WithRetry -Because 'role assignments' -Action {
        Get-PnPProperty -ClientObject $web -Property RoleAssignments
    }

    foreach ($ra in $assignments) {
        $member = Get-PnPProperty -ClientObject $ra -Property Member

        # Load the sub-properties EXPLICITLY. Get-PnPProperty -Property Member loads
        # the object but leaves LoginName/Title/PrincipalType lazily loaded, so under
        # StrictMode an unloaded one throws "property cannot be found". Loading each
        # here makes it deterministic.
        $mLogin = Get-PnPProperty -ClientObject $member -Property LoginName
        $mTitle = Get-PnPProperty -ClientObject $member -Property Title
        $mType  = Get-PnPProperty -ClientObject $member -Property PrincipalType

        if (Test-SystemGroup $mTitle) { continue }

        $roles = (Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings |
                    ForEach-Object { $_.Name }) -join ', '

        $route = $null; $routeType = $null

        if (Test-EveryoneClaim $mLogin $mTitle) {
            $route = "Everyone claim ($mTitle)"
            $routeType = 'Overshared'
        }
        elseif ($mType -eq 'User' -and $mLogin -eq $UserLogin) {
            $route = 'Direct grant'
            $routeType = 'Granted'
        }
        elseif ($mType -eq 'SharePointGroup') {
            $mem = @(Invoke-WithRetry -Because "members of $mTitle" -Action {
                Get-PnPGroupMember -Group $mTitle -ErrorAction SilentlyContinue
            })
            # Iterate, do NOT do $mem.LoginName. On an EMPTY group $mem is @(),
            # and @().LoginName throws under StrictMode exactly like $null.LoginName.
            # Where-Object over an empty array is simply falsy - no property access
            # on the collection itself.
            if ($mem | Where-Object { $_.LoginName -eq $UserLogin }) {
                $route = "SharePoint group '$mTitle'"
                $routeType = 'Granted'
            }
        }
        elseif ($mType -eq 'SecurityGroup') {
            # An Entra security or M365 group. Confirm the user is actually in it
            # (transitively, so nesting counts) via Graph. A confirmed non-member
            # is dropped - listing a route the user is not in is a false positive.
            # If Graph cannot answer, keep the honest "unconfirmed" label.
            $gid = Get-GroupIdFromLogin $mLogin
            $isMember = if ($gid) { Test-UserIsGroupMember -UserUpn $userDisplay -GroupId $gid -Cache $MembershipCache } else { $null }
            if ($isMember -ne $false) {
                $suffix = if ($isMember) { '' } else { ' (membership unconfirmed)' }
                $route = "Entra group '$mTitle'$suffix"
                $routeType = 'Granted'
            }
        }

        if ($route) {
            # Some sites (the root collection) return an empty title - fall back
            # to the URL so no row is blank.
            $webTitle = Get-PnPProperty -ClientObject $web -Property Title
            if ([string]::IsNullOrWhiteSpace($webTitle)) { $webTitle = $SiteUrl }

            [pscustomobject]@{
                User            = $userDisplay
                SiteUrl         = $SiteUrl
                SiteTitle       = $webTitle
                EffectiveAccess = $level          # the user's OVERALL access here
                GrantedVia      = $route
                RouteType       = $routeType       # Granted | Overshared
                Permission      = $roles           # what THIS route grants
                # Deep-link straight to this site's advanced-permissions page, so
                # the GUI can open where the access is managed in one click.
                PermUrl         = "$SiteUrl/_layouts/15/user.aspx"
            }
        }
    }
}