Blue Team of One field notes · security

HomePowerShell LibraryIdentity & access

PowerShell · access reviews

A privileged-access reporter: who holds admin, and is it standing or JIT?

The Entra roles blade shows you who holds a privileged role. It doesn't show you the thing that actually matters for an access review — how they hold it. A permanent Global Admin and someone mid-PIM-activation look identical there, and they're opposites. This interactive, read-only script reads the schedule instances that tell them apart, across every privileged role, and hands you a hash-sealed report.

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:

StepWhat happens
Asks what to connect toPrompts for the tenant, connects read-only (with a device-code fallback), confirms the session actually established
Checks your access firstProbes 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 roleUses Entra's isPrivileged flag as the default scope — you can add extra roles, but all privileged is the baseline
Classifies how each holder holds itStanding (Assigned) vs live PIM activation (Activated) vs eligible-only — the distinction the portal blurs
Resolves groups & flags edge casesExpands role-assignable groups to members; detects cross-tenant partner (GDAP/DAP) grants and orphaned assignments to deleted groups
Detects PIM availabilityChecks for Entra ID P2; if there's no P2 (or nothing is eligible), that's called out as a finding
Writes hash-sealed evidenceA 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 caseHow it's handled
Cross-tenant partner grantsDelegated-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 assignmentsA 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 P2If 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

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.