Public/Get-PIMRoleAssignments.ps1

<#
.SYNOPSIS
    Audits Privileged Identity Management (PIM) role assignments and identifies security risks.

.DESCRIPTION
    This function audits PIM-enabled role assignments in Entra ID, identifying:
    - Eligible role assignments (can be activated on-demand)
    - Active permanent assignments (should be minimized per Zero Trust)
    - Active time-bound assignments (JIT access)
    - Unused eligible assignments (never activated)
    - Assignments without MFA or approval requirements
    - High-privilege roles with weak activation policies

    Per Zero Trust principles, permanent admin access should be eliminated in favor
    of just-in-time (JIT) eligible assignments with MFA and approval workflows.

.PARAMETER ShowEligibleOnly
    Show only eligible (JIT) assignments. Default: $false (shows all)

.PARAMETER ShowNonElevated
    Show only users who have eligible roles but have NEVER activated them.
    This helps identify unused eligible assignments that may be candidates for removal.

.PARAMETER ShowActivationHistory
    Include recent activation history for eligible assignments. Queries last 30 days.

.PARAMETER IncludeInactive
    Include eligible assignments that have never been activated (candidates for removal).

.PARAMETER RolesToCheck
    Specific role names to audit. Default checks all critical admin roles.

.PARAMETER ExportPath
    Optional path to export results to CSV.

.EXAMPLE
    Get-PIMRoleAssignments

    Returns all PIM role assignments with risk assessment.

.EXAMPLE
    Get-PIMRoleAssignments -ShowEligibleOnly $true

    Shows only eligible (JIT) assignments.

.EXAMPLE
    Get-PIMRoleAssignments -ShowNonElevated

    Shows only users who have eligible admin roles but have NEVER activated them.
    Useful for identifying unused eligible assignments (removal candidates).

.EXAMPLE
    Get-PIMRoleAssignments -IncludeInactive $true

    Highlights eligible assignments that have never been activated (unused access).

.EXAMPLE
    Get-PIMRoleAssignments -ShowActivationHistory $true

    Includes activation history for the last 30 days.

.NOTES
    Author: Kent Agent (kentagent-ai)
    Created: 2026-03-14
    Requires: Microsoft.Graph PowerShell module
    Permissions: RoleManagement.Read.Directory, AuditLog.Read.All, Directory.Read.All

    PIM Best Practices:
    - Eliminate permanent admin assignments (use eligible instead)
    - Require MFA + approval for Global Admin activations
    - Set maximum activation duration (≤8 hours for high-privilege roles)
    - Regularly review unused eligible assignments
    - Enable notifications for role activations

.LINK
    https://github.com/kentagent-ai/EntraIDSecurityScripts
#>

