functions/Test-PIMPolicyDrift.ps1

<#
.SYNOPSIS
Tests PIM role assignment policy configuration for drift against live settings.
 
.DESCRIPTION
Reads a policy configuration JSON file (with optional templates), resolves the expected
settings for Entra roles, Azure resource roles, and group roles, and compares them with
the live PIM policies in the specified tenant. Reports matches, drift, and errors.
Optionally throws when drift is detected.
 
.PARAMETER TenantId
The Entra tenant ID to query for PIM policy settings.
 
.PARAMETER ConfigPath
Path to the JSON configuration file describing expected PIM policies. Supports line
comments (//) and block comments (/* */) which will be removed before parsing.
 
.PARAMETER SubscriptionId
Optional Azure subscription ID. Required if the config includes Azure resource role
policies to validate.
 
.PARAMETER FailOnDrift
If set, throws an error when any policy drift or error is detected.
 
.PARAMETER PassThru
If set, suppresses formatted console output and is intended for use in pipelines.
Note: The function always returns the results array; PassThru only affects host output.
 
.INPUTS
None. You cannot pipe objects to this function.
 
.OUTPUTS
PSCustomObject. One object per evaluated policy with properties:
Type, Name, Target, Status (Match|Drift|Error|SkippedRoleNotFound), Differences.
 
.EXAMPLE
Test-PIMPolicyDrift -TenantId 00000000-0000-0000-0000-000000000000 -ConfigPath .\examples\scripts\pim-policies.json
 
Compares Entra and group role policies from the config to live settings in the tenant.
 
.EXAMPLE
Test-PIMPolicyDrift -TenantId 00000000-0000-0000-0000-000000000000 -ConfigPath .\config\pim.json -SubscriptionId 11111111-1111-1111-1111-111111111111 -FailOnDrift -Verbose
 
Validates Entra, group, and Azure resource role policies and throws if drift is found.
 
.EXAMPLE
Test-PIMPolicyDrift -TenantId $env:TenantId -ConfigPath .\config\pim.json -PassThru | Where-Object Status -ne 'Match'
 
Returns only the items where drift or error is present.
 
.NOTES
Module: EasyPIM.Orchestrator (requires EasyPIM core module)
Author: Kayasax and contributors
License: MIT (same as EasyPIM)
 
Authentication Context and MFA Requirements:
Microsoft Entra PIM automatically removes MultiFactorAuthentication requirements when
Authentication Context is enabled to prevent MfaAndAcrsConflict. This is expected
behavior and will not be flagged as drift by this function.
 
.LINK
https://github.com/kayasax/EasyPIM
#>

# Public wrapper: moved from internal/functions to functions to satisfy manifest export tests.


function Test-PIMPolicyDrift {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPositionalParameters", "", Justification="Parameters are named at call sites; internal helper calls may trigger false positives.")]
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ConfigPath,
        [string]$SubscriptionId,
        [switch]$FailOnDrift,
        [switch]$PassThru
    )

    Write-Verbose -Message "Starting PIM policy drift test for config: $ConfigPath"

    try { $ConfigPath = (Resolve-Path -Path $ConfigPath -ErrorAction Stop).Path } catch { throw "Config file not found: $ConfigPath" }

    function Remove-JsonComments {
        param([string]$Content)
        $noBlock    = [regex]::Replace($Content,'(?s)/\*.*?\*/','')
        $noFullLine = [regex]::Replace($noBlock,'(?m)^[ \t]*//.*?$','')
    $sb = New-Object -TypeName System.Text.StringBuilder
        foreach ($line in $noFullLine -split "`n") {
            $inString = $false; $escaped=$false; $out = New-Object -TypeName System.Text.StringBuilder
            for ($i=0; $i -lt $line.Length; $i++) {
                $ch = $line[$i]
                if ($escaped) { [void]$out.Append($ch); $escaped=$false; continue }
                if ($ch -eq '\\') { $escaped=$true; [void]$out.Append($ch); continue }
                if ($ch -eq '"') { $inString = -not $inString; [void]$out.Append($ch); continue }
                if (-not $inString -and $ch -eq '/' -and $i+1 -lt $line.Length -and $line[$i+1] -eq '/') { break }
                [void]$out.Append($ch)
            }
            [void]$sb.AppendLine(($out.ToString()))
        }
        return $sb.ToString()
    }

    $configRaw = Get-Content -Raw -Path $ConfigPath
    try {
    $clean = Remove-JsonComments -Content $configRaw
    $json  = $clean | ConvertFrom-Json -ErrorAction Stop
    } catch {
    Write-Verbose -Message "Raw first 200: $($configRaw.Substring(0,[Math]::Min(200,$configRaw.Length)))"
        throw "Failed to parse config: $($_.Exception.Message)"
    }
    if (-not $json) { throw "Parsed JSON object is null - invalid configuration." }

    function Get-ResolvedPolicyObject { param([Parameter(Mandatory)][object]$Policy); if ($Policy.PSObject.Properties['ResolvedPolicy'] -and $Policy.ResolvedPolicy) { return $Policy.ResolvedPolicy }; return $Policy }

    # Protected role definitions (consistent with orchestrator logic)
    $protectedEntraRoles = @("Global Administrator","Privileged Role Administrator","Security Administrator","User Access Administrator")
    $protectedAzureRoles = @("Owner","User Access Administrator")

    function Test-IsProtectedRole {
        param([string]$RoleName, [string]$Type)
        switch ($Type) {
            'EntraRole' { return $protectedEntraRoles -contains $RoleName }
            'AzureRole' { return $protectedAzureRoles -contains $RoleName }
            default { return $false }
        }
    }

    $expectedAzure=@(); $expectedEntra=@(); $expectedGroup=@(); $templates=@{}
    if ($json.PSObject.Properties['PolicyTemplates']) {
    foreach ($t in ($json.PolicyTemplates | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name)) { $templates[$t] = $json.PolicyTemplates.$t }
    }
    function Resolve-Template { param([Parameter(Mandatory)]$Object) if (-not $Object) { return $Object }; if ($Object.Template -and $templates.ContainsKey($Object.Template)) { $base = $templates[$Object.Template] | ConvertTo-Json -Depth 20 | ConvertFrom-Json; foreach ($p in $Object.PSObject.Properties) { $base | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value -Force }; return $base }; return $Object }

    if ($json.PSObject.Properties['AzureRolePolicies']) { $expectedAzure += $json.AzureRolePolicies }
    if ($json.PSObject.Properties['EntraRolePolicies']) { $expectedEntra += $json.EntraRolePolicies }
    if ($json.PSObject.Properties['GroupPolicies']) { $expectedGroup += $json.GroupPolicies }

    if ($json.PSObject.Properties['AzureRoles'] -and $json.AzureRoles.PSObject.Properties['Policies']) {
        foreach ($prop in $json.AzureRoles.Policies.PSObject.Properties) {
            $roleName=$prop.Name; $p=$prop.Value; if (-not $p) { continue }
            $obj=[pscustomobject]@{ RoleName=$roleName; Scope=$p.Scope }
            foreach ($pp in $p.PSObject.Properties) { if ($pp.Name -notin @('Scope')) { $obj | Add-Member -NotePropertyName $pp.Name -NotePropertyValue $pp.Value -Force } }
            $expectedAzure += $obj
        }
    }
    if ($json.PSObject.Properties['EntraRoles'] -and $json.EntraRoles.PSObject.Properties['Policies']) {
        foreach ($prop in $json.EntraRoles.Policies.PSObject.Properties) {
            $roleName=$prop.Name; $p=$prop.Value; if (-not $p) { continue }
            $obj=[pscustomobject]@{ RoleName=$roleName }
            foreach ($pp in $p.PSObject.Properties) { $obj | Add-Member -NotePropertyName $pp.Name -NotePropertyValue $pp.Value -Force }
            $expectedEntra += $obj
        }
    }
    if ($json.PSObject.Properties['GroupRoles'] -and $json.GroupRoles.PSObject.Properties['Policies']) {
        foreach ($gprop in $json.GroupRoles.Policies.PSObject.Properties) {
            $groupId=$gprop.Name; $roleBlock=$gprop.Value; if (-not $roleBlock) { continue }
            foreach ($rprop in $roleBlock.PSObject.Properties) {
                $roleName=$rprop.Name; $p=$rprop.Value; if (-not $p) { continue }
                $obj=[pscustomobject]@{ GroupId=$groupId; RoleName=$roleName }
                foreach ($pp in $p.PSObject.Properties) { $obj | Add-Member -NotePropertyName $pp.Name -NotePropertyValue $pp.Value -Force }
                $expectedGroup += $obj
            }
        }
    }

    $expectedAzure = $expectedAzure | ForEach-Object -Process { $_ | Add-Member -NotePropertyName ResolvedPolicy -NotePropertyValue (Resolve-Template -Object $_) -Force; $_ }
    $expectedEntra = $expectedEntra | ForEach-Object -Process { $_ | Add-Member -NotePropertyName ResolvedPolicy -NotePropertyValue (Resolve-Template -Object $_) -Force; $_ }
    $expectedGroup = $expectedGroup | ForEach-Object -Process { $_ | Add-Member -NotePropertyName ResolvedPolicy -NotePropertyValue (Resolve-Template -Object $_) -Force; $_ }

    $fields = @('ActivationDuration','ActivationRequirement','ApprovalRequired','MaximumEligibilityDuration','AllowPermanentEligibility','MaximumActiveAssignmentDuration','AllowPermanentActiveAssignment')
    $liveNameMap = @{ 'ActivationRequirement'='EnablementRules'; 'MaximumEligibilityDuration'='MaximumEligibleAssignmentDuration'; 'AllowPermanentEligibility'='AllowPermanentEligibleAssignment' }
    $script:results=@(); $script:driftCount=0

    function Convert-RequirementValue {
        param([string]$Value)
        if (-not $Value) { return '' }
        $v = $Value.Trim()
        if ($v -eq '') { return '' }
        if ($v -match '^(none|null|no(ne)?requirements?)$') { return '' }
        # Split on comma / semicolon
        $tokens = $v -split '[,;]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
    $norm = foreach ($t in $tokens) {
            switch -Regex ($t) {
                '^(mfa|multifactorauthentication)$' { 'MFA'; break }
                '^(justification)$' { 'Justification'; break }
                default { $t }
            }
        }
    ($norm | Sort-Object -Unique) -join ','
    }

    function Compare-Policy {
        param(
            [Parameter(Mandatory)][string]$Type,
            [Parameter(Mandatory)][string]$Name,
            [Parameter()][object]$Expected,
            [Parameter()][object]$Live,
            [Parameter()][string]$ExtraId=$null,
            [Parameter()][int]$ApproverCountExpected=$null
        )
        $differences=@()
        foreach ($f in $fields) {
            if ($Expected.PSObject.Properties[$f]) {
                $exp=$Expected.$f; $liveProp=$f; if ($liveNameMap.ContainsKey($f)) { $liveProp=$liveNameMap[$f] }
                $liveVal=$null; if ($Live -and $Live.PSObject -and $Live.PSObject.Properties[$liveProp]) { $liveVal=$Live.$liveProp }
                if ($exp -is [System.Collections.IEnumerable] -and -not ($exp -is [string])) { $exp = ($exp | ForEach-Object { "$_" }) -join ',' }
                if ($liveVal -is [System.Collections.IEnumerable] -and -not ($liveVal -is [string])) { $liveVal = ($liveVal | ForEach-Object { "$_" }) -join ',' }
                if ($f -eq 'ActivationRequirement' -or $f -eq 'ActiveAssignmentRequirement') {
                    $expNorm  = Convert-RequirementValue -Value $exp
                    $liveNorm = Convert-RequirementValue -Value $liveVal

                    # Apply business rules validation using the internal function
                    # Pass the entire Expected settings as PSCustomObject to maintain property access
                $policyForBusinessRules = [PSCustomObject]@{}
                $Expected.PSObject.Properties | ForEach-Object {
                    $policyForBusinessRules | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value
                }
                $businessRuleResult = Test-PIMPolicyBusinessRules -PolicySettings $policyForBusinessRules -CurrentPolicy $Live -ApplyAdjustments
                $hasBusinessRuleAdjustment = $businessRuleResult.HasChanges
                if ($hasBusinessRuleAdjustment) {
                    $adjustedExp = $businessRuleResult.AdjustedSettings.$f
                    $adjustedExpNorm = Convert-RequirementValue -Value $adjustedExp

                        if ($adjustedExpNorm -eq $liveNorm) {
                            # This is expected behavior due to business rules, not drift
                            if ($businessRuleResult.Conflicts -and $businessRuleResult.Conflicts.Count -gt 0) {
                                Write-Verbose "$Name - Business rule applied: $($businessRuleResult.Conflicts[0]) (expected behavior, not drift)"
                            }
                            continue  # Skip adding to differences
                        } else {
                            # Still drift even after business rule adjustments
                            $expNorm = $adjustedExpNorm
                            $exp = $adjustedExp
                        }
                    }

                    if ($expNorm -ne $liveNorm) {
                        $displayExp  = if ($null -eq $exp -or $exp -eq '' -or $exp -eq 'None') { 'None' } else { $exp }
                        $displayLive = if ($null -eq $liveVal -or $liveVal -eq '' -or $liveVal -eq 'None') { 'None' } else { $liveVal }
                        $driftMessage = "{0}: expected='{1}' actual='{2}'" -f $f,$displayExp,$displayLive

                        # Add explanatory notes for business rule conflicts
                        if ($hasBusinessRuleAdjustment) {
                            $driftMessage += " (Note: Expected value adjusted for Authentication Context business rules)"
                        }

                        $differences += $driftMessage
                    }
                }
                else {
                    if ("$exp" -ne "$liveVal") { $differences += ("{0}: expected='{1}' actual='{2}'" -f $f,$exp,$liveVal) }
                }
            }
        }
        if ($null -ne $ApproverCountExpected -and $Expected.PSObject.Properties['ApprovalRequired'] -and $Expected.ApprovalRequired) {
            $liveApproverCount=$null
            foreach ($aprop in 'Approvers','Approver','Approval','approval','ApproverCount') {
                if ($Live.PSObject -and $Live.PSObject.Properties[$aprop]) {
                    $val=$Live.$aprop
                    if ($val -is [System.Collections.IEnumerable] -and -not ($val -is [string])) { $liveApproverCount=@($val).Count }
                    elseif ($val -match '^[0-9]+$') { $liveApproverCount=[int]$val }
                    if ($null -ne $liveApproverCount) { break }
                }
            }
            if ($null -ne $liveApproverCount -and $liveApproverCount -ne $ApproverCountExpected) { $differences += "ApproversCount: expected=$ApproverCountExpected actual=$liveApproverCount" }
        }
        if ($differences.Count -gt 0) { $script:driftCount++; $status='Drift' } else { $status='Match' }

        # Add protected role indicator to the name display
        $displayName = $Name
        if (Test-IsProtectedRole -RoleName $Name -Type $Type) {
            $displayName = "$Name [⚠️ PROTECTED]"
        }

        $script:results += [pscustomobject]@{ Type=$Type; Name=$displayName; Target=$ExtraId; Status=$status; Differences=($differences -join '; ') }
    }

    if ($expectedAzure.Count -gt 0 -and -not $SubscriptionId) {
    Write-Warning -Message "Azure role policies present but no -SubscriptionId provided; skipping Azure role validation."
    } elseif ($expectedAzure.Count -gt 0) {
        foreach ($p in $expectedAzure) {
            $r = Get-ResolvedPolicyObject -Policy $p
            if (-not $p.Scope) { $script:results += [pscustomobject]@{ Type='AzureRole'; Name=$p.RoleName; Target='(missing scope)'; Status='Error'; Differences='Missing Scope' }; $script:driftCount++; continue }
            try {
                $live = Get-PIMAzureResourcePolicy -tenantID $TenantId -subscriptionID $SubscriptionId -rolename $p.RoleName -ErrorAction Stop
                if ($live -is [System.Collections.IEnumerable] -and -not ($live -is [string])) { $live = @($live)[0] }
                $approverCount = if ($r.Approvers) { $r.Approvers.Count } else { $null }
                Compare-Policy -Type 'AzureRole' -Name $p.RoleName -Expected $r -Live $live -ExtraId $p.Scope -ApproverCountExpected $approverCount
            } catch { $script:results += [pscustomobject]@{ Type='AzureRole'; Name=$p.RoleName; Target=$p.Scope; Status='Error'; Differences=$_.Exception.Message }; $script:driftCount++ }
        }
    }

    foreach ($p in $expectedEntra) {
        if ($p._RoleNotFound) { $script:results += [pscustomobject]@{ Type='EntraRole'; Name=$p.RoleName; Target='/'; Status='SkippedRoleNotFound'; Differences='' }; continue }
    $r = Get-ResolvedPolicyObject -Policy $p
        try {
            $live = Get-PIMEntraRolePolicy -tenantID $TenantId -rolename $p.RoleName -ErrorAction Stop
            if ($live -is [System.Collections.IEnumerable] -and -not ($live -is [string])) { $live = @($live)[0] }
            if (-not $live) { throw "Live policy returned null for role '$($p.RoleName)'" }
            $approverCount = if ($r.Approvers) { $r.Approvers.Count } else { $null }
            Compare-Policy -Type 'EntraRole' -Name $p.RoleName -Expected $r -Live $live -ApproverCountExpected $approverCount
        } catch { $script:results += [pscustomobject]@{ Type='EntraRole'; Name=$p.RoleName; Target='/'; Status='Error'; Differences=$_.Exception.Message }; $script:driftCount++ }
    }

    foreach ($p in $expectedGroup) {
    $r = Get-ResolvedPolicyObject -Policy $p
        if (-not $r.PSObject.Properties['ActivationRequirement'] -and $r.PSObject.Properties['EnablementRules'] -and $r.EnablementRules) { try { $r | Add-Member -NotePropertyName ActivationRequirement -NotePropertyValue $r.EnablementRules -Force } catch { $r.ActivationRequirement = $r.EnablementRules } }
        if (-not $r.PSObject.Properties['ActivationDuration'] -and $r.PSObject.Properties['Duration'] -and $r.Duration) { try { $r | Add-Member -NotePropertyName ActivationDuration -NotePropertyValue $r.Duration -Force } catch { $r.ActivationDuration = $r.Duration } }
        if (-not $p.GroupId -and $p.GroupName) {
            try {
                $endpoint = "groups?`$filter=displayName eq '$($p.GroupName.Replace("'","''"))'"
                $resp = invoke-graph -Endpoint $endpoint
                if ($resp.value -and $resp.value.Count -gt 0) { $p | Add-Member -NotePropertyName GroupId -NotePropertyValue $resp.value[0].id -Force }
            } catch { Write-Warning -Message "Group resolution failed for '$($p.GroupName)': $($_.Exception.Message)" }
        }
        $gid = $p.GroupId
        if (-not $gid) {
            $targetGroupRef = if ($p.GroupName) { $p.GroupName } else { '(unknown)' }
            $script:results += [pscustomobject]@{ Type='Group'; Name=$p.RoleName; Target=$targetGroupRef; Status='Error'; Differences='Missing GroupId' }
            $script:driftCount++
            continue
        }
        try {
            $live = Get-PIMGroupPolicy -tenantID $TenantId -groupID $gid -type ($p.RoleName.ToLower()) -ErrorAction Stop
            if ($live -is [System.Collections.IEnumerable] -and -not ($live -is [string])) { $live = @($live)[0] }
            $approverCount = if ($r.Approvers) { $r.Approvers.Count } else { $null }
            Compare-Policy -Type 'Group' -Name $p.RoleName -Expected $r -Live $live -ExtraId $gid -ApproverCountExpected $approverCount
        } catch { $script:results += [pscustomobject]@{ Type='Group'; Name=$p.RoleName; Target=$gid; Status='Error'; Differences=$_.Exception.Message }; $script:driftCount++ }
    }

    if (-not $PassThru) {
    Write-Host -Object "Policy Verification Results:" -ForegroundColor Cyan
    $script:results | Sort-Object -Property Type, Name | Format-Table -AutoSize
    $summary = $script:results | Group-Object -Property Status | Select-Object -Property Name,Count
    Write-Host -Object "`nSummary:" -ForegroundColor Cyan
    $summary | Format-Table -AutoSize
        if ($script:results.Count -eq 0) {
            Write-Host -Object "No policies discovered in config (nothing compared)." -ForegroundColor Yellow
        } else {
            $script:driftCount = ($script:results | Where-Object { $_.Status -in 'Drift','Error' }).Count
            if ($script:driftCount -eq 0) { Write-Host -Object "All compared policy fields match expected values." -ForegroundColor Green }
            else { Write-Host -Object "Drift detected in $script:driftCount policy item(s)." -ForegroundColor Yellow }
        }
    }

    if ($FailOnDrift -and ($script:results | Where-Object -FilterScript { $_.Status -in 'Drift','Error' })) {
        throw "PIM policy drift detected."
    }

    return $script:results
}