Privileged Identity Management (PIM) is one of the cornerstones of a Zero Trust strategy in Microsoft Entra ID. Instead of leaving administrators permanently assigned to sensitive roles, PIM lets them remain eligible and then activate a role for a limited period, ideally with a justification and MFA challenge. That is great for security, but clicking through the Entra admin portal every time you need Global Reader or Exchange Administrator gets old fast, especially when a scheduled maintenance window involves three or four roles in a row.
This post walks through automating PIM role activations with PowerShell and the Microsoft Graph SDK. We will list your eligible assignments, activate a single role with a justification and duration, batch-activate a set of roles for a change window, and clean them up afterwards.
Prerequisites
- Microsoft Graph PowerShell SDK v2 or later:
Install-Module Microsoft.Graph -Scope CurrentUser - An Entra ID P2 or Microsoft Entra ID Governance license, which is required to use PIM
- At least one eligible role assignment on your own account (this is what you are activating)
- Delegated Graph scopes:
RoleAssignmentSchedule.ReadWrite.DirectoryandRoleEligibilitySchedule.Read.Directory
If your tenant enforces an approval workflow or extra MFA step for activation, the Graph call will fail with a policy error that tells you exactly which step is missing. That is fine: you still need to complete the approval in the portal, but this script handles the common case where you are the only approver required.
Connecting to Graph
The first step is a delegated sign-in with the scopes PIM needs. Using Connect-MgGraph with explicit scopes keeps the consent prompt honest, so you can see in the browser exactly what is being granted.
# Install modules if not already available
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module Microsoft.Graph.Identity.Governance -Scope CurrentUser
# Import module
Import-Module Microsoft.Graph.Identity.Governance
$scopes = @(
"RoleEligibilitySchedule.Read.Directory", # Read your eligible assignments
"RoleAssignmentSchedule.ReadWrite.Directory" # Activate your assignments
)
Connect-MgGraph -Scopes $scopes -NoWelcome -DeviceCode -ContextScope Process
$me = (Get-MgContext).Account
Write-Host "Signed in as $me"
Listing your eligible roles
Before activating anything, it helps to know what you are eligible for. The Get-MgRoleManagementDirectoryRoleEligibilitySchedule cmdlet returns every eligible assignment in the tenant. Filter by principalId eq [yourObjectId] so the result is just your own assignments.
$userId = (Get-MgUser -UserId $me).Id
$eligible = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
-Filter "principalId eq '$userId'" `
-ExpandProperty "roleDefinition"
$eligible | Select-Object `
@{n='Role'; e={$_.RoleDefinition.DisplayName}},
@{n='Scope'; e={$_.DirectoryScopeId}},
@{n='EndsOn'; e={$_.ScheduleInfo.Expiration.EndDateTime}} |
Format-Table -AutoSize
Each eligibility row gives you three pieces of information you need to activate: the role definition ID, the principal ID (your user object), and the directory scope. The scope is almost always / for tenant-wide roles, but PIM for Groups and administrative units use a more specific scope here.
Activating a single role
An activation is a role assignment schedule request with action selfActivate. The request body specifies how long you want the role for, a business justification, and a ticket number if your tenant policy requires one. Graph expects the duration as an ISO 8601 period, so two hours becomes PT2H.
function Enable-PimRole {
param(
[Parameter(Mandatory)][string]$RoleName,
[Parameter(Mandatory)][string]$Justification,
[string]$DurationIso = 'PT2H',
[string]$TicketNumber = '',
[string]$TicketSystem = 'Ticket'
)
# Get user ID from active session
$userId = (Get-MgUser -UserId (Get-MgContext).Account).Id
# Find matching eligible role
$match = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
-Filter "principalId eq '$userId'" `
-ExpandProperty "roleDefinition" |
Where-Object { $_.RoleDefinition.DisplayName -eq $RoleName }
if (-not $match) {
throw "No eligible assignment found for role '$RoleName'."
}
# Check for already active assignment
$active = Get-MgRoleManagementDirectoryRoleAssignmentSchedule `
-Filter "principalId eq '$userId' and roleDefinitionId eq '$($match.RoleDefinitionId)'" |
Where-Object { $_.AssignmentType -eq 'Activated' }
if ($active) {
Write-Host "'$RoleName' is active."
Write-Host "Expires: $($active.ScheduleInfo.Expiration.EndDateTime)"
return
}
# Check for existing pending approval request
$pending = Get-MgRoleManagementDirectoryRoleAssignmentScheduleRequest `
-Filter "principalId eq '$userId' and roleDefinitionId eq '$($match.RoleDefinitionId)' and status eq 'PendingApproval'"
if ($pending) {
Write-Warning "There is already a pending approval request for '$RoleName'."
Write-Warning "Request ID : $($pending.Id)"
Write-Warning "Submitted : $($pending.CreatedDateTime)"
Write-Warning "Status : $($pending.Status)"
return
}
# Build request body
$body = @{
action = 'selfActivate'
principalId = $userId
roleDefinitionId = $match.RoleDefinitionId
directoryScopeId = $match.DirectoryScopeId
justification = $Justification
scheduleInfo = @{
startDateTime = (Get-Date).ToUniversalTime().ToString('o')
expiration = @{
type = 'AfterDuration'
duration = $DurationIso
}
}
ticketInfo = @{
ticketNumber = $TicketNumber
ticketSystem = $TicketSystem
}
}
# Submit activation request
try {
$result = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $body
switch ($result.Status) {
'Provisioned' { Write-Host "'$RoleName' activated successfully." -ForegroundColor Green }
'PendingApproval' { Write-Host "'$RoleName' is pending approval." -ForegroundColor Yellow }
'PendingApprovalProvisioning'{ Write-Host "'$RoleName' approval request sent." -ForegroundColor Yellow }
default { Write-Host "'$RoleName' status: $($result.Status)" -ForegroundColor Cyan }
}
return $result
}
catch {
Write-Warning "Failed to activate '$RoleName': $($_.Exception.Message)"
}
}
Enable-PimRole -RoleName 'Global Reader' `
-Justification 'Weekly tenant health review' `
-DurationIso 'PT1H'
The cmdlet returns a request object with a status. A value of Provisioned means the role is live. PendingApproval means someone else still needs to approve it, and Failed usually points at an MFA, ticket, or duration policy violation.
Batch activating for a change window
Most maintenance work needs more than one role. The helper below reads a simple list of role names plus desired durations and activates each one. Wrapping the loop in try/catch means one policy-blocked role does not stop the rest of the batch.
$changeWindow = @(
@{ Role = 'Exchange Administrator'; Duration = 'PT3H' }
@{ Role = 'Teams Administrator'; Duration = 'PT3H' }
@{ Role = 'Global Reader'; Duration = 'PT3H' }
)
$justification = 'CHG0045821: monthly M365 patching window'
foreach ($entry in $changeWindow) {
try {
Enable-PimRole -RoleName $entry.Role `
-Justification $justification `
-DurationIso $entry.Duration `
-TicketNumber 'CHG0045821'
}
catch {
Write-Warning ("Failed to activate {0}: {1}" -f $entry.Role, $_.Exception.Message)
}
}
Deactivating when you are done
Roles expire on their own, but it is good hygiene to drop them the moment the change window closes. The action type this time is selfDeactivate, and you do not need to specify a schedule.
function Disable-PimRole {
param(
[Parameter(Mandatory)][string]$RoleName
)
# Get user ID from active session
$userId = (Get-MgUser -UserId (Get-MgContext).Account).Id
# Find matching eligible role
$match = Get-MgRoleManagementDirectoryRoleEligibilitySchedule `
-Filter "principalId eq '$userId'" `
-ExpandProperty "roleDefinition" |
Where-Object { $_.RoleDefinition.DisplayName -eq $RoleName }
if (-not $match) {
throw "No eligible assignment found for role '$RoleName'."
}
# Check that the role is actually active
$active = Get-MgRoleManagementDirectoryRoleAssignmentSchedule `
-Filter "principalId eq '$userId' and roleDefinitionId eq '$($match.RoleDefinitionId)'" |
Where-Object { $_.AssignmentType -eq 'Activated' }
if (-not $active) {
Write-Host "'$RoleName' is not currently active, nothing to deactivate."
return
}
# Build request body
$body = @{
action = 'selfDeactivate'
principalId = $userId
roleDefinitionId = $match.RoleDefinitionId
directoryScopeId = $match.DirectoryScopeId
}
# Submit deactivation request
try {
$result = New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $body
switch ($result.Status) {
'Revoked' { Write-Host "'$RoleName' deactivated successfully." -ForegroundColor Green }
default { Write-Host "'$RoleName' status: $($result.Status)" -ForegroundColor Cyan }
}
return $result
}
catch {
Write-Warning "Failed to deactivate '$RoleName': $($_.Exception.Message)"
}
}
'Exchange Administrator','Teams Administrator','Global Reader' | ForEach-Object {
Disable-PimRole -RoleName $_
}
Putting it all together
With three small functions you now have a repeatable pattern: list eligible assignments, activate the ones you need with a justification and duration, and deactivate them at the end of the maintenance window. Pair the script with a signed PowerShell module, drop it in a branded admin toolkit, or call it from an Azure Automation runbook if you want scheduled activations without a human at the keyboard.
The same Graph endpoints back PIM for Azure Resources and PIM for Groups, so once you are comfortable with roleManagement/directory the jump to roleManagement/enterpriseApps or privilegedAccess/aadGroups is mostly about swapping the resource namespace. The core pattern, an eligibility schedule plus a self-activate request, stays the same and gives you a clean, audit-friendly way to run least-privilege administration at the speed of modern IT operations.
