Blue Team of One field notes · security

HomePowerShell LibraryIdentity & access

PowerShell · access reviews

Proving "no standing admin" to an auditor: an Intune privileged-access evidence generator

You moved every admin role onto PIM — now prove it. A screenshot of the roles blade doesn't distinguish a permanent Global Admin from someone mid-activation, and that difference is the entire point of your access-review control. This read-only script reads the schedule instances that do tell them apart, resolves group members, flags partner grants, and writes a hash-sealed evidence file.

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:

StepWhat happens
Scans the in-scope rolesIntune Administrator and Global Administrator (editable) across every principal that holds them
Separates the three statesStanding (Assigned) vs live PIM activation (Activated) vs eligible-only — the distinction the portal blurs
Resolves group membersRoles assigned to role-assignable tier groups are expanded to the actual people
Flags partner (GDAP) grantsCross-tenant partner access is detected, labelled, and noted as not-enumerable-here
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 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

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.