Entra/Get-EntraSoDConfig.ps1

<#
.SYNOPSIS
    Evaluates separation of duties for critical Entra ID admin roles.
.DESCRIPTION
    Checks whether critical admin roles (Global Administrator, Privileged Role
    Administrator) have at minimum two separate users assigned and that no single
    user holds both roles simultaneously. Also verifies PIM is in use so that
    activations require approval.

    Requires an active Microsoft Graph connection with RoleManagement.Read.Directory
    and Directory.Read.All permissions.
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> .\Entra\Get-EntraSoDConfig.ps1

    Displays separation of duties evaluation results.
.EXAMPLE
    PS> .\Entra\Get-EntraSoDConfig.ps1 -OutputPath '.\entra-sod-config.csv'

    Exports the evaluation to CSV.
.NOTES
    Author: Daren9m
    CMMC: AC.L2-3.1.4 — Separation of Duties
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

$_scriptDir = if ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } else { $PSScriptRoot }
. (Join-Path -Path $_scriptDir -ChildPath '..\Common\SecurityConfigHelper.ps1')

$ctx = Initialize-SecurityConfig
$settings = $ctx.Settings
$checkIdCounter = $ctx.CheckIdCounter

function Add-Setting {
    param(
        [string]$Category, [string]$Setting, [string]$CurrentValue,
        [string]$RecommendedValue, [string]$Status,
        [string]$CheckId = '', [string]$Remediation = ''
    )
    $p = @{
        Settings         = $settings
        CheckIdCounter   = $checkIdCounter
        Category         = $Category
        Setting          = $Setting
        CurrentValue     = $CurrentValue
        RecommendedValue = $RecommendedValue
        Status           = $Status
        CheckId          = $CheckId
        Remediation      = $Remediation
    }
    Add-SecuritySetting @p
}

# ------------------------------------------------------------------
# Well-known role template IDs
# ------------------------------------------------------------------
$globalAdminRoleId = '62e90394-69f5-4237-9190-012177145e10'
$privRoleAdminRoleId = 'e8611ab8-c189-46e8-94e1-60213ab1f814'

# ------------------------------------------------------------------
# 1. Check Global Admin and Privileged Role Admin assignments
# ------------------------------------------------------------------
try {
    Write-Verbose 'Checking role assignments for Global Administrator...'
    $gaParams = @{
        Method      = 'GET'
        Uri         = "/v1.0/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$globalAdminRoleId'&`$top=999&`$expand=principal"
        ErrorAction = 'Stop'
    }
    $gaAssignments = Invoke-MgGraphRequest @gaParams

    $gaMembers = @()
    if ($gaAssignments -and $gaAssignments['value']) {
        $gaMembers = @($gaAssignments['value'])
    }

    Write-Verbose 'Checking role assignments for Privileged Role Administrator...'
    $praParams = @{
        Method      = 'GET'
        Uri         = "/v1.0/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$privRoleAdminRoleId'&`$top=999&`$expand=principal"
        ErrorAction = 'Stop'
    }
    $praAssignments = Invoke-MgGraphRequest @praParams

    $praMembers = @()
    if ($praAssignments -and $praAssignments['value']) {
        $praMembers = @($praAssignments['value'])
    }

    # Extract unique principal IDs for each role
    $gaPrincipals = @($gaMembers | ForEach-Object { $_['principalId'] } | Sort-Object -Unique)
    $praPrincipals = @($praMembers | ForEach-Object { $_['principalId'] } | Sort-Object -Unique)

    # Check separation: no single user should hold both roles
    $overlap = @($gaPrincipals | Where-Object { $praPrincipals -contains $_ })

    $gaCount = $gaPrincipals.Count
    $praCount = $praPrincipals.Count
    $overlapCount = $overlap.Count

    # Pass criteria: each critical role has >= 2 separate assignees AND no overlap
    $separated = ($gaCount -ge 2) -and ($praCount -ge 1) -and ($overlapCount -eq 0)

    $currentValue = "Global Admins: $gaCount, Priv Role Admins: $praCount, Overlap: $overlapCount"

    $settingParams = @{
        Category         = 'Separation of Duties'
        Setting          = 'Critical Role Separation (Global Admin vs Privileged Role Admin)'
        CurrentValue     = $currentValue
        RecommendedValue = 'At least 2 Global Admins, no user holding both roles'
        Status           = if ($separated) { 'Pass' } else { 'Fail' }
        CheckId          = 'ENTRA-SOD-001'
        Remediation      = 'Ensure Global Administrator and Privileged Role Administrator roles are assigned to separate accounts. Entra admin center > Identity > Roles & admins. Enable PIM approval workflows for role activation.'
    }
    Add-Setting @settingParams
}
catch {
    if ($_.Exception.Message -match '403|Forbidden|Authorization') {
        $settingParams = @{
            Category         = 'Separation of Duties'
            Setting          = 'Critical Role Separation (Global Admin vs Privileged Role Admin)'
            CurrentValue     = 'Insufficient permissions'
            RecommendedValue = 'At least 2 Global Admins, no user holding both roles'
            Status           = 'Review'
            CheckId          = 'ENTRA-SOD-001'
            Remediation      = 'Requires RoleManagement.Read.Directory and Directory.Read.All permissions.'
        }
        Add-Setting @settingParams
    }
    else {
        Write-Warning "Could not check separation of duties: $_"
    }
}

# ------------------------------------------------------------------
# Output results
# ------------------------------------------------------------------
Export-SecurityConfigReport -Settings $settings -OutputPath $OutputPath -ServiceLabel 'Entra SoD'