Private/Resolve-SPMPrincipal.ps1

function Resolve-SPMGroupMemberEntry {
    <#
    .SYNOPSIS
        Converts a member of a SharePoint group into a member-tree entry (nested groups resolved via Graph).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $User,

        [Parameter(Mandatory)]
        [hashtable]$GroupCache
    )

    $login = [string]$User.LoginName

    switch ([string]$User.PrincipalType) {
        'User' {
            if ($login -eq 'c:0(.s|true') {
                return [pscustomobject]@{ name = 'Jeder'; type = 'Everyone' }
            }
            $upn = $null
            if ($User.Email) { $upn = [string]$User.Email }
            elseif ($login -match '\|membership\|(.+)$') { $upn = $Matches[1] }
            return [pscustomobject]@{ name = [string]$User.Title; type = 'User'; upn = $upn }
        }
        'SecurityGroup' {
            $claim = Get-SPMClaimClassification -LoginName $login
            switch ($claim.Type) {
                'Everyone' { return [pscustomobject]@{ name = 'Jeder'; type = 'Everyone' } }
                'EveryoneExceptExternal' { return [pscustomobject]@{ name = 'Jeder ausser externen Benutzern'; type = 'EveryoneExceptExternal' } }
                default {
                    $relation = if ($claim.OwnersClaim) { 'owners' } else { 'members' }
                    $label = if ($claim.OwnersClaim) { [string]$User.Title + ' (nur Besitzer der M365-Gruppe)' } else { [string]$User.Title }
                    $entry = [pscustomobject]@{ name = $label; type = $claim.Type; members = $null }
                    if ($claim.GroupId) {
                        $entry.members = @(Resolve-SPMEntraGroup -GroupId $claim.GroupId -GroupCache $GroupCache -Relation $relation)
                    }
                    return $entry
                }
            }
        }
        default {
            return [pscustomobject]@{ name = [string]$User.Title; type = 'Other' }
        }
    }
}

function Resolve-SPMPrincipal {
    <#
    .SYNOPSIS
        Registers a role-assignment member as a principal in the per-site principal index and returns its id.
    .DESCRIPTION
        SharePoint groups get their members enumerated (including nested Entra ID groups,
        resolved transitively). Sharing-link groups are detected and typed separately.
        Each principal is resolved only once per site.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Member,

        [Parameter(Mandatory)]
        [System.Collections.Specialized.OrderedDictionary]$PrincipalIndex,

        [Parameter(Mandatory)]
        [hashtable]$GroupCache
    )

    $login = [string]$Member.LoginName
    if ($PrincipalIndex.Contains($login)) {
        return $PrincipalIndex[$login].id
    }

    $name = [string]$Member.Title
    $type = 'Other'
    $upn = $null
    $membersTree = $null

    switch ([string]$Member.PrincipalType) {
        'SharePointGroup' {
            $type = if ($name -like 'SharingLinks.*' -or $login -like '*SharingLinks.*') { 'SharingLink' } else { 'SharePointGroup' }
            $list = [System.Collections.Generic.List[object]]::new()
            try {
                $spMembers = @(Invoke-SPMWithRetry { Get-PnPGroupMember -Group $Member.Id })
                foreach ($u in $spMembers) {
                    $entry = Resolve-SPMGroupMemberEntry -User $u -GroupCache $GroupCache
                    if ($entry) { $list.Add($entry) }
                }
            }
            catch {
                Write-Warning "Mitglieder der Gruppe '$name' konnten nicht abgerufen werden: $($_.Exception.Message)"
                $list.Add([pscustomobject]@{ name = '(Mitglieder nicht abrufbar)'; type = 'Note' })
            }
            $membersTree = $list.ToArray()
        }
        'SecurityGroup' {
            $claim = Get-SPMClaimClassification -LoginName $login
            $type = $claim.Type
            if ($type -eq 'EveryoneExceptExternal') { $name = 'Jeder ausser externen Benutzern' }
            if ($claim.OwnersClaim) { $name = $name + ' (nur Besitzer der M365-Gruppe)' }
            if ($claim.GroupId) {
                $relation = if ($claim.OwnersClaim) { 'owners' } else { 'members' }
                $membersTree = @(Resolve-SPMEntraGroup -GroupId $claim.GroupId -GroupCache $GroupCache -Relation $relation)
            }
        }
        'User' {
            if ($login -eq 'c:0(.s|true') {
                $type = 'Everyone'
                $name = 'Jeder'
            }
            else {
                $type = 'User'
                if ($Member.Email) { $upn = [string]$Member.Email }
                elseif ($login -match '\|membership\|(.+)$') { $upn = $Matches[1] }
            }
        }
    }

    $record = [pscustomobject]@{
        id        = 'p{0}' -f $PrincipalIndex.Count
        name      = $name
        type      = $type
        loginName = $login
        upn       = $upn
        members   = $membersTree
    }
    $PrincipalIndex[$login] = $record
    return $record.id
}