functions/Build-DefinitionsFolders.ps1

function Build-DefinitionsFolders {

[CmdletBinding()]
param (
    [parameter(Mandatory = $false, HelpMessage = "Defines which Policy as Code (PAC) environment we are using, if omitted, the script prompts for a value. The values are read from `$DefinitionsRootFolder/global-settings.jsonc.", Position = 0)]
    [string] $PacEnvironmentSelector,

    [Parameter(Mandatory = $false, HelpMessage = "Definitions folder path. Defaults to environment variable `$env:PAC_DEFINITIONS_FOLDER or './Definitions'.")]
    [string]$definitionsRootFolder,

    [Parameter(Mandatory = $false, HelpMessage = "Output Folder. Defaults to environment variable `$env:PAC_OUTPUT_FOLDER or './Outputs'.")]
    [string] $outputFolder,

    [Parameter(Mandatory = $false, HelpMessage = "Set to false if used non-interactive")]
    [bool] $interactive = $true,

    [Parameter(Mandatory = $false, HelpMessage = "Switch to include Policies and Policy Sets definitions in child scopes")]
    [switch] $includeChildScopes,

    [ValidateSet("json", "jsonc")]
    [Parameter(Mandatory = $false, HelpMessage = "File extension type for the output files. Defaults to '.jsonc'.")]
    [string] $fileExtension = "jsonc"   
)

#region Script Dot sourcing

# Common Functions





#endregion dot sourcing

#region Initialize

$InformationPreference = "Continue"
$pacEnvironment = Select-PacEnvironment $PacEnvironmentSelector -definitionsRootFolder $DefinitionsRootFolder -outputFolder $OutputFolder -interactive $interactive
$pacSelector = $pacEnvironment.pacSelector
Set-AzCloudTenantSubscription -cloud $pacEnvironment.cloud -tenantId $pacEnvironment.tenantId -interactive $pacEnvironment.interactive

$outputFolder = $pacEnvironment.outputFolder
$definitionsFolder = "$($pacEnvironment.outputFolder)/Definitions"
$policyDefinitionsFolder = "$definitionsFolder/policyDefinitions"
$policySetDefinitionsFolder = "$definitionsFolder/policySetDefinitions"
$policyAssignmentsFolder = "$definitionsFolder/policyAssignments"
$invalidChars = [IO.Path]::GetInvalidFileNameChars()
$invalidChars += ("[]()$".ToCharArray())
$globalNotScopesList = [System.Collections.ArrayList]::new()
foreach ($notScope in $pacEnvironment.globalNotScopes) {
    if ($notScope.StartsWith("/resourceGroupPatterns/")) {
        $notScope = $notScope -replace "/resourceGroupPatterns/", "/subscriptions/*/resourceGroups/"
    }
    $null = $globalNotScopesList.Add($notScope)
}
$globalNotScopes = $globalNotScopesList |  Sort-Object | Get-Unique
if (Test-Path $definitionsFolder) {
    if ($interactive) {
        Write-Information ""
        Remove-Item $definitionsFolder -Recurse -Confirm
        Write-Information ""
    }
    else {
        Remove-Item $definitionsFolder -Recurse
    }
}

# Retrieve Policy resources
$scopeTable = Get-AzScopeTree -pacEnvironment $pacEnvironment
$deployed = Get-AzPolicyResources -pacEnvironment $pacEnvironment -scopeTable $scopeTable -skipRoleAssignments -skipExemptions -collectAllPolicies:$includeChildScopes

$policyDefinitions = $deployed.policydefinitions.custom
$policySetDefinitions = $deployed.policysetdefinitions.custom
$policyAssignments = $deployed.policyassignments.all
$allDefinitions = @{}

Write-Information ""
Write-Information "==================================================================================================="
Write-Information "Processing $($policyDefinitions.Count) Policies"
Write-Information "==================================================================================================="

$policyNames = @{}
foreach ($policyDefinition in $policyDefinitions.Values) {
    $properties = Get-PolicyResourceProperties -policyResource $policyDefinition
    $metadata = Get-CustomMetadata $properties.metadata -remove "pacOwnerId"
    $version = $properties.version
    $id = $policyDefinition.id
    $name = $policyDefinition.name
    if ($null -eq $version) {
        $version = "1.0.0"
    }
    $definition = [PSCustomObject]@{
        properties = [PSCustomObject]@{
            displayName = $properties.displayName
            description = $properties.description
            mode        = $properties.mode
            metadata    = $metadata
            version     = $version
            parameters  = $properties.parameters
            policyRule  = [PSCustomObject]@{
                if   = $properties.policyRule.if
                then = $properties.policyRule.then
            }
        }
        name       = $name
    }
    Out-PolicyDefinition -definition $definition -folder $policyDefinitionsFolder -policyNames $policyNames -invalidChars $invalidChars -typeString "Policy" -id $id -fileExtension $fileExtension
}

Write-Information ""
Write-Information "==================================================================================================="
Write-Information "Processing $($policySetDefinitions.Count) Policy Sets"
Write-Information "==================================================================================================="

$policySetNames = @{}
foreach ($policySetDefinition in $policySetDefinitions.Values) {
    $properties = Get-PolicyResourceProperties -policyResource $policySetDefinition
    $metadata = Get-CustomMetadata $properties.metadata -remove "pacOwnerId"
    $version = $properties.version
    if ($null -eq $version) {
        $version = "1.0.0"
    }

    # Adjust policyDefinitions for EPAC
    $policyDefinitionsIn = Get-DeepClone $properties.policyDefinitions -AsHashTable
    $policyDefinitionsOut = [System.Collections.ArrayList]::new()
    foreach ($policyDefinitionIn in $policyDefinitionsIn) {
        $parts = Split-AzPolicyResourceId -id $policyDefinitionIn.policyDefinitionId
        $policyDefinitionOut = $null
        if ($parts.scopeType -eq "builtin") {
            $policyDefinitionOut = [PSCustomObject]@{
                policyDefinitionReferenceId = $policyDefinitionIn.policyDefinitionReferenceId
                policyDefinitionId          = $policyDefinitionIn.policyDefinitionId
                parameters                  = $policyDefinitionIn.parameters
            }
        }
        else {
            $policyDefinitionOut = [PSCustomObject]@{
                policyDefinitionReferenceId = $policyDefinitionIn.policyDefinitionReferenceId
                policyDefinitionName        = $parts.name
                parameters                  = $policyDefinitionIn.parameters
            }
        }
        $groupNames = $policyDefinitionIn.groupNames
        if ($null -ne $groupNames -and $groupNames.Count -gt 0) {
            Add-Member -InputObject $policyDefinitionOut -TypeName "NoteProperty" -NotePropertyName "groupNames" -NotePropertyValue $groupNames
        }
        $null = $policyDefinitionsOut.Add($policyDefinitionOut)
    }

    $definition = [PSCustomObject]@{
        properties = [PSCustomObject]@{
            displayName            = $properties.displayName
            description            = $properties.description
            metadata               = $metadata
            version                = $version
            parameters             = $properties.parameters
            policyDefinitions      = $policyDefinitionsOut.ToArray()
            policyDefinitionGroups = $properties.policyDefinitionGroups
        }
        name       = $policySetDefinition.name
    }
    Out-PolicyDefinition -definition $definition -folder $policySetDefinitionsFolder -policyNames $policySetNames -invalidChars $invalidChars -typeString "Policy" -id $policySetDefinition.id -fileExtension $fileExtension
}

Write-Information ""
Write-Information "==================================================================================================="
Write-Information "Processing $($policyAssignments.Count) Policy Assignments"
Write-Information "==================================================================================================="
Write-Information "WARNING! This script assumes the following:"
Write-Information "* Names of Policies and Policy Sets are unique across multiple scopes."
Write-Information "* Assignment names are the same if the parameters match across multiple assignments across scopes."
Write-Information "* Ignores Assignments auto-assigned by Security Center."
Write-Information "* Does not collate across multiple tenants."
Write-Information "* Does not calculate any additionalRoleAssignments."
Write-Information "==================================================================================================="

# Collate multiple entries by policyDefinitionId and than by scope and also parameters
$assignments = @{}
foreach ($policyAssignment in $policyAssignments.Values) {
    $id = $policyAssignment.id
    if ($id -like "/subscriptions/*/providers/Microsoft.Authorization/policyAssignments/ASC-*" -or $id -like "/subscriptions/*/providers/Microsoft.Authorization/policyAssignments/SecurityCenterBuiltIn") {
        # Write-Warning "Do not process Security Center: $id"
    }
    else {
        # Important elements
        $properties = Get-PolicyResourceProperties -policyResource $policyAssignment
        $metadata = Get-CustomMetadata $properties.metadata -remove "pacOwnerId,roles"
        $name = $policyAssignment.name
        $scope = $policyAssignment.resourceIdParts.scope
        $parameters = @{}
        if ($null -ne $properties.parameters -and $properties.parameters.Count -gt 0) {
            $parameters = Get-DeepClone $properties.parameters -AsHashTable
        }
        $policyDefinitionId = $properties.policyDefinitionId

        # Generate key for hashtable
        $parts = Split-AzPolicyResourceId -id $policyDefinitionId
        $policyDefinitionKey = $parts.definitionKey

        # Fill structure
        $perDefinition = $null
        if ($assignments.ContainsKey($policyDefinitionKey)) {
            $perDefinition = $assignments.$policyDefinitionKey

            # Collate by parameter cluster
            $match = $false
            $parameterClusters = $perDefinition.parameterClusters
            foreach ($clusterParameters in $parameterClusters.Keys) {
                # Find a match for clustering
                $perParameterCluster = $parameterClusters.$clusterParameters
                $localMatch = Confirm-AssignmentParametersMatch -existingParametersObj $clusterParameters -definedParametersObj $parameters -compareTwoExistingParametersObj
                if ($localMatch) {
                    # Add to existing cluster
                    $match = $true
                    $perParameterCluster[$id] = $policyAssignment
                    break
                }
            }
            if (!$match) {
                # Start a new cluster
                $parameterClusters = $perDefinition.parameterClusters
                $parameterClusters[$parameters] = @{
                    $id = $policyAssignment
                }
            }
        }
        else {
            # Initialize structure with first entry
            $definitions = $deployed.policysetdefinitions.all
            if ($parts.kind -eq "policyDefinitions") {
                $definitions = $deployed.policydefinitions.all
            }
            $definition = $definitions.$policyDefinitionId
            $definitionDisplayName = $definition.properties.displayName
            $perDefinition = @{
                parameterClusters     = @{
                    $parameters = @{
                        $id = $policyAssignment
                    }
                }
                definitionId          = $parts.id
                definitionName        = $parts.name
                definitionDisplayName = $definitionDisplayName
                definitionKind        = $parts.kind
                isBuiltin             = $parts.scopeType -eq "builtin"
            }
            $assignments[$policyDefinitionKey] = $perDefinition
        }
    }
}

foreach ($policyDefinitionKey in $assignments.Keys) {
    $perDefinition = $assignments.$policyDefinitionKey
    $parameterClusters = $perDefinition.parameterClusters

    $subfolder = $perDefinition.definitionKind -replace "Definitions", ""
    $fullPath = Get-DefinitionsFullPath `
        -folder $policyAssignmentsFolder `
        -rawSubFolder $subFolder `
        -name $perDefinition.definitionName `
        -displayName $perDefinition.definitionDisplayName `
        -invalidChars $invalidChars `
        -maxLengthSubFolder 30 `
        -maxLengthFileName 100 `
        -fileExtension $fileExtension

    # Create definitionEntry
    $definitionEntry = @{}
    if ($perDefinition.isBuiltin) {
        if ($perDefinition.definitionKind -eq "policySetDefinitions") {
            $definitionEntry = @{
                policySetId = $perDefinition.definitionId
                displayName = $perDefinition.definitionDisplayName
            }
        }
        else {
            $definitionEntry = @{
                policyId    = $perDefinition.definitionId
                displayName = $perDefinition.definitionDisplayName
            }
        }
    }
    else {
        # Custom
        $definition = $allDefinitions[$policyDefinitionKey]
        if ($perDefinition.definitionKind -eq "policySetDefinitions") {
            $definitionEntry = @{
                policySetName = $perDefinition.definitionName
                displayName   = $perDefinition.definitionDisplayName
            }
        }
        else {
            $definitionEntry = @{
                policyName  = $perDefinition.definitionName
                displayName = $perDefinition.definitionDisplayName
            }
        }
    }

    $assignmentDefinition = [ordered]@{
        nodeName        = "/root"
        definitionEntry = $definitionEntry
    }
    $children = [System.Collections.ArrayList]::new()
    foreach ($parameterSet in $parameterClusters.Keys) {

        $perParameterCluster = $parameterClusters.$parameterSet

        $flatParameters = @{}
        foreach ($parameterName in $parameterSet.Keys) {
            $flatParameters[$parameterName] = ($parameterSet[$parameterName]).value
        }

        $child = [ordered]@{}
        $grandChildren = [System.Collections.ArrayList]::new()
        $grandChildScopes = [System.Collections.ArrayList]::new()
        $allScopes = [System.Collections.ArrayList]::new()
        $allNotScopes = [System.Collections.ArrayList]::new()
        $allNotScopeProcessed = @{}
        foreach ($id in $perParameterCluster.Keys) {
            $currentAssignment = $perParameterCluster.$id
            $currentProperties = Get-PolicyResourceProperties -policyResource $currentAssignment
            $currentMetadata = Get-CustomMetadata $currentProperties.metadata -remove "pacOwnerId,roles"
            $grandChild = [ordered]@{
                nodeName        = "/$($currentAssignment.name)"
                assignment      = [ordered]@{
                    name        = $currentAssignment.name
                    displayName = $currentProperties.displayName
                    description = $currentProperties.description
                }
                metadata        = $currentMetadata
                enforcementMode = $currentProperties.enforcementMode
            }
            $location = $currentAssignment.location
            if ($null -ne $location -and $location -ne "global") {
                $grandChild.managedIdentityLocations = @{
                    $pacSelector = $location
                }
            }

            $scope = $currentAssignment.resourceIdParts.scope
            $null = $allScopes.Add($scope)
            $notScopeProcessed = @{}
            $notScopes = [System.Collections.ArrayList]::new()
            foreach ($notScope in $currentProperties.notScopes) {
                if (!($notScopeProcessed.ContainsKey($notScope))) {
                    # Is this a notScope not covered by a global notScope
                    $isInGlobalNotScopes = ($globalNotScopes | ForEach-Object { $notScope -like $_ }) -contains $true
                    if (!$isInGlobalNotScopes) {
                        # Only use notScopes not in globalNotScopes
                        $null = $notScopes.Add($notScope);
                        if (!($allNotScopeProcessed.ContainsKey($notScope))) {
                            $null = $allNotScopes.Add($notScope)
                        }
                    }
                    $notScopeProcessed[$notScope] = $true
                    $allNotScopeProcessed[$notScope] = $true
                }
            }

            $grandChildScope = @{
                scopes    = @( $scope )
                notScopes = $notScopes.ToArray()
            }
            $null = $grandChildren.Add($grandChild)
            $null = $grandChildScopes.Add($grandChildScope)
        }

        # Check if we can flatten the tree by folding grandChildren into child
        $previousGrandChild = @{}
        $match = $true
        foreach ($grandChild in $grandChildren) {
            if ($previousGrandChild.Count -ne 0) {
                if (!(Confirm-ObjectValueEqualityDeep -existingObj $previousGrandChild -definedObj $grandChild)) {
                    $match = $false
                }
            }
            $previousGrandChild = $grandChild
        }
        if ($match) {
            # we can flatten grandChildren into child
            $child += $grandChildren[0]
            $child.parameters = $flatParameters
            $child.scope = @{
                $pacSelector = $allScopes.ToArray()
            }
            if ($allNotScopes.Count -gt 0) {
                $child.notScope = @{
                    $pacSelector = $allNotScopes.ToArray()
                }
            }
        }
        else {
            # complete grandChildren with scope
            $count = $grandChildren.Count
            for ($i = 0; $i -lt $count; $i++) {
                $grandChild = $grandChildren[$i]
                $grandChildScope = $grandChildScopes[$i]
                $grandChildScopeArray = $grandChildScope.scopes
                $grandChild.scope = @{
                    $pacSelector = $grandChildScopeArray
                }
                $notScopes = $grandChildScope.notScopes
                if ($notScopes.Count -gt 0) {
                    $grandChild.notScope = @{
                        $pacSelector = $notScopes
                    }
                }
            }
            $child = [ordered]@{
                nodeName   = "/parameters-$($children.Count + 1)"
                parameters = $flatParameters
                children   = $grandChildren.ToArray()
            }
        }
        $null = $children.Add($child)
    }

    # Check is we can flatten the tree more
    if ($children.Count -eq 1) {
        # Only one parameter cluster, flatten structure
        $childZero = $children[0]
        $childZero.Remove("nodeName")
        $assignmentDefinition += $childZero
    }
    else {
        $assignmentDefinition.children = $children.ToArray()
    }

    # Write structure to file
    $json = ConvertTo-Json $assignmentDefinition -Depth 100
    $null = New-Item $fullPath -Force -ItemType File -Value $json

}
}