This is the tooling companion to killing standing admin with PIM. Standing up the tiered model is half the job; the other half is demonstrating, on demand and defensibly, that it's actually in effect — for an access review, a SOC 2 / ISO 27001 control, or your own peace of mind. The hard part is that the obvious evidence is misleading, so the script exists to produce evidence that isn't.
01At a glance
What the script does, in one breath.
It's a single read-only PowerShell script. Point it at a tenant and it:
| Step | What happens |
|---|---|
| Scans the in-scope roles | Intune Administrator and Global Administrator (editable) across every principal that holds them |
| Separates the three states | Standing (Assigned) vs live PIM activation (Activated) vs eligible-only — the distinction the portal blurs |
| Resolves group members | Roles assigned to role-assignable tier groups are expanded to the actual people |
| Flags partner (GDAP) grants | Cross-tenant partner access is detected, labelled, and noted as not-enumerable-here |
| Writes hash-sealed evidence | A date-stamped HTML report, colour-coded by state, with a SHA-256 over the data + tenant + timestamp + operator |
02Why a screenshot lies
The whole reason the script exists.
Open the Entra roles blade and you see who "has" Global Administrator right now. What you can't see is why they have it at this instant. Two people can appear identical in that view while being opposites for audit purposes:
Same picture, opposite meaning
Person A holds Global Admin because it was permanently assigned years ago and never removed — a standing exception, exactly what your control says shouldn't exist. Person B holds Global Admin because they activated an eligible role via PIM ten minutes ago, time-bound, expiring this afternoon — proof the control is working. In a naïve export they're one and the same "active Global Administrator." Present that to an auditor and you've either flagged a healthy JIT activation as a finding, or worse, hidden a real standing assignment inside the noise.
03The key move — read the schedule instances
The assignmentType field is what makes the distinction possible.
The trick is which Graph endpoint you read. Most exports use the flat role-assignment list, which can't tell you activation state. The script reads the role-assignment schedule instances, which carry assignmentType (Assigned vs Activated), memberType, an end time, and — crucially — a link back to the eligible assignment a JIT activation came from:
# the three reads per in-scope role — this is the core # 1. ACTIVE, with activation state + expiry + linked-eligible proof Get-MgRoleManagementDirectoryRoleAssignmentScheduleInstance -Filter "roleDefinitionId eq '$rid'" -ExpandProperty Principal -All # 2. durable assignments — safety net for partner/GDAP grants Get-MgRoleManagementDirectoryRoleAssignment -Filter "roleDefinitionId eq '$rid'" -ExpandProperty Principal -All # 3. ELIGIBLE — the baseline "no standing" state Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "roleDefinitionId eq '$rid'" -ExpandProperty Principal -All
From there, each active principal is classified. A user with assignmentType=Activated is a JIT activation (with its expiry and the eligible-assignment id recorded as proof). A user with Assigned and no end is genuine standing — unless they're on the break-glass list, in which case it's standing by design. Groups are expanded to members; partner groups are labelled and left un-enumerated.
# the classification, distilled if ($atype -eq 'Activated') { # live PIM activation — time-bound, linked to an eligible assignment → NOT standing 'Active - PIM-activated (JIT)' } elseif ($BreakGlassUpns -contains $pi.Upn) { 'Active - BREAK-GLASS (by design)' # standing GA, but deliberate } else { 'Active - STANDING/DIRECT' # the real exception — target zero }
04Dedup, flags, and the integrity hash
The bits that make it evidence rather than a dump.
A user can appear both eligible and currently activated, so the script deduplicates per (role, user), keeping the most privileged representation by rank (standing > activated > via-group > eligible). It then raises flags — any standing/direct admin (target zero), break-glass accounts with a reminder to verify their control set, live activations as positive proof, and any partner group holding Intune Administrator.
Finally it computes a SHA-256 over a canonical serialization of the assignment data plus tenant id, timestamp, and the collecting operator, and stamps it into the report. That's what turns a point-in-time HTML file into tamper-evident evidence: change any row afterward and the hash no longer matches.
# canonical serialization → hash → sealed into the HTML footer
$canonical = ($records | Sort-Object Role, Name, State | ForEach-Object {
"$($_.Role)|$($_.Name)|$($_.Upn)|$($_.Tier)|$($_.State)|$($_.AssignmentType)|$($_.IsPartner)|$($_.IsStanding)"
}) -join "`n"
$canonical += "`n$TenantId`n$stamp`n$Collector"
$sha = [System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($canonical))).Replace('-','').ToLower()
05Running it
Read-only, needs directory/role-management read scopes.
# prerequisites Install-Module Microsoft.Graph -Scope CurrentUser # the script connects with read-only scopes: # RoleManagement.Read.Directory, Directory.Read.All, # GroupMember.Read.All, User.Read.All # run it (writes an HTML evidence file to the output dir) .\Get-IntunePrivilegedAccess.ps1 -ExpectedTenantId '<your-tenant-id>'
Edit the CONFIG block before you run it — and don't assert controls you haven't built
The published script is a sanitized template: the tenant id, tier group names, per-user justifications, break-glass account, partner relationships, and dispositions are all example values in a clearly-marked CONFIG block. Replace them with your own. One rule matters more than the rest: the break-glass justification lists a control set (CA-excluded, FIDO2, vaulted rotated password, sign-in alerting, GA-only). Trim it to only the controls actually in place — never claim a control you haven't implemented in something you'll hand an auditor.
The takeaway
The portal's roles view can't distinguish a standing Global Admin from someone mid-PIM-activation — and that distinction is the entire point of a no-standing-admin control. This script reads the role-assignment schedule instances to separate Assigned, Activated, and eligible; resolves tier-group members; flags partner GDAP paths; and seals it all into a date-stamped, SHA-256'd HTML report. It's read-only, it's a template you fill in, and it turns "trust me, we're on PIM" into evidence you can hand over.
Further reading & the script
- Get-IntunePrivilegedAccess.ps1the full sanitized script (right-click → save)
- Killing standing admin: tiered privileged access with PIMthe model this script proves
- roleAssignmentScheduleInstances — Microsoft Graphthe endpoint that carries assignmentType
The published script is a sanitized template; tenant, user, partner, and disposition values are examples to be replaced. Read-only, but review any script before running it against your tenant.
Comments
Questions or corrections welcome. Sign in with GitHub to join the thread.