functions/Invoke-EasyPIMOrchestrator.ps1
<#
.SYNOPSIS Invokes the EasyPIM end-to-end orchestration (policies, cleanup, assignments) with safety validation. .DESCRIPTION Loads a configuration (file or Key Vault secret), validates principals, (optionally) applies/validates role & group policies, performs cleanup (initial full reconcile or delta additive mode), and provisions assignments. Designed for progressive adoption using -WhatIf previews and an explicit destructive 'initial' mode. .PARAMETER ConfigFilePath Path to a JSON configuration file containing ProtectedUsers, PolicyTemplates, role policies, and Assignments blocks. .PARAMETER KeyVaultName Name of Azure Key Vault containing a secret that stores the JSON configuration (alternative to ConfigFilePath). .PARAMETER SecretName Name of the Key Vault secret that holds the JSON configuration. .PARAMETER TenantId Target Entra (Azure AD) tenant GUID. If omitted, attempts to use $env:tenantid. .PARAMETER SubscriptionId Target Azure subscription GUID for Azure Resource role policy/assignment operations. If omitted, attempts $env:subscriptionid. .PARAMETER Mode Assignment cleanup mode: 'delta' (add/update only) or 'initial' (destructive reconcile removing undeclared assignments, except ProtectedUsers). .PARAMETER Operations Filter which assignment domains (AzureRoles, EntraRoles, GroupRoles) to process. Default 'All'. .PARAMETER PolicyOperations Filter which policy domains to process. Default 'All'. .PARAMETER SkipAssignments Skip the assignment creation phase (useful for policy-only validation or cleanup-only scenarios). .PARAMETER SkipCleanup Skip cleanup (no removal / WouldRemove evaluation). Assignments still created if not skipped. .PARAMETER SkipPolicies Skip policy processing; existing policies are left untouched. .PARAMETER WouldRemoveExportPath Directory OR file path to export the full list of assignments that WOULD be removed during a -WhatIf run (or that WERE removed in a non -WhatIf initial run). Behavior: * If a directory is supplied, a timestamped file 'EasyPIM-WouldRemove-<UTC>.json' is created. * If a file path is supplied without extension, '.json' is appended. * If the extension is '.csv', a CSV file (headers: PrincipalId,PrincipalName,RoleName,Scope,ResourceType,Mode) is produced; otherwise JSON. * File is ALWAYS written even under -WhatIf to provide a tangible audit artifact (empty list => empty JSON array or header-only CSV). Use cases: change review, audit evidence, diffing consecutive previews, verifying ProtectedUsers coverage before destructive apply. .EXAMPLE Invoke-EasyPIMOrchestrator -ConfigFilePath .\pim-config.json -TenantId $env:tenantid -SubscriptionId $env:subscriptionid -Mode initial -WhatIf -WouldRemoveExportPath .\LOGS Produces a preview (no changes) and writes a timestamped JSON file under .\LOGS listing every assignment that would be removed by an initial reconcile. .EXAMPLE Invoke-EasyPIMOrchestrator -ConfigFilePath .\pim-config.json -TenantId <tenant> -SubscriptionId <sub> -Mode initial -WhatIf -WouldRemoveExportPath .\preview.csv Same preview, but exports CSV (because extension is .csv) suitable for Excel review / sign-off. .NOTES Always run destructive 'initial' mode with -WhatIf first; inspect summary and export file, adjust ProtectedUsers, then re-run without -WhatIf. .LINK https://github.com/kayasax/EasyPIM/wiki/Invoke%E2%80%90EasyPIMOrchestrator #> function Invoke-EasyPIMOrchestrator { [CmdletBinding(DefaultParameterSetName = 'Default', SupportsShouldProcess = $true, ConfirmImpact='Medium')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPositionalParameters", "", Justification="All public cmdlets use named parameters; any remaining triggers are false positives or internal methods.")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "", Justification="Top-level ShouldProcess invoked; inner creation functions also use ShouldProcess")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSShouldProcess", "", Justification="False positive previously; pattern implemented below")] param ( [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] [string]$KeyVaultName, [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')] [string]$SecretName, [Parameter(Mandatory = $false)] [string]$SubscriptionId, [Parameter(Mandatory = $true, ParameterSetName = 'FilePath')] [string]$ConfigFilePath, [Parameter(Mandatory = $false)] [ValidateSet("initial", "delta")] [string]$Mode = "delta", [Parameter(Mandatory = $false)] [string]$TenantId, [Parameter(Mandatory = $false)] [ValidateSet("All", "AzureRoles", "EntraRoles", "GroupRoles")] [string[]]$Operations = @("All"), [Parameter(Mandatory = $false)] [switch]$SkipAssignments, [Parameter(Mandatory = $false)] [switch]$SkipCleanup, [Parameter(Mandatory = $false)] [switch]$SkipPolicies, [Parameter(Mandatory = $false)] [ValidateSet("All", "AzureRoles", "EntraRoles", "GroupRoles")] [string[]]$PolicyOperations = @("All"), [Parameter(Mandatory = $false)] [string]$WouldRemoveExportPath ) # Non-gating ShouldProcess: still emits WhatIf message but always executes body for rich simulation output. $null = $PSCmdlet.ShouldProcess("EasyPIM Orchestration lifecycle", "Execute") # Normalize mode casing for internal logic (accepts initial/delta in any case) $Mode = $Mode.ToLowerInvariant() Write-SectionHeader -Message "Starting EasyPIM Orchestration (Mode: $Mode)" # Display usage if no parameters are provided if (-not $PSBoundParameters) { Show-EasyPIMUsage return } try { # 1. Load configuration $config = if ($PSCmdlet.ParameterSetName -eq 'KeyVault') { Get-EasyPIMConfiguration -KeyVaultName $KeyVaultName -SecretName $SecretName } else { Get-EasyPIMConfiguration -ConfigFilePath $ConfigFilePath } # Session rule: prefer environment variables for TenantId / SubscriptionId when not explicitly supplied if (-not $TenantId -or [string]::IsNullOrWhiteSpace($TenantId)) { $TenantId = $env:tenantid if ($TenantId) { Write-Host -Object "[INFO] Using TenantId from environment: $TenantId" -ForegroundColor DarkCyan } else { Write-Host -Object "[WARN] TenantId not provided and TENANTID env var is empty." -ForegroundColor Yellow } } # 2. Process and normalize config based on selected operations $processedConfig = Initialize-EasyPIMAssignments -Config $config # 2.1. Process policy configurations if present $policyConfig = $null # If user constrained Operations but did not explicitly set PolicyOperations, mirror the Operations filter for policies if (-not $PSBoundParameters.ContainsKey('PolicyOperations') -and $PSBoundParameters.ContainsKey('Operations') -and ($Operations -notcontains 'All')) { $PolicyOperations = $Operations } if (-not $SkipPolicies -and ( ($config.PSObject.Properties['AzureRolePolicies'] -and $config.AzureRolePolicies) -or ($config.PSObject.Properties['EntraRolePolicies'] -and $config.EntraRolePolicies) -or ($config.PSObject.Properties['GroupPolicies'] -and $config.GroupPolicies) -or ($config.PSObject.Properties['PolicyTemplates'] -and $config.PolicyTemplates) -or ($config.PSObject.Properties['Policies'] -and $config.Policies) -or ($config.PSObject.Properties['EntraRoles'] -and $config.EntraRoles.PSObject.Properties['Policies'] -and $config.EntraRoles.Policies) -or ($config.PSObject.Properties['AzureRoles'] -and $config.AzureRoles.PSObject.Properties['Policies'] -and $config.AzureRoles.Policies) -or ($config.PSObject.Properties['GroupRoles'] -and $config.GroupRoles.PSObject.Properties['Policies'] -and $config.GroupRoles.Policies) )) { Write-Host -Object "[PROC] Processing policy configurations..." -ForegroundColor Cyan $policyConfig = Initialize-EasyPIMPolicies -Config $config -PolicyOperations $PolicyOperations # Filter policy config based on selected policy operations if ($PolicyOperations -notcontains "All") { $filteredPolicyConfig = @{} foreach ($op in $PolicyOperations) { switch ($op) { "AzureRoles" { if ($policyConfig.ContainsKey('AzureRolePolicies')) { $filteredPolicyConfig.AzureRolePolicies = $policyConfig.AzureRolePolicies } } "EntraRoles" { if ($policyConfig.ContainsKey('EntraRolePolicies')) { $filteredPolicyConfig.EntraRolePolicies = $policyConfig.EntraRolePolicies } } "GroupRoles" { if ($policyConfig.ContainsKey('GroupPolicies')) { $filteredPolicyConfig.GroupPolicies = $policyConfig.GroupPolicies } } } } # Make filtered policy config the active policy config for policy processing $policyConfig = $filteredPolicyConfig # Merge filtered policy config with processed assignment config (for visibility) foreach ($key in $filteredPolicyConfig.Keys) { if ($processedConfig.PSObject.Properties[$key]) { $processedConfig.PSObject.Properties[$key].Value = $filteredPolicyConfig[$key] } else { $processedConfig | Add-Member -MemberType NoteProperty -Name $key -Value $filteredPolicyConfig[$key] } } } else { # Merge all policy config with processed config foreach ($key in $policyConfig.Keys) { if ($key -match ".*Policies$") { if ($processedConfig.PSObject.Properties[$key]) { $processedConfig.PSObject.Properties[$key].Value = $policyConfig[$key] } else { $processedConfig | Add-Member -MemberType NoteProperty -Name $key -Value $policyConfig[$key] } } } } } elseif ($SkipPolicies) { Write-Host -Object "[WARN] Skipping policy processing as requested by SkipPolicies parameter" -ForegroundColor Yellow } # Filter config based on selected operations if ($Operations -notcontains "All") { $filteredConfig = @{} foreach ($op in $Operations) { switch ($op) { "AzureRoles" { $filteredConfig.AzureRoles = $processedConfig.AzureRoles $filteredConfig.AzureRolesActive = $processedConfig.AzureRolesActive } "EntraRoles" { $filteredConfig.EntraIDRoles = $processedConfig.EntraIDRoles $filteredConfig.EntraIDRolesActive = $processedConfig.EntraIDRolesActive } "GroupRoles" { $filteredConfig.GroupRoles = $processedConfig.GroupRoles $filteredConfig.GroupRolesActive = $processedConfig.GroupRolesActive } } } $processedConfig = $filteredConfig } # Always perform principal & group validation before any policy or assignment operations Write-Host -Object "[TEST] Validating principal and group IDs..." -ForegroundColor Cyan $principalIds = New-Object -TypeName "System.Collections.Generic.HashSet[string]" $policyApproverRefs = @() if ($processedConfig.PSObject.Properties.Name -contains 'Assignments' -and $processedConfig.Assignments) { $assign = $processedConfig.Assignments foreach ($section in 'EntraRoles','AzureRoles','Groups') { if ($assign.PSObject.Properties.Name -contains $section -and $assign.$section) { foreach ($roleBlock in $assign.$section) { if ($roleBlock.PSObject.Properties.Name -contains 'assignments') { foreach ($a in $roleBlock.assignments) { if ($a.principalId) { [void]$principalIds.Add($a.principalId) } } } if ($section -eq 'Groups' -and $roleBlock.groupId) { [void]$principalIds.Add($roleBlock.groupId) } } } } } foreach ($legacySection in 'EntraIDRoles','EntraIDRolesActive','AzureRoles','AzureRolesActive','GroupRoles','GroupRolesActive') { if ($processedConfig.PSObject.Properties.Name -contains $legacySection -and $processedConfig.$legacySection) { foreach ($item in $processedConfig.$legacySection) { if ($item.PrincipalId) { [void]$principalIds.Add($item.PrincipalId) } if ($item.GroupId) { [void]$principalIds.Add($item.GroupId) } } } } # Include approver IDs from policy configurations for validation $approverRefsFound = 0 $hasEntraPolicies = $false if ($policyConfig -and ( ($policyConfig -is [hashtable] -and $policyConfig.ContainsKey('EntraRolePolicies') -and $policyConfig.EntraRolePolicies) -or ($policyConfig -isnot [hashtable] -and $policyConfig.PSObject.Properties['EntraRolePolicies'] -and $policyConfig.EntraRolePolicies) )) { $hasEntraPolicies = $true foreach ($pol in $policyConfig.EntraRolePolicies) { $roleNameRef = $pol.RoleName # Prefer ResolvedPolicy (new path), else Policy (legacy), else the object itself $policyRef = $null if ($pol.PSObject.Properties['ResolvedPolicy'] -and $pol.ResolvedPolicy) { $policyRef = $pol.ResolvedPolicy } elseif ($pol.PSObject.Properties['Policy'] -and $pol.Policy) { $policyRef = $pol.Policy } else { $policyRef = $pol } # Extract approvers regardless of type (hashtable vs. PSCustomObject) $approvers = $null if ($policyRef -is [hashtable]) { if ($policyRef.ContainsKey('Approvers')) { $approvers = $policyRef['Approvers'] } } elseif ($policyRef -and $policyRef.PSObject.Properties['Approvers']) { $approvers = $policyRef.Approvers } if ($approvers) { foreach ($ap in $approvers) { $apId = $null if ($ap -is [string]) { $apId = $ap } else { $apId = $ap.Id; if (-not $apId) { $apId = $ap.id } } if ($apId) { [void]$principalIds.Add([string]$apId) $policyApproverRefs += [pscustomobject]@{ PrincipalId = [string]$apId; RoleName = $roleNameRef } $approverRefsFound++ } } } } Write-Verbose -Message ("[Orchestrator] Collected {0} approver references ({1} unique) from policyConfig.EntraRolePolicies" -f $approverRefsFound, $policyApproverRefs.Count) } # Fallback: if not found via policyConfig, inspect processedConfig attachment for visibility if (-not $hasEntraPolicies -or $approverRefsFound -eq 0) { if ($processedConfig.PSObject.Properties['EntraRolePolicies'] -and $processedConfig.EntraRolePolicies) { foreach ($pol in $processedConfig.EntraRolePolicies) { $roleNameRef = $pol.RoleName $policyRef = $null if ($pol.PSObject.Properties['ResolvedPolicy'] -and $pol.ResolvedPolicy) { $policyRef = $pol.ResolvedPolicy } elseif ($pol.PSObject.Properties['Policy'] -and $pol.Policy) { $policyRef = $pol.Policy } else { $policyRef = $pol } $approvers = $null if ($policyRef -is [hashtable]) { if ($policyRef.ContainsKey('Approvers')) { $approvers = $policyRef['Approvers'] } } elseif ($policyRef -and $policyRef.PSObject.Properties['Approvers']) { $approvers = $policyRef.Approvers } if ($approvers) { foreach ($ap in $approvers) { $apId = $null if ($ap -is [string]) { $apId = $ap } else { $apId = $ap.Id; if (-not $apId) { $apId = $ap.id } } if ($apId) { [void]$principalIds.Add([string]$apId) $policyApproverRefs += [pscustomobject]@{ PrincipalId = [string]$apId; RoleName = $roleNameRef } $approverRefsFound++ } } } } Write-Verbose -Message ("[Orchestrator] Collected {0} approver references ({1} unique) from processedConfig.EntraRolePolicies" -f $approverRefsFound, $policyApproverRefs.Count) } } $validationResults = @() foreach ($principalIdIter in $principalIds) { $exists = Test-PrincipalExists -PrincipalId $principalIdIter $type = $null; $displayName = $null if ($exists) { # Reuse cached object if available if ($script:principalObjectCache -and $script:principalObjectCache.ContainsKey($principalIdIter)) { $obj = $script:principalObjectCache[$principalIdIter] } else { try { $obj = Invoke-Graph -Endpoint "directoryObjects/$principalIdIter" -ErrorAction Stop } catch { Write-Verbose -Message "Suppressed directory object fetch failure for ${principalIdIter}: $($_.Exception.Message)" } } if ($obj -and $obj.'@odata.type') { $type = $obj.'@odata.type' } if ($type -eq '#microsoft.graph.group') { try { $g = Get-MgGroup -GroupId $principalIdIter -Property Id,DisplayName -ErrorAction SilentlyContinue if ($g) { $displayName = $g.DisplayName } } catch { Write-Verbose -Message "Suppressed group lookup failure for ${principalIdIter}: $($_.Exception.Message)" } } } $validationResults += [pscustomobject]@{ PrincipalId = $principalIdIter; Exists = $exists; Type = $type; DisplayName = $displayName } } $missing = $validationResults | Where-Object -FilterScript { -not $_.Exists } if ($missing.Count -gt 0) { Write-Host -Object "[WARN] Principal validation failed:" -ForegroundColor Yellow foreach ($m in $missing) { $refRoles = ($policyApproverRefs | Where-Object -FilterScript { $_.PrincipalId -eq $m.PrincipalId } | Select-Object -ExpandProperty RoleName -Unique) if ($refRoles) { Write-Host -Object " - $($m.PrincipalId): DOES NOT EXIST (referenced as Approver for Entra role(s): $([string]::Join(', ', $refRoles)))" -ForegroundColor Red } else { Write-Host -Object " - $($m.PrincipalId): DOES NOT EXIST" -ForegroundColor Red } } if ($WhatIfPreference) { Write-Host -Object "Proceeding due to -WhatIf (preview) to allow cleanup delta visibility. These principals will be ignored." -ForegroundColor Yellow } else { Write-Host -Object "Aborting before any policy or assignment processing. Fix these IDs or run with -WhatIf to preview." -ForegroundColor Red return } } else { $checked = $validationResults.Count Write-Host -Object "[OK] Principal validation passed ($checked principals checked, 0 missing)" -ForegroundColor Green } # Debug: show processed assignment counts (eligible/active) before policy & cleanup phases try { $dbgAzureElig = ($processedConfig.AzureRoles | Measure-Object).Count $dbgAzureAct = ($processedConfig.AzureRolesActive | Measure-Object).Count $dbgEntraElig = ($processedConfig.EntraIDRoles | Measure-Object).Count $dbgEntraAct = ($processedConfig.EntraIDRolesActive | Measure-Object).Count $dbgGroupElig = ($processedConfig.GroupRoles | Measure-Object).Count $dbgGroupAct = ($processedConfig.GroupRolesActive | Measure-Object).Count Write-Host -Object "[Orchestrator Debug] Assignment counts -> Azure(E:$dbgAzureElig A:$dbgAzureAct) Entra(E:$dbgEntraElig A:$dbgEntraAct) Groups(E:$dbgGroupElig A:$dbgGroupAct)" -ForegroundColor DarkCyan } catch { Write-Host -Object "[Orchestrator Debug] Failed to compute assignment debug counts: $($_.Exception.Message)" -ForegroundColor DarkYellow } if (-not $SubscriptionId -or [string]::IsNullOrWhiteSpace($SubscriptionId)) { $SubscriptionId = $env:subscriptionid if ($SubscriptionId) { Write-Host -Object "[INFO] Using SubscriptionId from environment: $SubscriptionId" -ForegroundColor DarkCyan } else { Write-Host -Object "[WARN] SubscriptionId not provided and SUBSCRIPTIONID env var is empty (Azure role operations may be limited)." -ForegroundColor Yellow } } # 3. Process policies FIRST (skip if requested) - CRITICAL: Policies must be applied before assignments to ensure compliance $policyResults = $null if (-not $SkipPolicies -and $policyConfig -and ( ($policyConfig.ContainsKey('AzureRolePolicies') -and $policyConfig.AzureRolePolicies) -or ($policyConfig.ContainsKey('EntraRolePolicies') -and $policyConfig.EntraRolePolicies) -or ($policyConfig.ContainsKey('GroupPolicies') -and $policyConfig.GroupPolicies) )) { # Policy functions no longer support a separate 'validate' mode. Always use 'delta'; rely on -WhatIf for preview. $effectivePolicyMode = "delta" # Convert hashtable to PSCustomObject for the policy function $policyConfigObject = [PSCustomObject]$policyConfig $policyResults = New-EPOEasyPIMPolicy -Config $policyConfigObject -TenantId $TenantId -SubscriptionId $SubscriptionId -PolicyMode $effectivePolicyMode -WhatIf:$WhatIfPreference if ($WhatIfPreference) { Write-Host -Object "[OK] Policy dry-run completed (-WhatIf) - role policies appear correctly configured for assignment compliance" -ForegroundColor Green } else { $failed = 0; $succeeded = 0 try { if ($policyResults -and $policyResults.Summary) { $failed = [int]$policyResults.Summary.Failed $succeeded = [int]$policyResults.Summary.Successful } } catch { Write-Verbose -Message ("[Orchestrator] Unable to read policy summary counts: {0}" -f $_.Exception.Message) } if ($failed -gt 0) { Write-Host -Object "[WARN] Policy configuration completed with errors (Successful: $succeeded, Failed: $failed). Proceeding with assignments." -ForegroundColor Yellow } else { Write-Host -Object "[OK] Policy configuration completed - proceeding with assignments using updated role policies" -ForegroundColor Green } } } elseif ($SkipPolicies) { Write-Warning -Message "Policy processing skipped - assignments may not comply with intended role policies" } # 4. Perform cleanup operations AFTER policy processing (skip if requested or if assignments are skipped) $cleanupResults = if ($Operations -contains "All" -and -not $SkipCleanup -and -not $SkipAssignments) { Write-Host -Object "[CLEANUP] Performing cleanup operations based on updated policies..." -ForegroundColor Cyan Invoke-EasyPIMCleanup -Config $processedConfig -Mode $Mode -TenantId $TenantId -SubscriptionId $SubscriptionId -WhatIf:$WhatIfPreference -WouldRemoveExportPath $WouldRemoveExportPath } else { if ($SkipAssignments) { Write-Host -Object "[WARN] Skipping cleanup because SkipAssignments was specified (no assignment delta expected)" -ForegroundColor Yellow } elseif ($SkipCleanup) { Write-Host -Object "[WARN] Skipping cleanup as requested by SkipCleanup parameter" -ForegroundColor Yellow } else { Write-Host -Object "[WARN] Skipping cleanup as specific operations were selected" -ForegroundColor Yellow } $null } # High removal warning for initial mode if ($cleanupResults -and $Mode -eq 'initial' -and -not $WhatIfPreference) { $threshold = [int]([Environment]::GetEnvironmentVariable('EASYPIM_INITIAL_REMOVAL_WARN_THRESHOLD') | ForEach-Object -Process { if ($_ -as [int]) { $_ } else { 10 } }) $removed = if ($cleanupResults.PSObject.Properties.Name -contains 'RemovedCount') { $cleanupResults.RemovedCount } else { $cleanupResults.Removed } if ($removed -gt 0) { $color = if ($removed -ge $threshold) { 'Red' } else { 'Yellow' } Write-Host -Object "[WARN] Initial mode removed $removed assignments (threshold=$threshold). Verify this matches intent. Use delta mode for add/update-only runs." -ForegroundColor $color } } # 5. Process assignments AFTER policies are confirmed (skip if requested) if (-not $SkipAssignments) { Write-Host -Object "[ASSIGN] Creating assignments with role policies validated and applied..." -ForegroundColor Cyan # New-EasyPIMAssignments does not itself expose -WhatIf; inner Invoke-ResourceAssignment handles simulation. $assignmentResults = New-EasyPIMAssignments -Config $processedConfig -TenantId $TenantId -SubscriptionId $SubscriptionId # After assignments, attempt deferred group policies if any if (Get-Command -Name Invoke-EPODeferredGroupPolicies -ErrorAction SilentlyContinue) { # Deferred group policies follow the same rule: always use 'delta' mode; -WhatIf controls preview only. $retryMode = 'delta' $deferredSummary = Invoke-EPODeferredGroupPolicies -TenantId $TenantId -Mode $retryMode -WhatIf:$WhatIfPreference if ($deferredSummary) { $script:EasyPIM_DeferredGroupPoliciesSummary = $deferredSummary Write-Host -Object "Deferred Group Policies Retry:" -ForegroundColor Cyan Write-Host -Object " Applied: $($deferredSummary.Applied)" -ForegroundColor Cyan Write-Host -Object " Skipped: $($deferredSummary.Skipped)" -ForegroundColor Cyan Write-Host -Object " Failed : $($deferredSummary.Failed)" -ForegroundColor Cyan # Optionally attach to policyResults summary counts if ($policyResults -and $policyResults.Summary) { $policyResults.Summary.TotalProcessed += ($deferredSummary.Applied + $deferredSummary.Skipped + $deferredSummary.Failed) $policyResults.Summary.Successful += $deferredSummary.Applied $policyResults.Summary.Failed += $deferredSummary.Failed $policyResults.Summary.Skipped += $deferredSummary.Skipped } } } } else { Write-Host -Object "[WARN] Skipping assignment creation as requested" -ForegroundColor Yellow $assignmentResults = $null } # 6. Display summary # Summary no longer distinguishes 'validate' policy mode; pass 'delta' and rely on -WhatIf for preview messaging upstream $effectivePolicyMode = 'delta' Write-EasyPIMSummary -CleanupResults $cleanupResults -AssignmentResults $assignmentResults -PolicyResults $policyResults -PolicyMode $effectivePolicyMode Write-Host -Object "Mode semantics: delta = add/update only (no removals), initial = full reconcile (destructive)." -ForegroundColor Gray Write-Host -Object "=== EasyPIM orchestration completed successfully ===" -ForegroundColor Green } catch { Write-Error -Message "[ERROR] An error occurred: $($_.Exception.Message)" Write-Verbose -Message "Stack trace: $($_.ScriptStackTrace)" throw } } |