Overview#
Reads the unified role definitions (beta, for the isPrivileged flag), the active role assignments (GET /roleManagement/directory/roleAssignments with $expand=principal) and, when available, the PIM eligibility schedules (GET /roleManagement/directory/roleEligibilitySchedules) through Microsoft Graph. Every assignment is returned as one row with the role name, whether Microsoft classifies the role as privileged, the assignment type, the directory scope and the principal details (type, display name, UPN or app id, enabled state and whether the account is synchronised from on-premises AD). The result is exported to CSV with a per-role summary.
Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-EntraPrivilegedRoleMembers.ps1 -Full for the complete help text.
Parameters#
| Parameter | What it does |
|---|---|
-RoleName | Only roles whose display name matches this wildcard pattern, for example 'Global*' or '*Administrator'. |
-PrivilegedOnly | Only roles flagged as privileged by Microsoft (isPrivileged = true). |
-IncludeEligible | Includes PIM-eligible assignments (default). Use -IncludeEligible:$false to report active assignments only. |
-OutputPath | Path of the CSV report. Defaults to .\Reports\EntraPrivilegedRoleMembers_yyyyMMdd-HHmm.csv. |
-PassThru | Also emits the report objects to the pipeline. |
Examples#
PS> .\Get-EntraPrivilegedRoleMembers.ps1Exports every active and eligible directory role assignment and prints the count per role.
PS> .\Get-EntraPrivilegedRoleMembers.ps1 -PrivilegedOnly -OutputPath C:\Temp\PrivilegedRoles.csv -VerboseReports only the roles Microsoft classifies as privileged and saves them to the given CSV.
PS> .\Get-EntraPrivilegedRoleMembers.ps1 -RoleName 'Global Administrator' -IncludeEligible:$false -PassThruLists the active Global Administrators in the console without the PIM eligibility lookup.
Permissions, modules and notes#
Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : RoleManagement.Read.Directory, Directory.Read.All; RoleEligibilitySchedule.Read.Directory is added when -IncludeEligible is on (default). A reader role such as Global Reader or Security Reader is sufficient. Notes : Role definitions are read from the beta endpoint because the isPrivileged flag is not exposed in v1.0 yet; if the beta call fails the script falls back to v1.0 and IsPrivilegedRole stays empty. PIM eligibility requires Microsoft Entra ID P2 (or Microsoft Entra ID Governance); without it the eligible lookup is skipped with a warning. Group assignments are listed as the group, not as its members. Active rows include PIM activations that are current at the time of the run.
Full source#
<#
.SYNOPSIS
Reports who holds Microsoft Entra directory roles, including active assignments and PIM-eligible assignments.
.DESCRIPTION
Reads the unified role definitions (beta, for the isPrivileged flag), the active role assignments
(GET /roleManagement/directory/roleAssignments with $expand=principal) and, when available, the PIM eligibility
schedules (GET /roleManagement/directory/roleEligibilitySchedules) through Microsoft Graph. Every assignment is returned
as one row with the role name, whether Microsoft classifies the role as privileged, the assignment type,
the directory scope and the principal details (type, display name, UPN or app id, enabled state and whether
the account is synchronised from on-premises AD). The result is exported to CSV with a per-role summary.
.PARAMETER RoleName
Only roles whose display name matches this wildcard pattern, for example 'Global*' or '*Administrator'.
.PARAMETER PrivilegedOnly
Only roles flagged as privileged by Microsoft (isPrivileged = true).
.PARAMETER IncludeEligible
Includes PIM-eligible assignments (default). Use -IncludeEligible:$false to report active assignments only.
.PARAMETER OutputPath
Path of the CSV report. Defaults to .\Reports\EntraPrivilegedRoleMembers_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
Also emits the report objects to the pipeline.
.EXAMPLE
PS> .\Get-EntraPrivilegedRoleMembers.ps1
Exports every active and eligible directory role assignment and prints the count per role.
.EXAMPLE
PS> .\Get-EntraPrivilegedRoleMembers.ps1 -PrivilegedOnly -OutputPath C:\Temp\PrivilegedRoles.csv -Verbose
Reports only the roles Microsoft classifies as privileged and saves them to the given CSV.
.EXAMPLE
PS> .\Get-EntraPrivilegedRoleMembers.ps1 -RoleName 'Global Administrator' -IncludeEligible:$false -PassThru
Lists the active Global Administrators in the console without the PIM eligibility lookup.
.NOTES
Author : Omer Eltayeb
Blog : https://www.oeltayeb.com
GitHub : https://github.com/omer-eltayeb
Version : 1.0.0
Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
Permissions : RoleManagement.Read.Directory, Directory.Read.All; RoleEligibilitySchedule.Read.Directory is added
when -IncludeEligible is on (default). A reader role such as Global Reader or Security Reader is sufficient.
Notes : Role definitions are read from the beta endpoint because the isPrivileged flag is not exposed in
v1.0 yet; if the beta call fails the script falls back to v1.0 and IsPrivilegedRole stays empty.
PIM eligibility requires Microsoft Entra ID P2 (or Microsoft Entra ID Governance); without it the
eligible lookup is skipped with a warning. Group assignments are listed as the group, not as its
members. Active rows include PIM activations that are current at the time of the run.
.LINK
https://learn.microsoft.com/graph/api/rbacapplication-list-roleassignments
.LINK
https://learn.microsoft.com/graph/api/rbacapplication-list-roleeligibilityschedules
.LINK
https://learn.microsoft.com/graph/api/rbacapplication-list-roledefinitions
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication
[CmdletBinding()]
param(
[Parameter()]
[string]$RoleName,
[Parameter()]
[switch]$PrivilegedOnly,
[Parameter()]
[switch]$IncludeEligible = $true,
[Parameter()]
[string]$OutputPath,
[Parameter()]
[switch]$PassThru
)
$ErrorActionPreference = 'Stop'
#region Helpers
function Connect-GraphIfNeeded {
<# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Scopes
)
$context = Get-MgContext
$missingScopes = @()
if ($null -ne $context) {
$missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
}
if ($null -eq $context -or $missingScopes.Count -gt 0) {
Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
}
else {
Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
}
}
function Invoke-GraphPaged {
<# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Uri,
[Parameter()]
[hashtable]$Headers
)
$results = New-Object -TypeName System.Collections.Generic.List[object]
$nextLink = $Uri
while (-not [string]::IsNullOrEmpty($nextLink)) {
$requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
$response = Invoke-MgGraphRequest @requestParams
if ($null -ne $response.PSObject.Properties['value']) {
foreach ($item in $response.value) { $results.Add($item) }
}
elseif ($null -ne $response) {
$results.Add($response)
}
$nextLink = $response.'@odata.nextLink'
}
return $results
}
function Get-PrincipalDetail {
<# Returns type, UPN/app id, enabled state and on-premises sync state for a principal; cached per object id. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[object]$Principal
)
$id = [string]$Principal.id
if ($script:PrincipalCache.ContainsKey($id)) { return $script:PrincipalCache[$id] }
$type = ([string]$Principal.'@odata.type') -replace '^#microsoft\.graph\.', ''
$detail = [PSCustomObject]@{ Type = $type; UpnOrAppId = $null; AccountEnabled = $null; OnPremisesSyncEnabled = $null }
# The expanded principal only carries the default property set, so the sign-in related
# properties are read with one typed request per unique principal.
$uri = $null
switch ($type) {
'user' { $uri = 'https://graph.microsoft.com/v1.0/users/{0}?$select=userPrincipalName,accountEnabled,onPremisesSyncEnabled' -f $id }
'group' { $uri = 'https://graph.microsoft.com/v1.0/groups/{0}?$select=mail,onPremisesSyncEnabled' -f $id }
'servicePrincipal' { $uri = 'https://graph.microsoft.com/v1.0/servicePrincipals/{0}?$select=appId,accountEnabled' -f $id }
}
if ($null -ne $uri) {
try {
$object = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject -ErrorAction Stop
Start-Sleep -Milliseconds 200
switch ($type) {
'user' {
$detail.UpnOrAppId = $object.userPrincipalName
$detail.AccountEnabled = [bool]$object.accountEnabled
$detail.OnPremisesSyncEnabled = [bool]$object.onPremisesSyncEnabled
}
'group' {
$detail.UpnOrAppId = $object.mail
$detail.OnPremisesSyncEnabled = [bool]$object.onPremisesSyncEnabled
}
'servicePrincipal' {
$detail.UpnOrAppId = $object.appId
$detail.AccountEnabled = [bool]$object.accountEnabled
}
}
}
catch {
Write-Warning ('Could not read details for {0} {1}: {2}' -f $type, $id, $_.Exception.Message)
}
}
$script:PrincipalCache[$id] = $detail
return $detail
}
#endregion Helpers
#region Main
$requiredScopes = @('RoleManagement.Read.Directory', 'Directory.Read.All')
if ($IncludeEligible) { $requiredScopes += 'RoleEligibilitySchedule.Read.Directory' }
$script:PrincipalCache = @{}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
$OutputPath = Join-Path -Path $reportFolder -ChildPath ('EntraPrivilegedRoleMembers_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}
try {
Connect-GraphIfNeeded -Scopes $requiredScopes
}
catch {
throw "Unable to connect to Microsoft Graph: $($_.Exception.Message)"
}
$roleLookup = @{}
$privilegedFlagAvailable = $true
try {
# beta: isPrivileged (Microsoft's "privileged role" classification) is only exposed on the beta roleDefinitions
# resource. Role ids are identical in v1.0, so the join with the v1.0 assignments below is unaffected.
$roleDefinitions = Invoke-GraphPaged -Uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?$select=id,displayName,isBuiltIn,isPrivileged'
}
catch {
Write-Warning "Role definitions could not be read from the beta endpoint; falling back to v1.0 without the isPrivileged flag (-PrivilegedOnly will match nothing). $($_.Exception.Message)"
$privilegedFlagAvailable = $false
$roleDefinitions = Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$select=id,displayName,isBuiltIn'
}
foreach ($definition in $roleDefinitions) { $roleLookup[[string]$definition.id] = $definition }
Write-Verbose "Loaded $($roleLookup.Count) role definitions."
try {
$assignments = New-Object -TypeName System.Collections.Generic.List[object]
foreach ($item in (Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$expand=principal')) {
$assignments.Add([PSCustomObject]@{ Source = $item; AssignmentType = 'Active' })
}
Write-Verbose "Loaded $($assignments.Count) active role assignments."
}
catch {
throw "Failed to read role assignments: $($_.Exception.Message)"
}
if ($IncludeEligible) {
try {
$eligibleCount = 0
foreach ($item in (Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleEligibilitySchedules?$expand=principal')) {
$assignments.Add([PSCustomObject]@{ Source = $item; AssignmentType = 'Eligible' })
$eligibleCount++
}
Write-Verbose "Loaded $eligibleCount PIM-eligible assignments."
}
catch {
Write-Warning "PIM-eligible assignments could not be read (requires Microsoft Entra ID P2 and RoleEligibilitySchedule.Read.Directory); continuing with active assignments only. $($_.Exception.Message)"
}
}
$rows = New-Object -TypeName System.Collections.Generic.List[object]
$processed = 0
foreach ($assignment in $assignments) {
$processed++
Write-Progress -Activity 'Resolving role assignments' -Status "$processed of $($assignments.Count)" -PercentComplete (($processed / $assignments.Count) * 100)
$source = $assignment.Source
$definition = $roleLookup[[string]$source.roleDefinitionId]
$currentRoleName = [string]$source.roleDefinitionId
$isPrivileged = $false
if ($null -ne $definition) {
$currentRoleName = $definition.displayName
$isPrivileged = [bool]$definition.isPrivileged
}
if (-not $privilegedFlagAvailable) { $isPrivileged = $null }
# Filter before the per-principal lookup so unwanted roles cost no extra Graph calls.
if (-not [string]::IsNullOrWhiteSpace($RoleName) -and $currentRoleName -notlike $RoleName) { continue }
if ($PrivilegedOnly -and -not $isPrivileged) { continue }
$principal = $source.principal
$principalName = '(principal not found)'
$detail = [PSCustomObject]@{ Type = 'unknown'; UpnOrAppId = $null; AccountEnabled = $null; OnPremisesSyncEnabled = $null }
if ($null -ne $principal) {
$principalName = $principal.displayName
$detail = Get-PrincipalDetail -Principal $principal
}
$rows.Add([PSCustomObject]@{
RoleName = $currentRoleName
IsPrivilegedRole = $isPrivileged
AssignmentType = $assignment.AssignmentType
DirectoryScope = $source.directoryScopeId
PrincipalType = $detail.Type
PrincipalDisplayName = $principalName
PrincipalUpnOrAppId = $detail.UpnOrAppId
AccountEnabled = $detail.AccountEnabled
OnPremisesSyncEnabled = $detail.OnPremisesSyncEnabled
PrincipalId = $source.principalId
})
}
Write-Progress -Activity 'Resolving role assignments' -Completed
$sortedRows = @($rows | Sort-Object -Property RoleName, AssignmentType, PrincipalDisplayName)
if ($sortedRows.Count -gt 0) {
$sortedRows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
Write-Warning 'No role assignments matched the selected filters; no CSV was written.'
}
Write-Host ''
Write-Host 'Directory role membership summary (active / eligible)' -ForegroundColor Cyan
foreach ($group in ($sortedRows | Group-Object -Property RoleName | Sort-Object -Property Count -Descending)) {
$activeCount = @($group.Group | Where-Object { $_.AssignmentType -eq 'Active' }).Count
Write-Host (' {0,-50} {1,4} / {2,-4}' -f $group.Name, $activeCount, ($group.Count - $activeCount))
}
Write-Host (' Assignments exported : {0} -> {1}' -f $sortedRows.Count, $OutputPath)
$globalAdmins = @($sortedRows | Where-Object { $_.RoleName -eq 'Global Administrator' -and $_.AssignmentType -eq 'Active' } | Select-Object -ExpandProperty PrincipalId -Unique)
if ($globalAdmins.Count -gt 5) {
Write-Warning ('{0} principals hold Global Administrator as an active assignment. Microsoft recommends fewer than 5 Global Administrators; move the rest to PIM eligibility or a lesser role.' -f $globalAdmins.Count)
}
$syncedPrivileged = @($sortedRows | Where-Object { $_.IsPrivilegedRole -and $_.OnPremisesSyncEnabled })
if ($syncedPrivileged.Count -gt 0) {
$syncedNames = ($syncedPrivileged | Select-Object -ExpandProperty PrincipalDisplayName -Unique) -join ', '
Write-Warning ('{0} privileged role assignment(s) belong to on-premises synced accounts, which should be cloud-only: {1}' -f $syncedPrivileged.Count, $syncedNames)
}
if ($PassThru) {
$sortedRows
}
#endregion Main