Public/Discovery/Get-EntraRoleMember.ps1

using namespace System.Management.Automation

class EntraRoleNames : IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        try {
            return ($script:SessionVariables.Roles |
                Select-Object -ExpandProperty DisplayName |
                Sort-Object)
        }
        catch {
            Write-Warning "Error retrieving role names: $_"
            return @('ErrorLoadingRoleNames')
        }
    }
}

function Get-PrincipalDetails {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string[]]$PrincipalIds,

        [Parameter(Mandatory = $true)]
        [hashtable]$ResultHashtable
    )

    if ($PrincipalIds.Count -eq 1) {
        $principalId = $PrincipalIds[0]

        try {
            $objectInfo = Invoke-MsGraph -relativeUrl "directoryObjects/$principalId" -NoBatch -OutputFormat Object -ErrorAction SilentlyContinue
            if ($objectInfo) {
                $principalType = "Unknown"
                if ($objectInfo.'@odata.type' -match '#microsoft.graph.user') {
                    $principalType = "User"
                }
                elseif ($objectInfo.'@odata.type' -match '#microsoft.graph.group') {
                    $principalType = "Group"
                }
                elseif ($objectInfo.'@odata.type' -match '#microsoft.graph.servicePrincipal') {
                    $principalType = "ServicePrincipal"
                }

                $ResultHashtable[$principalId] = @{
                    Type    = $principalType
                    Details = $objectInfo
                }
                return
            }
        }
        catch {
            Write-Verbose "DirectoryObjects endpoint failed for $principalId, trying individual endpoints"
        }

        try {
            $userInfo = Invoke-MsGraph -relativeUrl "users/$principalId" -NoBatch -OutputFormat Object -ErrorAction Stop
            $ResultHashtable[$principalId] = @{
                Type    = "User"
                Details = $userInfo
            }
            return
        }
        catch {
            Write-Verbose "User endpoint failed for $principalId"
        }

        try {
            $groupInfo = Invoke-MsGraph -relativeUrl "groups/$principalId" -NoBatch -OutputFormat Object -ErrorAction Stop
            $ResultHashtable[$principalId] = @{
                Type    = "Group"
                Details = $groupInfo
            }
            return
        }
        catch {
            Write-Verbose "Group endpoint failed for $principalId"
        }

        try {
            $spInfo = Invoke-MsGraph -relativeUrl "servicePrincipals/$principalId" -NoBatch -OutputFormat Object -ErrorAction Stop
            $ResultHashtable[$principalId] = @{
                Type    = "ServicePrincipal"
                Details = $spInfo
            }
            return
        }
        catch {
            Write-Verbose "ServicePrincipal endpoint failed for $principalId"
        }

        $ResultHashtable[$principalId] = @{
            Type    = "Unknown"
            Details = $null
        }
        return
    }

    $batchRequests = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($principalId in $PrincipalIds) {
        $batchRequests.Add(@{
                id     = $principalId
                method = "GET"
                url    = "/directoryObjects/$principalId"
            })
    }

    if ($batchRequests.Count -gt 0) {
        $batchResults = Invoke-MsGraph -BatchRequests $batchRequests -ErrorAction SilentlyContinue

        foreach ($principalId in $PrincipalIds) {
            $result = $batchResults[$principalId]

            if ($result -and $result.Success -eq $true) {
                $objectInfo = $result.Data

                $principalType = "Unknown"
                if ($objectInfo.'@odata.type' -match '#microsoft.graph.user') {
                    $principalType = "User"
                }
                elseif ($objectInfo.'@odata.type' -match '#microsoft.graph.group') {
                    $principalType = "Group"
                }
                elseif ($objectInfo.'@odata.type' -match '#microsoft.graph.servicePrincipal') {
                    $principalType = "ServicePrincipal"
                }

                $ResultHashtable[$principalId] = @{
                    Type    = $principalType
                    Details = $objectInfo
                }
            }
            else {
                $wasFound = $false

                try {
                    $userInfo = Get-MgUser -UserId $principalId -ErrorAction Stop
                    $ResultHashtable[$principalId] = @{
                        Type    = "User"
                        Details = $userInfo
                    }
                    $wasFound = $true
                }
                catch {
                    Write-Verbose "Principal $principalId not found as user"
                }

                if (-not $wasFound) {
                    try {
                        $groupInfo = Get-MgGroup -GroupId $principalId -ErrorAction Stop
                        $ResultHashtable[$principalId] = @{
                            Type    = "Group"
                            Details = $groupInfo
                        }
                        $wasFound = $true
                    }
                    catch {
                        Write-Verbose "Principal $principalId not found as group"
                    }
                }

                if (-not $wasFound) {
                    try {
                        $spInfo = Get-MgServicePrincipal -ServicePrincipalId $principalId -ErrorAction Stop
                        $ResultHashtable[$principalId] = @{
                            Type    = "ServicePrincipal"
                            Details = $spInfo
                        }
                        $wasFound = $true
                    }
                    catch {
                        Write-Verbose "Principal $principalId not found as service principal"
                    }
                }

                if (-not $wasFound) {
                    $ResultHashtable[$principalId] = @{
                        Type    = "Unknown"
                        Details = $null
                    }
                }
            }
        }
    }
}

