Private/Test-GroupMembership.ps1

function Get-GroupIdFromLogin {
    <#
        Pull the Entra object id out of a SharePoint claims login.

        SharePoint encodes directory principals in the login name:
          c:0t.c|tenant|<guid> - security group
          c:0o.c|federateddirectoryclaimprovider|<guid> - M365 group (members)
          c:0o.c|federateddirectoryclaimprovider|<guid>_o - M365 group (owners)

        All we need for a membership check is the GUID, so pull the first one out
        and let the caller decide. Returns $null when there is no GUID (an ordinary
        SharePoint group, a plain user), which is the signal to fall back to the
        "unconfirmed" label rather than claim anything.

        Pure - no Graph calls - so it is unit-tested on its own.
    #>

    param([string]$LoginName)

    if ([string]::IsNullOrWhiteSpace($LoginName)) { return $null }
    $m = [regex]::Match($LoginName, '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}')
    if ($m.Success) { return $m.Value }
    return $null
}

function Test-UserIsGroupMember {
    <#
        Is THIS user a member of THIS group? Answered by Graph's checkMemberGroups,
        which is TRANSITIVE - so a user who is in the group only through a nested
        group still counts. That transitivity is the whole point: a plain
        Get-PnPGroupMember misses nesting.

        Returns:
          $true - confirmed member
          $false - confirmed NOT a member
          $null - could not determine (no Graph token / scope, or the call
                    failed). The caller keeps the "membership unconfirmed" label
                    rather than dropping a route it cannot rule out.

        Results are cached per (user, group) in the supplied hashtable, because one
        group turns up on many objects across a scan - one Graph call each, not one
        per row.

        Needs the Graph delegated scope GroupMember.Read.All (or Directory.Read.All).
    #>

    param(
        [Parameter(Mandatory)] [string]    $UserUpn,
        [Parameter(Mandatory)] [string]    $GroupId,
        [hashtable]                         $Cache = @{}
    )

    $key = "$UserUpn|$GroupId"
    if ($Cache.ContainsKey($key)) { return $Cache[$key] }

    $result = $null
    try {
        $body = @{ groupIds = @($GroupId) } | ConvertTo-Json
        $resp = Invoke-PnPGraphMethod -Url "users/$UserUpn/checkMemberGroups" -Method Post -Content $body
        $result = [bool](@($resp.value) -contains $GroupId)
    } catch {
        Write-Verbose "Membership check for $GroupId could not be resolved: $($_.Exception.Message)"
        $result = $null
    }

    $Cache[$key] = $result
    return $result
}