Simplifying Azure Entra ID PIM Role Reviews with PowerShell

Why Reviewing Azure PIM Role Assignments Matters

Managing privileged access in Entra ID is critical for maintaining security and compliance in cloud environments. Privileged Identity Management (PIM) allows organizations to grant just-in-time access to sensitive roles, reducing the risk of over-privileged accounts.

However, over time, role assignments accumulate, leading to unnecessary or outdated privileges. Regularly auditing PIM role activation’s ensures that only necessary users retain access, minimizing security risks.

In this article, we’ll go over a PowerShell script that simplifies the process of reviewing PIM role assignments and identifying unused access.

What the Script Does

  • Retrieves a list of PIM role assignments
  • Retrieves Audit Logs related to PIM activations
  • Matches users to their last PIM activation
  • Exports findings to a CSV file for easy review
  • Helps organizations remove unnecessary access

By running this script, administrators gain a clear picture of who has access, when roles were last activated, and whether access is still needed.

$useWorkspace = $false # When set to false the script queries the relevant Activity Logs directly, when true it queries a Log Analytics Workspace
$workspaceId = '########-#######-########' # Enter the Log Analytics Workspace to use
$exportFilePath = "C:\temp\PIMReport.csv"
$historyToQueryInDays = 90 # The number of days to query, this will be limited by Azure Log retention

# Get users that are PIM eligible
Connect-MgGraph -Scopes "AuditLog.Read.All"

Write-Host "Retrieving Activity Logs..."

if($useWorkspace) {
    # Retrieve Audit Logs from workspace
    $startTime = (Get-Date).AddDays(-$historyToQueryInDays)
    $endTime = Get-Date

    $startTime = $startTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    $endTime = $endTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")

    # Query to return only the data required for only user
    $query = @"
        AuditLogs
        | where OperationName == 'Add member to role completed (PIM activation)' 
            and Result == 'success'
            and Category == 'RoleManagement'
            and TimeGenerated between (datetime($startTime) .. datetime($endTime))
        | project TimeGenerated, Identity, UPN = TargetResources[2].userPrincipalName, id = TargetResources[2].id, TargetRole = TargetResources[0].displayName, TargetRoleId = TargetResources[0].id, Result
"@

    $activityLogs = Invoke-AzOperationalInsightsQuery -WorkspaceId $workspaceId -Query $query -ErrorAction Stop | Select-Object -ExpandProperty Results
} else {
    if($historyToQueryInDays -gt 30) { $historyToQueryInDays = 30 }
    $startTime = (Get-Date).AddDays(-$historyToQueryInDays).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")

    # Retrieve AuditLogs directly
    $logs = Get-MgAuditLogDirectoryAudit -Filter "activityDisplayName eq 'Add member to role completed (PIM activation)' and Result eq 'success' and activityDateTime gt $startTime" -Property ActivityDateTime, TargetResources -All
    $activityLogs = $logs | ForEach-Object {
        $userData = $_.TargetResources | Where-Object {$_.Type -eq "User"}
        $roleData = $_.TargetResources | Where-Object {$_.Type -eq "Role"}

        [PSCustomObject]@{
            TimeGenerated = $_.ActivityDateTime
            Identity = $userData.DisplayName
            UPN = $userData.UserPrincipalName
            id = $userData.Id
            TargetRole = $roleData.DisplayName
            TargetRoleId = $roleData.Id
        }
    }
}

# Get all eligible role assignments
$eligibleRoles = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -All -ExpandProperty RoleDefinition -Property PrincipalId, RoleDefinition, RoleDefinitionId, DirectoryScopeId


# Get accounts to reference
$users = Get-MgUser -Property DisplayName, Id, UserPrincipalName -All
$servicePrincipals = Get-MgServicePrincipal -Property DisplayName, Id, AppId -All
$groups = Get-MgGroup -ExpandProperty Members -Filter "IsAssignableToRole eq true" -Property DisplayName, Id, Description, Members -All
$processedEligibleRoles = @()

