Checks/Test-HygienePrivilegedRole.ps1

<#
.SYNOPSIS
    Inventories directory role assignments and flags the risky ones.

.DESCRIPTION
    Produces two things at once, because an auditor needs both:

      an inventory of who holds the highest-privilege roles, emitted as Informational
      findings so the report answers "who can do what" even when nothing is wrong; and

      actual findings for the states that are known to go wrong -- too many Global
      Administrators, too few to recover from a lockout, guests holding admin roles,
      service principals holding tier-0 roles, and admin roles still attached to
      disabled accounts.

    Roles are classified by display name against a curated list rather than by
    template GUID. Display names for built-in directory roles are stable, and the
    alternative would mean hardcoding a table of GUIDs, where a single wrong digit
    silently downgrades a real Global Administrator to "other role" and the report
    looks clean. A name this module does not recognise is reported as an unclassified
    assignment rather than dropped.

.PARAMETER Context
    Shared audit context from New-HygieneAuditContext.

.OUTPUTS
    M365TenantHygiene.Finding objects.

.NOTES
    Graph endpoints: GET /roleManagement/directory/roleDefinitions
                     GET /roleManagement/directory/roleAssignments?$expand=principal
    Scope required : RoleManagement.Read.Directory (delegated)

    Only active assignments are visible here. A Privileged Identity Management
    eligible assignment -- an account that can elevate to Global Administrator but is
    not currently elevated -- does not appear in roleAssignments and is not counted.
    On a PIM-heavy tenant this check therefore understates who can reach tier 0.

    Roles assigned to a role-assignable group are reported against the group. The
    module does not expand group membership, so the individual holders are not listed.
#>

