Activate All Your Eligible PIM Roles in One Run — With Clean Status Output
If you hold multiple Privileged Identity Management (PIM) eligible assignments — some in Entra ID, some on Azure resources — activating them one at a time through the portal is a daily tax. A PowerShell script can do the whole set in a single run.
The interesting part isn't the activation call. It's making the output readable when half your roles are already active.
The problem: a false success line
A first-pass activation loop usually looks like this:
try {
New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $body | Out-Null
Write-Host "Activated: $roleName" -ForegroundColor Green
}
catch {
Write-Host "Failed: $roleName" -ForegroundColor Red
}
Run it twice in the same day and the console fills with red exception blocks like this:
The Role assignment already exists. Status: 400 (BadRequest) ErrorCode: RoleAssignmentExists
Recommendation: See service error codes: https://learn.microsoft.com/graph/errors
Activated : User Administrator for 9 hr(s)
Both the error and the success line print for the same role. That is not a cosmetic bug — the script is reporting an activation that never happened.
Why the catch block is skipped
Microsoft Graph PowerShell SDK cmdlets emit non-terminating errors by default. A non-terminating error is written to the error stream and execution continues to the next statement. try/catch only intercepts terminating errors, so:
| Behaviour | Non-terminating error | Terminating error |
|---|---|---|
Written to $Error | Yes | Yes |
| Printed to console | Yes | Yes |
Caught by try/catch | No | Yes |
Execution continues in try block | Yes | No |
The fix is one parameter:
New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter $body -ErrorAction Stop
-ErrorAction Stop promotes the non-terminating error to a terminating one for that call, so the catch fires and the success line is skipped.
The ARM half of the script uses Invoke-RestMethod, which throws on any non-2xx response, so it was already catching correctly — but its output deserves the same treatment.
Distinguishing "already active" from "failed"
Once the error is caught, the message needs to be classified. Three API responses matter:
| Error code | Meaning | Correct message |
|---|---|---|
RoleAssignmentExists | The role is already activated | Already active — no action needed |
RoleAssignmentRequestExists / pending approval | Request submitted, awaiting an approver | Pending — do not assume access |
| Anything else | Genuine failure (scope, policy, MFA, justification rules) | Failed, with the API message |
Collapsing the second row into the first is a real risk: an approval-gated role reports as "already active" and you go on assuming you have access you don't have. Keeping PENDING as its own state avoids that.
Colour-coded status labels
Instead of dumping exception objects, each role prints a single padded label with a background colour. Write-Host supports -BackgroundColor and -NoNewline, which is enough to build a badge-style line.
| Label | Background | Foreground |
|---|---|---|
ACTIVATED | Green | Black |
ALREADY ACTIVE | Yellow | Black |
PENDING | Cyan | Black |
FAILED | Red | White |
INFO | Gray | Black |
Sample output:
===== PART 1 - Entra ID (Microsoft Graph) PIM Roles =====
Logged in as: user@contoso.com
ALREADY ACTIVE User Administrator - role assignment already exists
ACTIVATED Exchange Administrator for 8 hr(s)
PENDING Global Reader - request awaiting approval
Auto-detecting the maximum duration
Hard-coding PT8H fails on any role whose policy caps activation lower. Both APIs expose the cap through the Expiration_EndUser_Assignment rule on the role management policy.
| Layer | How the policy is read | Duration format |
|---|---|---|
| Entra ID | Get-MgPolicyRoleManagementPolicyAssignment with -ExpandProperty "Policy($expand=Rules)" | ISO 8601 (PT8H, P1D) |
| Azure resources | roleManagementPolicyAssignments → policyId → effectiveRules | ISO 8601 (PT8H, P1D) |
The value comes back as an ISO 8601 duration, so a small regex converts it to hours:
if ($rawDuration -match "PT(\d+)H") {
$hours = [int]$Matches[1]
} elseif ($rawDuration -match "P(\d+)D") {
$hours = [int]$Matches[1] * 24
}
One detail worth isolating: the policy lookup gets its own try/catch. If reading the policy fails, the script should fall back to a default duration and still attempt activation — not mark the role as failed before it has tried anything.
Prerequisites
| Requirement | Detail |
|---|---|
| PowerShell | 7.x recommended |
| Modules | Az.Accounts, Az.Resources, Microsoft.Graph.Authentication, Microsoft.Graph.Identity.Governance, Microsoft.Graph.Identity.SignIns |
| Graph scopes | User.Read, RoleManagement.ReadWrite.Directory, RoleAssignmentSchedule.ReadWrite.Directory |
| Licensing | PIM requires Microsoft Entra ID P2 or Microsoft Entra ID Governance |
| ARM API version | 2020-10-01 for eligibility and assignment schedule requests |
All Graph sub-modules are installed at the same version as Microsoft.Graph.Authentication. Mixed versions across Graph sub-modules are a common source of assembly load errors.
Execution policy note
If the script is downloaded rather than typed locally, Windows attaches the Mark of the Web and RemoteSigned will refuse to run it:
Unblock-File -Path .\AzureRoleEnable_And_Contribute-v7.ps1
If that isn't enough, check Get-ExecutionPolicy -List. A MachinePolicy or UserPolicy scope set to AllSigned comes from group policy and can't be overridden locally — use Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass for the current session instead.
Complete script
# ============================================================
# Azure PIM Role Activator - Entra ID + Azure Resources
# ============================================================
# -- Status writer ------------------------------------------------------------
function Write-Status {
param(
[ValidateSet("Activated", "AlreadyActive", "Pending", "Failed", "Info")]
[string]$State,
[string]$Message
)
switch ($State) {
"Activated" { $label = " ACTIVATED "; $fg = "Black"; $bg = "Green" }
"AlreadyActive" { $label = " ALREADY ACTIVE "; $fg = "Black"; $bg = "Yellow" }
"Pending" { $label = " PENDING "; $fg = "Black"; $bg = "Cyan" }
"Failed" { $label = " FAILED "; $fg = "White"; $bg = "Red" }
"Info" { $label = " INFO "; $fg = "Black"; $bg = "Gray" }
}
Write-Host " " -NoNewline
Write-Host $label -ForegroundColor $fg -BackgroundColor $bg -NoNewline
Write-Host " $Message"
}
# -- Classify an exception into a state ---------------------------------------
function Get-PimErrorState {
param($ErrorRecord)
$text = @(
$ErrorRecord.Exception.Message
$ErrorRecord.ErrorDetails.Message
) -join " "
if ($text -match "RoleAssignmentExists|already exists") {
return "AlreadyActive"
} elseif ($text -match "PendingRoleAssignmentRequest|RoleAssignmentRequestExists|pending") {
return "Pending"
} else {
return "Failed"
}
}
function Get-PimErrorText {
param($ErrorRecord)
$detail = $null
if ($ErrorRecord.ErrorDetails.Message) {
$detail = ($ErrorRecord.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue).error.message
}
if ($detail) { return $detail } else { return $ErrorRecord.Exception.Message }
}
# -- Install & import modules -------------------------------------------------
foreach ($module in @("Az.Accounts", "Az.Resources")) {
if (-not (Get-Module -ListAvailable -Name $module)) {
Write-Host "Installing $module..." -ForegroundColor Yellow
Install-Module $module -Scope CurrentUser -Force -AllowClobber
}
Import-Module $module -ErrorAction Stop
}
$targetVersion = (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1).Version
if (-not $targetVersion) {
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force -AllowClobber
$targetVersion = (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1).Version
}
foreach ($module in @("Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.Governance", "Microsoft.Graph.Identity.SignIns")) {
if (-not (Get-Module -ListAvailable -Name $module | Where-Object { $_.Version -eq $targetVersion })) {
Write-Host "Installing $module $targetVersion..." -ForegroundColor Yellow
Install-Module $module -RequiredVersion $targetVersion -Scope CurrentUser -Force -AllowClobber
}
Import-Module $module -RequiredVersion $targetVersion -ErrorAction Stop
}
# ============================================================
# PART 1 - Entra ID (Microsoft Graph) PIM roles
# ============================================================
Write-Host "`n===== PART 1 - Entra ID (Microsoft Graph) PIM Roles =====" -ForegroundColor Cyan
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Connect-MgGraph -Scopes "User.Read", "RoleManagement.ReadWrite.Directory", "RoleAssignmentSchedule.ReadWrite.Directory" -NoWelcome
$user = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/me" -Method GET
if (-not $user) { Write-Error "Failed to retrieve user."; exit }
Write-Host "Logged in as: $($user.userPrincipalName)" -ForegroundColor Green
$eligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -Filter "principalId eq '$($user.id)'" -ExpandProperty RoleDefinition
if (-not $eligibleRoles) { Write-Warning "No eligible Entra ID PIM roles found." }
foreach ($role in $eligibleRoles) {
$roleName = $role.RoleDefinition.DisplayName
# Max allowed hours from the PIM policy
$hours = 8
try {
$rawDuration = ((Get-MgPolicyRoleManagementPolicyAssignment `
-Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$($role.RoleDefinitionId)'" `
-ExpandProperty "Policy(`$expand=Rules)" -ErrorAction Stop).Policy.Rules | Where-Object { $_.Id -eq "Expiration_EndUser_Assignment" }).AdditionalProperties["maximumDuration"]
if ($rawDuration -match "PT(\d+)H") {
$hours = [int]$Matches[1]
} elseif ($rawDuration -match "P(\d+)D") {
$hours = [int]$Matches[1] * 24
}
} catch {
Write-Host " Could not read policy for '$roleName', defaulting to $hours hr" -ForegroundColor DarkYellow
}
# Activate
try {
New-MgRoleManagementDirectoryRoleAssignmentScheduleRequest -BodyParameter @{
Action = "selfActivate"
PrincipalId = $user.id
RoleDefinitionId = $role.RoleDefinitionId
DirectoryScopeId = $role.DirectoryScopeId
Justification = "Daily administrative operations"
ScheduleInfo = @{ StartDateTime = (Get-Date).ToUniversalTime(); Expiration = @{ Type = "AfterDuration"; Duration = "PT${hours}H" } }
} -ErrorAction Stop | Out-Null
Write-Status -State "Activated" -Message "$roleName for $hours hr(s)"
} catch {
$state = Get-PimErrorState -ErrorRecord $_
if ($state -eq "AlreadyActive") {
Write-Status -State "AlreadyActive" -Message "$roleName - role assignment already exists"
} elseif ($state -eq "Pending") {
Write-Status -State "Pending" -Message "$roleName - request awaiting approval"
} else {
Write-Status -State "Failed" -Message "$roleName - $(Get-PimErrorText -ErrorRecord $_)"
}
}
}
# ============================================================
# PART 2 - Azure resource (ARM) PIM roles
# ============================================================
Write-Host "`n===== PART 2 - Azure Resources (ARM) PIM Roles =====" -ForegroundColor Cyan
Connect-AzAccount | Out-Null
$tokenObj = Get-AzAccessToken -ResourceUrl "https://management.azure.com"
$token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tokenObj.Token))
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
$userId = (Get-AzADUser -SignedIn).Id
Write-Host "Subscription : $((Get-AzContext).Subscription.Name)" -ForegroundColor Green
$roles = (Invoke-RestMethod `
-Uri "https://management.azure.com/providers/Microsoft.Authorization/roleEligibilityScheduleInstances?api-version=2020-10-01&`$filter=asTarget()" `
-Headers $headers).value
if (-not $roles) {
Write-Warning "No eligible Azure resource roles found."
} else {
$roles | ForEach-Object {
[PSCustomObject]@{
Role = $_.properties.expandedProperties.roleDefinition.displayName
Resource = $_.properties.expandedProperties.scope.displayName
Type = $_.properties.expandedProperties.scope.type
Status = $_.properties.status
}
} | Format-Table -AutoSize
}
foreach ($role in $roles) {
$roleName = $role.properties.expandedProperties.roleDefinition.displayName
$scopeName = $role.properties.expandedProperties.scope.displayName
$scopePath = $role.properties.scope
$roleDefId = $role.properties.roleDefinitionId
# Max allowed hours from the PIM policy
try {
$policyAssignments = (Invoke-RestMethod `
-Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleManagementPolicyAssignments?api-version=2020-10-01&`$filter=roleDefinitionId eq '$roleDefId'" `
-Headers $headers).value
$policyId = $policyAssignments[0].properties.policyId
$rules = (Invoke-RestMethod `
-Uri "https://management.azure.com$policyId`?api-version=2020-10-01" `
-Headers $headers).properties.effectiveRules
$rawDuration = ($rules | Where-Object { $_.id -eq "Expiration_EndUser_Assignment" }).maximumDuration
if ($rawDuration -match "PT(\d+)H") {
$hours = [int]$Matches[1]
} elseif ($rawDuration -match "P(\d+)D") {
$hours = [int]$Matches[1] * 24
} else {
$hours = 1
}
} catch {
$hours = 1
Write-Host " Could not read policy for '$roleName', defaulting to 1hr" -ForegroundColor DarkYellow
}
# Activate
try {
$body = @{ properties = @{
principalId = $userId
roleDefinitionId = $roleDefId
requestType = "SelfActivate"
linkedRoleEligibilityScheduleId = $role.properties.roleEligibilityScheduleId
justification = "Daily administrative operations"
scheduleInfo = @{ expiration = @{ type = "AfterDuration"; duration = "PT${hours}H" } }
}} | ConvertTo-Json -Depth 10
Invoke-RestMethod `
-Uri "https://management.azure.com$scopePath/providers/Microsoft.Authorization/roleAssignmentScheduleRequests/$([guid]::NewGuid())?api-version=2020-10-01" `
-Headers $headers -Method Put -Body $body -ErrorAction Stop | Out-Null
Write-Status -State "Activated" -Message "$roleName -> $scopeName for $hours hour(s)"
} catch {
$state = Get-PimErrorState -ErrorRecord $_
if ($state -eq "AlreadyActive") {
Write-Status -State "AlreadyActive" -Message "$roleName -> $scopeName - role assignment already exists"
} elseif ($state -eq "Pending") {
Write-Status -State "Pending" -Message "$roleName -> $scopeName - request awaiting approval"
} else {
Write-Status -State "Failed" -Message "$roleName -> $scopeName - $(Get-PimErrorText -ErrorRecord $_)"
}
}
}
Write-Host "`nDone!" -ForegroundColor Cyan
Things to watch
| Item | Note |
|---|---|
| Approval-gated roles | Return PENDING, not access. Confirm before relying on the role. |
| MFA / authentication context | Roles requiring step-up auth fail at activation unless the session already satisfies the claim. |
| Justification and ticket rules | If the policy requires a ticket number, the static justification is rejected — add the ticket fields to the request body. |
| ARM scope | The eligibility query returns every scope you are eligible at; activation is issued per scope. |
| Token lifetime | The ARM token is fetched once; a very long role list could outlive it. |
| Duration fallbacks | 8 hours for Entra ID, 1 hour for ARM when the policy can't be read — both are safe minimums, not policy truth. |
Takeaway
The single most useful line in the whole script is -ErrorAction Stop. Without it, a Graph SDK cmdlet fails loudly and the script reports success anyway. With it, every outcome flows through one classifier and one status writer, and a re-run tells you honestly what changed and what didn't.
No comments:
Post a Comment