This is the walkthrough for fixing standing admin properly — moving every privileged role off direct-to-user assignments and onto just-in-time access that people activate only when they need it. It's the same pattern I now roll out to every tenant I manage. Concept first, then the exact commands, including all the places it bites you, because it bit me in every one of them.
01The concept
Two ideas do all the work.
1. Nobody holds admin at rest. Instead of a user having Global Administrator, they're eligible for it. When they need it, they activate through Privileged Identity Management (PIM) — with MFA, a justification, and (for the crown-jewel roles) an approval. The grant is time-bound and expires on its own. Phish a dormant account and there's no standing admin to steal.
2. Access is assigned to groups, not people. You build a small number of role-assignable security groups and assign the roles to those. A person's access is entirely determined by which group they're in. Onboarding is "add to one group," offboarding is "remove from one group," and your access review is "look at three groups" instead of auditing hundreds of individual assignments.
Layer those two together into tiers:
L1 Read reader roles standing is fine (read-only) L2 Operator readers + day-to-day admin readers standing, admin ELIGIBLE L3 Admin readers + L2 admin + tenant-owning readers standing, admin ELIGIBLE
Two design choices worth calling out, because they're not obvious:
| Choice | Why |
|---|---|
| Readers can stay standing | Read-only roles don't change anything, so permanent Global Reader / Security Reader is an acceptable risk and saves everyone the friction of activating just to look. Everything that can change something is eligible-only. |
| Tiers are cumulative, not nested | You'd think you could nest L1 inside L2 inside L3. You can't — Entra role-assignable groups cannot be nested. So each tier group is assigned the union of its own roles plus every tier below it. A person is in exactly one group and gets the full stack for their level. |
A naming note, to avoid confusion
I use L3 = most senior = most access. That's the inverse of Microsoft's own admin-tier model, where Tier 0 is the most privileged. Same idea, opposite numbers.
02Prerequisites
Without these, eligible assignments simply fail.
| Requirement | Why |
|---|---|
| Entra ID P2 | PIM eligibility and activation policies require it. Without P2, eligible assignments fail and you're stuck with standing access. |
| PowerShell 7 | The Graph SDK is heavy and noticeably more fragile on Windows PowerShell 5.1. Use pwsh (winget install Microsoft.PowerShell). |
| Rights | Privileged Role Administrator or Global Administrator. |
| Modules | Graph Authentication, Identity.Governance, Groups, Users. |
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
foreach ($m in 'Microsoft.Graph.Authentication','Microsoft.Graph.Identity.Governance','Microsoft.Graph.Groups','Microsoft.Graph.Users') {
if (-not (Get-Module -ListAvailable -Name $m)) { Install-Module $m -Scope CurrentUser -Force -AllowClobber }
}
Connect-MgGraph -NoWelcome -Scopes @(
'RoleManagement.ReadWrite.Directory','RoleManagementPolicy.ReadWrite.Directory',
'Group.ReadWrite.All','User.ReadWrite.All','Directory.Read.All'
)
03Break-glass first — non-negotiable do this first
The moment Global Administrator becomes eligible-only, one bad policy can lock everyone out.
Before touching anything, stand up two emergency accounts that live outside the whole system. A single misconfigured Conditional Access policy or PIM setting can lock everyone out — these are your fire escape.
$OnMicrosoftDomain = 'contoso.onmicrosoft.com'
$gaId = (Get-MgRoleManagementDirectoryRoleDefinition -Filter "displayName eq 'Global Administrator'").Id
function New-StrongPassword {
$chars = (48..57)+(65..90)+(97..122)+(33,35,37,42,43,45,61,63,64,95)
-join (1..32 | ForEach-Object { [char]($chars | Get-Random) })
}
1..2 | ForEach-Object {
$upn = "bg-emergency-$_@$OnMicrosoftDomain"
if (-not (Get-MgUser -Filter "userPrincipalName eq '$upn'" -All)) {
$pwd = New-StrongPassword
$u = New-MgUser -BodyParameter @{
accountEnabled=$true; displayName="Break Glass $_"
userPrincipalName=$upn; mailNickname="bg-emergency-$_"
passwordProfile=@{ password=$pwd; forceChangePasswordNextSignIn=$false }
}
New-MgRoleManagementDirectoryRoleAssignment -PrincipalId $u.Id -RoleDefinitionId $gaId -DirectoryScopeId '/' | Out-Null
Write-Host "CREATED $upn — vault this password: $pwd" -ForegroundColor Magenta
}
}
Then, by hand: exclude both accounts from every Conditional Access policy, keep them out of PIM and every tier group (they stay permanent-active GA), store the credentials split and offline, and test a sign-in once. Build them before you start the fire.
04Create the tier groups
Three role-assignable security groups.
function Ensure-RoleAssignableGroup([string]$name) {
$g = Get-MgGroup -Filter "displayName eq '$name'" -All | Select-Object -First 1
if ($g) { return $g }
$g = New-MgGroup -DisplayName $name -MailNickname ($name -replace '[^a-zA-Z0-9]','') `
-SecurityEnabled:$true -MailEnabled:$false -IsAssignableToRole:$true -GroupTypes @()
Start-Sleep -Seconds 8 # let it replicate before assigning roles (see gotchas)
$g
}
$gL1 = Ensure-RoleAssignableGroup "Tier L1 - Read"
$gL2 = Ensure-RoleAssignableGroup "Tier L2 - Operator"
$gL3 = Ensure-RoleAssignableGroup "Tier L3 - Admin"
IsAssignableToRole is set at creation and can't be changed later. The group must be security-enabled, not mail-enabled, with empty GroupTypes.
05Assign the cumulative roles
Readers active, everything that can change something eligible.
I resolve role IDs by display name so a mistyped GUID can't silently assign the wrong role.
$RoleId = @{}
Get-MgRoleManagementDirectoryRoleDefinition -All | ForEach-Object { $RoleId[$_.DisplayName] = $_.Id }
$L1 = 'Global Reader','Security Reader','Reports Reader','Message Center Reader'
$L2 = 'Security Operator','Helpdesk Administrator','Authentication Administrator',
'Groups Administrator','Teams Administrator','SharePoint Administrator',
'Intune Administrator','Exchange Administrator','Compliance Administrator',
'Attack Simulation Administrator'
$L3 = 'Global Administrator','Privileged Role Administrator',
'Privileged Authentication Administrator','Security Administrator',
'Conditional Access Administrator','Application Administrator',
'Cloud Application Administrator','User Administrator'
function Set-ActiveRole($gid,$name){ $rid=$RoleId[$name]
if(-not (Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$gid' and roleDefinitionId eq '$rid'" -All)){
New-MgRoleManagementDirectoryRoleAssignment -PrincipalId $gid -RoleDefinitionId $rid -DirectoryScopeId '/' | Out-Null } }
function Set-EligibleRole($gid,$name){ $rid=$RoleId[$name]
if(-not (Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "principalId eq '$gid' and roleDefinitionId eq '$rid'" -All)){
New-MgRoleManagementDirectoryRoleEligibilityScheduleRequest -BodyParameter @{
Action='AdminAssign'; PrincipalId=$gid; RoleDefinitionId=$rid; DirectoryScopeId='/'
Justification='Tiered access provisioning'
ScheduleInfo=@{ StartDateTime=(Get-Date).ToUniversalTime().ToString('o'); Expiration=@{ Type='NoExpiration' } }
} | Out-Null } }
$L1 | ForEach-Object { Set-ActiveRole $gL1.Id $_ }
$L1 | ForEach-Object { Set-ActiveRole $gL2.Id $_ }; $L2 | ForEach-Object { Set-EligibleRole $gL2.Id $_ }
$L1 | ForEach-Object { Set-ActiveRole $gL3.Id $_ }; ($L2+$L3) | ForEach-Object { Set-EligibleRole $gL3.Id $_ }
06Activation policies — the guardrails
Eligibility alone lets someone activate with no friction. The policy adds MFA, justification, a time limit, and approval.
function Set-PimPolicy($name,$maxDuration,$requireApproval,$approverGroupId){
$rid=$RoleId[$name]
$pa=Get-MgPolicyRoleManagementPolicyAssignment -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$rid'" | Select-Object -First 1
if(-not $pa){ Write-Warning "no PIM policy for $name (P2?)"; return }
$pid=$pa.PolicyId
$tgt=@{ '@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyRuleTarget'; caller='EndUser'; operations=@('all'); level='Assignment' }
Update-MgPolicyRoleManagementPolicyRule -UnifiedRoleManagementPolicyId $pid -UnifiedRoleManagementPolicyRuleId 'Expiration_EndUser_Assignment' -BodyParameter @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyExpirationRule'; id='Expiration_EndUser_Assignment'
isExpirationRequired=$true; maximumDuration=$maxDuration; target=$tgt } | Out-Null
Update-MgPolicyRoleManagementPolicyRule -UnifiedRoleManagementPolicyId $pid -UnifiedRoleManagementPolicyRuleId 'Enablement_EndUser_Assignment' -BodyParameter @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyEnablementRule'; id='Enablement_EndUser_Assignment'
enabledRules=@('MultiFactorAuthentication','Justification'); target=$tgt } | Out-Null
if($requireApproval -and $approverGroupId){
$approvers=@(@{ '@odata.type'='#microsoft.graph.groupMembers'; id=$approverGroupId; description='L3 approvers' })
Update-MgPolicyRoleManagementPolicyRule -UnifiedRoleManagementPolicyId $pid -UnifiedRoleManagementPolicyRuleId 'Approval_EndUser_Assignment' -BodyParameter @{
'@odata.type'='#microsoft.graph.unifiedRoleManagementPolicyApprovalRule'; id='Approval_EndUser_Assignment'
setting=@{ '@odata.type'='#microsoft.graph.approvalSettings'; isApprovalRequired=$true; isApprovalRequiredForExtension=$false; isRequestorJustificationRequired=$true; approvalMode='SingleStage'
approvalStages=@(@{ '@odata.type'='#microsoft.graph.unifiedApprovalStage'; approvalStageTimeOutInDays=1; isApproverJustificationRequired=$true; escalationTimeInMinutes=0; isEscalationEnabled=$false; primaryApprovers=$approvers }) }
target=$tgt } | Out-Null
}
}
$L2 | ForEach-Object { Set-PimPolicy $_ 'PT8H' $false $null }
$L3 | ForEach-Object { Set-PimPolicy $_ 'PT4H' $true '<approver-group-guid>' }
This part of the Graph API is finicky about the @odata.type and rule IDs. If a role errors here, its eligibility still works — only the guardrail didn't apply, which you can fix in the portal.
07Put people in tiers, then verify
Adding someone to a group grants eligibility — prove the activation path works before you rely on it.
$UserTierMap = @{ '[email protected]'='L3'; '[email protected]'='L2'; '[email protected]'='L1' }
$Groups = @{ 'L1'=$gL1; 'L2'=$gL2; 'L3'=$gL3 }
foreach($upn in $UserTierMap.Keys){
$g=$Groups[$UserTierMap[$upn]]; $u=Get-MgUser -Filter "userPrincipalName eq '$upn'" -All | Select-Object -First 1
if($u -and -not (Get-MgGroupMember -GroupId $g.Id -All | Where-Object Id -eq $u.Id)){
New-MgGroupMemberByRef -GroupId $g.Id -BodyParameter @{ '@odata.id'="https://graph.microsoft.com/v1.0/directoryObjects/$($u.Id)" }
}
}
Do not skip verification
Before you strip anyone's old access, have one person from each tier actually activate a role through the PIM portal and confirm it grants (and prompts for MFA / justification). Adding someone to a group grants eligibility; if the activation path is broken, you'll only find out when you've already deleted their standing access and they're locked out mid-shift.
08Strip the old standing assignments
Only now, once activation is proven, remove the legacy direct assignments.
Always dry-run first, and protect your break-glass and any special accounts.
$StripUsers = @('[email protected]','[email protected]','[email protected]')
$ProtectedDomains = @('contoso.onmicrosoft.com') # e.g. break-glass / billing domain
$Commit = $false # flip to $true to execute
function Is-Protected($upn){ if(-not $upn){return $true}; $l=$upn.ToLower()
foreach($d in $ProtectedDomains){ if($l.EndsWith('@'+$d)){return $true} }
if($l.StartsWith('bg-emergency-')){return $true}; return $false }
$stripLower = $StripUsers | ForEach-Object { $_.ToLower() }
foreach($name in ($L1+$L2+$L3)){
$rid=$RoleId[$name]; if(-not $rid){continue}
Get-MgRoleManagementDirectoryRoleAssignment -Filter "roleDefinitionId eq '$rid'" -ExpandProperty Principal -All | ForEach-Object {
$upn=$_.Principal.AdditionalProperties.userPrincipalName
if(-not $upn){return}; if(-not ($stripLower -contains $upn.ToLower())){return}
if(Is-Protected $upn){ Write-Host "[protected] $upn / $name" -ForegroundColor DarkGray; return }
if($Commit){ Remove-MgRoleManagementDirectoryRoleAssignment -UnifiedRoleAssignmentId $_.Id; Write-Host "removed: $upn / $name" -ForegroundColor Green }
else { Write-Host "[dry-run] would remove: $upn / $name" -ForegroundColor Yellow }
}
}
09The gotchas — all real, all hit
This is the part the docs don't tell you.
You cannot remove your own Global Administrator
Signed in as yourself, deleting your own GA fails with "Removing self from Global Administrator built-in role is not allowed." It doesn't matter whether your privilege comes from the direct assignment or a group activation — Entra keys off who you're signed in as. Fix: have another admin do it. They activate GA via their tier group, connect as themselves, and remove yours.
The success message can lie
If your removal loop prints a "removed" line with Write-Host right after the delete call, that line fires even when the delete threw. I "removed" my own GA four times before realising it was still there. Always re-query to confirm, don't trust the log line:
Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($me.Id)' and roleDefinitionId eq '$rid'" -All
# empty = actually gone
Cross-tenant partner grants look like bugs but aren't
If you're managed by an MSP, you may find an admin role assigned to a group you can't open — Get-MgGroupMember returns a flat 404. That's because the group lives in the partner's tenant, not yours. Confirm by checking the assignment's home org:
Get-MgRoleManagementDirectoryRoleAssignment -Filter "roleDefinitionId eq '$rid'" -All |
Where-Object { $_.AdditionalProperties.principalOrganizationId } |
ForEach-Object { $_.AdditionalProperties.principalOrganizationId }
A principalOrganizationId different from your tenant = a delegated-admin grant. Then check whether it's the modern GDAP model or legacy DAP:
Get-MgTenantRelationshipDelegatedAdminRelationship -All | Select-Object DisplayName, Status
Relationships listed = GDAP (manage it in Partner Center). Nothing listed but a cross-tenant assignment exists = legacy DAP, which Microsoft is retiring. Either way, do not delete it from your own tenant — it re-syncs or orphans. Manage it partner-side, and in an audit, disclose and label it rather than hiding it.
The original-domain admin
Lots of tenants have a standing GA on the very first *.onmicrosoft.com domain, often tied to billing. Don't strip it blind — confirm what uses it, and consider dropping it to Billing Administrator if it doesn't truly need GA. Protect it in the strip script until you know.
PowerShell version traps
If you're on 5.1, PS7-only syntax detonates: the ternary ? :, the null-conditional ?., and calling .ToLower() on an array all throw parser errors. Either run in pwsh or write it 5.1-safe with if/else, explicit null guards, and | ForEach-Object { $_.ToLower() }.
Small operational ones
| Symptom | Fix |
|---|---|
| running scripts is disabled | Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, or Unblock-File .\script.ps1 for a single downloaded file |
| The paging file is too small launching the Graph SDK | You're out of RAM; close apps, raise the page file, reboot |
| WAM auth popup won't complete | Set-MgGraphOption -DisableLoginByWAM $true |
| Role assignment fails right after creating the group | Replication lag; sleep a few seconds and re-run (all the code above is idempotent) |
What you're left with
No standing admin. Every privileged role is activated on demand, with MFA and a justification, and it expires on its own. Onboarding and offboarding are a single group change. Your entire privileged-access surface is three groups you can hand an auditor. And the one or two standing accounts that remain — break-glass and, where it applies, a partner path — are deliberate, documented, and disclosed, not accidents nobody remembered. The goal was never zero privileged access. It's that every bit of it exists on purpose.
Further reading
- What is Privileged Identity Management — Microsoft Learneligibility, activation, and policies
- Role-assignable groups in Entra IDthe group model this relies on
- Manage emergency access accountsthe break-glass pattern
- Why a stolen token dies on the wrong laptopthe token side of privileged-access defence
Anonymized from real deployment work; tenant, domain, and user names are generic examples. Test in a lab and dry-run every destructive step before running against production.
Comments
Questions or corrections welcome. Sign in with GitHub to join the thread.