function Get-EntraRoleMember {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet([EntraRoleNames])]
        [string]$RoleName = "Global Administrator",

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$RoleId,

        [Parameter(Mandatory = $false)]
        [switch]$ShowSummary,
        
        [Parameter(Mandatory = $false)]
        [switch]$ExpandGroups,

        [Parameter(Mandatory = $false)]
        [ValidateSet("Object", "JSON", "CSV", "Table")]
        [string]$OutputFormat = "Table"
    )

    begin {
        Write-Verbose "Starting function $($MyInvocation.MyCommand.Name)"
        $MyInvocation.MyCommand.Name | Invoke-BlackCat -ResourceTypeName 'MSGraph'

        $startTime = Get-Date

        $script:RoleName = $RoleName
        $script:RoleId = $RoleId
        $script:roleMembers = $null
        $script:principalTypes = @{
            "User"             = 0
            "Group"            = 0
            "ServicePrincipal" = 0
            "Unknown"          = 0
        }
    }

    process {
        try {
            if (-not $RoleId) {
                $roleDefinition = $null

                # Check if session variables are available and populated
                if ($null -eq $script:SessionVariables -or $null -eq $script:SessionVariables.Roles -or $script:SessionVariables.Roles.Count -lt 10) {
                    Write-Verbose "Role session variables not available or incomplete. Roles should be loaded from EntraRoles.csv at module import."
                }

                # Now try to use the roles (whether they were just refreshed or already existed)
                if ($null -ne $script:SessionVariables -and $null -ne $script:SessionVariables.Roles) {
                    $roleDefinition = $script:SessionVariables.Roles | Where-Object { $_.DisplayName -eq $RoleName } | Select-Object -First 1

                    if ($roleDefinition) {
                        $roleId = $roleDefinition.Id
                    }
                    else {
                        Write-Warning "Could not find role definition for: $RoleName"
                        throw "Role '$RoleName' not found. Check the role name or provide a role ID directly."
                    }
                }
                else {
                    throw "Session variables for roles not available. Ensure you're connected with Connect-Entra before calling this function."
                }

                Write-Host "Using role: $RoleName (ID: $roleId)" -ForegroundColor Cyan
            }
            else {
                $roleId = $RoleId
                $roleDefinition = $null

                # Check if session variables need to be initialized
                if ($null -eq $script:SessionVariables -or $null -eq $script:SessionVariables.Roles -or $script:SessionVariables.Roles.Count -lt 10) {
                    Write-Verbose "Role session variables not available or incomplete. Roles should be loaded from EntraRoles.csv at module import."
                }

                # Try to use the role ID to get the display name
                if ($null -ne $script:SessionVariables -and $null -ne $script:SessionVariables.Roles) {
                    $roleDefinition = $script:SessionVariables.Roles | Where-Object { $_.Id -eq $roleId } | Select-Object -First 1

                    if ($roleDefinition) {
                        $RoleName = $roleDefinition.DisplayName
                        Write-Host "Using role: $RoleName (ID: $roleId)" -ForegroundColor Cyan
                    }
                    else {
                        Write-Host "Using role with ID: $roleId" -ForegroundColor Cyan
                    }
                }
                else {
                    Write-Host "Using role with ID: $roleId" -ForegroundColor Cyan
                }
            }

            $script:RoleName = $RoleName
            $script:RoleId = $roleId

            try {
                $allRoleAssignments = Invoke-MsGraph -relativeUrl "roleManagement/directory/roleAssignments" -OutputFormat Object
                if (-not $allRoleAssignments -or $allRoleAssignments.Count -eq 0) {
                    Write-Warning "No role assignments were returned. This may be due to permissions issues."
                    throw "No role assignments found. Check that you have Directory.Read.All permissions."
                }

                $targetRoleAssignments = $allRoleAssignments | Where-Object { $_.roleDefinitionId -eq $roleId }

                if (-not $targetRoleAssignments -or $targetRoleAssignments.Count -eq 0) {
                    Write-Host "No $RoleName assignments found" -ForegroundColor Yellow
                    return $null
                }

                Write-Host "Found $($targetRoleAssignments.Count) $RoleName assignments" -ForegroundColor Cyan
            }
            catch {
                Write-Warning "Error retrieving role assignments: $($_.Exception.Message)"
                throw "Failed to retrieve role assignments. Check your permissions and network connectivity."
            }
            $script:roleMembers = [System.Collections.Generic.List[PSCustomObject]]::new()
            $script:principalTypes = @{
                "User"             = 0
                "Group"            = 0
                "ServicePrincipal" = 0
                "Unknown"          = 0
            }

            $principalIdToAssignment = @{}
            foreach ($assignment in $targetRoleAssignments) {
                $principalId = $assignment.principalId

                if ($principalId -match '^[0-9]{1,2}$' -or $principalId.Length -lt 5) {
                    Write-Verbose "Skipping invalid principal ID: $principalId"
                    continue
                }

                $principalIdToAssignment[$principalId] = $assignment
            }

            $uniquePrincipalIds = @($principalIdToAssignment.Keys)
            $script:roleMembers = [System.Collections.Generic.List[PSCustomObject]]::new()
            $principalDetails = @{}
            $script:principalTypes = @{
                "User"             = 0
                "Group"            = 0
                "ServicePrincipal" = 0
                "Unknown"          = 0
            }

            $batchSize = 20
            for ($i = 0; $i -lt $uniquePrincipalIds.Count; $i += $batchSize) {
                $batchPrincipalIds = $uniquePrincipalIds[$i..([Math]::Min($i + $batchSize - 1, $uniquePrincipalIds.Count - 1))]

                Get-PrincipalDetails -PrincipalIds $batchPrincipalIds -ResultHashtable $principalDetails

                foreach ($principalId in $batchPrincipalIds) {
                    if ($principalDetails.ContainsKey($principalId)) {
                        $script:principalTypes[$principalDetails[$principalId].Type]++
                    }
                    else {
                        $script:principalTypes["Unknown"]++
                    }
                }

                foreach ($principalId in $batchPrincipalIds) {
                    $principalInfo = $principalDetails[$principalId]
                    $assignment = $principalIdToAssignment[$principalId]
                    $details = $principalInfo.Details
                    $isUnknown = ($principalInfo.Type -eq "Unknown" -or $null -eq $details)

                    $roleMember = [PSCustomObject]@{
                        PrincipalId       = $principalId
                        PrincipalType     = $isUnknown ? "Unknown" : $principalInfo.Type
                        DisplayName       = $isUnknown ? "Possibly Deleted or Inaccessible Object" : $details.displayName
                        UserPrincipalName = ($principalInfo.Type -eq "User" -and $details) ? $details.userPrincipalName : $null
                        Email             = $details ? $details.mail : $null
                        AccountEnabled    = ($principalInfo.Type -eq "User" -and $details) ? $details.accountEnabled : $null
                        AssignmentId      = $assignment.id
                        AssignmentScope   = $assignment.directoryScopeId
                        RoleName          = $RoleName
                        RoleId            = $roleId
                        Status            = $isUnknown ? "Possibly Deleted or Inaccessible" : "Active"
                        IsMemberOfGroup   = $false
                        ParentGroupId     = $null
                        ParentGroupName   = $null
                        MembershipPath    = $null
                    }

                    $script:roleMembers.Add($roleMember)
                    
                    if ($ExpandGroups -and $principalInfo.Type -eq "Group" -and -not $isUnknown) {
                        Write-Verbose "Expanding members for group: $($details.displayName) ($principalId)"
                        
                        try {
                            # First try to get members with transitive option if available
                            try {
                                $groupMembers = Invoke-MsGraph -relativeUrl "groups/$principalId/transitiveMembers" -NoBatch -ErrorAction Stop
                            } 
                            catch {
                                # Fall back to direct members if transitive fails
                                $groupMembers = Invoke-MsGraph -relativeUrl "groups/$principalId/members" -NoBatch -ErrorAction Stop
                            }
                            
                            if ($groupMembers -and $groupMembers.value) {
                                foreach ($member in $groupMembers.value) {
                                    $memberType = "Unknown"
                                    if ($member.'@odata.type' -match '#microsoft.graph.user') {
                                        $memberType = "User"
                                    }
                                    elseif ($member.'@odata.type' -match '#microsoft.graph.group') {
                                        $memberType = "Group"
                                    }
                                    elseif ($member.'@odata.type' -match '#microsoft.graph.servicePrincipal') {
                                        $memberType = "ServicePrincipal"
                                    }
                                    
                                    # Create member object with reference to parent group
                                    $groupMember = [PSCustomObject]@{
                                        PrincipalId       = $member.id
                                        PrincipalType     = $memberType
                                        DisplayName       = $member.displayName
                                        UserPrincipalName = $memberType -eq "User" ? $member.userPrincipalName : $null
                                        Email             = $member.mail
                                        AccountEnabled    = $memberType -eq "User" ? $member.accountEnabled : $null
                                        AssignmentId      = $assignment.id
                                        AssignmentScope   = $assignment.directoryScopeId
                                        RoleName          = $RoleName
                                        RoleId            = $roleId
                                        Status            = "Active"
                                        IsMemberOfGroup   = $true
                                        ParentGroupId     = $principalId
                                        ParentGroupName   = $details.displayName
                                        MembershipPath    = "$($details.displayName) > $($member.displayName)"
                                    }
                                    
                                    $script:roleMembers.Add($groupMember)
                                }
                                
                                Write-Verbose "Added $($groupMembers.value.Count) members from group $($details.displayName)"
                            }
                        }
                        catch {
                            Write-Verbose "Error retrieving members for group $($details.displayName): $_"
                        }
                    }
                }
            }

            if ($ShowSummary) {
                $duration = (Get-Date) - $startTime

                Write-Host "`n Role Member Discovery Summary:" -ForegroundColor Magenta
                Write-Host " Role: $RoleName (ID: $roleId)" -ForegroundColor Cyan
                Write-Host " Total Members Found: $($script:roleMembers.Count)" -ForegroundColor Green

                $principalTypeSummary = $script:roleMembers | Group-Object PrincipalType
                foreach ($group in $principalTypeSummary) {
                    $color = switch ($group.Name) {
                        "User" { "Green" }
                        "Group" { "Yellow" }
                        "ServicePrincipal" { "Cyan" }
                        "Unknown" { "Red" }
                        default { "White" }
                    }
                    Write-Host " $($group.Name): $($group.Count)" -ForegroundColor $color
                }

                if ($script:principalTypes["User"] -gt 0) {
                    $enabledUsers = $script:roleMembers | Where-Object { $_.PrincipalType -eq "User" -and $_.AccountEnabled -eq $true } | Measure-Object
                    $disabledUsers = $script:roleMembers | Where-Object { $_.PrincipalType -eq "User" -and $_.AccountEnabled -eq $false } | Measure-Object

                    if ($enabledUsers.Count -gt 0) {
                        Write-Host " Enabled Users: $($enabledUsers.Count)" -ForegroundColor Green
                    }
                    if ($disabledUsers.Count -gt 0) {
                        Write-Host " Disabled Users: $($disabledUsers.Count)" -ForegroundColor Yellow
                    }
                }

                $directoryScopes = $script:roleMembers | Where-Object { $_.AssignmentScope -ne "/" } | Measure-Object
                if ($directoryScopes.Count -gt 0) {
                    Write-Host " Scoped Assignments: $($directoryScopes.Count)" -ForegroundColor Yellow
                }

                if ($script:principalTypes["Group"] -gt 0 -and -not $ExpandGroups) {
                    Write-Host "`n Note: Group members also inherit this role but are not included in this count" -ForegroundColor Yellow
                    Write-Host " Use -ExpandGroups parameter to include group members in the results" -ForegroundColor Gray
                }
                
                if ($ExpandGroups) {
                    # Count direct vs nested members
                    $directMembers = $script:roleMembers | Where-Object { -not ($_.IsMemberOfGroup) }
                    $groupMembers = $script:roleMembers | Where-Object { $_.IsMemberOfGroup }
                    
                    if ($groupMembers.Count -gt 0) {
                        Write-Host "`n Group Expansion Summary:" -ForegroundColor Magenta
                        Write-Host " Direct Role Members: $($directMembers.Count)" -ForegroundColor Green
                        Write-Host " Nested Group Members: $($groupMembers.Count)" -ForegroundColor Yellow
                        
                        # Count by principal type within group members
                        $nestedPrincipalTypeSummary = $groupMembers | Group-Object PrincipalType
                        foreach ($group in $nestedPrincipalTypeSummary) {
                            $color = switch ($group.Name) {
                                "User" { "Green" }
                                "Group" { "Yellow" }
                                "ServicePrincipal" { "Cyan" }
                                "Unknown" { "Red" }
                                default { "White" }
                            }
                            Write-Host " Nested $($group.Name): $($group.Count)" -ForegroundColor $color
                        }
                        
                        # Get unique group sources
                        $groupSources = $groupMembers | Group-Object ParentGroupName | Sort-Object Count -Descending
                        Write-Host "`n Group Sources:" -ForegroundColor Cyan
                        foreach ($group in $groupSources) {
                            Write-Host " - $($group.Name): $($group.Count) members" -ForegroundColor White
                        }
                    }
                }

                Write-Host " Duration: $($duration.TotalSeconds.ToString('F2')) seconds" -ForegroundColor White
                Write-Host " Processing Rate: $([math]::Round($script:roleMembers.Count / $duration.TotalSeconds, 2)) principals/second" -ForegroundColor White

                Write-Host "`n Role member analysis completed successfully!" -ForegroundColor Green
            }

            $processingRate = if ($script:roleMembers.Count -gt 0 -and $duration.TotalSeconds -gt 0) {
                [math]::Round($script:roleMembers.Count / $duration.TotalSeconds, 2)
            }
            else { 0 }

            Write-Verbose "Processed $($script:roleMembers.Count) role members at $processingRate items/second"

            $formatParam = @{
                Data         = $script:roleMembers
                OutputFormat = $OutputFormat
                FunctionName = $MyInvocation.MyCommand.Name
                FilePrefix   = "$($RoleName.Replace(' ', ''))-Members"
            }

            try {
                return Format-BlackCatOutput @formatParam
            }
            catch {
                Write-Message -FunctionName $($MyInvocation.MyCommand.Name) -Message "Error formatting output: $($_.Exception.Message)" -Severity 'Warning'
                return $script:roleMembers
            }
        }
        catch {
            Write-Message -FunctionName $($MyInvocation.MyCommand.Name) -Message $($_.Exception.Message) -Severity 'Error'
            return $null
        }
    } # End of process block

    <#
