Why Implement PIM?

Privileged Identity Management (PIM) is one of the most effective security controls available in Azure AD. Traditional role assignments grant permanent, standing privileges that create significant security risks:

  • Expanded Attack Surface: Compromised accounts with permanent privileges give attackers immediate access
  • Compliance Gaps: Audit requirements often mandate Just-In-Time (JIT) access for privileged operations
  • Privilege Creep: Over time, users accumulate unnecessary permanent role assignments

PIM transforms these permanent assignments into time-bound, audited, and justified access - dramatically reducing your organization’s risk profile.

Understanding PIM Architecture

Before implementation, it’s crucial to understand PIM’s components:

Role Management Policies

Every Azure AD role has an associated role management policy that controls:

  • Activation requirements (MFA, justification, approval)
  • Maximum activation duration
  • Assignment eligibility rules
  • Notification settings

These policies are identified by a specific format:

DirectoryRole_<TenantID>_<RoleDefinitionID>

Assignment Types

  • Eligible: User can activate the role when needed
  • Active: Traditional permanent assignment (what we’re converting from)
  • Time-bound: Temporary assignments with specific start/end dates

Phase 1: Discovery and Planning

Step 1: Audit Current Role Assignments

First, identify all permanent role assignments in your tenant:

# Connect to Microsoft Graph
Import-Module Microsoft.Graph
Connect-MgGraph -Scopes "RoleManagement.Read.Directory", "Directory.Read.All"

# Get all active directory role assignments
$assignments = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments" -OutputType PSObject

# Export for analysis
$assignments.value | Select-Object principalId, roleDefinitionId, directoryScopeId | 
    Export-Csv -Path "current_role_assignments.csv" -NoTypeInformation

Write-Host "Found $($assignments.value.Count) role assignments" -ForegroundColor Green

Step 2: Identify Conversion Candidates

Not all assignments should be converted immediately. Prioritize based on:

  1. High-Risk Roles: Global Administrator, Privileged Role Administrator, Security Administrator
  2. User Assignments: Convert user accounts before service principals
  3. Non-Production: Test in dev/test environments first
  4. Business Impact: Consider operational requirements and team readiness

Create a CSV with your conversion plan:

PrincipalName,PrincipalType,RoleName,ConversionPriority,Notes
[email protected],User,Global Administrator,High,Primary admin account
monitoring-sp,ServicePrincipal,Directory Readers,Low,24/7 monitoring - possible exemption

Step 3: Validate Principals

Before conversion, ensure all principals exist and are correctly identified:

# Validate principals from your conversion plan
$conversionPlan = Import-Csv "conversion_plan.csv"
$validatedPrincipals = @()

