Skip to main content

PIM Role and Group Assignment Auditing with PowerShell

The Problem
#

If you ask “who has Global Administrator access in this tenant?” the answer is never simple. A user might have it directly. A group might be assigned the role, with PIM eligibility, and that group contains nested groups from two different business units. A service principal might hold it permanently. And all of this might be scoped to an Administrative Unit rather than the full directory.

The Entra ID portal shows fragments of this picture across multiple blades. PIM shows eligible assignments. Role assignments shows direct ones. Group membership shows who’s in the groups. But nowhere does it flatten the entire picture into: “here is every human and service principal that can – or currently does – hold each directory role, how they got there, and when it expires.”

That’s what this script builds.

Solution Overview
#

Fixing this requires stitching together four separate Graph API endpoints – none of which cross-reference each other. PIM group eligibility, PIM group active membership, role eligibility schedules, and role assignment schedules. There is also a fifth call to discover which groups are actually PIM-enabled (role-assignable) in the first place.

The script pulls all of these, resolves nested group membership transitively, maps Administrative Unit scope IDs to human-readable names, and lands everything in a single flat Excel file. One row per person per role, with exactly enough context to answer “why does this person have this access?”

Prerequisites
#

You need an account with Global Reader or Security Reader. No custom app registration, no secrets – the script uses interactive sign-in via Connect-MgGraph with the Microsoft Graph PowerShell first-party app. If you can read the portal, you can run this.

Modules: Microsoft.Graph.Authentication and ImportExcel (Install-Module both if you don’t have them).

Delegated scopes (requested automatically by the script):

RoleManagement.Read.Directory
RoleEligibilitySchedule.Read.Directory
RoleAssignmentSchedule.Read.Directory
PrivilegedEligibilitySchedule.Read.AzureADGroup
PrivilegedAssignmentSchedule.Read.AzureADGroup
Group.Read.All, User.Read.All, Directory.Read.All

Implementation
#

Authentication
#

Connect-MgGraph -Scopes @(
    'RoleManagement.Read.Directory',
    'RoleEligibilitySchedule.Read.Directory',
    'RoleAssignmentSchedule.Read.Directory',
    'PrivilegedEligibilitySchedule.Read.AzureADGroup',
    'PrivilegedAssignmentSchedule.Read.AzureADGroup',
    'Group.Read.All',
    'User.Read.All',
    'Directory.Read.All'
) -ErrorAction Stop

All Graph calls go through Invoke-MgGraphRequest. Token refresh is handled automatically, which matters more than it sounds – in a large tenant this script can run for several minutes, and managing your own bearer token across that window is a tax you don’t need to pay.

Building Lookup Tables
#

Before querying PIM or role data, the script builds hashtable lookups for users, groups, and Administrative Units:

$allUsersRaw = Invoke-GraphGetAllPages -Uri `
    'https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,companyName'
$allUsers = @{}
foreach ($u in $allUsersRaw) { $allUsers[$u.id] = $u }

This is a deliberate trade-off. Pulling all users and groups up front uses more memory but avoids hundreds of individual lookups later. In a tenant with 50,000 users and 200 PIM groups, the alternative (resolving each principal ID on demand) would mean thousands of Graph calls and would take hours instead of minutes.

Discovering PIM-Enabled Groups
#

The script finds all role-assignable groups using the v1.0 endpoint:

$pimGroups = Invoke-GraphGetAllPages -Uri `
    'https://graph.microsoft.com/v1.0/groups?$filter=isAssignableToRole eq true&$select=id,displayName'

This is the starting point for the entire PIM group resolution loop. If a group isn’t role-assignable, it can’t be PIM-enabled for directory roles.

Resolving PIM Group Membership
#

PIM-enabled groups have eligible and active assignments. The script queries both:

# Eligible members of PIM groups
$eligibleMembers = Invoke-GraphGetAllPages -Uri `
    "https://graph.microsoft.com/beta/identityGovernance/privilegedAccess/group/eligibilityScheduleInstances?`$filter=groupId eq '$($group.id)'"

