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.
#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 $OutputPathWrite-Host "Wrote $($results.Count) rows to $OutputPath"Two things that tripped me up the first time I wrote this:
- 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. - The
DirectoryScopeIdis/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.