.SYNOPSIS
    Gets all members of a specified Microsoft Entra ID (Azure AD) role.
 
.DESCRIPTION
Gets all members assigned to a specified Microsoft Entra ID role with optional group expansion. This function enumerates role members, showing their user type and status. When group expansion is enabled, resolves group memberships to identify all users with indirect role assignments.
.PARAMETER RoleName
    Specifies the display name of the Entra ID role to query. This parameter has tab completion
    for all available Entra ID roles. Default is "Global Administrator".
 
.PARAMETER RoleId
    Specifies the ID of the Entra ID role to query. If provided, this takes precedence over RoleName.
 
.PARAMETER ShowSummary
    When specified, displays a summary of the role members including counts by principal type
    and execution duration.
 
.PARAMETER ExpandGroups
    When specified, expands any groups found as role members to include the individual members
    of those groups. This helps identify all users who have access through group-based role assignments.
 
.PARAMETER OutputFormat
    Specifies the output format of the results. Valid values are:
    - Object: Returns PowerShell objects (default for pipeline operations)
    - JSON: Returns a JSON string
    - CSV: Returns a CSV string
    - Table: Displays the results as a formatted table (default)
     
    When used with -ExpandGroups, the output will include properties that identify group membership relationships:
    - IsMemberOfGroup: Indicates if this principal is a member of a group with the role
    - ParentGroupId: The object ID of the parent group (for group members)
    - ParentGroupName: The display name of the parent group (for group members)
    - MembershipPath: Shows the path from the parent group to the member
 