function Test-HygienePrivilegedRole {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][hashtable] $Context)

    $meta = (Get-HygieneCheckRegistry)['PrivilegedRole']

    # Tier 0: can grant themselves or others any other role, or reset an admin's
    # credentials. Compromise of one of these is compromise of the tenant.
    $tier0 = @(
        'Global Administrator'
        'Privileged Role Administrator'
        'Privileged Authentication Administrator'
        'Partner Tier2 Support'
    )

    # Tier 1: broad control over identity, data or devices, but cannot directly
    # promote themselves to tier 0.
    $tier1 = @(
        'Application Administrator'
        'Authentication Administrator'
        'Cloud Application Administrator'
        'Cloud Device Administrator'
        'Conditional Access Administrator'
        'Directory Writers'
        'Domain Name Administrator'
        'Exchange Administrator'
        'Groups Administrator'
        'Helpdesk Administrator'
        'Hybrid Identity Administrator'
        'Intune Administrator'
        'Partner Tier1 Support'
        'Password Administrator'
        'Security Administrator'
        'SharePoint Administrator'
        'Teams Administrator'
        'User Administrator'
        'Windows 365 Administrator'
    )

    $definitions = Invoke-HygieneGraphRequest `
        -Uri 'roleManagement/directory/roleDefinitions?$select=id,displayName,isBuiltIn' `
        -MaxPages $Context.MaxPages

    $definitionById = @{}
    foreach ($def in $definitions) { $definitionById[$def.id] = $def }

    $assignments = Invoke-HygieneGraphRequest `
        -Uri 'roleManagement/directory/roleAssignments?$expand=principal' `
        -MaxPages $Context.MaxPages

    Write-HygieneLog -Message "Read $($assignments.Count) role assignment(s) across $($definitions.Count) role definition(s)."

    $findings          = [System.Collections.Generic.List[object]]::new()
    $privilegedIds     = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $globalAdminHolders = [System.Collections.Generic.List[string]]::new()

    foreach ($assignment in $assignments) {

        $definition = $definitionById[$assignment.roleDefinitionId]
        $roleName   = if ($definition) { $definition.displayName } else { "Unknown role ($($assignment.roleDefinitionId))" }

        $tier = if ($tier0 -contains $roleName) { 'Tier0' }
                elseif ($tier1 -contains $roleName) { 'Tier1' }
                else { 'Other' }

        if ($tier -eq 'Other') { continue }

        $principal     = $assignment.principal
        $principalType = 'Unknown'
        if ($principal -and $principal.'@odata.type') {
            $principalType = ($principal.'@odata.type' -replace '^#microsoft\.graph\.', '')
        }

        $principalName = $null
        foreach ($prop in 'userPrincipalName', 'displayName', 'appId') {
            if ($principal -and $principal.$prop) { $principalName = $principal.$prop; break }
        }
        if (-not $principalName) { $principalName = $assignment.principalId }

        [void] $privilegedIds.Add($assignment.principalId)
        if ($roleName -eq 'Global Administrator') { [void] $globalAdminHolders.Add($principalName) }

        $scope = if ($assignment.directoryScopeId -eq '/') { 'Entire directory' } else { "Scoped to $($assignment.directoryScopeId)" }

        $evidence = @{
            roleName         = $roleName
            roleTier         = $tier
            roleDefinitionId = $assignment.roleDefinitionId
            principalId      = $assignment.principalId
            principalType    = $principalType
            directoryScopeId = $assignment.directoryScopeId
            accountEnabled   = if ($principal -and $null -ne $principal.accountEnabled) { [bool] $principal.accountEnabled } else { $null }
            userType         = if ($principal) { $principal.userType } else { $null }
        }

        $common = @{
            CheckId    = $meta.Id
            CheckName  = $meta.Name
            Category   = $meta.Category
            ObjectType = $principalType
            ObjectId   = $assignment.principalId
            ObjectName = $principalName
            Evidence   = $evidence
        }

        # --- Risky states, most severe first -------------------------------------

        if ($principalType -eq 'user' -and $principal.userType -eq 'Guest') {
            $findings.Add((New-HygieneFinding @common `
                -Severity $(if ($tier -eq 'Tier0') { 'Critical' } else { 'High' }) `
                -Title "Guest account holds the $roleName role" `
                -Detail "An account external to this tenant holds $roleName. Guest identities are governed by another organisation's password, MFA and offboarding processes, none of which this tenant controls." `
                -Recommendation 'Remove the role from the guest account. Where an external party genuinely needs administrative access, issue them an internal account subject to this tenant''s Conditional Access and lifecycle controls.' `
                -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/best-practices'))
            continue
        }

        if ($principalType -eq 'servicePrincipal' -and $tier -eq 'Tier0') {
            $findings.Add((New-HygieneFinding @common `
                -Severity 'High' `
                -Title "Service principal holds the tier-0 role $roleName" `
                -Detail "A non-human identity holds $roleName. Service principals authenticate with secrets or certificates that do not expire on their own and are not protected by MFA, so this is a permanent unattended path to tenant administration." `
                -Recommendation 'Replace the directory role with the narrowest application permissions the workload actually needs. If the role is genuinely required, move the credential to a certificate, set a short expiry, and monitor its sign-ins.' `
                -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/best-practices'))
            continue
        }

        if ($principalType -eq 'user' -and $null -ne $principal.accountEnabled -and -not $principal.accountEnabled) {
            $findings.Add((New-HygieneFinding @common `
                -Severity 'Medium' `
                -Title "Disabled account still holds the $roleName role" `
                -Detail "The account is disabled but the $roleName assignment remains. Re-enabling the account -- during an offboarding reversal, a migration, or an attack -- restores administrative access with it." `
                -Recommendation 'Remove role assignments as part of offboarding rather than relying on the account being disabled.' `
                -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/best-practices'))
            continue
        }

        if ($principalType -eq 'group') {
            $findings.Add((New-HygieneFinding @common `
                -Severity 'Low' `
                -Title "The $roleName role is assigned through a group" `
                -Detail "$roleName is held by a role-assignable group rather than by named principals, so the effective holders are whoever is in the group at any moment. This module does not expand group membership, so those holders are not listed in this report." `
                -Recommendation 'Review the group''s membership and its owners. Anyone who can add members to the group can effectively grant this role.' `
                -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/groups-concept'))
            continue
        }

        # --- Nothing wrong: record the assignment as inventory --------------------

        if ($tier -eq 'Tier0') {
            $findings.Add((New-HygieneFinding @common `
                -Severity 'Informational' `
                -Title "$roleName assignment" `
                -Detail "$principalName holds $roleName. $scope." `
                -Recommendation 'Confirm this assignment is still required and that the account is protected by phishing-resistant MFA.' `
                -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/best-practices'))
        }
    }

    # --- Tenant-level judgements on the Global Administrator population -----------

    $gaCount = $globalAdminHolders.Count
    $warnAt  = $Context.GlobalAdminWarnThreshold

    if ($gaCount -gt $warnAt) {
        $findings.Add((New-HygieneFinding `
            -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
            -Severity 'High' `
            -Title "$gaCount principals hold Global Administrator" `
            -ObjectType 'Tenant' `
            -ObjectId $(if ($Context.Tenant) { $Context.Tenant.Id } else { 'tenant' }) `
            -ObjectName $(if ($Context.Tenant) { $Context.Tenant.DisplayName } else { 'This tenant' }) `
            -Detail "Microsoft's guidance is to keep the number of permanent Global Administrators small -- commonly no more than $warnAt. Every additional holder is another account whose compromise is a full tenant compromise." `
            -Recommendation 'Move day-to-day work onto the least-privileged role that covers it, and make the remaining Global Administrator access eligible rather than permanent through Privileged Identity Management.' `
            -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/best-practices' `
            -Evidence @{ globalAdministratorCount = $gaCount; threshold = $warnAt; holders = @($globalAdminHolders) }))
    }
    elseif ($gaCount -eq 1) {
        $findings.Add((New-HygieneFinding `
            -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
            -Severity 'Medium' `
            -Title 'Only one principal holds Global Administrator' `
            -ObjectType 'Tenant' `
            -ObjectId $(if ($Context.Tenant) { $Context.Tenant.Id } else { 'tenant' }) `
            -ObjectName $(if ($Context.Tenant) { $Context.Tenant.DisplayName } else { 'This tenant' }) `
            -Detail 'A single Global Administrator is a single point of failure. If that account is lost, locked out by its own Conditional Access policy, or compromised, there is no second path back into tenant administration.' `
            -Recommendation 'Create at least one break-glass account: cloud-only, excluded from Conditional Access, with a long stored credential and alerting on use.' `
            -Reference 'https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access' `
            -Evidence @{ globalAdministratorCount = $gaCount }))
    }

    # Handed to the inactive account check so a dormant admin is scored accordingly.
    $Context.Cache['PrivilegedPrincipalIds'] = @($privilegedIds)

    New-HygieneCheckResult `
        -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
        -Status 'Completed' `
        -Findings @($findings) `
        -ObjectsEvaluated $assignments.Count `
        -RequiredScopes $meta.RequiredScopes
}