charlie.tools
2 min read

Auditing Microsoft 365 admin role assignments with Graph PowerShell

A five-minute script that pulls every directory role assignment in your tenant, including PIM-eligible and group-based assignments, and writes a clean CSV your security team will actually read.

If you inherited a tenant that has been around for a while, you already know the pain: the Entra admin center shows active assignments, but it hides group-based ones behind an extra click and sends you to a second blade for PIM-eligible roles. For a quarterly review that needs to go to auditors, you want one CSV.

Here is the Graph PowerShell script I run the first week of every quarter. It walks every directory role, resolves group members, and flags which assignments are PIM-eligible vs. active.

Get-AdminRoleAssignments.ps1
#Requires -Modules Microsoft.Graph.Identity.Governance, Microsoft.Graph.Groups
[CmdletBinding()]
param(
[string]$OutputPath = "./admin-roles-$(Get-Date -Format 'yyyy-MM-dd').csv"
)
Connect-MgGraph -Scopes 'RoleManagement.Read.Directory', 'Directory.Read.All' -NoWelcome
$roles = Get-MgRoleManagementDirectoryRoleDefinition -All
$results = foreach ($role in $roles) {
$active = Get-MgRoleManagementDirectoryRoleAssignment `
-Filter "roleDefinitionId eq '$($role.Id)'" -ExpandProperty Principal -All
$eligible = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
-Filter "roleDefinitionId eq '$($role.Id)'" -ExpandProperty Principal -All
foreach ($a in @($active) + @($eligible)) {
[pscustomobject]@{
Role = $role.DisplayName
AssignmentKind = if ($a.PSObject.TypeNames -match 'Eligibility') { 'Eligible' } else { 'Active' }
PrincipalType = $a.Principal.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.', ''
Principal = $a.Principal.AdditionalProperties.displayName
UPN = $a.Principal.AdditionalProperties.userPrincipalName
Scope = $a.DirectoryScopeId
}
}
}
$results | Sort-Object Role, Principal | Export-Csv -NoTypeInformation $OutputPath
Write-Host "Wrote $($results.Count) rows to $OutputPath"

Two things that tripped me up the first time I wrote this:

  1. Role assignments to groups show up with PrincipalType = group. You still need to walk the members — I handle that in a follow-up pass, which I will cover in another post.
  2. The DirectoryScopeId is / for tenant-wide, otherwise it is an administrative unit. If your org uses AUs, keep that column; if not, drop it to make the CSV easier to read.

I run this unattended via a scheduled task with a certificate-auth app registration. If there is interest, I will write up the app registration setup next.