internal/functions/Build-AssignmentPlan.ps1

#Requires -PSEdition Core

function Write-AssignmentDetails {
    [CmdletBinding()]
    param (
        $displayName,
        $scope,
        $prefix
    )

    $shortScope = $scope -replace "/providers/Microsoft.Management", ""
    Write-Information "$($prefix) '$($displayName)' at $($shortScope)"
}

function Build-AssignmentPlan {
    [CmdletBinding()]
    param (
        [string] $assignmentsRootFolder,
        [hashtable] $pacEnvironment,
        [hashtable] $scopeTable,
        [hashtable] $deployedPolicyResources,
        [hashtable] $assignments,
        [hashtable] $roleAssignments,
        [hashtable] $allDefinitions,
        [hashtable] $allAssignments,
        [hashtable] $replaceDefinitions,
        [hashtable] $policyRoleIds
    )

    Write-Information "==================================================================================================="
    Write-Information "Processing Policy Assignments JSON files in folder '$assignmentsRootFolder'"
    Write-Information "==================================================================================================="
    $assignmentFiles = @()
    $assignmentFiles += Get-ChildItem -Path $assignmentsRootFolder -Recurse -File -Filter "*.json"
    $assignmentFiles += Get-ChildItem -Path $assignmentsRootFolder -Recurse -File -Filter "*.jsonc"
    $csvFiles = Get-ChildItem -Path $assignmentsRootFolder -Recurse -File -Filter "*.csv"
    $parameterFilesCsv = @{}
    if ($assignmentFiles.Length -gt 0) {
        Write-Information "Number of Policy Assignment files = $($assignmentFiles.Length)"
        foreach ($csvFile in $csvFiles) {
            $parameterFilesCsv.Add($csvFile.Name, $csvFile.FullName)
        }
    }
    else {
        Write-Information "There aren't any Policy Assignment files in the folder provided!"
    }

    # Cache role definitions
    $roleDefinitionList = Get-AzRoleDefinition
    [hashtable] $roleDefinitions = @{}
    foreach ($roleDefinition in $roleDefinitionList) {
        if (!$roleDefinitions.ContainsKey($roleDefinition.Id)) {
            $null = $roleDefinitions.Add($roleDefinition.Id, $roleDefinition.Name)
        }
    }

    # Convert Policy and PolicySetDefinition to detailed Info
    $combinedPolicyDetails = Convert-PolicySetsToDetails `
        -allPolicyDefinitions $allDefinitions.policydefinitions `
        -allPolicySetDefinitions $allDefinitions.policysetdefinitions

    # Process files
    $deployedPolicyAssignments = $deployedPolicyResources.policyassignments.managed
    $deployedRoleAssignmentsByPrincipalId = $deployedPolicyResources.roleAssignmentsByPrincipalId
    $deleteCandidates = Get-HashtableShallowClone $deployedPolicyAssignments
    foreach ($id  in $deployedPolicyAssignments.Keys) {
        $allAssignments[$id] = $deployedPolicyAssignments.$id
    }

    foreach ($assignmentFile in $assignmentFiles) {
        $Json = Get-Content -Path $assignmentFile.FullName -Raw -ErrorAction Stop
        if ((Test-Json $Json)) {
            Write-Information "Processing file '$($assignmentFile.FullName)'"
        }
        else {
            Write-Error "Assignment JSON file '$($assignmentFile.FullName)' is not valid." -ErrorAction Stop
        }
        $assignmentObject = $Json | ConvertFrom-Json -AsHashtable

        # Collect all assignment definitions (values)
        $rootAssignmentDefinition = @{
            nodeName                       = "/"
            assignment                     = @{
                append      = $false
                name        = ""
                displayName = ""
                description = ""
            }
            enforcementMode                = "Default"
            parameters                     = @{}
            additionalRoleAssignments      = @()
            nonComplianceMessages          = @()
            parameterSuppressDefaultValues = $false
            hasErrors                      = $false
            hasOnlyNotSelectedEnvironments = $false
            ignoreBranch                   = $false
            managedIdentityLocation        = $pacEnvironment.managedIdentityLocation
            notScope                       = $pacEnvironment.globalNotScopes
        }

        $hasErrors, $assignmentsList = Build-AssignmentDefinitionNode `
            -pacEnvironment $pacEnvironment `
            -scopeTable $scopeTable `
            -parameterFilesCsv $parameterFilesCsv `
            -definitionNode $assignmentObject `
            -assignmentDefinition $rootAssignmentDefinition `
            -combinedPolicyDetails $combinedPolicyDetails `
            -policyRoleIds $policyRoleIds

        if ($hasErrors) {
            Write-Error "Assignment definitions content errors" -ErrorAction Stop
        }

        foreach ($assignment in $assignmentsList) {

            $id = $assignment.id
            $allAssignments[$id] = $assignment
            $displayName = $assignment.displayName
            $description = $assignment.description
            $metadata = $assignment.metadata
            $parameters = $assignment.parameters
            $policyDefinitionId = $assignment.policyDefinitionId
            $scope = $assignment.scope
            $notScopes = $assignment.notScopes
            $enforcementMode = $assignment.enforcementMode
            $nonComplianceMessages = $assignment.nonComplianceMessages
            if ($deployedPolicyAssignments.ContainsKey($id)) {
                # Update and replace scenarios
                $deployedPolicyAssignment = $deployedPolicyAssignments[$id]
                $deployedPolicyAssignmentProperties = Get-PolicyResourceProperties $deployedPolicyAssignment
                $deleteCandidates.Remove($id) # do not delete

                $replacedDefinition = $replaceDefinitions.ContainsKey($policyDefinitionId)
                $changedPolicyDefinitionId = $policyDefinitionId -ne $deployedPolicyAssignmentProperties.policyDefinitionId
                $displayNameMatches = $displayName -eq $deployedPolicyAssignmentProperties.displayName
                $descriptionMatches = $description -eq $deployedPolicyAssignmentProperties.description
                $notScopesMatch = Confirm-ObjectValueEqualityDeep `
                    -existingObj $deployedPolicyAssignmentProperties.notScopes `
                    -definedObj $notScopes
                $parametersMatch = Confirm-AssignmentParametersMatch `
                    -existingParametersObj $deployedPolicyAssignmentProperties.parameters `
                    -definedParametersObj $parameters
                $metadataMatches, $changePacOwnerId = Confirm-MetadataMatches `
                    -existingMetadataObj $deployedPolicyAssignmentProperties.metadata `
                    -definedMetadataObj $metadata
                $enforcementModeMatches = $enforcementMode -eq $deployedPolicyAssignmentProperties.EnforcementMode
                $nonComplianceMessagesMatches = Confirm-ObjectValueEqualityDeep `
                    -existingObj $deployedPolicyAssignmentProperties.nonComplianceMessages `
                    -definedObj $nonComplianceMessages 

                $replace = $replacedDefinition -or $changedPolicyDefinitionId

                $changingRoleAssignments = $false
                $hasExistingIdentity = ($null -ne $deployedPolicyAssignment.identity) -and ($null -ne $deployedPolicyAssignment.identity.principalId)
                $identityRequired = $assignment.ContainsKey("identityRequired") -and $assignment.identityRequired
                $changedIdentityLocation = $false
                $changedIdentity = $false
                if ($hasExistingIdentity -or $identityRequired) {
                    $principalId = $null
                    $deployedRoleAssignments = @()
                    $requiredRoleDefinitions = @()
                    if ($identityRequired) {
                        $requiredRoleDefinitions = $assignment.metadata.roles
                    }
                    if ($hasExistingIdentity) {
                        $principalId = $deployedPolicyAssignment.identity.principalId
                        if ($deployedRoleAssignmentsByPrincipalId.ContainsKey($principalId)) {
                            $deployedRoleAssignments = $deployedRoleAssignmentsByPrincipalId.$principalId
                        }
                    }
                    if (!$replace) {
                        if ($hasExistingIdentity -and $identityRequired) {
                            $changedIdentityLocation = $deployedAssignment.location -ne $managedIdentityLocation
                            $replace = $changedIdentityLocation
                        }
                        else {
                            # adding or removing identity
                            $replace = $true
                            $changedIdentity = $true
                        }
                    }
                    $changingRoleAssignments = Build-AssignmentRoleChanges `
                        -principalIdForAddedRoles ($replace ? $null : $principalId) `
                        -requiredRoleDefinitions $requiredRoleDefinitions `
                        -deployedRoleAssignments $deployedRoleAssignments `
                        -assignment $assignment `
                        -roleAssignments $roleAssignments
                }

                # Check if Policy assignment in Azure is the same as in the JSON file

                $changesStrings = @()
                $match = $displayNameMatches -and $descriptionMatches -and $parametersMatch -and $metadataMatches -and !$changePacOwnerId -and $enforcementModeMatches -and $notScopesMatch -and $nonComplianceMessagesMatches -and !$replace
                if ($match) {
                    # no Assignment properties changed
                    $assignments.numberUnchanged++
                    if ($changingRoleAssignments) {
                        # role assignments for Managed Identity changed - caused by a mangedIdentityLocation changed or a previously failed role assignment failure
                        Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "Update(roles)"
                    }
                    else {
                        # Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "Unchanged"
                    }
                }
                else {
                    # One or more properties have changed
                    if ($replace) {
                        # Assignment must be deleted and recreated (new)
                        if ($changedPolicyDefinitionId) {
                            $changesStrings += "definitionId"
                        }
                        if ($replacedDefinition) {
                            $changesStrings += "replacedDefinition"
                        }
                        if ($changedIdentity) {
                            if ($hasExistingIdentity) {
                                $changesStrings += "removedIdentity"
                            }
                            else {
                                $changesStrings += "addedIdentity"
                            }
                        }
                        if ($changedIdentityLocation) {
                            $changesStrings += "identityLocation"
                        }
                    }

                    if (!$displayNameMatches) {
                        $changesStrings += "displayName"
                    }
                    if (!$descriptionMatches) {
                        $changesStrings += "description"
                    }
                    if ($changePacOwnerId) {
                        $changesStrings += "owner"
                    }
                    if ($changingRoleAssignments) {
                        $changesStrings += "roles"
                    }
                    if (!$metadataMatches) {
                        $changesStrings += "metadata"
                    }
                    if (!$parametersMatch) {
                        $changesStrings += "parameters"
                    }
                    if (!$enforcementModeMatches) {
                        $changesStrings += "enforcementMode"
                    }
                    if (!$notScopesMatch) {
                        $changesStrings += "notScopes"
                    }
                    if (!$nonComplianceMessagesMatches) {
                        $changesStrings += "nonComplianceMessages"
                    }

                    if ($replace) {
                        # Assignment must be deleted and recreated (new)
                        Remove-EmptyFields $assignment
                        $null = $assignments.replace.Add($id, $assignment)
                        $assignments.numberOfChanges++
                        $changesString = $changesStrings -join ","
                        Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "Replace($changesString)"
                    }
                    else {
                        $changesString = $changesStrings -join ","
                        $splatTransformString = $splatTransformStrings -join " "
                        $assignment.splatTransform = $splatTransformString
                        $null = $assignments.update.Add($id, $assignment)
                        $assignments.numberOfChanges++
                        Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "Update($changesString)"
                    }
                }
            }
            else {
                # New Assignment
                # Remove-EmptyFields $assignment
                $null = $assignments.new.Add($id, $assignment)
                $assignments.numberOfChanges++
                $requiredRoleDefinitions = $assignment.metadata.roles
                if ($requiredRoleDefinitions.Length -gt 0) {
                    $null = Build-AssignmentRoleChanges `
                        -requiredRoleDefinitions $requiredRoleDefinitions `
                        -deployedRoleAssignments @() `
                        -assignment $assignment `
                        -roleAssignments $roleAssignments
                }
                Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "New"
            }
        }
    }

    $strategy = $pacEnvironment.desiredState.strategy
    foreach ($id in $deleteCandidates.Keys) {
        $deleteCandidate = $deleteCandidates.$id
        $deleteCandidateProperties = Get-PolicyResourceProperties $deleteCandidate
        $displayName = $deleteCandidateProperties.displayName
        $scope = $deleteCandidateProperties.scope
        $pacOwner = $deleteCandidate.pacOwner
        $shallDelete = Confirm-DeleteForStrategy -pacOwner $pacOwner -strategy $strategy
        if ($shallDelete) {
            # always delete if owned by this Policy as Code solution
            # never delete if owned by another Policy as Code solution
            # if strategy is "full", delete with unknown owner (missing pacOwnerId)
            Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "Delete"
            $splat = @{
                id          = $id
                name        = $deleteCandidate.name
                scopeId     = $scope
                displayName = $deleteCandidateProperties.displayName
            }

            $allAssignments.Remove($id)
            $assignments.delete.Add($id, $splat)
            $assignments.numberOfChanges++
            $hasExistingIdentity = ($null -ne $deleteCandidate.identity) -and ($null -ne $deleteCandidate.identity.principalId)
            if ($hasExistingIdentity) {
                $principalId = $deleteCandidate.identity.principalId
                if ($deployedRoleAssignmentsByPrincipalId.ContainsKey($principalId)) {
                    $deployedRoleAssignments = $deployedRoleAssignmentsByPrincipalId.$principalId
                    $null = Build-AssignmentRoleChanges `
                        -requiredRoleDefinitions @() `
                        -deployedRoleAssignments $deployedRoleAssignments `
                        -assignment $assignment `
                        -roleAssignments $roleAssignments
                }
            }
        }
        else {
            # Write-AssignmentDetails -displayName $displayName -scope $scope -prefix "No delete($pacOwner,$strategy)"
        }
    }

    Write-Information "Number of unchanged Policy Assignments = $($assignments.numberUnchanged)"
    Write-Information ""
}