When a PIM group member is itself a group (nested group scenario), the script resolves transitive membership using the /transitiveMembers endpoint, which flattens all nesting levels automatically:

elseif ($allGroups.ContainsKey($member.principalId)) {
    $nestedMembers = Invoke-GraphGetAllPages -Uri `
        "https://graph.microsoft.com/v1.0/groups/$($member.principalId)/transitiveMembers?`$select=id,displayName,userPrincipalName,companyName"
    foreach ($nm in $nestedMembers) {
        # Add each resolved user with NestedGroup context
    }
}

The NestedGroup column in the output shows exactly which nested group the user inherited access through. Every PIM-enabled group I have seen in a production tenant has at least one nested group – usually from a legacy sync or a “department” group someone thought was convenient. Without transitive resolution, you are missing real privilege paths.

Combining Role Assignments
#

The script collects three types of role assignments into a single report:

  1. Eligible role assignments (via roleEligibilityScheduleInstances) – users or groups that can activate a role through PIM
  2. Direct role assignments (via roleAssignmentSchedules where assignmentType -ne "Activated") – assignments that were not created by a PIM activation event. These may be time-bound or permanent. The Activated filter is important: without it, every currently-active PIM session would appear as a duplicate “direct” assignment alongside its eligibility row.
  3. PIM group role assignments – this is where the real complexity lives. When a role is assigned to a group, and that group is PIM-enabled, the script cross-references against the PIM group data (by group ID) to show each individual’s effective access. Both eligible and active members are included – a group can have users in both states simultaneously.

The scope column resolves directoryScopeId to either “Directory” (tenant-wide) or the Administrative Unit’s display name:

$auId = $ScopeId.Split('/')[-1]
($adminUnits | Where-Object { $_.id -eq $auId } | Select-Object -First 1).displayName

The Resulting Report
#

Each row in the output contains:

ColumnDescription
RoleDirectory role name (e.g., “Global Administrator”)
TargetHow the assignment was made: User, Group, PIM Group, ServicePrincipal
GroupThe group name, if assigned via group
NestedGroupThe nested group name, if the user is in a sub-group
UserDisplay name
UPNUser principal name
CompanyCompany attribute (useful in multi-company tenants)
ServicePrincipalService principal name, if applicable
StartDateWhen the assignment started
EndDateWhen it expires, or “Permanent” if no end date
AssignmentEligible, Active, or Direct
ScopeDirectory or Administrative Unit name

What to Look For
#

The first time you run this in a tenant, look for these:

  • Any row where EndDate is “Permanent” – this means the assignment predates PIM, or someone bypassed it, or your PIM policy has a gap. In most tenants I have audited, there are Global Admin assignments with no end date that nobody knew were there. These are your highest-priority findings.
  • Service principals holding privileged roles – these are often forgotten app registrations from years ago that still have standing access. They don’t show up in access reviews unless you explicitly include them.
  • Users reaching a role only through a NestedGroup – these are the hidden privilege paths. The user might not even know they have the access, and the group owner might not know their group feeds into a PIM role.
  • Roles scoped to an Administrative Unit – a “User Administrator” scoped to one AU is very different from one scoped to the directory. If you are not checking scope, your audit is incomplete.

Known Limitations
#

  • Point-in-time snapshot. PIM activations that occurred and expired between runs are not captured. For continuous monitoring, use PIM alerting or feed findings into Sentinel watchlists.
  • Beta API dependency. The PIM group eligibility and assignment schedule endpoints (identityGovernance/privilegedAccess/group/...) are still beta. They work, but Microsoft could change them without notice.
  • Out-GridView is Windows-only. The script pops a grid view before exporting to Excel. On Linux/macOS this will fail – comment out that line if you are running cross-platform.

When to Use This / When Not To
#

Use this for quarterly privileged access reviews, audit preparation, governance reporting, and before any PIM policy changes. Also useful before you touch anything in a new tenant, so you know what you are inheriting.

Don’t use this for real-time monitoring of role activations – that’s what PIM alerting and Sentinel integration are for. This is a point-in-time snapshot tool, not a continuous monitoring solution.

GitHub
#

Script on GitHub