.EXAMPLE
    Get-EntraRoleMember
    Retrieves all Global Administrators in the tenant (default role) and displays them in a table format.
 
.EXAMPLE
    Get-EntraRoleMember -RoleName "User Administrator" -OutputFormat JSON
    Retrieves all User Administrators and exports the results to a JSON file.
 
.EXAMPLE
    Get-EntraRoleMember -RoleName "Conditional Access Administrator" -ShowSummary
    Retrieves all Conditional Access Administrators and displays a summary of the results.
 
.EXAMPLE
    Get-EntraRoleMember -RoleId "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3" -OutputFormat Object
    Retrieves members of the role with the specified ID and returns them as PowerShell objects.
 
.EXAMPLE
    Get-EntraRoleMember -RoleName "Privileged Role Administrator" -ExpandGroups
    Retrieves all Privileged Role Administrators, including nested members of any groups that have this role.
 
.NOTES
    Requires appropriate Microsoft Graph permissions to enumerate role members.
 
.LINK
    MITRE ATT&CK Tactic: TA0007 - Discovery
    https://attack.mitre.org/tactics/TA0007/
 
.LINK
    MITRE ATT&CK Technique: T1069.003 - Permission Groups Discovery: Cloud Groups
    https://attack.mitre.org/techniques/T1069/003/
#>

}