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, exactly who can act as an administrator right now and whether that access is standing or just-in-time — for an access review, a SOC 2 / ISO 27001 control, or your own sanity. The catch is that the obvious evidence is misleading, so the script exists to produce evidence that isn't.
01At a glance
Run it, answer a few prompts, get a report.
It's a single interactive script — no parameters to memorize, nothing to edit. Run it and it:
| Step | What happens |
|---|---|
| Asks what to connect to | Prompts for the tenant, connects read-only (with a device-code fallback), confirms the session actually established |
| Checks your access first | Probes the reads it needs; if the account can't read role data, it tells you exactly what role/scopes you need and exits |
| Scans every privileged role | Uses Entra's isPrivileged flag as the default scope — you can add extra roles, but all privileged is the baseline |
| Classifies how each holder holds it | Standing (Assigned) vs live PIM activation (Activated) vs eligible-only — the distinction the portal blurs |
| Resolves groups & flags edge cases | Expands role-assignable groups to members; detects cross-tenant partner (GDAP/DAP) grants and orphaned assignments to deleted groups |
| Detects PIM availability | Checks for Entra ID P2; if there's no P2 (or nothing is eligible), that's called out as a finding |
| 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 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 look identical there 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 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 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 grants that skip schedule instances 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 holder 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. A role assigned (not eligible) to a group means its members hold it standing via the group — which counts as standing too. Eligible-via-group is the only green case.
# the classification, distilled if ($atype -eq 'Activated') { 'Active - PIM-activated (JIT)' # time-bound, linked to an eligible assignment → NOT standing } else { 'Active - STANDING/DIRECT' # the real exception — target zero } # role Assigned to a group → members are 'Active - STANDING via group' (also standing) # role Eligible to a group → members are 'Eligible (PIM / JIT)' (the clean case)
04The edge cases it catches
Real tenants are messy — the script names the mess instead of choking on it.
Three things trip up a naïve scan, and each is handled as a finding rather than a crash:
| Edge case | How it's handled |
|---|---|
| Cross-tenant partner grants | Delegated-admin groups (AdminAgents, HelpdeskAgents, or the Name @( Org , domain ) foreign-principal form) are detected as GDAP/DAP, labelled, and not enumerated — their members live in the partner tenant. Flagged for review. |
| Orphaned assignments | A role still pointing at a deleted group returns a 404 on member lookup. Instead of failing, the script marks it ORPHANED (group deleted) — a stale grant to clean up. |
| No PIM / no P2 | If Entra ID P2 isn't present (or nothing is eligible), there's no just-in-time path at all — every holder is standing. That's surfaced as an explicit finding. |
A single un-readable group used to abort the whole run; now each role scan and each group read is isolated, so one bad object can't hide the rest of your privileged estate. (That mattered: a 404 on one deleted group was silently dropping Global Administrator from the report until this was fixed.)
05Dedup, 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 > eligible). Then 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 report footer
$canonical = ($records | Sort-Object Role, Name, State | ForEach-Object {
"$($_.Role)|$($_.Name)|$($_.Upn)|$($_.State)|$($_.AssignmentType)|$($_.IsPartner)|$($_.IsStanding)"
}) -join "`n"
$canonical += "`n$ConnectedTenant`n$stamp`n$Collector"
$sha = [System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($canonical))).Replace('-','').ToLower()
06Running it
Save it, run it as a file, answer the prompts.
# prerequisites (once) Install-Module Microsoft.Graph -Scope CurrentUser # run it as a FILE — do not paste the contents into the console .\Get-PrivilegedAccessReport.ps1
That's it — it asks for the tenant, whether to add any extra roles beyond all-privileged, and where to save the report. It connects read-only (RoleManagement.Read.Directory, Directory.Read.All, GroupMember.Read.All, User.Read.All), checks your access, scans, and drops a date-stamped HTML report in the folder you chose.
Run it as a file — not by pasting
A script this size has functions, loops, and try/catch blocks that only work when PowerShell reads the whole file at once. Paste the contents into the console line-by-line and multi-line blocks break (else and return land on their own and error). Save it and run .\Get-PrivilegedAccessReport.ps1. Use PowerShell 7 (pwsh) if you can — the Graph SDK is more reliable there. If sign-in fails with a Method not found … WithLogging error, that's a stale Graph/MSAL assembly; reinstall the SDK clean in a fresh window.
The takeaway
The portal's roles view can't tell 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 across every privileged role; expands groups; flags partner grants, orphaned assignments, and missing PIM; checks your own access first; and seals it all into a date-stamped, SHA-256'd HTML report. It's read-only, interactive, and turns "trust me, we're on PIM" into evidence you can hand over.
Further reading
- Killing standing admin: tiered privileged access with PIMthe model this script reports on
- roleAssignmentScheduleInstances — Microsoft Graphthe endpoint that carries assignmentType
- Privileged roles and permissions — Microsoft Learnwhat the isPrivileged flag covers
The published script is generic and parameter-free — no tenant-specific data is hard-coded; you answer prompts at runtime. 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.