Entra/Get-AdminRoleReport.ps1

<#
.SYNOPSIS
    Lists all active Entra ID directory role assignments and their members.
.DESCRIPTION
    Queries Microsoft Graph for all activated directory roles and enumerates
    their members. Produces a flat report showing each role-member combination,
    which is critical for reviewing privileged access during security assessments.

    Requires Microsoft.Graph.Identity.DirectoryManagement module and
    RoleManagement.Read.Directory permission.
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service Graph -Scopes 'RoleManagement.Read.Directory'
    PS> .\Entra\Get-AdminRoleReport.ps1

    Displays all directory role assignments in the tenant.
.EXAMPLE
    PS> .\Entra\Get-AdminRoleReport.ps1 -OutputPath '.\admin-roles.csv'

    Exports admin role membership to CSV for review.
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

# Verify Graph connection
if (-not (Assert-GraphConnection)) { return }

# Ensure required Graph submodule is loaded (PS 7.x does not auto-import)
Import-Module -Name Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop
if (-not (Get-Command -Name Invoke-GraphReadBatch -ErrorAction SilentlyContinue)) {
    . "$PSScriptRoot/../Common/Invoke-SafeGraphRequest.ps1"
    . "$PSScriptRoot/../Common/Invoke-GraphReadBatch.ps1"
}

# Retrieve all activated directory roles
try {
    Write-Verbose "Retrieving activated directory roles..."
    $directoryRoles = Get-MgDirectoryRole -All
}
catch {
    Write-Error "Failed to retrieve directory roles: $_"
    return
}

$allRoles = @($directoryRoles)
Write-Verbose "Found $($allRoles.Count) activated directory roles. Enumerating members..."

$roleMembers = @{}
$userIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($role in $allRoles) {
    try {
        $members = @(Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All)
    }
    catch {
        Write-Warning "Failed to retrieve members for role '$($role.DisplayName)': $_"
        continue
    }

    $roleMembers[$role.Id] = $members
    foreach ($member in $members) {
        if ($member.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.user') {
            [void]$userIds.Add([string]$member.Id)
        }
    }
}
$userDetails = @{}
if ($userIds.Count -gt 0) {
    $requests = @($userIds | ForEach-Object {
        @{ id = $_; url = '/users/' + [uri]::EscapeDataString($_) + '?$select=id,onPremisesSyncEnabled' }
    })
    try { $userDetails = Invoke-GraphReadBatch -Requests $requests }
    catch { Write-Warning 'Could not resolve user sync state; values remain unknown.' }
}

$report = foreach ($role in $allRoles) {
    foreach ($member in $roleMembers[$role.Id]) {
        $additionalProperties = $member.AdditionalProperties

        $memberDisplayName = $additionalProperties['displayName']
        $memberUpn = $additionalProperties['userPrincipalName']
        $memberType = $additionalProperties['@odata.type']

        # Clean up the OData type to a friendly name
        $friendlyType = switch ($memberType) {
            '#microsoft.graph.user'             { 'User' }
            '#microsoft.graph.servicePrincipal' { 'ServicePrincipal' }
            '#microsoft.graph.group'            { 'Group' }
            default                             { $memberType }
        }

        # Resolve each unique user once, in batches. Unavailable evidence stays
        # blank; it must never be reported as a known cloud-only account.
        $onPremSync = ''
        if ($friendlyType -eq 'User') {
            $detail = $userDetails[[string]$member.Id]
            $hasSyncProperty = if ($detail.body -is [System.Collections.IDictionary]) {
                $detail.body.Contains('onPremisesSyncEnabled')
            } else { $detail.body.PSObject.Properties.Name -contains 'onPremisesSyncEnabled' }
            # Graph can explicitly return null for accounts that are not synced.
            # Preserve that meaning, distinguishing it from an omitted property.
            if ($detail -and $detail.status -eq 200 -and $hasSyncProperty) {
                $onPremSync = if ($detail.body.onPremisesSyncEnabled -eq $true) { 'True' } else { 'False' }
            }
        }

        [PSCustomObject]@{
            RoleName                = $role.DisplayName
            RoleId                  = $role.Id
            MemberDisplayName       = $memberDisplayName
            MemberUPN               = $memberUpn
            MemberType              = $friendlyType
            MemberId                = $member.Id
            OnPremisesSyncEnabled   = $onPremSync
        }
    }
}

$report = @($report) | Sort-Object -Property RoleName, MemberDisplayName

Write-Verbose "Found $($report.Count) total role assignments"

if ($OutputPath) {
    $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Output "Exported admin role report ($($report.Count) assignments) to $OutputPath"
}
else {
    Write-Output $report
}