public/Get-MsecEntraPimPolicy.ps1

function Get-MsecEntraPimPolicy {
    <#
    .SYNOPSIS
        The Privileged Identity Management rules for each directory role - what activation
        demands, and whether a permanent assignment is allowed at all - as one row per setting.

    .DESCRIPTION
        Get-MsecEntraRoleHolder says WHO holds a role and whether it is active or eligible.
        This says what the eligibility is actually worth: a role that can be activated for
        eight hours with no MFA, no approval and no ticket is barely different from a permanent
        assignment, and nothing in the holder list shows that.

        TWO SETS OF RULES, AND THEY ANSWER DIFFERENT QUESTIONS.
          Activation (EndUser) - what an eligible person must do to switch the role on: MFA,
                                 justification, a ticket, an approver, an authentication
                                 context, and for how long it stays on.
          Assignment (Admin) - what an administrator may hand out in the first place. This is
                                 where PermanentActiveAllowed lives, and it is the setting that
                                 decides whether standing privilege is even possible.

        'ActivationRequiresMfa = False' DOES NOT MEAN ACTIVATION HAPPENS WITHOUT MFA. The
        person may already be covered by a Conditional Access policy that required MFA at
        sign-in. What this setting controls is whether PIM demands a FRESH authentication at
        the moment of activation - the thing that stops a stolen, already-authenticated session
        from quietly switching a role on. Read it as "no re-authentication", not "no MFA".

        A POLICY ON A ROLE NOBODY IS ELIGIBLE FOR GOVERNS NOTHING. There are around 150
        directory roles and a typical tenant has eligible holders for a handful, so
        HasEligibleHolder is on every row and -HighlyPrivilegedOnly narrows further. Rows are
        still returned for roles with no holders: a policy may be deliberately pre-configured
        ahead of an assignment, and hiding it would make that invisible.

        HIGHLY PRIVILEGED USES THE SAME LIST AS EVERY OTHER msec COMMAND, from
        Get-MsecPrivilegedRoleTemplate, so this report cannot silently disagree with
        Get-MsecEntraRoleHolder about which roles matter.

    .PARAMETER Role
        Role display names or roleTemplateIds. Omit for every role.

    .PARAMETER HighlyPrivilegedOnly
        Only the roles msec treats as highly privileged.

    .PARAMETER All
        One row per underlying PIM rule rather than the curated projection.

    .EXAMPLE
        Get-MsecEntraPimPolicy -HighlyPrivilegedOnly |
            Where-Object { $_.Setting -eq 'ActivationRequiresMfa' -and $_.Value -eq 'False' }

        Privileged roles that can be activated without re-authenticating.

    .EXAMPLE
        Get-MsecEntraPimPolicy |
            Where-Object { $_.Setting -eq 'PermanentActiveAllowed' -and $_.Value -eq 'True' -and $_.HasEligibleHolder }

        Roles where standing privilege is still permitted.

    .EXAMPLE
        Get-MsecEntraPimPolicy -Role 'Global Administrator'
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string[]] $Role,

        [switch] $HighlyPrivilegedOnly,

        [switch] $All
    )

    if (-not $script:MsecSession) { throw 'No msec session. Run Connect-Msec first.' }

    $privileged = Get-MsecPrivilegedRoleTemplate

    Write-Verbose 'Reading directory role definitions'
    $definitions = @{}
    try {
        foreach ($d in @(Invoke-MsecGraphRequest -All -Path '/v1.0/roleManagement/directory/roleDefinitions?$select=id,displayName,templateId')) {
            $definitions[[string] $d.id] = $d
        }
    }
    catch {
        throw "Could not read directory role definitions, so no policy could be named: $(Get-MsecGraphErrorMessage $_)"
    }

    # One call, so a policy that governs nobody can be told from one that governs a real
    # eligible holder. Unreadable leaves the flag null rather than claiming nobody is eligible.
    $eligibleRoles = $null
    try {
        $eligibleRoles = @{}
        foreach ($e in @(Invoke-MsecGraphRequest -All -Path '/v1.0/roleManagement/directory/roleEligibilityScheduleInstances?$select=roleDefinitionId&$top=999')) {
            $eligibleRoles[[string] $e.roleDefinitionId] = $true
        }
    }
    catch {
        $eligibleRoles = $null
        Write-Warning "Could not read role eligibility schedules, so HasEligibleHolder is null rather than false on every row: $(Get-MsecGraphErrorMessage $_)"
    }

    Write-Verbose 'Reading role management policies'
    $assignments = $null
    try {
        $assignments = @(Invoke-MsecGraphRequest -All -Path (
            "/v1.0/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'&`$expand=policy(`$expand=rules)"))
    }
    catch {
        throw "Could not read PIM role management policies. The app needs RoleManagementPolicy.Read.Directory or RoleManagement.Read.Directory: $(Get-MsecGraphErrorMessage $_)"
    }

    foreach ($assignment in $assignments) {
        $definitionId = [string] $assignment.roleDefinitionId
        $definition   = $definitions[$definitionId]
        $roleName     = if ($definition) { [string] $definition.displayName } else { $definitionId }
        $templateId   = if ($definition -and $definition.templateId) { [string] $definition.templateId } else { $definitionId }

        $isPrivileged = $privileged.ContainsKey($templateId)
        if ($HighlyPrivilegedOnly -and -not $isPrivileged) { continue }
        if ($Role -and -not (@($Role) | Where-Object { $_ -eq $roleName -or $_ -eq $templateId })) { continue }

        $rules = @{}
        foreach ($rule in @($assignment.policy.rules)) { $rules[[string] $rule.id] = $rule }

        $hasEligible = if ($null -eq $eligibleRoles) { $null } else { $eligibleRoles.ContainsKey($definitionId) }

        $emit = {
            param($Setting, $Value)
            [PSCustomObject]@{
                PSTypeName         = 'MsecEntraPimPolicy'
                RoleName           = $roleName
                RoleTemplateId     = $templateId
                IsHighlyPrivileged = $isPrivileged
                HasEligibleHolder  = $hasEligible
                Setting            = $Setting
                Value              = if ($null -eq $Value) { $null } else { [string] $Value }
            }
        }

        if ($All) {
            foreach ($id in ($rules.Keys | Sort-Object)) {
                $rule = $rules[$id]
                & $emit $id (($rule | ConvertTo-Json -Depth 6 -Compress))
            }
            continue
        }

        # enabledRules is the list of things PIM demands. An absent entry is a demand not made.
        $enabledOf = {
            param($RuleId)
            $rule = $rules[$RuleId]
            if (-not $rule) { return $null }
            ,@(@($rule.enabledRules) | ForEach-Object { [string] $_ })
        }

        $activation = & $enabledOf 'Enablement_EndUser_Assignment'
        $adminAssign = & $enabledOf 'Enablement_Admin_Assignment'

        $expEndUser    = $rules['Expiration_EndUser_Assignment']
        $expEligible   = $rules['Expiration_Admin_Eligibility']
        $expActive     = $rules['Expiration_Admin_Assignment']
        $approval      = $rules['Approval_EndUser_Assignment']
        $authContext   = $rules['AuthenticationContext_EndUser_Assignment']
        $notifyAdmin   = $rules['Notification_Admin_EndUser_Assignment']

        # --- what activation demands ---
        & $emit 'ActivationMaxDuration'            $(if ($expEndUser) { $expEndUser.maximumDuration })
        & $emit 'ActivationRequiresMfa'            $(if ($null -eq $activation) { $null } else { $activation -contains 'MultiFactorAuthentication' })
        & $emit 'ActivationRequiresJustification'  $(if ($null -eq $activation) { $null } else { $activation -contains 'Justification' })
        & $emit 'ActivationRequiresTicket'         $(if ($null -eq $activation) { $null } else { $activation -contains 'Ticketing' })
        & $emit 'ActivationRequiresApproval'       $(if ($approval) { [bool] $approval.setting.isApprovalRequired })

        $approvers = @()
        foreach ($stage in @($approval.setting.approvalStages)) {
            foreach ($a in @($stage.primaryApprovers)) {
                $name = if ($a.displayName) { [string] $a.displayName } else { [string] $a.id }
                if ($name) { $approvers += $name }
            }
        }
        & $emit 'ActivationApprovers'              $(if ($approvers.Count) { $approvers -join '; ' } else { $null })

        # A step-up Conditional Access context at activation - stronger than the MFA flag,
        # because it can demand a phishing-resistant method specifically.
        & $emit 'ActivationAuthenticationContext'  $(if ($authContext) {
            if ($authContext.isEnabled) { if ($authContext.claimValue) { [string] $authContext.claimValue } else { 'True' } } else { 'False' }
        })

        & $emit 'ActivationNotifiesAdmins'         $(if ($notifyAdmin) { [bool] $notifyAdmin.isDefaultRecipientsEnabled -or @($notifyAdmin.notificationRecipients).Count -gt 0 })

        # --- what an administrator may hand out ---
        # isExpirationRequired FALSE means a non-expiring assignment is permitted, so the
        # flag is inverted here to read the way the question is actually asked.
        & $emit 'PermanentEligibleAllowed'         $(if ($expEligible) { -not [bool] $expEligible.isExpirationRequired })
        & $emit 'MaxEligibleDuration'              $(if ($expEligible) { $expEligible.maximumDuration })
        & $emit 'PermanentActiveAllowed'           $(if ($expActive)   { -not [bool] $expActive.isExpirationRequired })
        & $emit 'MaxActiveDuration'                $(if ($expActive)   { $expActive.maximumDuration })
        & $emit 'ActiveAssignmentRequiresMfa'      $(if ($null -eq $adminAssign) { $null } else { $adminAssign -contains 'MultiFactorAuthentication' })
        & $emit 'ActiveAssignmentRequiresJustification' $(if ($null -eq $adminAssign) { $null } else { $adminAssign -contains 'Justification' })
    }
}