Private/Get-ArgusRoleMember.ps1

function Get-ArgusRoleMember {
    <#
    .SYNOPSIS
        Flattens directory role assignments into one row per role holder, expanding role-assignable groups.

    .DESCRIPTION
        Uses the DirectoryRoles data set (active assignments and, with Entra ID P2, eligible PIM
        assignments). When a role is assigned to a group, the group's transitive members are read with
        JSON batching (GET /groups/{id}/transitiveMembers) and reported with Via = 'Group: <name>'.
        Rows: RoleName, RoleTemplateId, PrincipalId, PrincipalType (User, Group member user, ServicePrincipal...),
        DisplayName, UserPrincipalName, UserType, Assignment (Active/Eligible), Via.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object] $DirectoryRoles,

        [Parameter()]
        [string[]] $RoleTemplateId
    )

    $definitions = @{}
    foreach ($definition in @(Get-ArgusProperty $DirectoryRoles 'Definitions' @())) {
        $definitions[[string]$definition.id] = $definition
    }

    $entries = [System.Collections.Generic.List[object]]::new()
    foreach ($assignment in @(Get-ArgusProperty $DirectoryRoles 'Assignments' @())) { $entries.Add(@{ Item = $assignment; Kind = 'Active' }) }
    foreach ($assignment in @(Get-ArgusProperty $DirectoryRoles 'Eligible' @())) { $entries.Add(@{ Item = $assignment; Kind = 'Eligible' }) }

    $typeName = {
        param($Principal)
        $odataType = [string](Get-ArgusProperty $Principal '@odata.type' '')
        switch -Wildcard ($odataType) {
            '*user' { 'User' }
            '*group' { 'Group' }
            '*servicePrincipal' { 'ServicePrincipal' }
            default { if ($odataType) { $odataType.Split('.')[-1] } else { 'Unknown' } }
        }
    }

    $rows = [System.Collections.Generic.List[object]]::new()
    $groupAssignments = [System.Collections.Generic.List[object]]::new()
    foreach ($entry in $entries) {
        $assignment = $entry.Item
        $roleId = [string](Get-ArgusProperty $assignment 'roleDefinitionId' '')
        $definition = $definitions[$roleId]
        $templateId = if ($definition) { [string](Get-ArgusProperty $definition 'templateId' $roleId) } else { $roleId }
        if ($RoleTemplateId -and $templateId -notin $RoleTemplateId -and $roleId -notin $RoleTemplateId) { continue }
        $roleName = if ($definition) { [string](Get-ArgusProperty $definition 'displayName' $roleId) } else { $roleId }
        $principal = Get-ArgusProperty $assignment 'principal'
        $type = & $typeName $principal
        $row = [pscustomobject]@{
            RoleName          = $roleName
            RoleTemplateId    = $templateId
            PrincipalId       = [string](Get-ArgusProperty $assignment 'principalId' '')
            PrincipalType     = $type
            DisplayName       = [string](Get-ArgusProperty $principal 'displayName' '')
            UserPrincipalName = [string](Get-ArgusProperty $principal 'userPrincipalName' '')
            UserType          = [string](Get-ArgusProperty $principal 'userType' '')
            Assignment        = $entry.Kind
            Via               = 'Direct'
        }
        if ($type -eq 'Group') { $groupAssignments.Add($row) } else { $rows.Add($row) }
    }

    if ($groupAssignments.Count -gt 0) {
        $groupIds = @($groupAssignments | ForEach-Object { $_.PrincipalId } | Sort-Object -Unique)
        $requests = @($groupIds | ForEach-Object { @{ Id = $_; Url = "/groups/$_/transitiveMembers?`$select=id,displayName,userPrincipalName,userType" } })
        $members = @{}
        foreach ($response in @(Invoke-ArgusGraphBatch -Request $requests)) {
            $members[$response.Id] = if ($response.Status -eq 200) { @(Get-ArgusProperty $response.Body 'value' @()) } else { $null }
        }
        foreach ($groupRow in $groupAssignments) {
            $groupMembers = $members[$groupRow.PrincipalId]
            if ($null -eq $groupMembers) {
                # Members could not be read: keep the group itself so it is still counted and visible.
                $rows.Add($groupRow)
                continue
            }
            foreach ($member in $groupMembers) {
                $rows.Add([pscustomobject]@{
                        RoleName          = $groupRow.RoleName
                        RoleTemplateId    = $groupRow.RoleTemplateId
                        PrincipalId       = [string](Get-ArgusProperty $member 'id' '')
                        PrincipalType     = & $typeName $member
                        DisplayName       = [string](Get-ArgusProperty $member 'displayName' '')
                        UserPrincipalName = [string](Get-ArgusProperty $member 'userPrincipalName' '')
                        UserType          = [string](Get-ArgusProperty $member 'userType' '')
                        Assignment        = $groupRow.Assignment
                        Via               = 'Group: ' + $groupRow.DisplayName
                    })
            }
        }
    }

    $rows
}