foreach ($role in $eligibleRoles) {
    # Work out the account type and get details
    $accountType = "User"
    $accountDetails = $users | Where-Object {$_.Id -eq $role.PrincipalId}
    if($null -eq $accountDetails) {
        $accountDetails = $servicePrincipals | Where-Object {$_.Id -eq $role.PrincipalId}
        $accountType = "Service Principal"
    }
    if($null -eq $accountDetails) {
        $accountDetails = $groups | Where-Object {$_.Id -eq $role.PrincipalId}
        $accountType = "Group"
    }
    
    # Save the details dependent on whether the assignment is a user or group
    if($accountType -eq "User" -or $accountType -eq "Service Principal") {
        # Get any related activity logs for the given user and role
        $relatedActivityLogs = $activityLogs | Where-Object {$_.id -eq $accountDetails.Id -and $_.TargetRoleId -eq $role.RoleDefinitionId}

        $processedEligibleRoles += [pscustomobject]@{
            DisplayName = $accountDetails.DisplayName
            PrincipalName = $accountDetails.UserPrincipalName || $accountDetails.AppId
            InUse = $relatedActivityLogs.Length -gt 0
            LastUsed = if($relatedActivityLogs.Length -gt 0) {$relatedActivityLogs | Measure-Object -Property TimeGenerated -Maximum | Select-Object -ExpandProperty Maximum} else {"Never"}
            GroupName = "N/A"
            GroupDescription = "N/A"
            RoleDisplayName = $role.RoleDefinition.DisplayName
            DirectoryScopeId = $role.DirectoryScopeId
            AccountType = $accountType
            AssignmentType = "Direct"
            AccountId = $accountDetails.Id
            GroupId = "N/A"
            RoleId = $role.RoleDefinitionId
        }
        continue;
    }
    elseif($accountType -eq "Group") { # If it's a group then evaluate each member and store as seperate rows
        for($i=0; $i -lt $accountDetails.Members.Length; $i++) {
            $user = $users | Where-Object {$_.Id -eq $accountDetails.Members[$i].Id}

            # Get any related activity logs for the given user and role
            $relatedActivityLogs = $activityLogs | Where-Object {$_.id -eq $user.Id -and $_.TargetRoleId -eq $role.RoleDefinitionId}
            
            $processedEligibleRoles += [pscustomobject]@{
                DisplayName = $user.DisplayName
                PrincipalName = $user.UserPrincipalName
                InUse = $relatedActivityLogs.Length -gt 0
                LastUsed = if($relatedActivityLogs.Length -gt 0) {$relatedActivityLogs | Measure-Object -Property TimeGenerated -Maximum | Select-Object -ExpandProperty Maximum} else {"Never"}
                GroupName = $accountDetails.DisplayName
                GroupDescription = $accountDetails.Description
                RoleDisplayName = $role.RoleDefinition.DisplayName
                DirectoryScopeId = $role.DirectoryScopeId
                AccountType = "User"
                AssignmentType = "Group"
                AccountId = $user.Id
                GroupId = $accountDetails.Id
                RoleId = $role.RoleDefinitionId
            }
        }
        continue;
    }
    else {
        $processedEligibleRoles += [pscustomobject]@{
            DisplayName = "None"
            PrincipalName = "N/A"
            InUse = $false
            LastUsed = if($relatedActivityLogs.Length -gt 0) {$relatedActivityLogs | Measure-Object -Property TimeGenerated -Maximum | Select-Object -ExpandProperty Maximum} else {"Never"}
            GroupName = "N/A"
            GroupDescription = "N/A"
            RoleDisplayName = $role.RoleDefinition.DisplayName
            DirectoryScopeId = $role.DirectoryScopeId
            AccountType = "Deleted"
            AssignmentType = "N/A"
            AccountId = "N/A"
            GroupId = "N/A"
            RoleId = $role.RoleDefinitionId
        }
    }
}

# Export the result as a CSV then open it
$processedEligibleRoles | Export-Csv -Path $exportFilePath -Force
Invoke-Item $exportFilePath

💡 Pro Tip – Run this script monthly to keep your privileged access policies tight and secure.

Have questions? Drop a comment below! 👇

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Design a site like this with WordPress.com
Get started