Friday, August 21, 2026

Activate All Your Eligible PIM Roles

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:

BehaviourNon-terminating errorTerminating error
Written to $ErrorYesYes
Printed to consoleYesYes
Caught by try/catchNoYes
Execution continues in try blockYesNo

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 codeMeaningCorrect message
RoleAssignmentExistsThe role is already activatedAlready active — no action needed
RoleAssignmentRequestExists / pending approvalRequest submitted, awaiting an approverPending — do not assume access
Anything elseGenuine 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.

LabelBackgroundForeground
ACTIVATEDGreenBlack
ALREADY ACTIVEYellowBlack
PENDINGCyanBlack
FAILEDRedWhite
INFOGrayBlack

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.

LayerHow the policy is readDuration format
Entra IDGet-MgPolicyRoleManagementPolicyAssignment with -ExpandProperty "Policy($expand=Rules)"ISO 8601 (PT8H, P1D)
Azure resourcesroleManagementPolicyAssignmentspolicyIdeffectiveRulesISO 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

RequirementDetail
PowerShell7.x recommended
ModulesAz.Accounts, Az.Resources, Microsoft.Graph.Authentication, Microsoft.Graph.Identity.Governance, Microsoft.Graph.Identity.SignIns
Graph scopesUser.Read, RoleManagement.ReadWrite.Directory, RoleAssignmentSchedule.ReadWrite.Directory
LicensingPIM requires Microsoft Entra ID P2 or Microsoft Entra ID Governance
ARM API version2020-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

ItemNote
Approval-gated rolesReturn PENDING, not access. Confirm before relying on the role.
MFA / authentication contextRoles requiring step-up auth fail at activation unless the session already satisfies the claim.
Justification and ticket rulesIf the policy requires a ticket number, the static justification is rejected — add the ticket fields to the request body.
ARM scopeThe eligibility query returns every scope you are eligible at; activation is issued per scope.
Token lifetimeThe ARM token is fetched once; a very long role list could outlive it.
Duration fallbacks8 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

Featured Post

Activate All Your Eligible PIM Roles

Activate All Your Eligible PIM Roles in One Run — With Clean Status Output If you hold multiple Privileged Identity Management (PIM) eligibl...

Popular posts