foreach ($item in $conversionPlan) {
    try {
        if ($item.PrincipalType -eq "User") {
            $principal = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users?`$filter=userPrincipalName eq '$($item.PrincipalName)'" -OutputType PSObject
            $principalId = $principal.value[0].id
        } else {
            $principal = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=displayName eq '$($item.PrincipalName)'" -OutputType PSObject
            $principalId = $principal.value[0].id
        }
        
        if ($principalId) {
            $validatedPrincipals += [PSCustomObject]@{
                PrincipalName = $item.PrincipalName
                PrincipalId = $principalId
                PrincipalType = $item.PrincipalType
                RoleName = $item.RoleName
                Status = "Validated"
            }
            Write-Host "✓ Validated: $($item.PrincipalName)" -ForegroundColor Green
        }
    } catch {
        Write-Host "✗ Failed to validate: $($item.PrincipalName) - $($_.Exception.Message)" -ForegroundColor Red
    }
}

$validatedPrincipals | Export-Csv -Path "validated_principals.csv" -NoTypeInformation

Phase 2: Role Management Policy Configuration

Before converting assignments, configure your role management policies. A common requirement is disabling the justification requirement for activation.

Understanding Role Management Policies

Each role has a policy with multiple rules:

  • Activation Rules: Control how roles are activated (MFA, justification, approval)
  • Assignment Rules: Control how roles are assigned
  • Notification Rules: Control who gets notified

Configuring Activation Requirements

Here’s how to disable the justification requirement for roles:

# Connect with policy write permissions
Connect-MgGraph -Scopes "RoleManagementPolicy.ReadWrite.Directory", "Directory.Read.All"

# Define roles to update
$roles = @(
    "Application Administrator",
    "Authentication Administrator",
    "Security Administrator"
    # Add other roles as needed
)

# Get all role definitions
$roleDefinitions = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions" -OutputType PSObject

# Create role name to ID mapping
$roleNameToId = @{}
foreach ($role in $roleDefinitions.value) {
    $roleNameToId[$role.displayName] = $role.id
}

# Get all role management policies
$policies = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/policies/roleManagementPolicies?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'" -OutputType PSObject

# Create policy mapping by extracting role definition ID from policy ID
$rolePolicyMap = @{}
foreach ($policy in $policies.value) {
    if ($policy.id -match "DirectoryRole_[^_]+_(.+)$") {
        $roleDefId = $matches[1]
        # Find the role name for this definition ID
        $roleName = ($roleDefinitions.value | Where-Object { $_.id -eq $roleDefId }).displayName
        if ($roleName) {
            $rolePolicyMap[$roleName] = $policy
        }
    }
}

# Update each role's policy
foreach ($roleName in $roles) {
    $policy = $rolePolicyMap[$roleName]
    
    if ($policy) {
        Write-Host "Processing: $roleName" -ForegroundColor Cyan
        
        # Get policy rules
        $rules = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/policies/roleManagementPolicies/$($policy.id)/rules" -OutputType PSObject
        
        # Find activation rule
        $activationRule = $rules.value | Where-Object { 
            $_.'@odata.type' -eq '#microsoft.graph.unifiedRoleManagementPolicyActivationRule'
        }
        
        if ($activationRule -and $activationRule.enabledRules -contains "Justification") {
            # Remove Justification from enabled rules
            $newEnabledRules = $activationRule.enabledRules | Where-Object { $_ -ne "Justification" }
            
            $updateBody = @{
                "@odata.type" = $activationRule.'@odata.type'
                "id" = $activationRule.id
                "isEnabled" = $activationRule.isEnabled
                "claimValue" = $activationRule.claimValue
                "enabledRules" = $newEnabledRules
                "maximumDuration" = $activationRule.maximumDuration
            }
            
            $uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicies/$($policy.id)/rules/$($activationRule.id)"
            Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body ($updateBody | ConvertTo-Json -Depth 10) -ContentType "application/json"
            
            Write-Host "✓ Disabled justification requirement for $roleName" -ForegroundColor Green
        }
    }
}

Configuring Activation Duration

You may also want to adjust the maximum activation duration:

# Update activation duration to 4 hours
$updateBody = @{
    "@odata.type" = "#microsoft.graph.unifiedRoleManagementPolicyActivationRule"
    "id" = $activationRule.id
    "isEnabled" = $true
    "maximumDuration" = "PT4H"  # ISO 8601 duration format (4 hours)
    "enabledRules" = $activationRule.enabledRules
}

Invoke-MgGraphRequest -Method PATCH -Uri $uri -Body ($updateBody | ConvertTo-Json) -ContentType "application/json"

Phase 3: Converting to PIM Eligible Assignments

Now that policies are configured, convert permanent assignments to PIM eligible:

# Load validated principals
$validatedPrincipals = Import-Csv "validated_principals.csv"

# Conversion results tracking
$conversionResults = @()

foreach ($principal in $validatedPrincipals) {
    Write-Host "`nConverting: $($principal.PrincipalName) - $($principal.RoleName)" -ForegroundColor White
    
    try {
        # Get role definition
        $roleDefinition = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?`$filter=displayName eq '$($principal.RoleName)'" -OutputType PSObject
        $roleDefId = $roleDefinition.value[0].id
        
        # Check for existing eligible assignment
        $existingEligible = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?`$filter=principalId eq '$($principal.PrincipalId)' and roleDefinitionId eq '$roleDefId' and status eq 'Provisioned'" -OutputType PSObject -ErrorAction SilentlyContinue
        
        if (!$existingEligible.value) {
            # Create PIM eligible assignment
            $requestBody = @{
                action = "adminAssign"
                justification = "Converting permanent active assignment to PIM eligible"
                roleDefinitionId = $roleDefId
                directoryScopeId = "/"
                principalId = $principal.PrincipalId
                scheduleInfo = @{
                    startDateTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
                    expiration = @{
                        type = "noExpiration"
                    }
                }
            }
            
            Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilityScheduleRequests" -Body ($requestBody | ConvertTo-Json -Depth 10) -ContentType "application/json"
            Write-Host "  ✓ Created PIM eligible assignment" -ForegroundColor Green
        }
        
        # Remove active assignment
        $activeAssignments = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=principalId eq '$($principal.PrincipalId)' and roleDefinitionId eq '$roleDefId'" -OutputType PSObject
        
        foreach ($assignment in $activeAssignments.value) {
            Invoke-MgGraphRequest -Method DELETE -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments/$($assignment.id)"
            Write-Host "  ✓ Removed active assignment" -ForegroundColor Green
        }
        
        $conversionResults += [PSCustomObject]@{
            PrincipalName = $principal.PrincipalName
            RoleName = $principal.RoleName
            Status = "Success"
            Error = ""
        }
        
    } catch {
        Write-Host "  ✗ Failed: $($_.Exception.Message)" -ForegroundColor Red
        $conversionResults += [PSCustomObject]@{
            PrincipalName = $principal.PrincipalName
            RoleName = $principal.RoleName
            Status = "Failed"
            Error = $_.Exception.Message
        }
    }
    
    Start-Sleep -Seconds 1
}

# Export results
$conversionResults | Export-Csv -Path "pim_conversion_results.csv" -NoTypeInformation

Phase 4: Automating PIM Activation

For service principals and automation accounts, create activation functions:

function Activate-PIMRole {
    param(
        [Parameter(Mandatory=$true)]
        [string]$PrincipalId,
        
        [Parameter(Mandatory=$true)]
        [string]$RoleName,
        
        [Parameter(Mandatory=$false)]
        [int]$DurationInHours = 4,
        
        [Parameter(Mandatory=$false)]
        [string]$Justification = "Automated process execution"
    )
    
    try {
        # Get role definition
        $roleDefinition = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?`$filter=displayName eq '$RoleName'" -OutputType PSObject
        $roleDefId = $roleDefinition.value[0].id
        
        # Create activation request
        $requestBody = @{
            action = "selfActivate"
            principalId = $PrincipalId
            roleDefinitionId = $roleDefId
            directoryScopeId = "/"
            justification = $Justification
            scheduleInfo = @{
                startDateTime = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
                expiration = @{
                    type = "AfterDuration"
                    duration = "PT$($DurationInHours)H"
                }
            }
        }
        
        $response = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignmentScheduleRequests" -Body ($requestBody | ConvertTo-Json -Depth 10) -ContentType "application/json"
        
        Write-Host "✓ Role '$RoleName' activated for $DurationInHours hours" -ForegroundColor Green
        return $response
        
    } catch {
        Write-Error "Failed to activate role: $($_.Exception.Message)"
        throw
    }
}

# Usage example
Activate-PIMRole -PrincipalId "12345678-1234-1234-1234-123456789012" -RoleName "Security Administrator" -DurationInHours 2 -Justification "Security incident response"

Integration with Automation Runbooks

For Azure Automation runbooks:

# In your runbook
param(
    [Parameter(Mandatory=$true)]
    [string]$RoleName
)

# Connect using managed identity
Connect-AzAccount -Identity

# Get runbook's managed identity
$managedIdentity = Get-AzADServicePrincipal -DisplayName $env:AUTOMATION_ACCOUNT_NAME

# Activate role
Activate-PIMRole -PrincipalId $managedIdentity.Id -RoleName $RoleName -DurationInHours 1

# Perform privileged operations
# ...

# Role automatically deactivates after duration expires

Phase 5: Monitoring and Governance

Setting Up Audit Queries

Monitor PIM activations using Azure AD audit logs:

# Query recent PIM activations
$startDate = (Get-Date).AddDays(-7)
$auditLogs = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $($startDate.ToString('yyyy-MM-dd')) and category eq 'RoleManagement'" -OutputType PSObject

# Analyze activation patterns
$activations = $auditLogs.value | Where-Object { 
    $_.activityDisplayName -like "*eligible role*" 
}

# Group by principal
$activationsByUser = $activations | Group-Object -Property {$_.initiatedBy.user.userPrincipalName}

# Alert on unusual patterns (>10 activations per week)
$activationsByUser | Where-Object {$_.Count -gt 10} | ForEach-Object {
    Write-Warning "High activation count for $($_.Name): $($_.Count) activations"
    # Trigger alert to security team
}

Creating a Review Dashboard

Build a simple dashboard for regular reviews:

# Get all current eligible assignments
$eligibleAssignments = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules" -OutputType PSObject

# Get all active assignments
$activeAssignments = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments" -OutputType PSObject

# Create summary report
$report = @{
    TotalEligibleAssignments = $eligibleAssignments.value.Count
    TotalActiveAssignments = $activeAssignments.value.Count
    ReportDate = Get-Date
    HighPrivilegeRoles = @()
}

# Identify high-privilege roles still with active assignments
$highPrivilegeRoleNames = @("Global Administrator", "Privileged Role Administrator", "Security Administrator")

foreach ($roleName in $highPrivilegeRoleNames) {
    $roleDefinition = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?`$filter=displayName eq '$roleName'" -OutputType PSObject
    $roleDefId = $roleDefinition.value[0].id
    
    $activeCount = ($activeAssignments.value | Where-Object { $_.roleDefinitionId -eq $roleDefId }).Count
    $eligibleCount = ($eligibleAssignments.value | Where-Object { $_.roleDefinitionId -eq $roleDefId }).Count
    
    $report.HighPrivilegeRoles += @{
        RoleName = $roleName
        ActiveCount = $activeCount
        EligibleCount = $eligibleCount
        Status = if ($activeCount -eq 0) { "Compliant" } else { "Review Required" }
    }
}

# Export report
$report | ConvertTo-Json -Depth 10 | Out-File "pim_governance_report.json"

Handling Exemptions

Some scenarios legitimately require permanent access. Document these rigorously:

# Create exemption record
$exemption = @{
    PrincipalName = "monitoring-service-principal"
    PrincipalType = "ServicePrincipal"
    RoleName = "Directory Readers"
    Reason = "24/7 monitoring service requires constant directory read access"
    BusinessJustification = "System monitoring cannot tolerate activation delays"
    SecurityCompensation = "Restricted to read-only operations, monitored activity logs"
    ApprovedBy = "Security Architecture Team"
    ApprovalDate = Get-Date
    ReviewDate = (Get-Date).AddMonths(6)
    ReviewOwner = "[email protected]"
}

# Store exemptions
$exemptions = @()
if (Test-Path "pim_exemptions.json") {
    $exemptions = Get-Content "pim_exemptions.json" | ConvertFrom-Json
}
$exemptions += $exemption
$exemptions | ConvertTo-Json -Depth 10 | Out-File "pim_exemptions.json"

# Set calendar reminder for review
# (Integrate with your organization's workflow system)

Best Practices and Lessons Learned

Configuration Best Practices

  1. Start Conservative: Begin with longer activation durations and stricter requirements, then relax based on operational experience
  2. Role-Specific Policies: Not all roles need the same settings - tailor activation requirements to risk level
  3. Test Thoroughly: Always test in non-production first, including failure scenarios
  4. Document Extensively: Keep detailed records of policy decisions and exemptions

Common Pitfalls to Avoid

  1. Bulk Conversion Without Testing: Don’t convert all roles at once - phase the rollout
  2. Ignoring Service Principals: Service principals need special consideration for automation scenarios
  3. Insufficient Training: Users need clear guidance on activation procedures before losing permanent access
  4. Missing Emergency Access: Always maintain break-glass accounts with permanent access
  5. Policy Consistency: Ensure Graph API calls match policy structure exactly - minor errors cause silent failures

Operational Considerations

  1. Activation Time: Consider time zones and on-call schedules when setting activation durations
  2. Approval Workflows: For highest-privilege roles, consider requiring approval in addition to MFA
  3. Integration Points: Update runbooks, scripts, and documentation that reference permanent role assignments
  4. User Communication: Provide clear, actionable communication about changes well in advance

Troubleshooting Guide

Issue: Policies Not Applying

Symptom: Changes to role management policies don’t take effect

Solution:

# Verify policy structure
$policy = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/policies/roleManagementPolicies/$policyId/rules" -OutputType PSObject

# Check rule format - must match exactly
$rule = $policy.value[0]
Write-Host "Rule type: $($rule.'@odata.type')" -ForegroundColor Cyan
Write-Host "Enabled rules: $($rule.enabledRules -join ', ')" -ForegroundColor Cyan

# Common issue: incorrect @odata.type
# Must be exactly: #microsoft.graph.unifiedRoleManagementPolicyActivationRule

Issue: Unable to Activate Role

Symptom: Eligible assignment exists but activation fails

Solution:

# Check eligibility status
$eligibility = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?`$filter=principalId eq '$principalId'" -OutputType PSObject

foreach ($role in $eligibility.value) {
    Write-Host "Role: $($role.roleDefinitionId)" -ForegroundColor White
    Write-Host "Status: $($role.status)" -ForegroundColor Cyan
    Write-Host "Start: $($role.scheduleInfo.startDateTime)" -ForegroundColor Gray
    Write-Host "Expiration: $($role.scheduleInfo.expiration.type)" -ForegroundColor Gray
}

# Verify status is "Provisioned" and schedule is active

Measuring Success

Track these metrics to demonstrate PIM value:

# Calculate reduction in standing privileges
$beforePIM = 150  # Baseline active assignments
$afterPIM = 12    # Remaining active assignments (exemptions only)
$reduction = (($beforePIM - $afterPIM) / $beforePIM) * 100

Write-Host "Standing privilege reduction: $reduction%" -ForegroundColor Green

# Track activation patterns
$avgActivationsPerWeek = 45
$avgDurationHours = 3.5

Write-Host "Average activations per week: $avgActivationsPerWeek" -ForegroundColor Cyan
Write-Host "Average activation duration: $avgDurationHours hours" -ForegroundColor Cyan

Present findings in terms of:

  • Risk Reduction: Standing privileges eliminated
  • Audit Trail: Complete history of privileged operations
  • Operational Impact: Minimal with proper planning
  • Cost: Often minimal (included in Azure AD Premium P2)

Conclusion

Implementing PIM transforms your security posture from permanent privileges to audited, time-bound access. While the initial setup requires careful planning and execution, the security benefits far outweigh the effort.

Key takeaways:

  • Plan thoroughly: Validate principals, test policies, and phase rollout
  • Configure appropriately: Match activation requirements to role risk levels
  • Automate wisely: Build activation into automation workflows where needed
  • Monitor continuously: Track activations and review regularly
  • Document everything: Policies, exemptions, and operational procedures

Start with non-critical roles, build confidence with your team, and gradually expand coverage. With proper implementation, PIM becomes a cornerstone of your zero-trust security strategy.

Resources