Src/Private/Get-AbrEntraIDRoles.ps1

function Get-AbrEntraIDRoles {
    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [string]$TenantId
    )

    begin {
        Write-PScriboMessage -Message "Collecting Entra ID Role Assignments for tenant $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'Directory Roles'
    }

    process {
        Section -Style Heading2 'Directory Roles' {
            Paragraph "The following section documents the Entra ID directory role assignments for tenant $TenantId, including ACSC Essential Eight Maturity Level compliance checks."
            BlankLine

            try {
                Write-Host " - Retrieving active directory role assignments..."
                $RoleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -All `
                    -ExpandProperty RoleDefinition `
                    -ErrorAction Stop

                # Resolve each principal using typed endpoints (typed calls return consistent
                # displayName/UPN unlike Get-MgDirectoryObject AdditionalProperties)
                $script:PrincipalCache = @{}
                foreach ($Assignment in $RoleAssignments) {
                    $PrincipalId = $Assignment.PrincipalId
                    if ($script:PrincipalCache.ContainsKey($PrincipalId)) { continue }
                    $resolved = $null
                    # Try User
                    try {
                        $U = Get-MgUser -UserId $PrincipalId -Property 'id,displayName,userPrincipalName,accountEnabled,assignedLicenses,mail' -ErrorAction SilentlyContinue
                        if ($U) {
                            $resolved = [pscustomobject]@{
                                Kind       = 'User'
                                DisplayName= $U.DisplayName
                                UPN        = $U.UserPrincipalName
                                Enabled    = $U.AccountEnabled
                                Licensed   = ($U.AssignedLicenses.Count -gt 0)
                                HasMailbox = ($null -ne $U.Mail -and $U.Mail -ne '')
                                Mail       = $U.Mail
                            }
                        }
                    } catch {}
                    if (-not $resolved) {
                        # Try Service Principal
                        try {
                            $SP = Get-MgServicePrincipal -ServicePrincipalId $PrincipalId -Property 'id,displayName,appId' -ErrorAction SilentlyContinue
                            if ($SP) {
                                $resolved = [pscustomobject]@{
                                    Kind       = 'Service Principal'
                                    DisplayName= $SP.DisplayName
                                    UPN        = "AppId: $($SP.AppId)"
                                    Enabled    = $true
                                    Licensed   = $false
                                    HasMailbox = $false
                                    Mail       = '--'
                                }
                            }
                        } catch {}
                    }
                    if (-not $resolved) {
                        # Try Group
                        try {
                            $G = Get-MgGroup -GroupId $PrincipalId -Property 'id,displayName,mail' -ErrorAction SilentlyContinue
                            if ($G) {
                                $resolved = [pscustomobject]@{
                                    Kind       = 'Group'
                                    DisplayName= $G.DisplayName
                                    UPN        = if ($G.Mail) { $G.Mail } else { '--' }
                                    Enabled    = $true
                                    Licensed   = $false
                                    HasMailbox = $false
                                    Mail       = '--'
                                }
                            }
                        } catch {}
                    }
                    if (-not $resolved) {
                        $resolved = [pscustomobject]@{
                            Kind       = 'Unknown/Foreign'
                            DisplayName= "Unknown ($PrincipalId)"
                            UPN        = '--'
                            Enabled    = $null
                            Licensed   = $false
                            HasMailbox = $false
                            Mail       = '--'
                        }
                    }
                    $script:PrincipalCache[$PrincipalId] = $resolved
                }

                if ($RoleAssignments) {

                    $PrivilegedRoles = @(
                        'Global Administrator','Privileged Role Administrator','Security Administrator',
                        'Exchange Administrator','SharePoint Administrator','Conditional Access Administrator',
                        'Authentication Administrator','Helpdesk Administrator','User Administrator',
                        'Application Administrator','Cloud Application Administrator',
                        'Billing Administrator','Password Administrator'
                    )

                    # --- Summary ---
                    $UniqueRoles      = ($RoleAssignments | Select-Object -ExpandProperty RoleDefinition | Select-Object -ExpandProperty DisplayName -Unique | Measure-Object).Count
                    $UniquePrincipals = ($RoleAssignments | Select-Object -ExpandProperty PrincipalId -Unique | Measure-Object).Count
                    $PrivAssignments  = @($RoleAssignments | Where-Object { $PrivilegedRoles -contains $_.RoleDefinition.DisplayName }).Count
                    $GACount          = @($RoleAssignments | Where-Object { $_.RoleDefinition.DisplayName -eq 'Global Administrator' }).Count

                    $RoleSumObj = [System.Collections.ArrayList]::new()
                    $roleSumInObj = [ordered] @{
                        'Total Role Assignments'       = @($RoleAssignments).Count
                        'Unique Roles Assigned'        = $UniqueRoles
                        'Unique Principals with Roles' = $UniquePrincipals
                        'Privileged Role Assignments'  = $PrivAssignments
                        'Global Administrators'        = $GACount
                    }
                    $RoleSumObj.Add([pscustomobject]$roleSumInObj) | Out-Null
                    $null = (& {
                        if ($HealthCheck.EntraID.Roles) {
                            $null = ($RoleSumObj | Where-Object { [int]$_.'Global Administrators' -gt 5 }        | Set-Style -Style Critical | Out-Null)
                            $null = ($RoleSumObj | Where-Object { [int]$_.'Privileged Role Assignments' -gt 10 } | Set-Style -Style Warning  | Out-Null)
                        }
                    })
                    $RoleSumTableParams = @{ Name = "Role Assignment Summary - $TenantId"; List = $true; ColumnWidths = 55, 45 }
                    if ($Report.ShowTableCaptions) { $RoleSumTableParams['Caption'] = "- $($RoleSumTableParams.Name)" }
                    $RoleSumObj | Table @RoleSumTableParams

                    # --- All Role Assignments ---
                    $RoleObj = [System.Collections.ArrayList]::new()
                    foreach ($Assignment in ($RoleAssignments | Sort-Object { $_.RoleDefinition.DisplayName })) {
                        try {
                            $RoleName = $Assignment.RoleDefinition.DisplayName
                            $IsPriv   = $PrivilegedRoles -contains $RoleName
                            $P        = $script:PrincipalCache[$Assignment.PrincipalId]
                            $roleInObj = [ordered] @{
                                'Role Name'       = $RoleName
                                'Privileged'      = if ($IsPriv) { 'Yes' } else { 'No' }
                                'Principal Name'  = if ($P) { $P.DisplayName } else { $Assignment.PrincipalId }
                                'Principal UPN'   = if ($P) { $P.UPN }         else { '--' }
                                'Principal Type'  = if ($P) { $P.Kind }        else { 'Unknown' }
                                'Directory Scope' = if ($Assignment.DirectoryScopeId -and $Assignment.DirectoryScopeId -ne '/') { $Assignment.DirectoryScopeId } else { 'Tenant-wide (/)' }
                            }
                            $RoleObj.Add([pscustomobject](ConvertTo-HashToYN $roleInObj)) | Out-Null
                        } catch {
                            Write-PScriboMessage -IsWarning -Message "Role assignment processing: $($_.Exception.Message)"
                        }
                    }
                    $null = (& {
                        if ($HealthCheck.EntraID.Roles) {
                            $null = ($RoleObj | Where-Object { $_.'Privileged' -eq 'Yes' }                     | Set-Style -Style Warning  | Out-Null)
                            $null = ($RoleObj | Where-Object { $_.'Role Name'  -eq 'Global Administrator' }    | Set-Style -Style Critical | Out-Null)
                        }
                    })
                    $RoleTableParams = @{ Name = "Directory Role Assignments - $TenantId"; List = $false; ColumnWidths = 22, 10, 20, 22, 14, 12 }
                    if ($Report.ShowTableCaptions) { $RoleTableParams['Caption'] = "- $($RoleTableParams.Name)" }
                    $RoleObj | Table @RoleTableParams
                    $null = ($script:ExcelSheets['Role Assignments'] = $RoleObj)

                    #region Privileged Role Distribution Bar Chart
                    if (Get-Command New-AbrHorizontalBarChart -ErrorAction SilentlyContinue) {
                        try {
                            $RoleGroups = $RoleAssignments | Group-Object { $_.RoleDefinition.DisplayName } |
                                Sort-Object Count -Descending | Select-Object -First 12
                            if ($RoleGroups) {
                                $roleBarItems = $RoleGroups | ForEach-Object {
                                    $isPriv = $PrivilegedRoles -contains $_.Name
                                    @{ Label = $_.Name; Value = $_.Count
                                       Color = if ($_.Name -eq 'Global Administrator') { '#c0392b' }
                                               elseif ($isPriv) { '#e87722' } else { '#1a6eb5' } }
                                }
                                $roleBarB64 = New-AbrHorizontalBarChart `
                                    -Items  $roleBarItems `
                                    -Title  "Directory Role Assignments -- $TenantId"
                                if ($roleBarB64) {
                                    BlankLine
                                    $null = (Image -Text 'Role Distribution' -Base64 $roleBarB64 -Percent 80 -Align Center)
                                    BlankLine
                                }
                            }
                        } catch {
                            Write-AbrDebugLog "Role bar chart failed: $($_.Exception.Message)" 'WARN' 'CHART'
                        }
                    }
                    #endregion

                    # --- Global Admins + ACSC E8 ---
                    if ($InfoLevel.Roles -ge 1) {
                        $GlobalAdmins = @($RoleAssignments | Where-Object { $_.RoleDefinition.DisplayName -eq 'Global Administrator' })
                        $GlobalAdminCount = $GlobalAdmins.Count
                        if ($GlobalAdmins.Count -gt 0) {
                            Section -Style Heading3 'Global Administrators' {
                                Paragraph "The following $($GlobalAdmins.Count) principal(s) hold the Global Administrator role in tenant $TenantId."
                                BlankLine
                                Paragraph "ACSC Essential Eight: GA accounts must be dedicated cloud-only accounts with no licence, no mailbox, and must not be used for daily activity. Maximum 5 Global Administrators recommended."
                                BlankLine

                                $GAObj = [System.Collections.ArrayList]::new()
                                foreach ($GA in ($GlobalAdmins | Sort-Object { ($script:PrincipalCache[$_.PrincipalId]).DisplayName })) {
                                    $P = $script:PrincipalCache[$GA.PrincipalId]
                                    $HasLicence = if ($P) { $P.Licensed }   else { $false }
                                    $HasMailbox = if ($P) { $P.HasMailbox } else { $false }
                                    $AccEnabled = if ($P) { $P.Enabled }    else { $null }
                                    $gaInObj = [ordered] @{
                                        'Display Name'          = if ($P) { $P.DisplayName } else { "Unknown ($($GA.PrincipalId))" }
                                        'UPN'                   = if ($P) { $P.UPN }         else { '--' }
                                        'Principal Type'        = if ($P) { $P.Kind }        else { 'Unknown' }
                                        'Account Enabled'       = if ($null -ne $AccEnabled) { $AccEnabled } else { '--' }
                                        'Licence (E8: None)'    = if ($HasLicence)  { '[FAIL] Has licence'   } else { '[OK] No licence' }
                                        'Mailbox (E8: None)'    = if ($HasMailbox)  { '[FAIL] Has mailbox'   } else { '[OK] No mailbox' }
                                    }
                                    $GAObj.Add([pscustomobject](ConvertTo-HashToYN $gaInObj)) | Out-Null
                                }
                                $null = (& {
                                    if ($HealthCheck.EntraID.Roles) {
                                        if ($GlobalAdmins.Count -gt 5)     { $null = ($GAObj | Set-Style -Style Critical | Out-Null) }
                                        elseif ($GlobalAdmins.Count -gt 2) { $null = ($GAObj | Set-Style -Style Warning  | Out-Null) }
                                        $null = ($GAObj | Where-Object { $_.'Licence (E8: None)' -like '*FAIL*' } | Set-Style -Style Critical | Out-Null)
                                        $null = ($GAObj | Where-Object { $_.'Mailbox (E8: None)'  -like '*FAIL*' } | Set-Style -Style Critical | Out-Null)
                                    }
                                })
                                $GATableParams = @{ Name = "Global Administrators - $TenantId"; List = $false; ColumnWidths = 18, 22, 13, 11, 18, 18 }
                                if ($Report.ShowTableCaptions) { $GATableParams['Caption'] = "- $($GATableParams.Name)" }
                                $GAObj | Table @GATableParams
                                $null = ($script:ExcelSheets['Global Admins'] = $GAObj)

                                # ACSC E8 Assessment table
                                BlankLine
                                Paragraph "ACSC Essential Eight Maturity Level Assessment -- Global Administrator Accounts:"
                                BlankLine

                                $GALicensed = @($GAObj | Where-Object { $_.'Licence (E8: None)' -like '*FAIL*' }).Count
                                $GAMailbox  = @($GAObj | Where-Object { $_.'Mailbox (E8: None)'  -like '*FAIL*' }).Count
                                $GANonUser  = @($GlobalAdmins | Where-Object { ($script:PrincipalCache[$_.PrincipalId]).Kind -ne 'User' }).Count

                                # Cloud-only check: admins synced from on-prem
                                $AllPrivRoles = @($RoleAssignments | Where-Object { $_.RoleDefinition.DisplayName -in @(
                                    'Global Administrator','Privileged Role Administrator','Security Administrator',
                                    'Exchange Administrator','SharePoint Administrator','User Administrator',
                                    'Conditional Access Administrator','Authentication Policy Administrator'
                                )})
                                $SyncedAdminCount = 0
                                foreach ($RA in $AllPrivRoles) {
                                    $P2 = $script:PrincipalCache[$RA.PrincipalId]
                                    if ($P2 -and $P2.PSObject.Properties['OnPremSynced'] -and $P2.OnPremSynced) { $SyncedAdminCount++ }
                                }

                                # Break-glass count: GAs excluded from ALL enabled CA policies
                                $BreakGlassCount = 0
                                try {
                                    $AllCAPolicies2 = Get-MgIdentityConditionalAccessPolicy -All -ErrorAction SilentlyContinue
                                    $EnabledCA = @($AllCAPolicies2 | Where-Object { $_.State -eq 'enabled' })
                                    $TotalEnabled2 = $EnabledCA.Count
                                    if ($TotalEnabled2 -gt 0) {
                                        $ExclMap = @{}
                                        foreach ($CAP in $EnabledCA) {
                                            foreach ($Uid in $CAP.Conditions.Users.ExcludeUsers) {
                                                if (-not $ExclMap.ContainsKey($Uid)) { $ExclMap[$Uid] = 0 }
                                                $ExclMap[$Uid]++
                                            }
                                        }
                                        $BreakGlassCount = @($ExclMap.GetEnumerator() | Where-Object { $_.Value -eq $TotalEnabled2 }).Count
                                    }
                                } catch {}


                                #region ACSC E8 Assessment (definitions from Src/Compliance/ACSC.E8.json)
                                $_ComplianceVars = @{
                                    'GlobalAdmins' = $GlobalAdmins
                                    'GlobalAdminCount' = $GlobalAdminCount
                                    'SyncedAdminCount' = $SyncedAdminCount
                                    'BreakGlassCount' = $BreakGlassCount
                                    'GALicensed' = $GALicensed
                                    'GAMailbox' = $GAMailbox
                                    'GANonUser' = $GANonUser
                                    'GALicensedDetail' = $GALicensedDetail
                                    'GAMailboxDetail' = $GAMailboxDetail
                                    'GANonUserDetail' = $GANonUserDetail
                                    'GACountRemediation' = $GACountRemediation
                                }
                                $E8Checks = Build-AbrComplianceChecks `
                                    -Definitions (Get-AbrE8Checks -Section 'Roles') `
                                    -Framework E8 `
                                    -CallerVariables $_ComplianceVars
                                New-AbrE8AssessmentTable -Checks $E8Checks -Name 'GA Assessment' -TenantId $TenantId
                                # Consolidated into ACSC E8 Assessment sheet
                    if ($E8Checks) { $null = $script:E8AllChecks.AddRange([object[]](@($E8Checks | Select-Object @{N='Section';E={'Roles & Privileged Access'}}, ML, Control, Status, Detail ))) }
                                #endregion

                                if ($script:IncludeCISBaseline) {
                                    BlankLine
                                    Paragraph "CIS Microsoft 365 Foundations Benchmark Assessment -- Global Administrator Accounts:"
                                    BlankLine
                                    #region CIS Assessment (definitions from Src/Compliance/CIS.M365.json)
                                    $CISGAChecks = Build-AbrComplianceChecks `
                                        -Definitions (Get-AbrCISChecks -Section 'Roles') `
                                        -Framework CIS `
                                        -CallerVariables $_ComplianceVars
                                    New-AbrCISAssessmentTable -Checks $CISGAChecks -Name 'Global Administrators' -TenantId $TenantId
                                    # Consolidated into CIS Assessment sheet
                    if ($CISGAChecks) { $null = $script:CISAllChecks.AddRange([object[]](@($CISGAChecks | Select-Object @{N='Section';E={'Roles & Privileged Access'}}, CISControl, Level, Status, Detail ))) }
                                    #endregion
                                }
                            }
                        }
                    }

                    # --- PIM Eligible (InfoLevel 2 only) ---
                    if ($InfoLevel.Roles -ge 2) {
                    try {
                        Write-Host " - Retrieving PIM eligible role assignments..."
                        $PIMEligible = Get-MgRoleManagementDirectoryRoleEligibilitySchedule -All `
                            -ExpandProperty RoleDefinition -ErrorAction SilentlyContinue

                        if ($PIMEligible) {
                            foreach ($Elig in $PIMEligible) {
                                $PrincipalId = $Elig.PrincipalId
                                if (-not $script:PrincipalCache.ContainsKey($PrincipalId)) {
                                    try {
                                        $U = Get-MgUser -UserId $PrincipalId -Property 'id,displayName,userPrincipalName' -ErrorAction SilentlyContinue
                                        if ($U) { $script:PrincipalCache[$PrincipalId] = [pscustomobject]@{ Kind='User'; DisplayName=$U.DisplayName; UPN=$U.UserPrincipalName } }
                                    } catch {}
                                }
                            }
                        }

                        if ($PIMEligible -and @($PIMEligible).Count -gt 0) {
                            Section -Style Heading3 'PIM Eligible Role Assignments' {
                                Paragraph "The following principals have PIM-eligible (just-in-time) role assignments in tenant $TenantId. PIM is an ACSC Essential Eight ML3 control -- all privileged access should be time-bound and require activation."
                                BlankLine
                                $PimObj = [System.Collections.ArrayList]::new()
                                foreach ($Elig in ($PIMEligible | Sort-Object { $_.RoleDefinition.DisplayName })) {
                                    $P = $script:PrincipalCache[$Elig.PrincipalId]
                                    $pimInObj = [ordered] @{
                                        'Role Name'      = $Elig.RoleDefinition.DisplayName
                                        'Principal Name' = if ($P) { $P.DisplayName } else { $Elig.PrincipalId }
                                        'Principal UPN'  = if ($P -and $P.UPN) { $P.UPN } else { '--' }
                                        'Schedule Start' = if ($Elig.ScheduleInfo.StartDateTime)          { ($Elig.ScheduleInfo.StartDateTime).ToString('yyyy-MM-dd') }          else { '--' }
                                        'Schedule End'   = if ($Elig.ScheduleInfo.Expiration.EndDateTime) { ($Elig.ScheduleInfo.Expiration.EndDateTime).ToString('yyyy-MM-dd') } else { 'No Expiry' }
                                        'Status'         = $Elig.Status
                                    }
                                    $PimObj.Add([pscustomobject]$pimInObj) | Out-Null
                                }
                                $PimTableParams = @{ Name = "PIM Eligible Assignments - $TenantId"; List = $false; ColumnWidths = 22, 20, 24, 12, 12, 10 }
                                if ($Report.ShowTableCaptions) { $PimTableParams['Caption'] = "- $($PimTableParams.Name)" }
                                $PimObj | Table @PimTableParams
                                $null = ($script:ExcelSheets['PIM Eligible'] = $PimObj)
                            }
                        }
                    } catch {
                        Write-AbrSectionError -Section 'PIM Eligible Assignments' -Message "$($_.Exception.Message)"
                    }
                    } # end InfoLevel.Roles -ge 2

                } else {
                    Paragraph "No directory role assignments found in tenant $TenantId."
                }

            } catch {
                Write-AbrSectionError -Section 'Directory Roles section' -Message "$($_.Exception.Message)"
            }
        } # end Section Directory Roles
    }

    end {
        Show-AbrDebugExecutionTime -End -TitleMessage 'Directory Roles'
    }
}