function Get-PIMRoleAssignments {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [bool]$ShowEligibleOnly = $false,

        [Parameter(Mandatory = $false)]
        [switch]$ShowNonElevated,

        [Parameter(Mandatory = $false)]
        [bool]$ShowActivationHistory = $false,

        [Parameter(Mandatory = $false)]
        [bool]$IncludeInactive = $false,

        [Parameter(Mandatory = $false)]
        [string[]]$RolesToCheck,

        [Parameter(Mandatory = $false)]
        [string]$ExportPath
    )

    begin {
        # Verify Graph connection
        $context = Get-MgContext
        if (-not $context) {
            throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'RoleManagement.Read.Directory', 'AuditLog.Read.All', 'Directory.Read.All'"
        }

        # Default critical roles to check
        $defaultCriticalRoles = @(
            'Global Administrator'
            'Privileged Role Administrator'
            'Security Administrator'
            'Exchange Administrator'
            'SharePoint Administrator'
            'User Administrator'
            'Authentication Administrator'
            'Privileged Authentication Administrator'
            'Conditional Access Administrator'
            'Intune Administrator'
            'Cloud Application Administrator'
            'Application Administrator'
            'Billing Administrator'
            'Compliance Administrator'
            'Global Reader'
            'Helpdesk Administrator'
            'Password Administrator'
            'Directory Synchronization Accounts'
        )

        $rolesToAudit = if ($RolesToCheck) { $RolesToCheck } else { $defaultCriticalRoles }

        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
        $roleDefinitions = @{}
        
        Write-Verbose "Retrieving PIM-enabled role definitions..."
    }

    process {
        try {
            # Get all role definitions first (for lookups)
            $allRoleDefinitions = Get-MgRoleManagementDirectoryRoleDefinition -All -ErrorAction Stop
            foreach ($roleDef in $allRoleDefinitions) {
                $roleDefinitions[$roleDef.Id] = $roleDef.DisplayName
            }

            # Filter to roles we care about
            $targetRoles = $allRoleDefinitions | Where-Object { $_.DisplayName -in $rolesToAudit }

            Write-Verbose "Auditing $($targetRoles.Count) privileged roles..."

            # Get role assignments (both active and eligible)
            $eligibleAssignments = Get-MgRoleManagementDirectoryRoleEligibilityScheduleInstance -All -ErrorAction Stop
            $activeAssignments = Get-MgRoleManagementDirectoryRoleAssignmentScheduleInstance -All -ErrorAction Stop

            Write-Verbose "Found $($eligibleAssignments.Count) eligible assignments and $($activeAssignments.Count) active assignments"

            # Process eligible assignments
            foreach ($assignment in $eligibleAssignments) {
                $roleId = $assignment.RoleDefinitionId
                $roleName = $roleDefinitions[$roleId]

                # Skip if not in target roles
                if ($roleName -notin $rolesToAudit) {
                    continue
                }

                # Get principal details
                $principal = $null
                $principalType = $assignment.PrincipalId
                $principalName = 'Unknown'
                $principalUPN = ''

                try {
                    $principal = Get-MgUser -UserId $assignment.PrincipalId -Property Id, DisplayName, UserPrincipalName -ErrorAction SilentlyContinue
                    if ($principal) {
                        $principalName = $principal.DisplayName
                        $principalUPN = $principal.UserPrincipalName
                        $principalType = 'User'
                    }
                }
                catch {
                    # Might be a group or service principal
                    try {
                        $group = Get-MgGroup -GroupId $assignment.PrincipalId -Property Id, DisplayName -ErrorAction SilentlyContinue
                        if ($group) {
                            $principalName = $group.DisplayName
                            $principalType = 'Group'
                        }
                    }
                    catch {
                        Write-Verbose "Could not resolve principal: $($assignment.PrincipalId)"
                    }
                }

                # Get activation policy for this role
                $requiresMFA = $false
                $requiresApproval = $false
                $maxActivationDuration = 'N/A'

                try {
                    $policy = Get-MgPolicyRoleManagementPolicyAssignment -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and roleDefinitionId eq '$roleId'" -ErrorAction SilentlyContinue
                    if ($policy) {
                        # Get the detailed policy rules
                        $policyDetails = Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId $policy.PolicyId -ExpandProperty "rules" -ErrorAction SilentlyContinue
                        
                        foreach ($rule in $policyDetails.Rules) {
                            $ruleType = $rule.AdditionalProperties['@odata.type']
                            
                            # Check for MFA requirement
                            if ($ruleType -eq '#microsoft.graph.unifiedRoleManagementPolicyAuthenticationContextRule') {
                                $requiresMFA = $rule.AdditionalProperties['isEnabled'] -eq $true
                            }
                            
                            # Check for approval requirement
                            if ($ruleType -eq '#microsoft.graph.unifiedRoleManagementPolicyApprovalRule') {
                                $requiresApproval = $rule.AdditionalProperties['setting']['isApprovalRequired'] -eq $true
                            }
                            
                            # Check activation duration
                            if ($ruleType -eq '#microsoft.graph.unifiedRoleManagementPolicyExpirationRule' -and 
                                $rule.AdditionalProperties['target']['targetObjects'] -contains 'EndUser') {
                                $maxHours = $rule.AdditionalProperties['maximumDuration']
                                if ($maxHours) {
                                    # Parse ISO 8601 duration (e.g., "PT8H")
                                    if ($maxHours -match 'PT(\d+)H') {
                                        $maxActivationDuration = "$($Matches[1]) hours"
                                    }
                                }
                            }
                        }
                    }
                }
                catch {
                    Write-Verbose "Could not retrieve policy for role: $roleName"
                }

                # Check activation history (if requested)
                $lastActivation = 'Never'
                $activationCount = 0
                $isUnused = $true

                if ($ShowActivationHistory -or $IncludeInactive -or $ShowNonElevated) {
                    try {
                        $startDate = (Get-Date).AddDays(-30).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
                        $filter = "activityDisplayName eq 'Add member to role completed (PIM activation)' and targetResources/any(t: t/id eq '$($assignment.PrincipalId)' and t/displayName eq '$roleName')"
                        
                        $activations = Get-MgAuditLogDirectoryAudit -Filter $filter -All -ErrorAction SilentlyContinue
                        
                        if ($activations) {
                            $activationCount = $activations.Count
                            $mostRecent = $activations | Sort-Object ActivityDateTime -Descending | Select-Object -First 1
                            $lastActivation = $mostRecent.ActivityDateTime.ToString("yyyy-MM-dd HH:mm")
                            $isUnused = $false
                        }
                    }
                    catch {
                        Write-Verbose "Could not retrieve activation history for $principalName"
                    }
                }

                # Skip if filtering for inactive and this has been used
                if ($IncludeInactive -and -not $isUnused) {
                    continue
                }

                # Determine risk level for eligible assignments
                $riskLevel = 'LOW'  # Eligible is good (JIT)
                $recommendation = 'Eligible assignment (JIT) - good practice'

                # Increase risk if high-privilege role lacks MFA/approval
                if ($roleName -in @('Global Administrator', 'Privileged Role Administrator')) {
                    if (-not $requiresMFA -or -not $requiresApproval) {
                        $riskLevel = 'HIGH'
                        $recommendation = "High-privilege role should require MFA and approval for activation"
                    }
                }

                # Flag unused assignments
                if ($isUnused -and $IncludeInactive) {
                    $riskLevel = 'MEDIUM'
                    $recommendation = "Eligible assignment never activated - consider removing"
                }

                $resultObj = [PSCustomObject]@{
                    PrincipalName           = $principalName
                    PrincipalUPN            = $principalUPN
                    PrincipalType           = $principalType
                    RoleName                = $roleName
                    AssignmentType          = 'Eligible (JIT)'
                    StartDateTime           = $assignment.StartDateTime
                    EndDateTime             = $assignment.EndDateTime
                    RequiresMFA             = $requiresMFA
                    RequiresApproval        = $requiresApproval
                    MaxActivationDuration   = $maxActivationDuration
                    LastActivation          = $lastActivation
                    ActivationCount30Days   = $activationCount
                    RiskLevel               = $riskLevel
                    Recommendation          = $recommendation
                }

                $results.Add($resultObj)
            }

            # Process active assignments (unless ShowEligibleOnly)
            if (-not $ShowEligibleOnly) {
                foreach ($assignment in $activeAssignments) {
                    $roleId = $assignment.RoleDefinitionId
                    $roleName = $roleDefinitions[$roleId]

                    # Skip if not in target roles
                    if ($roleName -notin $rolesToAudit) {
                        continue
                    }

                    # Get principal details
                    $principal = $null
                    $principalType = $assignment.PrincipalId
                    $principalName = 'Unknown'
                    $principalUPN = ''

                    try {
                        $principal = Get-MgUser -UserId $assignment.PrincipalId -Property Id, DisplayName, UserPrincipalName -ErrorAction SilentlyContinue
                        if ($principal) {
                            $principalName = $principal.DisplayName
                            $principalUPN = $principal.UserPrincipalName
                            $principalType = 'User'
                        }
                    }
                    catch {
                        # Might be a group or service principal
                        try {
                            $group = Get-MgGroup -GroupId $assignment.PrincipalId -Property Id, DisplayName -ErrorAction SilentlyContinue
                            if ($group) {
                                $principalName = $group.DisplayName
                                $principalType = 'Group'
                            }
                        }
                        catch {
                            Write-Verbose "Could not resolve principal: $($assignment.PrincipalId)"
                        }
                    }

                    # Determine if permanent or time-bound
                    $isPermanent = $null -eq $assignment.EndDateTime
                    $assignmentType = if ($isPermanent) { 'Active Permanent' } else { 'Active Time-Bound' }

                    # Determine risk level
                    $riskLevel = 'LOW'
                    $recommendation = 'Active time-bound assignment (JIT activation) - good practice'

                    if ($isPermanent) {
                        # Permanent assignments violate Zero Trust
                        if ($roleName -in @('Global Administrator', 'Privileged Role Administrator', 'Security Administrator')) {
                            $riskLevel = 'CRITICAL'
                            $recommendation = "CRITICAL: Permanent $roleName assignment violates Zero Trust - convert to eligible"
                        }
                        elseif ($roleName -match 'Administrator') {
                            $riskLevel = 'HIGH'
                            $recommendation = "Permanent admin assignment - convert to eligible (JIT)"
                        }
                        else {
                            $riskLevel = 'MEDIUM'
                            $recommendation = "Consider converting to eligible assignment for better security"
                        }
                    }

                    $resultObj = [PSCustomObject]@{
                        PrincipalName           = $principalName
                        PrincipalUPN            = $principalUPN
                        PrincipalType           = $principalType
                        RoleName                = $roleName
                        AssignmentType          = $assignmentType
                        StartDateTime           = $assignment.StartDateTime
                        EndDateTime             = if ($assignment.EndDateTime) { $assignment.EndDateTime } else { 'Permanent' }
                        RequiresMFA             = 'N/A (Active)'
                        RequiresApproval        = 'N/A (Active)'
                        MaxActivationDuration   = 'N/A (Active)'
                        LastActivation          = 'N/A (Currently Active)'
                        ActivationCount30Days   = 'N/A'
                        RiskLevel               = $riskLevel
                        Recommendation          = $recommendation
                    }

                    $results.Add($resultObj)
                }
            }
        }
        catch {
            Write-Error "Failed to retrieve PIM role assignments: $_"
            return
        }
    }

    end {
        # Apply ShowNonElevated filter if requested
        if ($ShowNonElevated) {
            $beforeCount = $results.Count
            $results = $results | Where-Object { 
                $_.AssignmentType -eq 'Eligible (JIT)' -and 
                $_.LastActivation -eq 'Never'
            }
            Write-Verbose "ShowNonElevated filter: Reduced from $beforeCount to $($results.Count) assignments (never activated)"
        }

        # Summary statistics
        $totalAssignments = $results.Count
        $eligibleAssignments = $results | Where-Object { $_.AssignmentType -eq 'Eligible (JIT)' }
        $activePermanent = $results | Where-Object { $_.AssignmentType -eq 'Active Permanent' }
        $activeTimeBound = $results | Where-Object { $_.AssignmentType -eq 'Active Time-Bound' }
        $critical = $results | Where-Object { $_.RiskLevel -eq 'CRITICAL' }
        $high = $results | Where-Object { $_.RiskLevel -eq 'HIGH' }
        $withoutMFA = $results | Where-Object { $_.RequiresMFA -eq $false -and $_.AssignmentType -eq 'Eligible (JIT)' }
        $withoutApproval = $results | Where-Object { $_.RequiresApproval -eq $false -and $_.AssignmentType -eq 'Eligible (JIT)' }

        Write-Host ""
        Write-Host "=== PIM Role Assignment Audit Summary ===" -ForegroundColor Yellow
        Write-Host "Total assignments audited: $totalAssignments" -ForegroundColor White
        Write-Host ""
        Write-Host "Assignment Types:" -ForegroundColor Cyan
        Write-Host " Eligible (JIT): $($eligibleAssignments.Count)" -ForegroundColor Green
        Write-Host " Active Permanent: $($activePermanent.Count)" -ForegroundColor $(if ($activePermanent.Count -gt 0) { 'Red' } else { 'Green' })
        Write-Host " Active Time-Bound: $($activeTimeBound.Count)" -ForegroundColor Green
        Write-Host ""
        Write-Host "Risk Assessment:" -ForegroundColor Cyan
        if ($critical.Count -gt 0) {
            Write-Host " CRITICAL risk assignments: $($critical.Count)" -ForegroundColor Red
        }
        if ($high.Count -gt 0) {
            Write-Host " HIGH risk assignments: $($high.Count)" -ForegroundColor Red
        }
        Write-Host ""
        Write-Host "Policy Gaps (Eligible Assignments):" -ForegroundColor Cyan
        Write-Host " Without MFA requirement: $($withoutMFA.Count)" -ForegroundColor $(if ($withoutMFA.Count -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host " Without approval requirement: $($withoutApproval.Count)" -ForegroundColor $(if ($withoutApproval.Count -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host ""
        Write-Host "Recommendations:" -ForegroundColor Cyan
        Write-Host " 1. Eliminate permanent admin assignments (convert to eligible)" -ForegroundColor White
        Write-Host " 2. Require MFA + approval for Global Admin activations" -ForegroundColor White
        Write-Host " 3. Set max activation duration ≤8h for high-privilege roles" -ForegroundColor White
        Write-Host " 4. Remove unused eligible assignments" -ForegroundColor White
        Write-Host "==========================================" -ForegroundColor Yellow
        Write-Host ""

        # Export if path specified
        if ($ExportPath) {
            try {
                $results | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8
                Write-Host "Results exported to: $ExportPath" -ForegroundColor Green
            }
            catch {
                Write-Error "Failed to export results: $_"
            }
        }

        return $results
    }
}

# Export function if loaded as module
Export-ModuleMember -Function Get-PIMRoleAssignments -ErrorAction SilentlyContinue