From 49e897f57e954d9a037c1d472e5c483f997eae9c Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Fri, 3 Apr 2026 23:31:52 +1100 Subject: [PATCH 01/22] Add integration tests for App Services and Function Apps with policy compliance checks - Introduced configuration file for policy assignments in `config.json`. - Created Bicep templates for both compliant (`main.good.bicep`) and non-compliant (`main.bad.bicep`) App Services and Function Apps. - Developed a comprehensive test template (`main.test.bicep`) to validate policy adherence across multiple scenarios, including cross-subscription private endpoints. - Implemented PowerShell test scripts (`tests.ps1`) to automate the testing of policy compliance and resource configurations. - Ensured that all resources are set up to either comply with or violate specific policies for thorough testing. --- .../azure-pipelines-pr-policy-int-tests.yml | 34 + ...b-detect-policy-integration-test-cases.yml | 69 ++ .../template-job-get-sub-directories.yml | 83 +++ ...emplate-stage-policy-integration-tests.yml | 590 ++++++++++++++++++ .../pol-int-test-detect-test-cases/action.yml | 43 ++ .../pol-int-test-get-sub-dir/action.yml | 45 ++ .../workflows/policy-integration-tests.yml | 491 +++++++++++++++ policyAssignments/dev/pa-d-eh.json | 2 +- policyAssignments/dev/pa-d-monitor.json | 3 +- policyAssignments/dev/pa-d-pe-lz.json | 4 +- policyAssignments/dev/pa-d-postgresql.json | 25 + .../dev/pa-d-res-restriction.json | 7 + policyAssignments/dev/pa-d-tags.json | 93 +++ policyAssignments/prod/pa-p-postgresql.json | 25 + policyAssignments/prod/pa-p-tags.json | 93 +++ .../pol-deploy-diag-function.json | 38 +- .../pol-deploy-diag-website.json | 58 +- .../pol-audit-deny-eh-local-auth.json | 11 +- ...pol-audit-deny-eh-minimum-tls-version.json | 2 +- ...l-audit-deny-eh-public-network-access.json | 2 +- ...udit-eh-namespaces-use-cmk-encryption.json | 5 +- ...-audit-eh-namespaces-use-private-link.json | 5 +- ...udit-postgresql-connection-throttling.json | 55 -- ...audit-postgresql-geo-redundant-backup.json | 47 -- ...pol-audit-postgresql-private-endpoint.json | 54 -- .../postgresql/pol-audit-postgresql-ssl.json | 51 -- ...gresql-flexible-servers-entraid-admin.json | 54 ++ ...lexible-servers-public-network-access.json | 5 +- ...single-zone-all-regions-match-groupid.json | 173 +++++ ...-slots-app-traffic-via-public-network.json | 34 +- ...ots-config-traffic-via-public-network.json | 44 +- .../.shared/initiate-test.ps1 | 140 +++++ .../policy_integration_test_config.jsonc | 142 +++++ .../.test-template/.testignore | 0 .../.test-template/README.md | 57 ++ .../.test-template/config.json | 16 + .../.test-template/main-bad-terraform/main.tf | 1 + .../main-bad-terraform/outputs.tf | 1 + .../main-bad-terraform/providers.tf | 13 + .../main-bad-terraform/variables.tf | 1 + .../main-test-terraform/data.tf | 16 + .../main-test-terraform/local.tf | 11 + .../main-test-terraform/main.tf | 1 + .../main-test-terraform/outputs.tf | 2 + .../main-test-terraform/providers.tf | 13 + .../main-test-terraform/variables.tf | 1 + .../.test-template/main.bad.bicep | 16 + .../.test-template/main.good.bicep | 16 + .../.test-template/main.test.bicep | 30 + .../.test-template/tests.ps1 | 44 ++ tests/policy-integration-tests/README.md | 164 +++++ .../policy-integration-tests/acr/config.json | 14 + .../acr/main.test.bicep | 126 ++++ tests/policy-integration-tests/acr/tests.ps1 | 100 +++ .../event-hub/config.json | 16 + .../event-hub/main.bad.bicep | 43 ++ .../event-hub/main.good.bicep | 44 ++ .../event-hub/main.test.bicep | 83 +++ .../event-hub/tests.ps1 | 87 +++ .../key-vault/README.md | 27 + .../key-vault/config.json | 15 + .../key-vault/main.bad.bicep | 51 ++ .../key-vault/main.good.bicep | 55 ++ .../key-vault/main.test.bicep | 90 +++ .../key-vault/tests.ps1 | 89 +++ .../monitor/README.md | 24 + .../monitor/config.json | 14 + .../monitor/main.bad.bicep | 95 +++ .../monitor/main.good.bicep | 90 +++ .../monitor/tests.ps1 | 192 ++++++ .../network-security-group/config.json | 14 + .../network-security-group/main.bad.nsg.bicep | 96 +++ .../main.bad.nsg.rule.bicep | 54 ++ .../main.good.nsg.bicep | 96 +++ .../main.good.nsg.rule.bicep | 54 ++ .../network-security-group/main.test.bicep | 83 +++ .../network-security-group/tests.ps1 | 69 ++ .../postgresql/config.json | 12 + .../postgresql/main.bad.bicep | 148 +++++ .../postgresql/main.good.bicep | 145 +++++ .../postgresql/tests.ps1 | 64 ++ .../private-endpoint/config.json | 12 + .../private-endpoint/main.bad.bicep | 108 ++++ .../private-endpoint/tests.ps1 | 54 ++ .../storage-account/config.json | 14 + .../main-bad-terraform/main.tf | 33 + .../main-bad-terraform/outputs.tf | 14 + .../main-bad-terraform/providers.tf | 13 + .../main-bad-terraform/variables.tf | 14 + .../main-test-terraform/data.tf | 15 + .../main-test-terraform/local.tf | 9 + .../main-test-terraform/main.tf | 54 ++ .../main-test-terraform/outputs.tf | 22 + .../main-test-terraform/providers.tf | 13 + .../main-test-terraform/variables.tf | 17 + .../storage-account/tests.ps1 | 106 ++++ tests/policy-integration-tests/tags/README.md | 30 + .../policy-integration-tests/tags/config.json | 14 + .../tags/main.bad.resource.bicep | 35 ++ .../tags/main.bad.rg.bicep | 36 ++ .../tags/main.test.bicep | 26 + tests/policy-integration-tests/tags/tests.ps1 | 145 +++++ .../virtual-network/config.json | 15 + .../virtual-network/main.bad.bicep | 78 +++ .../virtual-network/main.good.bicep | 77 +++ .../virtual-network/main.test.bicep | 118 ++++ .../virtual-network/tests.ps1 | 93 +++ .../policy-integration-tests/web/config.json | 22 + .../web/main.bad.bicep | 162 +++++ .../web/main.good.bicep | 253 ++++++++ .../web/main.test.bicep | 415 ++++++++++++ tests/policy-integration-tests/web/tests.ps1 | 158 +++++ 112 files changed, 6931 insertions(+), 264 deletions(-) create mode 100644 .azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml create mode 100644 .azuredevops/templates/template-job-detect-policy-integration-test-cases.yml create mode 100644 .azuredevops/templates/template-job-get-sub-directories.yml create mode 100644 .azuredevops/templates/template-stage-policy-integration-tests.yml create mode 100644 .github/actions/templates/pol-int-test-detect-test-cases/action.yml create mode 100644 .github/actions/templates/pol-int-test-get-sub-dir/action.yml create mode 100644 .github/workflows/policy-integration-tests.yml create mode 100644 policyAssignments/dev/pa-d-postgresql.json create mode 100644 policyAssignments/dev/pa-d-tags.json create mode 100644 policyAssignments/prod/pa-p-postgresql.json create mode 100644 policyAssignments/prod/pa-p-tags.json delete mode 100644 policyDefinitions/postgresql/pol-audit-postgresql-connection-throttling.json delete mode 100644 policyDefinitions/postgresql/pol-audit-postgresql-geo-redundant-backup.json delete mode 100644 policyDefinitions/postgresql/pol-audit-postgresql-private-endpoint.json delete mode 100644 policyDefinitions/postgresql/pol-audit-postgresql-ssl.json create mode 100644 policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-entraid-admin.json create mode 100644 policyDefinitions/private-endpoints-dns-registration/pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid.json create mode 100644 tests/policy-integration-tests/.shared/initiate-test.ps1 create mode 100644 tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc create mode 100644 tests/policy-integration-tests/.test-template/.testignore create mode 100644 tests/policy-integration-tests/.test-template/README.md create mode 100644 tests/policy-integration-tests/.test-template/config.json create mode 100644 tests/policy-integration-tests/.test-template/main-bad-terraform/main.tf create mode 100644 tests/policy-integration-tests/.test-template/main-bad-terraform/outputs.tf create mode 100644 tests/policy-integration-tests/.test-template/main-bad-terraform/providers.tf create mode 100644 tests/policy-integration-tests/.test-template/main-bad-terraform/variables.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/data.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/local.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/main.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/outputs.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/providers.tf create mode 100644 tests/policy-integration-tests/.test-template/main-test-terraform/variables.tf create mode 100644 tests/policy-integration-tests/.test-template/main.bad.bicep create mode 100644 tests/policy-integration-tests/.test-template/main.good.bicep create mode 100644 tests/policy-integration-tests/.test-template/main.test.bicep create mode 100644 tests/policy-integration-tests/.test-template/tests.ps1 create mode 100644 tests/policy-integration-tests/README.md create mode 100755 tests/policy-integration-tests/acr/config.json create mode 100755 tests/policy-integration-tests/acr/main.test.bicep create mode 100755 tests/policy-integration-tests/acr/tests.ps1 create mode 100644 tests/policy-integration-tests/event-hub/config.json create mode 100644 tests/policy-integration-tests/event-hub/main.bad.bicep create mode 100644 tests/policy-integration-tests/event-hub/main.good.bicep create mode 100644 tests/policy-integration-tests/event-hub/main.test.bicep create mode 100755 tests/policy-integration-tests/event-hub/tests.ps1 create mode 100644 tests/policy-integration-tests/key-vault/README.md create mode 100644 tests/policy-integration-tests/key-vault/config.json create mode 100644 tests/policy-integration-tests/key-vault/main.bad.bicep create mode 100644 tests/policy-integration-tests/key-vault/main.good.bicep create mode 100644 tests/policy-integration-tests/key-vault/main.test.bicep create mode 100644 tests/policy-integration-tests/key-vault/tests.ps1 create mode 100644 tests/policy-integration-tests/monitor/README.md create mode 100644 tests/policy-integration-tests/monitor/config.json create mode 100644 tests/policy-integration-tests/monitor/main.bad.bicep create mode 100644 tests/policy-integration-tests/monitor/main.good.bicep create mode 100644 tests/policy-integration-tests/monitor/tests.ps1 create mode 100644 tests/policy-integration-tests/network-security-group/config.json create mode 100644 tests/policy-integration-tests/network-security-group/main.bad.nsg.bicep create mode 100644 tests/policy-integration-tests/network-security-group/main.bad.nsg.rule.bicep create mode 100644 tests/policy-integration-tests/network-security-group/main.good.nsg.bicep create mode 100644 tests/policy-integration-tests/network-security-group/main.good.nsg.rule.bicep create mode 100644 tests/policy-integration-tests/network-security-group/main.test.bicep create mode 100644 tests/policy-integration-tests/network-security-group/tests.ps1 create mode 100644 tests/policy-integration-tests/postgresql/config.json create mode 100644 tests/policy-integration-tests/postgresql/main.bad.bicep create mode 100644 tests/policy-integration-tests/postgresql/main.good.bicep create mode 100644 tests/policy-integration-tests/postgresql/tests.ps1 create mode 100644 tests/policy-integration-tests/private-endpoint/config.json create mode 100644 tests/policy-integration-tests/private-endpoint/main.bad.bicep create mode 100644 tests/policy-integration-tests/private-endpoint/tests.ps1 create mode 100644 tests/policy-integration-tests/storage-account/config.json create mode 100644 tests/policy-integration-tests/storage-account/main-bad-terraform/main.tf create mode 100644 tests/policy-integration-tests/storage-account/main-bad-terraform/outputs.tf create mode 100644 tests/policy-integration-tests/storage-account/main-bad-terraform/providers.tf create mode 100644 tests/policy-integration-tests/storage-account/main-bad-terraform/variables.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/data.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/local.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/main.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/outputs.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/providers.tf create mode 100644 tests/policy-integration-tests/storage-account/main-test-terraform/variables.tf create mode 100644 tests/policy-integration-tests/storage-account/tests.ps1 create mode 100644 tests/policy-integration-tests/tags/README.md create mode 100644 tests/policy-integration-tests/tags/config.json create mode 100644 tests/policy-integration-tests/tags/main.bad.resource.bicep create mode 100644 tests/policy-integration-tests/tags/main.bad.rg.bicep create mode 100644 tests/policy-integration-tests/tags/main.test.bicep create mode 100644 tests/policy-integration-tests/tags/tests.ps1 create mode 100644 tests/policy-integration-tests/virtual-network/config.json create mode 100644 tests/policy-integration-tests/virtual-network/main.bad.bicep create mode 100644 tests/policy-integration-tests/virtual-network/main.good.bicep create mode 100644 tests/policy-integration-tests/virtual-network/main.test.bicep create mode 100644 tests/policy-integration-tests/virtual-network/tests.ps1 create mode 100644 tests/policy-integration-tests/web/config.json create mode 100644 tests/policy-integration-tests/web/main.bad.bicep create mode 100644 tests/policy-integration-tests/web/main.good.bicep create mode 100644 tests/policy-integration-tests/web/main.test.bicep create mode 100644 tests/policy-integration-tests/web/tests.ps1 diff --git a/.azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml b/.azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml new file mode 100644 index 0000000..a656625 --- /dev/null +++ b/.azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml @@ -0,0 +1,34 @@ +name: $(BuildDefinitionName)_$(SourceBranchName)_$(Date:yyyyMMdd)$(Rev:.r) + +trigger: none + +parameters: + - name: removeTestResource + displayName: Remove Test Resources + type: boolean + default: true + + - name: testToRun + displayName: "Tests To Run (separate with commas ',')" + type: string + default: " " + +variables: + - template: ../../../settings.yml + +stages: +# dev integration test stage + - template: ../../templates/template-stage-policy-integration-tests.yml + parameters: + vmImage: "${{ variables['defaultAgentPoolName'] }}" + azureServiceConnection: "${{ variables['ado-devPolicyServiceConnection'] }}" + azureLocation: "${{ variables['default-region'] }}" + environment: "${{ variables['devEnv'] }}" + #bicepModuleSubscriptionId: "${{ variables['vmlTemplateSpecsSubscriptionId'] }}" + deploymentMaxRetry: ${{ variables['policyIntegrationTestDeploymentMaxRetry'] }} + removeDeployment: ${{ parameters.removeTestResource }} + #preferredAzurePowerShellVersion: "${{ variables['preferredAzurePowerShellVersion'] }}" + preferredBicepCliVersion: "${{ variables['preferredBicepCliVersion'] }}" + testDirectory: "${{ variables['policyIntegrationTestsDirectory'] }}" + testIgnoreFileName: "${{ variables['policyIntegrationTestIgnoreFileName'] }}" + testToRun: "${{ parameters.testToRun }}" diff --git a/.azuredevops/templates/template-job-detect-policy-integration-test-cases.yml b/.azuredevops/templates/template-job-detect-policy-integration-test-cases.yml new file mode 100644 index 0000000..f8be6aa --- /dev/null +++ b/.azuredevops/templates/template-job-detect-policy-integration-test-cases.yml @@ -0,0 +1,69 @@ +parameters: +- name: testConfigFilePath + displayName: "Test Global Config File Path" + type: string + +- name: targetGitBranch + displayName: "Target Git Branch" + type: string + default: "main" + +- name: testCaseDir + displayName: "Test Case Directory" + type: string + default: "tests/policy-integration-tests" + +- name: displayName + displayName: "Job Display Name" + type: string + default: "Detect Policy Integration Test Cases" + +- name: poolName + displayName: "Self Hosted Agent Pool Name" + type: string + default: "" + +- name: vmImage + displayName: "MS Hosted Agent VM Image Name" + type: string + default: "" + +- name: dependsOn + displayName: "Depends on" + type: object + default: [] + +- name: condition + displayName: "Custom Job Conditions" + type: string + default: "succeeded()" + +jobs: + - job: mapTestCases + displayName: ${{ parameters.displayName }} + condition: ${{ parameters.condition }} + dependsOn: ${{parameters.dependsOn}} + variables: + - name: mapTestCasesScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1" + workspace: + clean: all + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + steps: + - checkout: self + fetchDepth: 0 + - task: PowerShell@2 + name: mapTestCasesTask + displayName: "Map Git File Changes to Test Cases" + inputs: + targetType: "filePath" + pwsh: true + filePath: $(mapTestCasesScriptPath) + arguments: + -testConfigFilePath "${{parameters.testConfigFilePath}}" ` + -targetGitBranch "${{parameters.targetGitBranch}}" ` + -testCaseDir "${{parameters.testCaseDir}}" diff --git a/.azuredevops/templates/template-job-get-sub-directories.yml b/.azuredevops/templates/template-job-get-sub-directories.yml new file mode 100644 index 0000000..f5131b1 --- /dev/null +++ b/.azuredevops/templates/template-job-get-sub-directories.yml @@ -0,0 +1,83 @@ +parameters: +- name: directory + displayName: "Parent Directory" + type: string + +- name: ignoreFileName + displayName: "Ignore File Name" + type: string + default: " " + +- name: includedDirectory + displayName: "Included Directory" + type: string + default: " " + +- name: skip + displayName: "Skip and return an empty list" + type: string + default: 'true' + +- name: displayName + displayName: "Job Display Name" + type: string + default: "Get Sub Directories" + +- name: jobName + displayName: "Job Name" + type: string + default: "getSubDirs" + +- name: poolName + displayName: "Self Hosted Agent Pool Name" + type: string + default: "" + +- name: vmImage + displayName: "MS Hosted Agent VM Image Name" + type: string + default: "" + +- name: dependsOn + displayName: "Depends on" + type: object + default: [] + +- name: condition + displayName: "Custom Job Conditions" + type: string + default: "succeeded()" + +jobs: + - job: ${{ parameters.jobName }} + displayName: ${{ parameters.displayName }} + condition: ${{ parameters.condition }} + dependsOn: ${{parameters.dependsOn}} + variables: + - name: getSubDirScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1" + - name: skip + value: ${{ parameters.skip }} + - name: includedDirectory + value: ${{ parameters.includedDirectory }} + workspace: + clean: all + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + steps: + - checkout: self + - task: PowerShell@2 + name: getSubDirsTask + displayName: "Get Sub Dirs" + inputs: + targetType: "filePath" + pwsh: true + filePath: $(getSubDirScriptPath) + arguments: + -directory "${{parameters.directory}}" ` + -ignoreFileName "${{parameters.ignoreFileName}}" ` + -includedDirectory "$(includedDirectory)" ` + -skip $(skip) diff --git a/.azuredevops/templates/template-stage-policy-integration-tests.yml b/.azuredevops/templates/template-stage-policy-integration-tests.yml new file mode 100644 index 0000000..fef3706 --- /dev/null +++ b/.azuredevops/templates/template-stage-policy-integration-tests.yml @@ -0,0 +1,590 @@ +parameters: + - name: poolName + displayName: "Self Hosted Agent Pool Name" + type: string + default: "" + + - name: vmImage + displayName: "MS Hosted Agent VM Image Name" + type: string + default: "" + + - name: condition + displayName: "Custom Stage Conditions" + type: string + default: "succeeded()" + + - name: environment + displayName: "Environment" + type: string + + - name: dependsOn + displayName: "Depends on" + type: object + default: [] + + - name: azureServiceConnection + displayName: "Name of the Azure Service Connection" + type: string + + - name: azureLocation + displayName: "Policy Test Azure Location" + type: string + + - name: bicepModuleSubscriptionId + displayName: "Bicep module subscription Id (Optional)" + type: string + default: "" + + - name: deploymentMaxRetry + displayName: "Maximum retry attempt for the test Bicep template deployment" + type: number + default: 3 + + - name: timeoutInMinutes + displayName: "Timeout in Minutes" + type: number + default: 180 + + - name: preferredAzurePowerShellVersion + displayName: "Az Powershell module version." + type: string + default: "" + + - name: preferredBicepCliVersion + displayName: "Bicep CLI version." + type: string + default: "" + + - name: preferredTerraformVersion + displayName: "Terraform version." + type: string + default: "latest" + + - name: testDirectory + displayName: "Directory contains Policy Integration Tests" + type: string + + - name: removeDeployment + displayName: "Remove Deployment" + type: boolean + default: true + + - name: testIgnoreFileName + displayName: "Test Ignore File Name" + type: string + default: " " + + - name: testToRun + displayName: "Test To Run" + type: string + default: " " + +stages: + - stage: "policy_tests_${{ parameters.environment }}" + displayName: "Policy Integration Tests ${{ parameters.environment }}" + dependsOn: ${{ parameters.dependsOn }} + variables: + - template: ../../settings.yml + - group: 'policy-testing' + - name: testGlobalConfigFilePath + value: "tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc" + - name: testInitiationScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1" + - name: getTestConfigsScript + value: "scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1" + - name: testBicepDeploymentScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1" + - name: testTFDeploymentDestroyScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1" + - name: waitPolicyInitialEvalScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1" + - name: complianceScanScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1" + - name: testDeploymentParseResultScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1" + - name: testResourceDeleteScriptPath + value: "scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1" + + condition: ${{ parameters.condition }} + jobs: + #initiation job to parse the global test config file + - job: initiation + displayName: "Tests initiation" + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + steps: + - checkout: self + - download: none + - task: PowerShell@2 + name: listEnvVar + displayName: "List Environment Variables" + inputs: + targetType: "inline" + pwsh: true + script: | + get-childitem env: + - task: PowerShell@2 + name: parseConfigFile + displayName: Parse Global Test Config File + inputs: + targetType: 'filePath' + pwsh: true + filePath: $(testInitiationScriptPath) + arguments: + -testDirectory '${{parameters.testDirectory}}' ` + -testConfigFilePath '$(testGlobalConfigFilePath)' + + #detect required integration test cases based on git file changes in a PR + - template: template-job-detect-policy-integration-test-cases.yml + parameters: + condition: "and(succeeded(), eq(variables['Build.Reason'], 'PullRequest'))" + displayName: "Detect Test Cases from Git Diff" + poolName: ${{parameters.poolName}} + vmImage: ${{parameters.vmImage}} + dependsOn: ['initiation'] + testConfigFilePath: '$(testGlobalConfigFilePath)' + testCaseDir: ${{parameters.testDirectory}} + targetGitBranch: $(System.PullRequest.targetBranchName) + + #determine the test delay minutes based on the in scope test cases + - job: "getTestConfigs" + displayName: "Get Test Configurations" + condition: in(dependencies.mapTestCases.result, 'Succeeded', 'Skipped') + dependsOn: + - initiation + - mapTestCases + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + variables: + testLocalConfigFileName: "$[ dependencies.initiation.outputs['parseConfigFile.testLocalConfigFileName'] ]" + testScriptName: "$[ dependencies.initiation.outputs['parseConfigFile.testScriptName'] ]" + appendModifyDelay: "$[ dependencies.initiation.outputs['parseConfigFile.waitTimeForAppendModifyPoliciesAfterDeployment'] ]" + policyComplianceStateDelay: "$[ dependencies.initiation.outputs['parseConfigFile.waitTimeForPolicyComplianceStateAfterDeployment'] ]" + DINEDelay: "$[ dependencies.initiation.outputs['parseConfigFile.waitTimeForDeployIfNotExistsPoliciesAfterDeployment'] ]" + + ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + includedDirectory: $[ dependencies.mapTestCases.outputs['mapTestCasesTask.requiredTestCases'] ] + skip: $[ dependencies.mapTestCases.outputs['mapTestCasesTask.shouldSkipTest'] ] + ${{ else }}: + includedDirectory: "${{parameters.testToRun}}" + skip: 'false' + steps: + - checkout: self + - download: none + - task: PowerShell@2 + name: getTestConfigsTask + displayName: Get Test Config + inputs: + targetType: 'filePath' + pwsh: true + filePath: $(getTestConfigsScript) + arguments: + -directory '${{parameters.testDirectory}}' ` + -ignoreFileName ${{parameters.testIgnoreFileName}} ` + -includedDirectory "$(includedDirectory)" ` + -policyComplianceStateDelay $(policyComplianceStateDelay) ` + -appendModifyDelay $(appendModifyDelay) ` + -DINEDelay $(DINEDelay) ` + -testLocalConfigFileName $(testLocalConfigFileName) ` + -testScriptName $(testScriptName) ` + -skip $(skip) + + #get sub directories job to get the list of test cases + - template: template-job-get-sub-directories.yml + parameters: + condition: and(in(dependencies.mapTestCases.result, 'Succeeded', 'Skipped'), eq(dependencies.getTestConfigs.result, 'Succeeded')) + directory: ${{parameters.testDirectory}} + ignoreFileName: ${{parameters.testIgnoreFileName}} + ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + includedDirectory: $[ dependencies.mapTestCases.outputs['mapTestCasesTask.requiredTestCases'] ] + skip: $[ dependencies.mapTestCases.outputs['mapTestCasesTask.shouldSkipTest'] ] + ${{ else }}: + includedDirectory: ${{parameters.testToRun}} + skip: 'false' + jobName: "getTests" + displayName: "Get Test Cases" + poolName: ${{parameters.poolName}} + vmImage: ${{parameters.vmImage}} + dependsOn: + - mapTestCases + - getTestConfigs + + #deploy test bicep templates job to deploy the test bicep templates for each test case + - job: deployTestResources + displayName: "Deploy Resources" + condition: and(eq(dependencies.getTests.result, 'Succeeded'), ne(dependencies.getTests.outputs['getSubDirsTask.SubDirCount'], 0)) + dependsOn: + - getTests + - initiation + workspace: + clean: all + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + variables: + - name: testBicepTemplateName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testBicepTemplateName'] ]" + - name: testTerraformDirectoryName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformDirectoryName'] ]" + - name: testLocalConfigFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testLocalConfigFileName'] ]" + - name: initialEvalMaximumWaitTime + value: "$[ dependencies.initiation.outputs['parseConfigFile.initialEvalMaximumWaitTime'] ]" + - name: testBicepDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testBicepDeploymentOutputArtifactPrefix'] ]" + - name: testTerraformDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformDeploymentOutputArtifactPrefix'] ]" + - name: testDeploymentOutputFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testDeploymentOutputFileName'] ]" + - name: testTerraformStateFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformStateFileName'] ]" + - name: testTerraformEncryptedStateFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformEncryptedStateFileName'] ]" + strategy: + matrix: $[ dependencies.getTests.outputs['getSubDirsTask.SubDirectories']] + steps: + - checkout: self + - download: none + - template: ./template-task-install-ps-modules.yml + parameters: + displayName: "Install Az Module v${{parameters.preferredAzurePowerShellVersion}}" + moduleNames: "Az`@${{parameters.preferredAzurePowerShellVersion}}" + shouldInstall: ${{ ne(parameters.preferredAzurePowerShellVersion, '') }} + - template: ./template-task-install-bicep-cli.yml + parameters: + desiredVersion: '${{ parameters.preferredBicepCliVersion }}' + - task: TerraformInstaller@1 + displayName: Install Terraform version ${{ parameters.preferredTerraformVersion }} + inputs: + terraformVersion: '${{ parameters.preferredTerraformVersion }}' + - task: AzurePowerShell@5 + name: waitForInitialEval + displayName: "Wait Initial Policy Evaluation" + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + pwsh: true + #If preferredAzurePowerShellVersion is not provided, or it's running on Self-hosted agents, then use the latest version of Az module + ${{ if or(eq(parameters.preferredAzurePowerShellVersion, ''), ne(parameters.poolName, '')) }}: + azurePowerShellVersion: 'LatestVersion' + #if preferredAzurePowerShellVersion is provided, and it's running on MS-hosted agents, then use the provided version of Az module + ${{ else }}: + azurePowerShellVersion: 'OtherVersion' + preferredAzurePowerShellVersion: '${{parameters.preferredAzurePowerShellVersion}}' + scriptType: filePath + scriptPath: $(waitPolicyInitialEvalScriptPath) + scriptArguments: + -configFilePath '$(matrixSubDirRelativePath)/$(testLocalConfigFileName)' ` + -wait 'true' ` + -maximumWaitMinutes $(initialEvalMaximumWaitTime) + - task: AzurePowerShell@5 + name: testBicepDeploy + displayName: "Deploy Test Bicep Template" + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + pwsh: true + #If preferredAzurePowerShellVersion is not provided, or it's running on Self-hosted agents, then use the latest version of Az module + ${{ if or(eq(parameters.preferredAzurePowerShellVersion, ''), ne(parameters.poolName, '')) }}: + azurePowerShellVersion: 'LatestVersion' + #if preferredAzurePowerShellVersion is provided, and it's running on MS-hosted agents, then use the provided version of Az module + ${{ else }}: + azurePowerShellVersion: 'OtherVersion' + preferredAzurePowerShellVersion: '${{parameters.preferredAzurePowerShellVersion}}' + scriptType: filePath + scriptPath: $(testBicepDeploymentScriptPath) + scriptArguments: + -BicepFilePath '$(matrixSubDirRelativePath)/$(testBicepTemplateName)' ` + -TestConfigFilePath '$(matrixSubDirRelativePath)/$(testLocalConfigFileName)' ` + -BuildNumber $(Build.BuildId) ` + -maxRetry ${{ parameters.deploymentMaxRetry }} ` + -bicepModuleSubscriptionId '${{ parameters.bicepModuleSubscriptionId }}' ` + -deploymentResultFilePath '$(matrixSubDirFullPath)/$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)/$(testDeploymentOutputFileName)' + errorActionPreference: 'stop' + - task: PublishBuildArtifacts@1 + displayName: "Publish Bicep Deployment Result Artifact" + condition: succeededOrFailed() + inputs: + artifactName: "$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + PathtoPublish: '$(matrixSubDirFullPath)/$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)' + - task: AzureCLI@2 + name: testTFDeploy + displayName: "Deploy Test Terraform Template" + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + scriptType: pscore + scriptLocation: 'scriptPath' + scriptPath: $(testTFDeploymentDestroyScriptPath) + arguments: + -TestConfigFilePath '$(matrixSubDirRelativePath)/$(testLocalConfigFileName)' ` + -terraformPath '$(matrixSubDirRelativePath)/$(testTerraformDirectoryName)' ` + -tfBackendConfigFileName 'backend-$(Build.BuildId).tf' ` + -tfAction 'apply' ` + -tfBackendStateFileDirectory '$(Build.ArtifactStagingDirectory)/$(matrixSubDirName)-tfstate-$(Build.BuildId)' ` + -tfStateFileName '$(testTerraformStateFileName)' ` + -tfEncryptedStateFileName '$(testTerraformEncryptedStateFileName)' ` + -deploymentResultFileName '$(testDeploymentOutputFileName)' ` + -uninitializeTerraform 'true' ` + -aesEncryptionKey '$(aesEncryptionKey)' ` + -aesIV '$(aesIV)' + - task: PublishBuildArtifacts@1 + displayName: "Publish Terraform Deployment Result Artifact" + condition: succeededOrFailed() + inputs: + artifactName: "$(testTerraformDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + PathtoPublish: '$(Build.ArtifactStagingDirectory)/$(matrixSubDirName)-tfstate-$(Build.BuildId)' + + #Run Policy Compliance Scan asynchronously after test resource deployment, before running the tests + - job: runPolicyComplianceScan + displayName: "Run Policy Compliance Scan" + condition: and(eq(dependencies.deployTestResources.result, 'Succeeded'), ne(dependencies.getTests.outputs['getSubDirsTask.SubDirCount'], 0)) + dependsOn: + - initiation + - getTests + - getTestConfigs + - deployTestResources + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + variables: + - name: runComplianceScan + value: "$[ dependencies.getTestConfigs.outputs['getTestConfigsTask.runComplianceScan'] ]" + - name: complianceScanSubNames + value: "$[ dependencies.getTestConfigs.outputs['getTestConfigsTask.complianceScanSubNames'] ]" + steps: + - task: AzureCLI@2 + displayName: Initiate Policy Compliance Scan + condition: and(succeeded(), eq(variables['runComplianceScan'], true)) + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + scriptType: pscore + scriptLocation: 'scriptPath' + scriptPath: $(complianceScanScriptPath) + arguments: + -testGlobalConfigFilePath '$(testGlobalConfigFilePath)' ` + -complianceScanSubNames '$(complianceScanSubNames)' + + #delay job to wait for the policy evaluation of deployed test resources to complete + - job: delayAfterTemplateDeployment + displayName: "Wait After Template Deployment" + condition: and(eq(dependencies.runPolicyComplianceScan.result, 'Succeeded'), eq(dependencies.initiation.outputs['parseConfigFile.bicepDeploymentRequired'], true), ne(dependencies.getTests.outputs['getSubDirsTask.SubDirCount'], 0)) + dependsOn: + - getTests + - deployTestResources + - getTestConfigs + - runPolicyComplianceScan + pool: server + variables: + - name: waitMinutes + value: "$[ dependencies.getTestConfigs.outputs['getTestConfigsTask.testDelayStartMinutes'] ]" + steps: + - task: Delay@1 + displayName: "Wait $(waitMinutes) min for initial evaluation" + inputs: + delayForMinutes: $(waitMinutes) + + #execute each test case + - job: runTests + displayName: "Run Tests" + condition: and(not(failed()), ne(dependencies.getTests.outputs['getSubDirsTask.SubDirCount'], 0)) + dependsOn: + - getTests + - initiation + - deployTestResources + - delayAfterTemplateDeployment + workspace: + clean: all + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + variables: + - name: testOutputFilePrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testOutputFilePrefix'] ]" + - name: testOutputFilePath + value: "$(matrixSubDirRelativePath)/$(testOutputFilePrefix)-$(matrixSubDirName).XML" + - name: testOutputFormat + value: "$[ dependencies.initiation.outputs['parseConfigFile.testOutputFormat'] ]" + - name: testScriptName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testScriptName'] ]" + - name: testBicepDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testBicepDeploymentOutputArtifactPrefix'] ]" + - name: testTerraformDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformDeploymentOutputArtifactPrefix'] ]" + - name: testDeploymentOutputFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testDeploymentOutputFileName'] ]" + strategy: + matrix: $[ dependencies.getTests.outputs['getSubDirsTask.SubDirectories']] + steps: + - template: ./template-task-install-ps-modules.yml + parameters: + displayName: "Install AzResourceTest Module" + moduleNames: "AzResourceTest`@2.0.3" + - template: ./template-task-install-bicep-cli.yml + parameters: + desiredVersion: '${{ parameters.preferredBicepCliVersion }}' + - task: TerraformInstaller@1 + displayName: Install Terraform version ${{ parameters.preferredTerraformVersion }} + inputs: + terraformVersion: '${{ parameters.preferredTerraformVersion }}' + - template: ./template-task-install-ps-modules.yml + parameters: + displayName: "Install Az Module" + moduleNames: "Az`@${{parameters.preferredAzurePowerShellVersion}}" + shouldInstall: ${{ ne(parameters.preferredAzurePowerShellVersion, '') }} + - task: DownloadBuildArtifacts@1 + displayName: "Download Bicep Deployment Result Artifact" + inputs: + artifactName: "$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + downloadPath: "$(matrixSubDirFullPath)" + - task: DownloadBuildArtifacts@1 + displayName: "Download Terraform Deployment Result Artifact" + inputs: + artifactName: "$(testTerraformDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + downloadPath: "$(matrixSubDirFullPath)" + - task: Powershell@2 + name: parseBicepDeploymentResult + displayName: "Parse Bicep Deployment Result - $(matrixSubDirName)" + inputs: + targetType: 'filePath' + filePath: $(testDeploymentParseResultScriptPath) + arguments: + -jsonFilePath $(matrixSubDirFullPath)/$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)/$(testDeploymentOutputFileName) ` + -overallJsonVariableName 'bicepDeploymentResult' + - task: Powershell@2 + name: parseTerraformDeploymentResult + displayName: "Parse Terraform Deployment Result - $(matrixSubDirName)" + inputs: + targetType: 'filePath' + filePath: $(testDeploymentParseResultScriptPath) + arguments: + -jsonFilePath $(matrixSubDirFullPath)/$(testTerraformDeploymentOutputArtifactPrefix)-$(matrixSubDirName)/$(testDeploymentOutputFileName) + -overallJsonVariableName 'terraformDeploymentResult' + - task: AzureCLI@2 #must run using AzureCLI task because Terraform only supports Azure CLI authentication + name: resourceTest + displayName: "Resource Test - $(matrixSubDirName)" + env: + bicepDeploymentResult: $(parseBicepDeploymentResult.bicepDeploymentResult) + terraformDeploymentResult: $(parseTerraformDeploymentResult.terraformDeploymentResult) + outputFilePath: $(testOutputFilePath) + outputFormat: $(testOutputFormat) + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + scriptType: pscore + scriptLocation: 'scriptPath' + scriptPath: '$(matrixSubDirRelativePath)/$(testScriptName)' + - task: PublishTestResults@2 + displayName: "Publish Test results - $(matrixSubDirName)" + inputs: + testRunTitle: "PolicyIntegrationTest-$(matrixSubDirName)" + testRunner: NUnit + testResultsFiles: "$(testOutputFilePath)" + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + failTaskOnFailureToPublishResults: true + + #remove the deployed test resources + - job: resourceRemoval + displayName: "Remove Resources" + condition: and(succeededOrFailed(), eq(${{ parameters.removeDeployment }}, true), ne(dependencies.getTests.outputs['getSubDirsTask.SubDirCount'], 0)) + dependsOn: + - getTests + - initiation + - deployTestResources + - runTests + workspace: + clean: all + pool: + ${{ if ne(parameters.vmImage, '') }}: + vmImage: ${{ parameters.vmImage }} + ${{ if ne(parameters.poolName, '') }}: + name: ${{ parameters.poolName }} + variables: + - name: testBicepDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testBicepDeploymentOutputArtifactPrefix'] ]" + - name: testTerraformDeploymentOutputArtifactPrefix + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformDeploymentOutputArtifactPrefix'] ]" + - name: testTerraformDirectoryName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformDirectoryName'] ]" + - name: testDeploymentOutputFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testDeploymentOutputFileName'] ]" + - name: testTerraformStateFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformStateFileName'] ]" + - name: testTerraformEncryptedStateFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testTerraformEncryptedStateFileName'] ]" + - name: testLocalConfigFileName + value: "$[ dependencies.initiation.outputs['parseConfigFile.testLocalConfigFileName'] ]" + strategy: + matrix: $[ dependencies.getTests.outputs['getSubDirsTask.SubDirectories']] + steps: + - task: TerraformInstaller@1 + displayName: Install Terraform version ${{ parameters.preferredTerraformVersion }} + inputs: + terraformVersion: '${{ parameters.preferredTerraformVersion }}' + - task: DownloadBuildArtifacts@1 + displayName: "Download Terraform Deployment Result Artifact" + inputs: + artifactName: "$(testTerraformDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + downloadPath: '$(Build.ArtifactStagingDirectory)/$(matrixSubDirName)-tfstate-$(Build.BuildId)' + - task: DownloadBuildArtifacts@1 + displayName: "Download Bicep Deployment Result Artifact" + inputs: + artifactName: "$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)" + downloadPath: "$(matrixSubDirFullPath)" + - task: Powershell@2 + name: parseBicepDeploymentResult + displayName: "Parse Bicep Deployment Result - $(matrixSubDirName)" + inputs: + targetType: 'filePath' + filePath: $(testDeploymentParseResultScriptPath) + arguments: + -jsonFilePath $(matrixSubDirFullPath)/$(testBicepDeploymentOutputArtifactPrefix)-$(matrixSubDirName)/$(testDeploymentOutputFileName) + -overallJsonVariableName 'bicepDeploymentResult' + - task: AzurePowerShell@5 + name: removeBicepTestResources + displayName: "Remove Bicep Test Resources" + condition: and(succeeded(), or (not(eq(variables['bicepDeploymentId'],'')), eq(variables['bicepRemoveTestResourceGroup'],'True'))) + env: + bicepDeploymentResult: "$(bicepDeploymentResult)" + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + pwsh: true + #If preferredAzurePowerShellVersion is not provided, or it's running on Self-hosted agents, then use the latest version of Az module + ${{ if or(eq(parameters.preferredAzurePowerShellVersion, ''), ne(parameters.poolName, '')) }}: + azurePowerShellVersion: 'LatestVersion' + #if preferredAzurePowerShellVersion is provided, and it's running on MS-hosted agents, then use the provided version of Az module + ${{ else }}: + azurePowerShellVersion: 'OtherVersion' + preferredAzurePowerShellVersion: '${{parameters.preferredAzurePowerShellVersion}}' + scriptType: filePath + scriptPath: $(testResourceDeleteScriptPath) + errorActionPreference: 'stop' + - task: AzureCLI@2 + name: testTFDestroy + displayName: "Remove Test Terraform Resources" + inputs: + azureSubscription: "${{parameters.azureServiceConnection}}" + scriptType: pscore + scriptLocation: 'scriptPath' + scriptPath: $(testTFDeploymentDestroyScriptPath) + arguments: + -TestConfigFilePath '$(matrixSubDirRelativePath)/$(testLocalConfigFileName)' ` + -terraformPath '$(matrixSubDirRelativePath)/$(testTerraformDirectoryName)' ` + -tfBackendConfigFileName 'backend-$(Build.BuildId).tf' ` + -tfAction 'destroy' ` + -tfBackendStateFileDirectory '$(Build.ArtifactStagingDirectory)/$(matrixSubDirName)-tfstate-$(Build.BuildId)/$(testTerraformDeploymentOutputArtifactPrefix)-$(matrixSubDirName)' ` + -tfStateFileName '$(testTerraformStateFileName)' ` + -tfEncryptedStateFileName '$(testTerraformEncryptedStateFileName)' ` + -uninitializeTerraform 'true' ` + -aesEncryptionKey '$(aesEncryptionKey)' ` + -aesIV '$(aesIV)' diff --git a/.github/actions/templates/pol-int-test-detect-test-cases/action.yml b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml new file mode 100644 index 0000000..9643405 --- /dev/null +++ b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml @@ -0,0 +1,43 @@ +name: "Detect Policy Integration Test Cases" +description: "Map git file changes to policy integration test cases to determine which tests need to be executed. Requires checkout with fetch-depth: 0." +author: "Tao Yang" + +inputs: + test-config-file-path: + description: "Test Global Config File Path" + required: true + + target-git-branch: + description: "Target Git Branch" + required: false + default: "main" + + test-case-dir: + description: "Test Case Directory" + required: false + default: "tests/policy-integration-tests" + +outputs: + shouldSkipTest: + description: "Whether the test execution should be skipped" + value: ${{ steps.mapTestCases.outputs.shouldSkipTest }} + + requiredTestCases: + description: "Comma-separated list of required test cases, or '*' for all, or '_NONE_' if none" + value: ${{ steps.mapTestCases.outputs.requiredTestCases }} + + runComplianceScan: + description: "Whether a compliance scan is required" + value: ${{ steps.mapTestCases.outputs.runComplianceScan }} + +runs: + using: "composite" + steps: + - name: Map Git File Changes to Test Cases + id: mapTestCases + shell: pwsh + run: | + ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 ` + -testConfigFilePath "${{ inputs.test-config-file-path }}" ` + -targetGitBranch "${{ inputs.target-git-branch }}" ` + -testCaseDir "${{ inputs.test-case-dir }}" diff --git a/.github/actions/templates/pol-int-test-get-sub-dir/action.yml b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml new file mode 100644 index 0000000..9eae478 --- /dev/null +++ b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml @@ -0,0 +1,45 @@ +name: "Get Sub Directories" +description: "Get all sub directories in the specified directory" +author: "Tao Yang" + +inputs: + directory: + description: "Parent Directory" + required: true + + ignore-file-name: + description: "Ignore File Name" + required: false + default: " " + + included-directory: + description: "Included Directory" + required: false + default: " " + + skip: + description: "Skip and return an empty list" + required: false + default: "true" + +outputs: + SubDirCount: + description: "Number of sub directories found" + value: ${{ steps.getSubDirsTask.outputs.SubDirCount }} + + SubDirectories: + description: "JSON object containing sub directory details" + value: ${{ steps.getSubDirsTask.outputs.SubDirectories }} + +runs: + using: "composite" + steps: + - name: Get Sub Dirs + id: getSubDirsTask + shell: pwsh + run: | + ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 ` + -directory "${{ inputs.directory }}" ` + -ignoreFileName "${{ inputs.ignore-file-name }}" ` + -includedDirectory "${{ inputs.included-directory }}" ` + -skip "${{ inputs.skip }}" diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml new file mode 100644 index 0000000..85d768b --- /dev/null +++ b/.github/workflows/policy-integration-tests.yml @@ -0,0 +1,491 @@ +# ────────────────────────────────────────────────────────────── +# Policy Integration Tests +# Converted from Azure DevOps pipeline: +# .azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml +# ────────────────────────────────────────────────────────────── +name: policy-integration-tests + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + removeTestResource: + description: "Remove Test Resources" + type: boolean + default: true + testToRun: + description: "Tests To Run (separate with commas ',')" + type: string + default: " " + debug: + description: "Enable debug logging" + type: boolean + default: false + +permissions: + contents: read + +env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} + # Test directories and config + testDirectory: "tests/policy-integration-tests" + testIgnoreFileName: ".testignore" + testGlobalConfigFilePath: "tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc" + deploymentMaxRetry: "3" + preferredBicepCliVersion: "0.41.2" + preferredTerraformVersion: "latest" + azureLocation: "australiaeast" + # Script paths + testInitiationScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1" + getTestConfigsScript: "scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1" + testBicepDeploymentScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1" + testTFDeploymentDestroyScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1" + waitPolicyInitialEvalScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1" + complianceScanScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1" + testDeploymentParseResultScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1" + testResourceDeleteScriptPath: "scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1" + installPSModuleScriptPath: "scripts/pipelines/pipeline-install-moduleFromRepo.ps1" + installBicepScriptPath: "scripts/pipelines/pipeline-install-bicep.ps1" + +jobs: + # ────────────────────────────────────────── + # Initiation – parse global test config file + # ────────────────────────────────────────── + initiation: + name: Tests Initiation + runs-on: ubuntu-latest + outputs: + testBicepTemplateName: ${{ steps.parseConfigFile.outputs.testBicepTemplateName }} + testTerraformDirectoryName: ${{ steps.parseConfigFile.outputs.testTerraformDirectoryName }} + testLocalConfigFileName: ${{ steps.parseConfigFile.outputs.testLocalConfigFileName }} + initialEvalMaximumWaitTime: ${{ steps.parseConfigFile.outputs.initialEvalMaximumWaitTime }} + testBicepDeploymentOutputArtifactPrefix: ${{ steps.parseConfigFile.outputs.testBicepDeploymentOutputArtifactPrefix }} + testTerraformDeploymentOutputArtifactPrefix: ${{ steps.parseConfigFile.outputs.testTerraformDeploymentOutputArtifactPrefix }} + testDeploymentOutputFileName: ${{ steps.parseConfigFile.outputs.testDeploymentOutputFileName }} + testTerraformStateFileName: ${{ steps.parseConfigFile.outputs.testTerraformStateFileName }} + testTerraformEncryptedStateFileName: ${{ steps.parseConfigFile.outputs.testTerraformEncryptedStateFileName }} + testOutputFilePrefix: ${{ steps.parseConfigFile.outputs.testOutputFilePrefix }} + testOutputFormat: ${{ steps.parseConfigFile.outputs.testOutputFormat }} + testScriptName: ${{ steps.parseConfigFile.outputs.testScriptName }} + waitTimeForAppendModifyPoliciesAfterDeployment: ${{ steps.parseConfigFile.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} + waitTimeForPolicyComplianceStateAfterDeployment: ${{ steps.parseConfigFile.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} + waitTimeForDeployIfNotExistsPoliciesAfterDeployment: ${{ steps.parseConfigFile.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} + bicepDeploymentRequired: ${{ steps.parseConfigFile.outputs.bicepDeploymentRequired }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: List Environment Variables + shell: pwsh + run: "Get-ChildItem env:" + - name: Parse Global Test Config File + id: parseConfigFile + shell: pwsh + run: | + ./${{ env.testInitiationScriptPath }} ` + -testDirectory '${{ env.testDirectory }}' ` + -testConfigFilePath '${{ env.testGlobalConfigFilePath }}' + + # ────────────────────────────────────────── + # Detect test cases from git diff (PR only) + # ────────────────────────────────────────── + mapTestCases: + name: Detect Test Cases from Git Diff + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + needs: initiation + outputs: + shouldSkipTest: ${{ steps.detectTestCases.outputs.shouldSkipTest }} + requiredTestCases: ${{ steps.detectTestCases.outputs.requiredTestCases }} + runComplianceScan: ${{ steps.detectTestCases.outputs.runComplianceScan }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Detect Test Cases + id: detectTestCases + uses: ./.github/actions/templates/pol-int-test-detect-test-cases + with: + test-config-file-path: "${{ env.testGlobalConfigFilePath }}" + target-git-branch: "${{ github.base_ref }}" + test-case-dir: "${{ env.testDirectory }}" + + # ────────────────────────────────────────── + # Get test configurations + # ────────────────────────────────────────── + getTestConfigs: + name: Get Test Configurations + runs-on: ubuntu-latest + if: >- + always() && + (needs.mapTestCases.result == 'success' || needs.mapTestCases.result == 'skipped') + needs: + - initiation + - mapTestCases + outputs: + testDelayStartMinutes: ${{ steps.getTestConfigsTask.outputs.testDelayStartMinutes }} + runComplianceScan: ${{ steps.getTestConfigsTask.outputs.runComplianceScan }} + complianceScanSubNames: ${{ steps.getTestConfigsTask.outputs.complianceScanSubNames }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Get Test Config + id: getTestConfigsTask + shell: pwsh + run: | + ./${{ env.getTestConfigsScript }} ` + -directory '${{ env.testDirectory }}' ` + -ignoreFileName '${{ env.testIgnoreFileName }}' ` + -includedDirectory "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" ` + -policyComplianceStateDelay ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} ` + -appendModifyDelay ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} ` + -DINEDelay ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} ` + -testLocalConfigFileName '${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -testScriptName '${{ needs.initiation.outputs.testScriptName }}' ` + -skip ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} + + # ────────────────────────────────────────── + # Get test case sub directories + # ────────────────────────────────────────── + getTests: + name: Get Test Cases + runs-on: ubuntu-latest + if: >- + always() && + (needs.mapTestCases.result == 'success' || needs.mapTestCases.result == 'skipped') && + needs.getTestConfigs.result == 'success' + needs: + - mapTestCases + - getTestConfigs + outputs: + SubDirCount: ${{ steps.getSubDirs.outputs.SubDirCount }} + SubDirectories: ${{ steps.getSubDirs.outputs.SubDirectories }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Get Sub Directories + id: getSubDirs + uses: ./.github/actions/templates/pol-int-test-get-sub-dir + with: + directory: "${{ env.testDirectory }}" + ignore-file-name: "${{ env.testIgnoreFileName }}" + included-directory: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" + skip: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }}" + - name: Debug Outputs + shell: bash + run: | + echo "SubDirCount='${{ steps.getSubDirs.outputs.SubDirCount }}'" + echo 'SubDirectories=${{ steps.getSubDirs.outputs.SubDirectories }}' + + # ────────────────────────────────────────── + # Deploy test resources (matrix) + # ────────────────────────────────────────── + deployTestResources: + name: "Deploy Resources [${{ matrix.matrixSubDirName }}]" + runs-on: ubuntu-latest + timeout-minutes: 180 + if: >- + always() && + needs.getTests.result == 'success' && + needs.initiation.result == 'success' && + needs.getTests.outputs.SubDirCount != '0' + needs: + - getTests + - initiation + env: + AZURE_CREDENTIALS: ${{ secrets.POLICY_DEV_MG_OWNER }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.getTests.outputs.SubDirectories) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Azure Login + uses: azure/login@v3 + with: + creds: ${{ env.AZURE_CREDENTIALS }} + enable-AzPSSession: true + - name: Install Bicep CLI + shell: pwsh + run: | + ./${{ env.installBicepScriptPath }} ` + -desiredVersion '${{ env.preferredBicepCliVersion }}' + - name: Install Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ env.preferredTerraformVersion }} + - name: Wait Initial Policy Evaluation + shell: pwsh + run: | + ./${{ env.waitPolicyInitialEvalScriptPath }} ` + -configFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -wait 'true' ` + -maximumWaitMinutes ${{ needs.initiation.outputs.initialEvalMaximumWaitTime }} + - name: Deploy Test Bicep Template + shell: pwsh + run: | + ./${{ env.testBicepDeploymentScriptPath }} ` + -BicepFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testBicepTemplateName }}' ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -BuildNumber ${{ github.run_number }} ` + -maxRetry ${{ env.deploymentMaxRetry }} ` + -bicepModuleSubscriptionId '' ` + -deploymentResultFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' + - name: Publish Bicep Deployment Result Artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: "${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + - name: Deploy Test Terraform Template + shell: pwsh + run: | + ./${{ env.testTFDeploymentDestroyScriptPath }} ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` + -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` + -tfAction 'apply' ` + -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}' ` + -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` + -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` + -deploymentResultFileName '${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -uninitializeTerraform 'true' ` + -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` + -aesIV '${{ secrets.AES_IV }}' + - name: Publish Terraform Deployment Result Artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: "${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}" + + # ────────────────────────────────────────── + # Run Policy Compliance Scan + # ────────────────────────────────────────── + runPolicyComplianceScan: + name: Run Policy Compliance Scan + runs-on: ubuntu-latest + if: >- + always() && + needs.deployTestResources.result == 'success' && + needs.getTests.outputs.SubDirCount != '0' + needs: + - initiation + - getTests + - getTestConfigs + - deployTestResources + env: + AZURE_CREDENTIALS: ${{ secrets.POLICY_DEV_MG_OWNER }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Azure Login + uses: azure/login@v3 + with: + creds: ${{ env.AZURE_CREDENTIALS }} + - name: Initiate Policy Compliance Scan + if: needs.getTestConfigs.outputs.runComplianceScan == 'true' + shell: pwsh + run: | + ./${{ env.complianceScanScriptPath }} ` + -testGlobalConfigFilePath '${{ env.testGlobalConfigFilePath }}' ` + -complianceScanSubNames '${{ needs.getTestConfigs.outputs.complianceScanSubNames }}' + + # ────────────────────────────────────────── + # Wait after template deployment for policy + # evaluation to complete + # ────────────────────────────────────────── + delayAfterTemplateDeployment: + name: Wait After Template Deployment + runs-on: ubuntu-latest + if: >- + always() && + needs.runPolicyComplianceScan.result == 'success' && + needs.initiation.outputs.bicepDeploymentRequired == 'true' && + needs.getTests.outputs.SubDirCount != '0' + needs: + - initiation + - getTests + - deployTestResources + - getTestConfigs + - runPolicyComplianceScan + steps: + - name: "Wait ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} min for initial evaluation" + shell: pwsh + run: | + Write-Output "::group::Waiting ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} minutes for Initial Policy Evaluation" + $delayMinutes = '${{ needs.getTestConfigs.outputs.testDelayStartMinutes }}' + $now = "$([DateTime]::UtcNow.ToString('u')) UTC" + if ($delayMinutes -match '^\d+$' -and [int]$delayMinutes -gt 0) { + Write-Output "[$now]: Waiting $delayMinutes minutes for policy evaluation..." + Start-Sleep -Seconds ([int]$delayMinutes * 60) + } else { + Write-Output "[$now]: No delay required (value: '$delayMinutes')" + } + Write-Output '::endgroup::' + + # ────────────────────────────────────────── + # Execute test cases (matrix) + # ────────────────────────────────────────── + runTests: + name: "Run Tests [${{ matrix.matrixSubDirName }}]" + runs-on: ubuntu-latest + timeout-minutes: 180 + if: >- + always() && + !failure() && !cancelled() && + needs.getTests.outputs.SubDirCount != '0' + needs: + - getTests + - initiation + - deployTestResources + - delayAfterTemplateDeployment + env: + AZURE_CREDENTIALS: ${{ secrets.POLICY_DEV_MG_OWNER }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.getTests.outputs.SubDirectories) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Azure Login + uses: azure/login@v3 + with: + creds: ${{ env.AZURE_CREDENTIALS }} + - name: Install AzResourceTest Module + shell: pwsh + run: | + ./${{ env.installPSModuleScriptPath }} ` + -modules 'AzResourceTest@2.0.3' ` + -repoName 'PSGallery' ` + -maxRetry 3 ` + -allowPrerelease 'false' + - name: Install Bicep CLI + shell: pwsh + run: | + ./${{ env.installBicepScriptPath }} ` + -desiredVersion '${{ env.preferredBicepCliVersion }}' + - name: Install Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ env.preferredTerraformVersion }} + - name: Download Bicep Deployment Result Artifact + uses: actions/download-artifact@v8 + with: + name: "${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + - name: Download Terraform Deployment Result Artifact + uses: actions/download-artifact@v8 + with: + name: "${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + - name: "Parse Bicep Deployment Result - ${{ matrix.matrixSubDirName }}" + id: parseBicepDeploymentResult + shell: pwsh + run: | + ./${{ env.testDeploymentParseResultScriptPath }} ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -overallJsonVariableName 'bicepDeploymentResult' + - name: "Parse Terraform Deployment Result - ${{ matrix.matrixSubDirName }}" + id: parseTerraformDeploymentResult + shell: pwsh + run: | + ./${{ env.testDeploymentParseResultScriptPath }} ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -overallJsonVariableName 'terraformDeploymentResult' + - name: "Resource Test - ${{ matrix.matrixSubDirName }}" + shell: pwsh + env: + bicepDeploymentResult: ${{ steps.parseBicepDeploymentResult.outputs.bicepDeploymentResult }} + terraformDeploymentResult: ${{ steps.parseTerraformDeploymentResult.outputs.terraformDeploymentResult }} + outputFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testOutputFilePrefix }}-${{ matrix.matrixSubDirName }}.XML" + outputFormat: ${{ needs.initiation.outputs.testOutputFormat }} + run: | + ./${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testScriptName }} + - name: "Process Test Results - ${{ matrix.matrixSubDirName }}" + if: always() + uses: ./.github/actions/templates/parse-pester-results + with: + test-result-files: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testOutputFilePrefix }}-${{ matrix.matrixSubDirName }}.XML" + test-title: "Policy Integration Test - ${{ matrix.matrixSubDirName }}" + check-name: "Test - ${{ matrix.matrixSubDirName }}" + skip-passed-tests-report: "false" + + # ────────────────────────────────────────── + # Remove deployed test resources (matrix) + # ────────────────────────────────────────── + resourceRemoval: + name: "Remove Resources [${{ matrix.matrixSubDirName }}]" + runs-on: ubuntu-latest + timeout-minutes: 60 + if: >- + always() && !cancelled() && + (github.event_name != 'workflow_dispatch' || inputs.removeTestResource) && + needs.getTests.outputs.SubDirCount != '0' + needs: + - getTests + - initiation + - deployTestResources + - runTests + env: + AZURE_CREDENTIALS: ${{ secrets.POLICY_DEV_MG_OWNER }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.getTests.outputs.SubDirectories) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Azure Login + uses: azure/login@v3 + with: + creds: ${{ env.AZURE_CREDENTIALS }} + enable-AzPSSession: true + - name: Install Terraform + uses: hashicorp/setup-terraform@v4 + with: + terraform_version: ${{ env.preferredTerraformVersion }} + - name: Download Terraform Deployment Result Artifact + uses: actions/download-artifact@v8 + with: + name: "${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + - name: Download Bicep Deployment Result Artifact + uses: actions/download-artifact@v8 + with: + name: "${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + path: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + - name: "Parse Bicep Deployment Result - ${{ matrix.matrixSubDirName }}" + id: parseBicepDeploymentResult + shell: pwsh + run: | + ./${{ env.testDeploymentParseResultScriptPath }} ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -overallJsonVariableName 'bicepDeploymentResult' + - name: "Remove Bicep Test Resources - ${{ matrix.matrixSubDirName }}" + if: >- + steps.parseBicepDeploymentResult.outputs.bicepDeploymentId != '' || + steps.parseBicepDeploymentResult.outputs.bicepRemoveTestResourceGroup == 'True' + shell: pwsh + env: + bicepDeploymentResult: ${{ steps.parseBicepDeploymentResult.outputs.bicepDeploymentResult }} + run: | + $ErrorActionPreference = 'Stop' + ./${{ env.testResourceDeleteScriptPath }} + - name: "Remove Test Terraform Resources - ${{ matrix.matrixSubDirName }}" + shell: pwsh + run: | + ./${{ env.testTFDeploymentDestroyScriptPath }} ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` + -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` + -tfAction 'destroy' ` + -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}' ` + -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` + -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` + -uninitializeTerraform 'true' ` + -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` + -aesIV '${{ secrets.AES_IV }}' diff --git a/policyAssignments/dev/pa-d-eh.json b/policyAssignments/dev/pa-d-eh.json index 8be40a1..4c257d9 100644 --- a/policyAssignments/dev/pa-d-eh.json +++ b/policyAssignments/dev/pa-d-eh.json @@ -11,7 +11,7 @@ }, "parameters": { "EH-001_Effect": { - "value": "Disabled" + "value": "Deny" }, "EH-002_Effect": { "value": "Audit" diff --git a/policyAssignments/dev/pa-d-monitor.json b/policyAssignments/dev/pa-d-monitor.json index c632310..08839ac 100644 --- a/policyAssignments/dev/pa-d-monitor.json +++ b/policyAssignments/dev/pa-d-monitor.json @@ -14,8 +14,7 @@ }, "MON-001_allowedEmailDomains": { "value": [ - "contoso.com", - "lumagatena.com" + "contoso.com" ] }, "MON-002_Effect": { diff --git a/policyAssignments/dev/pa-d-pe-lz.json b/policyAssignments/dev/pa-d-pe-lz.json index a9a6930..d355baf 100644 --- a/policyAssignments/dev/pa-d-pe-lz.json +++ b/policyAssignments/dev/pa-d-pe-lz.json @@ -19,7 +19,9 @@ "value": "Deny" }, "PE-003_AllowedCrossSubPrivateLinkResources": { - "value": [] + "value": [ + "/subscriptions/dc2d72b7-a48d-45e8-91cc-81193ecc659b/resourcegroups/rg-ae-d-policy-test-web-001/providers/Microsoft.Web/sites/web-typoltst-web1-02" + ] } }, "nonComplianceMessages": [] diff --git a/policyAssignments/dev/pa-d-postgresql.json b/policyAssignments/dev/pa-d-postgresql.json new file mode 100644 index 0000000..a87fb7b --- /dev/null +++ b/policyAssignments/dev/pa-d-postgresql.json @@ -0,0 +1,25 @@ +{ + "$schema": "../policyAssignment.schema.json", + "policyAssignment": { + "name": "pa-d-postgresql", + "displayName": "PostgreSQL Policies Dev", + "description": "Policy Assignment for Azure PostgreSQL - Dev", + "metadata": { + "category": "PostgreSQL" + }, + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policySetDefinitions/polset-postgresql", + "identity": "SystemAssigned", + "parameters": { + "PGS-001_Effect": { + "value": "Deny" + }, + "PGS-002_Effect": { + "value": "Deny" + } + }, + "roleDefinitionIds": [], + "nonComplianceMessages": [] + }, + "definitionSourceManagementGroupId": "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV", + "managementGroupId": "CONTOSO-DEV" +} diff --git a/policyAssignments/dev/pa-d-res-restriction.json b/policyAssignments/dev/pa-d-res-restriction.json index dac648a..5eadca8 100644 --- a/policyAssignments/dev/pa-d-res-restriction.json +++ b/policyAssignments/dev/pa-d-res-restriction.json @@ -24,6 +24,9 @@ }, "RR-005_Effect": { "value": "Deny" + }, + "RR-006_Effect": { + "value": "Deny" } }, "notScopes": [ @@ -49,6 +52,10 @@ { "policyDefinitionReferenceId": "RR-005", "message": "The resource type 'Microsoft.ContainerRegistry/registries/scopeMaps' is not allowed." + }, + { + "policyDefinitionReferenceId": "RR-006", + "message": "The resource type 'Microsoft.DBforPostgreSQL/servers' is not allowed. Use PostgreSQL Flexible Server instead." } ] }, diff --git a/policyAssignments/dev/pa-d-tags.json b/policyAssignments/dev/pa-d-tags.json new file mode 100644 index 0000000..2ecf377 --- /dev/null +++ b/policyAssignments/dev/pa-d-tags.json @@ -0,0 +1,93 @@ +{ + "$schema": "../policyAssignment.schema.json", + "policyAssignment": { + "name": "pa-d-tags", + "displayName": "Tagging Policies Dev", + "description": "Policy Assignment for Azure tagging policies - Dev", + "metadata": { + "category": "Tags" + }, + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policySetDefinitions/polset-tags", + "identity": "SystemAssigned", + "parameters": { + "TAG-001_Effect": { + "value": "Deny" + }, + "TAG-002_Effect": { + "value": "Deny" + }, + "TAG-003_Effect": { + "value": "Deny" + }, + "TAG-004_Effect": { + "value": "Deny" + }, + "TAG-005_Effect": { + "value": "Modify" + }, + "TAG-006_Effect": { + "value": "Modify" + }, + "TAG-007_Effect": { + "value": "Modify" + }, + "TAG-008_Effect": { + "value": "Modify" + }, + "TAG-009_Effect": { + "value": "Modify" + }, + "TAG-010_Effect": { + "value": "Modify" + }, + "TAG-011_Effect": { + "value": "Modify" + }, + "TAG-012_Effect": { + "value": "Modify" + }, + "TAG-013_Effect": { + "value": "Deny" + }, + "TAG-014_Effect": { + "value": "Deny" + }, + "TAG-015_Effect": { + "value": "Deny" + }, + "TAG-016_Effect": { + "value": "Deny" + }, + "TAG-017_Effect": { + "value": "Deny" + }, + "TAG-018_Effect": { + "value": "Modify" + }, + "TAG-019_Effect": { + "value": "Modify" + }, + "TAG-dataclass_allowedTagValues": { + "value": [ + "official", + "sensitive", + "protected" + ] + }, + "TAG-environment_allowedTagValues": { + "value": [ + "prod", + "dev", + "test", + "staging" + ] + } + }, + "roleDefinitionIds": [ + "/providers/microsoft.authorization/roleDefinitions/4a9ae827-6dc8-4573-8ac7-8239d42aa03f" + ], + "nonComplianceMessages": [] + }, + "definitionSourceManagementGroupId": "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV", + "managementGroupId": "CONTOSO-DEV" +} diff --git a/policyAssignments/prod/pa-p-postgresql.json b/policyAssignments/prod/pa-p-postgresql.json new file mode 100644 index 0000000..a67e5b1 --- /dev/null +++ b/policyAssignments/prod/pa-p-postgresql.json @@ -0,0 +1,25 @@ +{ + "$schema": "../policyAssignment.schema.json", + "policyAssignment": { + "name": "pa-p-postgresql", + "displayName": "PostgreSQL Policies Prod", + "description": "Policy Assignment for Azure PostgreSQL - Prod", + "metadata": { + "category": "PostgreSQL" + }, + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policySetDefinitions/polset-postgresql", + "identity": "SystemAssigned", + "parameters": { + "PGS-001_Effect": { + "value": "Deny" + }, + "PGS-002_Effect": { + "value": "Deny" + } + }, + "roleDefinitionIds": [], + "nonComplianceMessages": [] + }, + "definitionSourceManagementGroupId": "/providers/Microsoft.Management/managementGroups/CONTOSO", + "managementGroupId": "CONTOSO" +} diff --git a/policyAssignments/prod/pa-p-tags.json b/policyAssignments/prod/pa-p-tags.json new file mode 100644 index 0000000..a33b4e9 --- /dev/null +++ b/policyAssignments/prod/pa-p-tags.json @@ -0,0 +1,93 @@ +{ + "$schema": "../policyAssignment.schema.json", + "policyAssignment": { + "name": "pa-p-tags", + "displayName": "Tagging Policies Prod", + "description": "Policy Assignment for Azure tagging policies - Prod", + "metadata": { + "category": "Tags" + }, + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policySetDefinitions/polset-tags", + "identity": "SystemAssigned", + "parameters": { + "TAG-001_Effect": { + "value": "Deny" + }, + "TAG-002_Effect": { + "value": "Deny" + }, + "TAG-003_Effect": { + "value": "Deny" + }, + "TAG-004_Effect": { + "value": "Deny" + }, + "TAG-005_Effect": { + "value": "Modify" + }, + "TAG-006_Effect": { + "value": "Modify" + }, + "TAG-007_Effect": { + "value": "Modify" + }, + "TAG-008_Effect": { + "value": "Modify" + }, + "TAG-009_Effect": { + "value": "Modify" + }, + "TAG-010_Effect": { + "value": "Modify" + }, + "TAG-011_Effect": { + "value": "Modify" + }, + "TAG-012_Effect": { + "value": "Modify" + }, + "TAG-013_Effect": { + "value": "Deny" + }, + "TAG-014_Effect": { + "value": "Deny" + }, + "TAG-015_Effect": { + "value": "Deny" + }, + "TAG-016_Effect": { + "value": "Deny" + }, + "TAG-017_Effect": { + "value": "Deny" + }, + "TAG-018_Effect": { + "value": "Modify" + }, + "TAG-019_Effect": { + "value": "Modify" + }, + "TAG-dataclass_allowedTagValues": { + "value": [ + "official", + "sensitive", + "protected" + ] + }, + "TAG-environment_allowedTagValues": { + "value": [ + "prod", + "dev", + "test", + "staging" + ] + } + }, + "roleDefinitionIds": [ + "/providers/microsoft.authorization/roleDefinitions/4a9ae827-6dc8-4573-8ac7-8239d42aa03f" + ], + "nonComplianceMessages": [] + }, + "definitionSourceManagementGroupId": "/providers/Microsoft.Management/managementGroups/CONTOSO", + "managementGroupId": "CONTOSO" +} diff --git a/policyDefinitions/diagnostics-settings/pol-deploy-diag-function.json b/policyDefinitions/diagnostics-settings/pol-deploy-diag-function.json index b705095..fe3d1cb 100644 --- a/policyDefinitions/diagnostics-settings/pol-deploy-diag-function.json +++ b/policyDefinitions/diagnostics-settings/pol-deploy-diag-function.json @@ -71,6 +71,18 @@ "False" ], "defaultValue": "True" + }, + "logCategoryGroup": { + "type": "string", + "metadata": { + "displayName": "Log Category Group", + "description": "Log Category Group to capture." + }, + "allowedValues": [ + "audit", + "allLogs" + ], + "defaultValue": "allLogs" } }, "policyRule": { @@ -98,8 +110,22 @@ "existenceCondition": { "allOf": [ { - "field": "Microsoft.Insights/diagnosticSettings/logs.enabled", - "equals": "True" + "count": { + "field": "Microsoft.Insights/diagnosticSettings/logs[*]", + "where": { + "allOf": [ + { + "field": "Microsoft.Insights/diagnosticSettings/logs[*].categoryGroup", + "equals": "[parameters('logCategoryGroup')]" + }, + { + "field": "Microsoft.Insights/diagnosticSettings/logs[*].enabled", + "equals": "true" + } + ] + } + }, + "equals": 1 }, { "field": "Microsoft.Insights/diagnosticSettings/metrics.enabled", @@ -133,6 +159,9 @@ "logsEnabled": { "type": "string" }, + "logCategoryGroup": { + "type": "string" + }, "location": { "type": "string" } @@ -160,7 +189,7 @@ ], "logs": [ { - "category": "FunctionAppLogs", + "categoryGroup": "[parameters('logCategoryGroup')]", "enabled": "[parameters('logsEnabled')]" } ] @@ -182,6 +211,9 @@ "logsEnabled": { "value": "[parameters('logsEnabled')]" }, + "logCategoryGroup": { + "value": "[parameters('logCategoryGroup')]" + }, "location": { "value": "[field('location')]" }, diff --git a/policyDefinitions/diagnostics-settings/pol-deploy-diag-website.json b/policyDefinitions/diagnostics-settings/pol-deploy-diag-website.json index e6cf6a5..c273bd9 100644 --- a/policyDefinitions/diagnostics-settings/pol-deploy-diag-website.json +++ b/policyDefinitions/diagnostics-settings/pol-deploy-diag-website.json @@ -71,6 +71,18 @@ "False" ], "defaultValue": "True" + }, + "logCategoryGroup": { + "type": "string", + "metadata": { + "displayName": "Log Category Group", + "description": "Log Category Group to capture." + }, + "allowedValues": [ + "audit", + "allLogs" + ], + "defaultValue": "allLogs" } }, "policyRule": { @@ -98,8 +110,22 @@ "existenceCondition": { "allOf": [ { - "field": "Microsoft.Insights/diagnosticSettings/logs.enabled", - "equals": "True" + "count": { + "field": "Microsoft.Insights/diagnosticSettings/logs[*]", + "where": { + "allOf": [ + { + "field": "Microsoft.Insights/diagnosticSettings/logs[*].categoryGroup", + "equals": "[parameters('logCategoryGroup')]" + }, + { + "field": "Microsoft.Insights/diagnosticSettings/logs[*].enabled", + "equals": "true" + } + ] + } + }, + "equals": 1 }, { "field": "Microsoft.Insights/diagnosticSettings/metrics.enabled", @@ -133,6 +159,9 @@ "logsEnabled": { "type": "string" }, + "logCategoryGroup": { + "type": "string" + }, "location": { "type": "string" } @@ -160,27 +189,7 @@ ], "logs": [ { - "category": "AppServiceHTTPLogs", - "enabled": "[parameters('logsEnabled')]" - }, - { - "category": "AppServiceConsoleLogs", - "enabled": "[parameters('logsEnabled')]" - }, - { - "category": "AppServiceAppLogs", - "enabled": "[parameters('logsEnabled')]" - }, - { - "category": "AppServiceAuditLogs", - "enabled": "[parameters('logsEnabled')]" - }, - { - "category": "AppServiceIPSecAuditLogs", - "enabled": "[parameters('logsEnabled')]" - }, - { - "category": "AppServicePlatformLogs", + "categoryGroup": "[parameters('logCategoryGroup')]", "enabled": "[parameters('logsEnabled')]" } ] @@ -202,6 +211,9 @@ "logsEnabled": { "value": "[parameters('logsEnabled')]" }, + "logCategoryGroup": { + "value": "[parameters('logCategoryGroup')]" + }, "location": { "value": "[field('location')]" }, diff --git a/policyDefinitions/event-hub/pol-audit-deny-eh-local-auth.json b/policyDefinitions/event-hub/pol-audit-deny-eh-local-auth.json index 757eb71..3f38e7a 100644 --- a/policyDefinitions/event-hub/pol-audit-deny-eh-local-auth.json +++ b/policyDefinitions/event-hub/pol-audit-deny-eh-local-auth.json @@ -4,11 +4,10 @@ "displayName": "Azure Event Hub namespaces should have local authentication methods disabled", "description": "Disabling local authentication methods improves security by ensuring that Azure Event Hub namespaces exclusively require Azure Active Directory identities for authentication. Learn more at: https://aka.ms/disablelocalauth-eh.", "metadata": { - "category": "Data Protection", + "category": "Event Hub", "version": "1.0.0", "preview": false, - "deprecated": false, - "source_builtIn_name": "5d4e3c65-4873-47be-94f3-6f8b953a3598" + "deprecated": false }, "mode": "Indexed", "parameters": { @@ -33,12 +32,6 @@ "field": "type", "equals": "Microsoft.EventHub/namespaces" }, - { - "not": { - "field": "tags[application]", - "equals": "purview" - } - }, { "field": "Microsoft.EventHub/namespaces/disableLocalAuth", "notEquals": true diff --git a/policyDefinitions/event-hub/pol-audit-deny-eh-minimum-tls-version.json b/policyDefinitions/event-hub/pol-audit-deny-eh-minimum-tls-version.json index 9f335fd..12bf602 100644 --- a/policyDefinitions/event-hub/pol-audit-deny-eh-minimum-tls-version.json +++ b/policyDefinitions/event-hub/pol-audit-deny-eh-minimum-tls-version.json @@ -4,7 +4,7 @@ "displayName": "Event Hub namespaces should have the specified minimum TLS version", "description": "Configure a minimum TLS version for secure communication between the client application and the Event Hub Namespace. To minimize security risk, the recommended minimum TLS version is the latest released version, which is currently TLS 1.2.", "metadata": { - "category": "Data Protection", + "category": "Event Hub", "version": "1.0.0", "preview": false, "deprecated": false diff --git a/policyDefinitions/event-hub/pol-audit-deny-eh-public-network-access.json b/policyDefinitions/event-hub/pol-audit-deny-eh-public-network-access.json index 1034eb5..3e22608 100644 --- a/policyDefinitions/event-hub/pol-audit-deny-eh-public-network-access.json +++ b/policyDefinitions/event-hub/pol-audit-deny-eh-public-network-access.json @@ -4,7 +4,7 @@ "displayName": "Azure Event Hub namespaces should disable public network access", "description": "Disable public network access for your Event Hub namespaces so that it's not accessible over the public internet. This can reduce data leakage risks", "metadata": { - "category": "Network Security", + "category": "Event Hub", "version": "1.1.0", "preview": false, "deprecated": false diff --git a/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-cmk-encryption.json b/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-cmk-encryption.json index c7621e1..ec9ff8f 100644 --- a/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-cmk-encryption.json +++ b/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-cmk-encryption.json @@ -4,11 +4,10 @@ "displayName": "Event Hub namespaces should use a customer-managed key for encryption", "description": "Azure Event Hubs supports the option of encrypting data at rest with either Microsoft-managed keys (default) or customer-managed keys. Choosing to encrypt data using customer-managed keys enables you to assign, rotate, disable, and revoke access to the keys that Event Hub will use to encrypt data in your namespace. Note that Event Hub only supports encryption with customer-managed keys for namespaces in dedicated clusters.", "metadata": { - "category": "Data Protection", + "category": "Event Hub", "version": "1.0.0", "preview": false, - "deprecated": false, - "source_builtIn_name": "a1ad735a-e96f-45d2-a7b2-9a4932cab7ec" + "deprecated": false }, "mode": "Indexed", "parameters": { diff --git a/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-private-link.json b/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-private-link.json index de14e52..e51534a 100644 --- a/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-private-link.json +++ b/policyDefinitions/event-hub/pol-audit-eh-namespaces-use-private-link.json @@ -4,11 +4,10 @@ "displayName": "Event Hub namespaces should use private link", "description": "Azure Private Link lets you connect your virtual network to Azure services without a public IP address at the source or destination. The Private Link platform handles the connectivity between the consumer and services over the Azure backbone network. By mapping private endpoints to Event Hub namespaces, data leakage risks are reduced.", "metadata": { - "category": "Network Security", + "category": "Event Hub", "version": "1.0.0", "preview": false, - "deprecated": false, - "source_builtIn_name": "b8564268-eb4a-4337-89be-a19db070c59d" + "deprecated": false }, "mode": "Indexed", "parameters": { diff --git a/policyDefinitions/postgresql/pol-audit-postgresql-connection-throttling.json b/policyDefinitions/postgresql/pol-audit-postgresql-connection-throttling.json deleted file mode 100644 index 1c044ed..0000000 --- a/policyDefinitions/postgresql/pol-audit-postgresql-connection-throttling.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "pol-audit-postgresql-connection-throttling", - "properties": { - "displayName": "Connection throttling should be enabled for PostgreSQL database servers", - "description": "This policy helps audit any PostgreSQL databases in your environment without Connection throttling enabled. This setting enables temporary connection throttling per IP for too many invalid password login failures.", - "metadata": { - "category": "Application and Database Security", - "version": "1.0.0", - "preview": false, - "deprecated": false, - "source_builtIn_name": "5345bb39-67dc-4960-a1bf-427e16b9a0bd" - }, - "mode": "Indexed", - "parameters": { - "effect": { - "type": "String", - "allowedValues": [ - "AuditIfNotExists", - "Disabled" - ], - "defaultValue": "AuditIfNotExists", - "metadata": { - "displayName": "Effect", - "description": "Enable or disable the execution of the policy" - } - }, - "evaluationDelay": { - "type": "string", - "metadata": { - "displayName": "Evaluation Delay", - "description": "Specifies when the existence of the related resources should be evaluated. The delay is only used for evaluations that are a result of a create or update resource request. Allowed values are AfterProvisioning, AfterProvisioningSuccess, AfterProvisioningFailure, or an ISO 8601 duration between 0 and 360 minutes." - }, - "defaultValue": "AfterProvisioning" - } - }, - "policyRule": { - "if": { - "field": "type", - "equals": "Microsoft.DBforPostgreSQL/servers" - }, - "then": { - "effect": "[parameters('effect')]", - "details": { - "type": "Microsoft.DBforPostgreSQL/servers/configurations", - "evaluationDelay": "[parameters('evaluationDelay')]", - "name": "connection_throttling", - "existenceCondition": { - "field": "Microsoft.DBforPostgreSQL/servers/configurations/value", - "equals": "ON" - } - } - } - } - } -} diff --git a/policyDefinitions/postgresql/pol-audit-postgresql-geo-redundant-backup.json b/policyDefinitions/postgresql/pol-audit-postgresql-geo-redundant-backup.json deleted file mode 100644 index ea8d4b3..0000000 --- a/policyDefinitions/postgresql/pol-audit-postgresql-geo-redundant-backup.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "pol-audit-postgresql-geo-redundant-backup", - "properties": { - "displayName": "Public network access should be disabled for PostgreSQL flexible servers", - "description": "Disabling the public network access property improves security by ensuring your Azure Database for PostgreSQL flexible servers can only be accessed from a private endpoint. This configuration strictly disables access from any public address space outside of Azure IP range and denies all logins that match IP or virtual network-based firewall rules.", - "metadata": { - "category": "Network Security", - "version": "1.0.1", - "preview": false, - "deprecated": false, - "source_builtIn_name": "48af4db5-9b8b-401c-8e74-076be876a430" - }, - "mode": "Indexed", - "parameters": { - "effect": { - "type": "String", - "allowedValues": [ - "Audit", - "Deny", - "Disabled" - ], - "defaultValue": "Audit", - "metadata": { - "displayName": "Effect", - "description": "Enable or disable the execution of the policy" - } - } - }, - "policyRule": { - "if": { - "allOf": [ - { - "field": "type", - "equals": "Microsoft.DBforPostgreSQL/servers" - }, - { - "field": "Microsoft.DBforPostgreSQL/servers/storageProfile.geoRedundantBackup", - "notEquals": "Enabled" - } - ] - }, - "then": { - "effect": "[parameters('effect')]" - } - } - } -} diff --git a/policyDefinitions/postgresql/pol-audit-postgresql-private-endpoint.json b/policyDefinitions/postgresql/pol-audit-postgresql-private-endpoint.json deleted file mode 100644 index 172be56..0000000 --- a/policyDefinitions/postgresql/pol-audit-postgresql-private-endpoint.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "pol-audit-postgresql-private-endpoint", - "properties": { - "displayName": "Private endpoint should be enabled for PostgreSQL servers", - "description": "Private endpoint connections enforce secure communication by enabling private connectivity to Azure Database for PostgreSQL. Configure a private endpoint connection to enable access to traffic coming only from known networks and prevent access from all other IP addresses, including within Azure.", - "metadata": { - "category": "Network Security", - "version": "1.0.0", - "preview": false, - "deprecated": false, - "source_builtIn_name": "0564d078-92f5-4f97-8398-b9f58a51f70b" - }, - "mode": "Indexed", - "parameters": { - "effect": { - "type": "String", - "allowedValues": [ - "AuditIfNotExists", - "Disabled" - ], - "defaultValue": "AuditIfNotExists", - "metadata": { - "displayName": "Effect", - "description": "Enable or disable the execution of the policy" - } - }, - "evaluationDelay": { - "type": "string", - "metadata": { - "displayName": "Evaluation Delay", - "description": "Specifies when the existence of the related resources should be evaluated. The delay is only used for evaluations that are a result of a create or update resource request. Allowed values are AfterProvisioning, AfterProvisioningSuccess, AfterProvisioningFailure, or an ISO 8601 duration between 0 and 360 minutes." - }, - "defaultValue": "AfterProvisioning" - } - }, - "policyRule": { - "if": { - "field": "type", - "equals": "Microsoft.DBforPostgreSQL/servers" - }, - "then": { - "effect": "[parameters('effect')]", - "details": { - "type": "Microsoft.DBforPostgreSQL/servers/privateEndpointConnections", - "evaluationDelay": "[parameters('evaluationDelay')]", - "existenceCondition": { - "field": "Microsoft.DBforPostgreSQL/servers/privateEndpointConnections/privateLinkServiceConnectionState.status", - "equals": "Approved" - } - } - } - } - } -} diff --git a/policyDefinitions/postgresql/pol-audit-postgresql-ssl.json b/policyDefinitions/postgresql/pol-audit-postgresql-ssl.json deleted file mode 100644 index f862af0..0000000 --- a/policyDefinitions/postgresql/pol-audit-postgresql-ssl.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "pol-audit-postgresql-ssl", - "properties": { - "displayName": "Enforce SSL connection should be enabled for PostgreSQL database servers", - "description": "Azure Database for PostgreSQL supports connecting your Azure Database for PostgreSQL server to client applications using Secure Sockets Layer (SSL). Enforcing SSL connections between your database server and your client applications helps protect against 'man in the middle' attacks by encrypting the data stream between the server and your application. This configuration enforces that SSL is always enabled for accessing your database server.", - "metadata": { - "category": "Network Security", - "version": "1.0.1", - "preview": false, - "deprecated": false, - "source_builtIn_name": "d158790f-bfb0-486c-8631-2dc6b4e8e6af" - }, - "mode": "Indexed", - "parameters": { - "effect": { - "type": "String", - "allowedValues": [ - "Audit", - "Deny", - "Disabled" - ], - "defaultValue": "Audit", - "metadata": { - "displayName": "Effect", - "description": "Enable or disable the execution of the policy" - } - } - }, - "policyRule": { - "if": { - "allOf": [ - { - "field": "type", - "equals": "Microsoft.DBforPostgreSQL/servers" - }, - { - "field": "Microsoft.DBforPostgreSQL/servers/sslEnforcement", - "exists": "true" - }, - { - "field": "Microsoft.DBforPostgreSQL/servers/sslEnforcement", - "notEquals": "Enabled" - } - ] - }, - "then": { - "effect": "[parameters('effect')]" - } - } - } -} diff --git a/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-entraid-admin.json b/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-entraid-admin.json new file mode 100644 index 0000000..ba2c8df --- /dev/null +++ b/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-entraid-admin.json @@ -0,0 +1,54 @@ +{ + "name": "pol-deny-postgresql-flexible-servers-entraid-admin", + "properties": { + "displayName": "Azure PostgreSQL flexible server should have Microsoft Entra Only Authentication enabled", + "mode": "Indexed", + "description": "Disabling local authentication methods and allowing only Microsoft Entra Authentication improves security by ensuring that Azure PostgreSQL flexible server can exclusively be accessed by Microsoft Entra identities.", + "metadata": { + "version": "1.0.0", + "category": "PostgreSQL", + "preview": false, + "deprecated": false + }, + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + }, + "allowedValues": [ + "Deny", + "Disabled", + "Audit" + ], + "defaultValue": "Audit" + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.DBForPostgreSql/flexibleServers" + }, + { + "anyOf": [ + { + "field": "Microsoft.DBForPostgreSql/flexibleServers/authConfig.activeDirectoryAuth", + "notEquals": "Enabled" + }, + { + "field": "Microsoft.DBForPostgreSql/flexibleServers/authConfig.passwordAuth", + "notEquals": "Disabled" + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + } + } +} diff --git a/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-public-network-access.json b/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-public-network-access.json index 65dc623..0cbd750 100644 --- a/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-public-network-access.json +++ b/policyDefinitions/postgresql/pol-deny-postgresql-flexible-servers-public-network-access.json @@ -4,11 +4,10 @@ "displayName": "Public network access should be disabled for PostgreSQL flexible servers", "description": "Disabling the public network access property improves security by ensuring your Azure Database for PostgreSQL flexible servers can only be accessed from a private endpoint. This configuration strictly disables access from any public address space outside of Azure IP range and denies all logins that match IP or virtual network-based firewall rules.", "metadata": { - "category": "Network Security", + "category": "PostgreSQL", "version": "1.0.0", "preview": false, - "deprecated": false, - "source_builtIn_name": "5e1de0e3-42cb-4ebc-a86d-61d0c619ca48" + "deprecated": false }, "mode": "Indexed", "parameters": { diff --git a/policyDefinitions/private-endpoints-dns-registration/pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid.json b/policyDefinitions/private-endpoints-dns-registration/pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid.json new file mode 100644 index 0000000..7f528d4 --- /dev/null +++ b/policyDefinitions/private-endpoints-dns-registration/pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid.json @@ -0,0 +1,173 @@ +{ + "name": "pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid", + "properties": { + "displayName": "Configure Private Endpoints to use Private DNS Zones (Single DNS Zone All Regions with Wildcard Group ID Match)", + "description": "This policy creates a Private DNS Group link for a Azure PaaS Private Endpoint Resource that requires a DNS record in a single Private DNS zone with Wildcard Group ID Match.", + "metadata": { + "category": "Network Security", + "version": "1.0.0", + "preview": false, + "deprecated": false + }, + "mode": "All", + "parameters": { + "evaluationDelay": { + "type": "string", + "metadata": { + "displayName": "Evaluation Delay", + "description": "Specifies when the existence of the related resources should be evaluated. The delay is only used for evaluations that are a result of a create or update resource request. Allowed values are AfterProvisioning, AfterProvisioningSuccess, AfterProvisioningFailure, or an ISO 8601 duration between 0 and 360 minutes." + }, + "defaultValue": "AfterProvisioning" + }, + "groupId": { + "type": "String", + "metadata": { + "displayName": "Group ID with wildcard match", + "description": "Target group id with wildcard match (sub resource type) for the private endpoint." + } + }, + "effect": { + "type": "string", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy" + }, + "allowedValues": [ + "DeployIfNotExists", + "Disabled" + ], + "defaultValue": "DeployIfNotExists" + }, + "privateDnsZoneId": { + "metadata": { + "displayName": "Private DNS Zone resource Id", + "description": "The resource Id of the private DNS zone", + "strongType": "Microsoft.Network/privateDnsZones" + }, + "type": "String" + }, + "privateLinkServiceResourceType": { + "metadata": { + "displayName": "Private Link Service Resource Type", + "description": "The resource type of the private link service" + }, + "type": "String" + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Network/privateEndpoints" + }, + { + "anyOf": [ + { + "count": { + "field": "Microsoft.Network/privateEndpoints/privateLinkServiceConnections[*]", + "where": { + "allOf": [ + { + "field": "Microsoft.Network/privateEndpoints/privateLinkServiceConnections[*].privateLinkServiceId", + "contains": "[parameters('privateLinkServiceResourceType')]" + }, + { + "field": "Microsoft.Network/privateEndpoints/privateLinkServiceConnections[*].groupIds[*]", + "like": "[parameters('groupId')]" + } + ] + } + }, + "greaterOrEquals": 1 + }, + { + "count": { + "field": "Microsoft.Network/privateEndpoints/manualPrivateLinkServiceConnections[*]", + "where": { + "allOf": [ + { + "field": "Microsoft.Network/privateEndpoints/manualPrivateLinkServiceConnections[*].privateLinkServiceId", + "contains": "[parameters('privateLinkServiceResourceType')]" + }, + { + "field": "Microsoft.Network/privateEndpoints/manualPrivateLinkServiceConnections[*].groupIds[*]", + "like": "[parameters('groupId')]" + } + ] + } + }, + "greaterOrEquals": 1 + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "evaluationDelay": "[parameters('evaluationDelay')]", + "roleDefinitionIds": [ + "/providers/Microsoft.Authorization/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7" + ], + "existenceCondition": { + "field": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups/privateDnsZoneConfigs[*].privateDnsZoneId", + "like": "[concat('*/',last(split(parameters('privateDnsZoneId'), '/')))]" + }, + "deployment": { + "properties": { + "mode": "incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "privateDnsZoneId": { + "type": "string" + }, + "privateEndpointName": { + "type": "string" + }, + "location": { + "type": "string" + } + }, + "variables": {}, + "resources": [ + { + "name": "[concat(parameters('privateEndpointName'), '/deployedByPolicy')]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-04-01", + "location": "[parameters('location')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[replace(last(split(parameters('privateDnsZoneId'), '/')), '.', '-')]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneId')]" + } + } + ] + } + } + ], + "outputs": {} + }, + "parameters": { + "privateDnsZoneId": { + "value": "[parameters('privateDnsZoneId')]" + }, + "privateEndpointName": { + "value": "[field('name')]" + }, + "location": { + "value": "[field('location')]" + } + } + } + } + } + } + } + } +} diff --git a/policyDefinitions/web/pol-deny-fa-slots-app-traffic-via-public-network.json b/policyDefinitions/web/pol-deny-fa-slots-app-traffic-via-public-network.json index 408dc4f..a52bbdc 100644 --- a/policyDefinitions/web/pol-deny-fa-slots-app-traffic-via-public-network.json +++ b/policyDefinitions/web/pol-deny-fa-slots-app-traffic-via-public-network.json @@ -1,7 +1,7 @@ { "name": "pol-deny-fa-slots-app-traffic-via-public-network", "properties": { - "displayName": "Function app slots should route application traffic over the virtual network", + "displayName": "Function app slots should enable outbound non-RFC 1918 traffic to Azure Virtual Network", "description": "Application routing defines what traffic is routed from your app and into the virtual network.", "metadata": { "category": "Network Security", @@ -41,8 +41,36 @@ "notContains": "workflowapp" }, { - "field": "Microsoft.Web/sites/slots/vnetRouteAllEnabled", - "notEquals": "true" + "field": "kind", + "notContains": "azurecontainerapps" + }, + { + "anyOf": [ + { + "allOf": [ + { + "value": "[requestContext().apiVersion]", + "less": "2024-11-01" + }, + { + "field": "Microsoft.Web/sites/slots/vnetRouteAllEnabled", + "notEquals": "true" + } + ] + }, + { + "allOf": [ + { + "value": "[requestContext().apiVersion]", + "greaterOrEquals": "2024-11-01" + }, + { + "field": "Microsoft.Web/sites/slots/outboundVnetRouting.applicationTraffic", + "notEquals": "true" + } + ] + } + ] } ] }, diff --git a/policyDefinitions/web/pol-deny-fa-slots-config-traffic-via-public-network.json b/policyDefinitions/web/pol-deny-fa-slots-config-traffic-via-public-network.json index a48edfc..cd93b69 100644 --- a/policyDefinitions/web/pol-deny-fa-slots-config-traffic-via-public-network.json +++ b/policyDefinitions/web/pol-deny-fa-slots-config-traffic-via-public-network.json @@ -40,15 +40,51 @@ "field": "kind", "notContains": "workflowapp" }, + { + "field": "kind", + "notContains": "azurecontainerapps" + }, { "anyOf": [ { - "field": "Microsoft.Web/sites/slots/vnetImagePullEnabled", - "notEquals": "true" + "allOf": [ + { + "value": "[requestContext().apiVersion]", + "less": "2024-11-01" + }, + { + "anyOf": [ + { + "field": "Microsoft.Web/sites/slots/vnetImagePullEnabled", + "notEquals": "true" + }, + { + "field": "Microsoft.Web/sites/slots/vnetContentShareEnabled", + "notEquals": "true" + } + ] + } + ] }, { - "field": "Microsoft.Web/sites/slots/vnetContentShareEnabled", - "notEquals": "true" + "allOf": [ + { + "value": "[requestContext().apiVersion]", + "greaterOrEquals": "2024-11-01" + }, + { + "anyOf": [ + { + "field": "Microsoft.Web/sites/slots/outboundVnetRouting.imagePullTraffic", + "notEquals": "true" + }, + { + "field": "Microsoft.Web/sites/slots/outboundVnetRouting.contentShareTraffic", + "notEquals": "true" + } + ] + } + ] } ] } diff --git a/tests/policy-integration-tests/.shared/initiate-test.ps1 b/tests/policy-integration-tests/.shared/initiate-test.ps1 new file mode 100644 index 0000000..4491c75 --- /dev/null +++ b/tests/policy-integration-tests/.shared/initiate-test.ps1 @@ -0,0 +1,140 @@ +#Requires -Modules Az.Resources +#Requires -Version 7.0 + + +<# +======================================================= +AUTHOR: Tao Yang +DATE: 14/03/2026 +NAME: initiate-test.ps1 +VERSION: 1.0.0 +COMMENT: Initiate test for policy integration testing +======================================================= +#> + +[CmdletBinding()] +Param ( + + [Parameter(Mandatory = $true, HelpMessage = 'Specify the global configuration file path.')] + [string]$globalConfigFilePath, + + [Parameter(Mandatory = $true, HelpMessage = 'Specify the test directory.')] + [string]$TestDirectory +) + +#Get the variable values passed in from the pipeline (via environment variables) and set them as script level variables for later use in test scripts +$script:bicepDeploymentResult = $env:bicepDeploymentResult | ConvertFrom-Json -Depth 99 +$script:terraformDeploymentResult = $env:terraformDeploymentResult | ConvertFrom-Json -Depth 99 +$script:outputFilePath = $env:outputFilePath +$script:outputFormat = $env:outputFormat + +Write-Verbose "The following variables are passed in from pipeline and set as script level variables for later use in test scripts:" -verbose +Write-Verbose "bicepDeploymentResult:" -verbose +Write-Verbose $($script:bicepDeploymentResult | ConvertTo-Json -Depth 99) -verbose +Write-Verbose "terraformDeploymentResult:" -verbose +Write-Verbose $($script:terraformDeploymentResult | ConvertTo-Json -Depth 99) -verbose +Write-Verbose "outputFilePath: $script:outputFilePath" -verbose +Write-Verbose "outputFormat: $script:outputFormat" -verbose + +#if bicepDeploymentResult contains the bicepDeploymentOutputs and bicepProvisioningState properties, set them as script level variables for later use in test scripts, otherwise set them to $null +if ($null -ne $script:bicepDeploymentResult.PSObject.Properties['bicepDeploymentOutputs']) { + $script:bicepDeploymentOutputs = $script:bicepDeploymentResult.bicepDeploymentOutputs | ConvertFrom-Json -Depth 99 +} else { + $script:bicepDeploymentOutputs = [PSCustomObject]@{} +} + +if ($null -ne $script:bicepDeploymentResult.PSObject.Properties['bicepProvisioningState']) { + $script:bicepProvisioningState = $script:bicepDeploymentResult.bicepProvisioningState + Write-Verbose "bicepProvisioningState: $script:bicepProvisioningState" -verbose +} else { + $script:bicepProvisioningState = $null +} +Write-Verbose "bicepDeploymentOutputs:" -verbose +Write-Verbose $($script:bicepDeploymentOutputs | ConvertTo-Json -Depth 99) -verbose + + +#if terraformDeploymentResult contains the terraformDeploymentOutputs and terraformProvisioningState properties, set them as script level variables for later use in test scripts, otherwise set them to $null +if ($null -ne $script:terraformDeploymentResult.PSObject.Properties['terraformDeploymentOutputs']) { + $script:terraformDeploymentOutputs = $script:terraformDeploymentResult.terraformDeploymentOutputs | ConvertFrom-Json -Depth 99 +} else { + $script:terraformDeploymentOutputs = [PSCustomObject]@{} +} + +if ($null -ne $script:terraformDeploymentResult.PSObject.Properties['terraformProvisioningState']) { + $script:terraformProvisioningState = $script:terraformDeploymentResult.terraformProvisioningState + Write-Verbose "terraformProvisioningState: $script:terraformProvisioningState" -verbose +} else { + $script:terraformProvisioningState = $null +} + +Write-Verbose "terraformDeploymentOutputs:" -verbose +Write-Verbose $($script:terraformDeploymentOutputs | ConvertTo-Json -Depth 99) -verbose + + +#load helper functions +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path +. $helperFunctionScriptPath + +$globalConfigVariableNamePrefix = 'GlobalConfig_' +$localConfigVariableNamePrefix = 'LocalConfig_' + +#Generate Azure oauth token +$script:token = (az account get-access-token --resource https://management.azure.com/ --query accessToken -o tsv) + +If (-not $script:token) { + throw "Failed to acquire Azure access token. Please sign in to Azure using Azure CLI." +} + +#load Global config +$globalTestConfig = getTestConfig -TestConfigFilePath $globalConfigFilePath + +#create an variable for each config from global config for later use in test scripts +Write-Output "Loading global config from file: $globalConfigFilePath" +foreach ($config in $globalTestConfig.GetEnumerator()) { + $name = $globalConfigVariableNamePrefix + $config.Key + # Set variable + Set-Variable -Name $name -Value $config.Value -Scope Script +} + +#load Local config +$testLocalConfigFileName = $script:GlobalConfig_testLocalConfigFileName +$localConfigFilePath = Join-Path $TestDirectory $testLocalConfigFileName +$localTestConfig = getTestConfig -TestConfigFilePath $localConfigFilePath + +#create an variable for each config from local config for later use in test scripts +Write-Output "Loading local config from file: $localConfigFilePath" +foreach ($config in $localTestConfig.GetEnumerator()) { + + $name = $localConfigVariableNamePrefix + $config.Key + # Set variable + Set-Variable -Name $name -Value $config.Value -Scope Script +} +#Tags for resource group +if (!$script:LocalConfig_tagsForResourceGroup) { + $script:LocalConfig_tagsForResourceGroup = $false +} + +#Additional calculated variables +$script:whatIfComplyBicepTemplatePath = Join-Path $TestDirectory $script:GlobalConfig_whatIfComplyBicepTemplateName +$script:whatIfViolateBicepTemplatePath = Join-Path $TestDirectory $script:GlobalConfig_whatIfViolateBicepTemplateName +$script:terraformBackendStateFileDirectory = Join-Path $TestDirectory 'tf-state' +$script:terraformViolateDirectoryPath = Join-Path $TestDirectory $script:GlobalConfig_terraformViolateDirectoryName +$script:terraformComplyDirectoryPath = Join-Path $TestDirectory $script:GlobalConfig_terraformComplyDirectoryName +$script:testTerraformDirectoryPath = join-path $TestDirectory $script:GlobalConfig_testTerraformDirectoryName +$script:testTitle = "$script:LocalConfig_testName Configuration Test" +$script:contextTitle = "$script:LocalConfig_testName Configuration" +$script:testSuiteName = $script:LocalConfig_testName +$testSubscriptionName = $script:LocalConfig_testSubscription + +Write-Verbose "Test Subscription Name: $testSubscriptionName" -Verbose +$script:testSubscriptionId = $script:GlobalConfig_subscriptions.$testSubscriptionName.id +Write-Verbose "Test Subscription ID: $script:testSubscriptionId" -Verbose +$script:testSubscriptionConfig = $script:GlobalConfig_subscriptions.$testSubscriptionName + +#Set the environment variable 'ARM_SUBSCRIPTION_ID' to the test subscription id so that terraform can pick it up for authentication +$env:ARM_SUBSCRIPTION_ID = $script:testSubscriptionId + +if ($script:LocalConfig_testResourceGroup.length -gt 0) { + $script:testResourceGroupId = '/subscriptions/{0}/resourceGroups/{1}' -f $script:testSubscriptionId, $script:LocalConfig_testResourceGroup + Write-Verbose "Test Resource Group ID: $script:testResourceGroupId" -Verbose +} diff --git a/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc b/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc new file mode 100644 index 0000000..31de403 --- /dev/null +++ b/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc @@ -0,0 +1,142 @@ +{ + "tags": { + "owner": "cloud-platform-team", + "dataclass": "official", + "supportteam": "cloud-platform-team", + "environment": "dev", + "appid": "00000" + }, //tags to be applied to all resources created by the tests + "namePrefix": "typoltst", //prefix to be applied to the names of all resources created by the tests. This helps to easily identify and filter test resources in the Azure portal. + "deploymentPrefix": "typoltst", //prefix to be applied to the names of all deployments created by the tests. This helps to easily identify and filter test deployments in the Azure portal. + "privateDNSSubscription": "sub-d-connectivity-01", //subscription where the private DNS zones are located. This is needed to create private endpoints in the tests. + "privateDNSResourceGroup": "rg-ae-d-net-hub", //resource group where the private DNS zones are located. This is needed to create private endpoints in the tests. + "testBicepTemplateName": "main.test.bicep", //name of the bicep template used for testing. This template should be located in the directory of each test case. if it is not found, the test will not deploy any Bicep templates during testing. + "whatIfViolateBicepTemplateName": "main.bad.bicep", //name of the bicep template used for What-If deployments that are expected to violate the policy. This template should be located in the directory of each test case. + "whatIfComplyBicepTemplateName": "main.good.bicep", //name of the bicep template used for What-If deployments that are expected to comply with the policy. This template should be located in the directory of each test case. + "testTerraformDirectoryName": "main-test-terraform", //name of the directory containing the terraform configuration used for testing. This directory should be located in the directory of each test case. If not found, the test will not deploy any Terraform configurations during testing. + "terraformViolateDirectoryName": "main-bad-terraform", //name of the terraform directory used for validate against Policy Violation API that expected to violate deny or audit policies. + "terraformComplyDirectoryName": "main-good-terraform", //name of the terraform directory used for validate against Policy Violation API that expected to comply with the policy. + "testTerraformBackendConfigFileName": "backend.tf", //Name of the terraform file that contains the backend configuration. This file should be located in the 'testTerraformDirectoryName' directory. + "testTerraformStateFileName": "terraform_state.tfstate", //Terraform state file name + "testTerraformEncryptedStateFileName": "terraform_state.enc", //Encrypted terraform state file name + "testLocalConfigFileName": "config.json", //name of the local configuration file used for testing. This file will be created in the same directory as the test scripts during testing and will contain the configuration for the tests. + "initialEvalMaximumWaitTime": 20, //maximum wait time in minutes for the initial evaluation of the policy assignment to complete. This is needed to ensure that the tests do not proceed until the policy assignment is fully evaluated and any non-compliant resources are identified. + "waitTimeForPolicyComplianceStateAfterDeployment": 15, //Minutes to wait after the resource deployment for testing audit policies before proceeding with the tests. This is needed to ensure that the compliance state of resources is updated before the tests check for compliance. + "waitTimeForAppendModifyPoliciesAfterDeployment": 1, //Minutes to wait after the resource deployment for testing append and modify policies before proceeding with the tests. This is needed to ensure that the policies are fully deployed and in effect before the tests check for compliance. + "waitTimeForDeployIfNotExistsPoliciesAfterDeployment": 5, //Minutes to wait after the resource deployment for testing deployIfNotExists policies before proceeding with the tests. This is needed to ensure that the policies are fully deployed and have had enough time to create any necessary resources before the tests check for compliance. + "whatIfMaxRetry": 5, //maximum number of retries for the What-If deployment operation in case of transient failures. This is needed to improve the reliability of the tests by allowing for retries in case of temporary issues with the deployment. + "testScriptName": "tests.ps1", //Name of the test script used for testing. This script should be located in the directory of each test case. + "testOutputFilePrefix": "TEST-Resource-Config", //prefix to be applied to the names of the output files generated by the tests. This helps to easily identify and filter test output files in the Azure portal and in the test results. + "testOutputFormat": "NUnitXml", //Pester test output format + //prefix for the Azure pipeline build artifact that contains the Bicep deployment result output files generated during the test runs + "testBicepDeploymentOutputArtifactPrefix": "intTestBicepDeployResult", + //prefix for the Azure pipeline build artifact that contains the Terraform deployment result output files generated during the test runs + "testTerraformDeploymentOutputArtifactPrefix": "intTestTFDeployResult", + //Name of the file that contains the Bicep or Terraform deployment result output generated during the test runs. + "testDeploymentOutputFileName": "result.json", + "diagnosticSettingsAPIVersion": "2021-05-01-preview", //API version for diagnostic settings + "resourceGroupApiVersion": "2025-04-01", //API version for resource groups + "privateDNSZoneGroupAPIVersion": "2025-05-01", //API version for private DNS zone groups + "privateEndpointAPIVersion": "2025-05-01", //API version for private endpoints + "vnetFlowLogApiVersion": "2024-07-01", //API version for VNet flow logs + "appServicesAPIVersion": "2025-03-01", //API version for App Services + "diagnosticSettingsIdSuffix": "/providers/microsoft.insights/diagnosticSettings/setByPolicyLAW", //suffix for the resource ID of the diagnostic settings created by the policy. This is used for testing of the diagnostic settings DINE policies + "privateEndpointPrivateDNSZoneGroupIdSuffix": "/privateDnsZoneGroups/deployedByPolicy", //suffix for the resource ID of the private DNS zone group created by the policy for private endpoints. This is used for testing of the private endpoint DINE policies + //Subscriptions and details about the each subscription used in the tests. This is needed to provide the necessary information for the tests to create and manage resources in the correct subscriptions and resource groups. + "subscriptions": { + "sub-d-mgmt-01": { + "id": "f27ab1cb-9c1a-4bdb-9e18-bb11ec9205db", + "networkResourceGroup": "rg-ae-d-net-spoke", + "vNet": "vnet-ae-d-mgmt-01", + "peSubnet": "sn-private-endpoint", + "resourceSubnet": "sn-vm" + }, + "sub-d-connectivity-01": { + "id": "3cea5943-a7f2-4991-ba22-19e240671e63", + "networkResourceGroup": "rg-ae-d-net-hub" + }, + "sub-d-lz-corp-01": { + "id": "dc2d72b7-a48d-45e8-91cc-81193ecc659b", + "networkResourceGroup": "rg-ae-d-net-spoke", + "vNet": "vnet-ae-d-lz-corp-01", + "peSubnet": "sn-private-endpoint", + "resourceSubnet": "sn-vm" + }, + "sub-d-lz-online-01": { + "id": "f793e6e7-9219-4d57-8582-89bb7be49bba", + "networkResourceGroup": "rg-ae-d-net-spoke", + "vNet": "vnet-ae-d-lz-online-01", + "peSubnet": "sn-private-endpoint", + "resourceSubnet": "sn-vm" + } + }, + //Configurations for triggers for the Azure Pipeline for Policy integration tests. + // The testTriggers configuration helps the pipeline to automatically determine what test cases need to be executed based on the files that have been changed in a pull request. + "testTriggers": { + //Paths to the policy definitions, initiatives, and assignments in the repository. + // The pipeline will look at the files that have been changed in a pull request and check if any of the changed files are under these paths. + // If there are changes under these paths, the pipeline will trigger the corresponding tests that are related to the changed files. + // For example, if a policy definition file is changed under the "policyDefinitions" path, the pipeline will trigger the tests that are related to that policy definition. + "policyDefinitionsPath": "policyDefinitions", + "policyInitiativesPath": "policyInitiatives", + "policyAssignmentsPath": "policyAssignments/dev", + //The path to the policy integration tests in the repository. + // Each sub folder represents a test case. + "policyIntegrationTestsPath": "tests/policy-integration-tests", + //For the configuration file for each policy assignment json file, the property that represents the name of the policy assignment. This is used by the test framework to identify the policy assignment during the test runs. + "policyAssignmentConfigurationJsonPathForAssignmentName": "name", + //For the configuration file for each policy assignment json file, the property that represents the ID of the policy definition or initiative that is assigned. + //This is used by the test framework to look up the corresponding policy definition or initiative during the test runs. + "policyAssignmentConfigurationJsonPathForPolicyDefinitionId": "policyDefinitionId", + //The globalTestPaths contains the list of files that if any of them are changed in a pull request, the pipeline will trigger all the policy integration test cases. + //This is useful for changes that may impact multiple test cases or the overall test framework, such as changes to the test scripts, helper functions, or the configuration file itself. + "globalTestPaths": [ + "tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc", + "bicep/templates/policyAssignments", + "bicep/templates/policyDefinitions", + "bicep/templates/policyInitiatives", + "bicep/modules/authorization/policy-assignment", + "bicep/modules/authorization/policy-definition", + "bicep/modules/authorization/policy-set-definition", + "bicep/modules/authorization/role-assignment", + ".azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml", + ".azuredevops/pipelines/policies/azure-pipelines-policy-assignments.yml", + ".azuredevops/pipelines/policies/azure-pipelines-policy-definitions.yml", + ".azuredevops/pipelines/policies/azure-pipelines-policy-initiatives.yml", + ".azuredevops/templates/template-stage-initiation.yml", + ".azuredevops/templates/template-stage-policy-assignment-exemption-build.yml", + ".azuredevops/templates/template-job-test-and-validate.yml", + ".azuredevops/templates/template-job-get-parameter-files.yml", + ".azuredevops/templates/template-stage-multiple-deployments.yml", + ".azuredevops/templates/template-stage-policy-def-build.yml", + ".azuredevops/templates/template-stage-policy-tests.yml", + ".azuredevops/templates/template-job-detect-policy-integration-test-cases.yml", + ".azuredevops/templates/template-job-get-sub-directories.yml", + ".azuredevops/templates/template-task-install-ps-modules.yml", + ".azuredevops/templates/template-stage-policy-integration-tests.yml", + "scripts/pipelines/helper", + "scripts/pipelines/policy-integration-tests", + "scripts/pipelines/pipeline-get-deployment-target-from-parameter-file.ps1", + "scripts/pipelines/pipeline-get-parameter-files.ps1", + "scripts/pipelines/pipeline-install-moduleFromRepo.ps1", + "scripts/pipelines/pipeline-template-deployment.ps1", + "scripts/pipelines/pipeline-template-deployment-rest.ps1", + "scripts/pipelines/pipeline-template-validation.ps1", + "scripts/pipelines/pipeline-set-policy-resource-bicep-template-file.ps1", + "scripts/pipelines/pipeline-set-policy-non-compliance-messages.ps1" + ], + //changes to the following files will be ignored and will not trigger any tests. + "ignoredFiles": [ + "*.md", + ".gitignore", + "markdownlint.json", + "ps-rule.yaml", + "bicepconfig.json", + "*.xml", + "*.nuspec", + ".testignore", + "LICENSE", + ".DS_Store" + ] + } +} diff --git a/tests/policy-integration-tests/.test-template/.testignore b/tests/policy-integration-tests/.test-template/.testignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/policy-integration-tests/.test-template/README.md b/tests/policy-integration-tests/.test-template/README.md new file mode 100644 index 0000000..521a11e --- /dev/null +++ b/tests/policy-integration-tests/.test-template/README.md @@ -0,0 +1,57 @@ +# Test Template for Azure Policy Integration Tests + +## Introduction + +This folder contains a template for creating Azure Policy integration tests. You can copy the entire folder and use it as a starting point for your own tests. + +## Instructions + +### 1. Copy the `test-template` folder and rename it to something relevant to your test case + +Make a copy of this folder and give it a name that reflects the specific policy or scenario you are testing. For example, if you are testing a policy related to storage accounts, you could name the folder `storage-account`. + +### 2. Update the local configuration file (`config.json`) with the specific settings for your test case + +Use the document [Policy Integration Tests - Local Configuration](../TEST-LOCAL-CONFIG.md) as a reference to understand the mandatory and optional variables that you need to define in the [`config.json`](config.json) file for your test case. + +### 3. Define the Bicep and/or Terraform templates for your test case + +- If you plan to use Bicep or Terraform templates to deploy test resources for `Audit`, `AuditIfNotExists`, `Append`, `Modify`, or `DeployIfNotExists` policies, make sure to create the necessary templates and place them in the test case folder. + + - For Bicep template: Finalize [`main.test.bicep`](main.test.bicep) for the resources to be deployed during testing. + - For Terraform: Finalize the Terraform files in the [`main-test-terraform`](main-test-terraform) directory for the resources to be deployed during testing. + +- If you plan to use Bicep templates for testing `Deny` policies, define the [`main.bad.bicep`](main.bad.bicep) template for the resources that violate the policy, and optionally the [`main.good.bicep`](main.good.bicep) template for the resources that comply with the policy. Together, these define both positive and negative scenarios. +- If you plan to use Terraform for testing `Deny` or `Audit` policies against the Policy restriction API, define the Terraform files in the [`main-bad-terraform`](main-bad-terraform) directory for the resources that violate the policy, and optionally the Terraform files in the [`main-good-terraform`](main-good-terraform) directory for the resources that comply with the policy. Together, these define both positive and negative scenarios. + +### 4. Update the test script (`tests.ps1`) with the specific test logic for your test case + +Update the [`tests.ps1`](tests.ps1) file with the specific test logic for your test case. Use the example tests as references. + +The `tests.ps1` script has prepopulated sections that are common for all test cases. You will need to populate the `#region defining tests` section with the specific tests for your test case. You can also add additional sections as needed for your test logic. + +Use the documents [Policy Integration Tests - Local Configuration](../TEST-LOCAL-CONFIG.md) and [Policy Integration Tests - Global Configuration](../TEST-GLOBAL-CONFIG.md) as references to understand how to access the configuration variables in your test script and to ensure that your test logic aligns with the configurations defined in both the local and global configuration files. + +### 4. Delete the `.testignore` file if it exists + +Make sure to delete the `.testignore` file in the test case folder if it exists. This file is used to exclude the test case from being executed in the test pipelines. Deleting this file ensures that your test case will be included in the test runs. + +## Executing the Tests + +### Prerequisites + +Prior to executing the tests, ensure that you have the following prerequisites in place: + +- The policy assignments that you intend to test are already created and properly configured in your Azure environment and already finished the initial policy evaluation. This is crucial to ensure that the tests can accurately validate the compliance state of the deployed resources against the policies. +- Have the Azure PowerShell module installed on the computer where you will be running the tests. +- If you are using Bicep templates in your tests, ensure that the standalone version of the Bicep CLI is installed and properly configured on your machine. +- If you are using Terraform templates in your tests, ensure that Terraform is installed and properly configured on your machine. +- If you are using Terraform templates, ensure Azure CLI is installed and properly configured on your machine, as it is required for authentication when running Terraform commands that interact with Azure. +- The identity running the tests has sufficient permissions to deploy resources in the target subscription and resource group. +- The identity running the tests has at least `Reader` role on the tenant root management group level. This is required to ensure the `AzResourceTest` module can retrieve information from various APIs. +- Signed in to Azure using `Connect-AzAccount`. +- If you are using Terraform templates, also sign in to Azure CLI using `az login`. + +### Running the Tests + +The test will be automatically executed as part of the Azure Policy integration test pipelines in Azure DevOps. diff --git a/tests/policy-integration-tests/.test-template/config.json b/tests/policy-integration-tests/.test-template/config.json new file mode 100644 index 0000000..8f4888b --- /dev/null +++ b/tests/policy-integration-tests/.test-template/config.json @@ -0,0 +1,16 @@ +{ + "policyAssignmentIds": [ + ], + "testName": "SpecifyTestName", + "assignmentName": "", + "testSubscription": "", + "testResourceGroup": "", + "testManagementGroup": "", + "location": "", + "tagsForResourceGroup": false, + "testAuditPoliciesFromDeployedResources": false, + "testAppendModifyPolicies": false, + "testDeployIfNotExistsPolicies": false, + "testDenyPolicies": true, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/.test-template/main-bad-terraform/main.tf b/tests/policy-integration-tests/.test-template/main-bad-terraform/main.tf new file mode 100644 index 0000000..e9efd5e --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-bad-terraform/main.tf @@ -0,0 +1 @@ +// define terraform resources (must use AzAPI resource provider) diff --git a/tests/policy-integration-tests/.test-template/main-bad-terraform/outputs.tf b/tests/policy-integration-tests/.test-template/main-bad-terraform/outputs.tf new file mode 100644 index 0000000..95baaeb --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-bad-terraform/outputs.tf @@ -0,0 +1 @@ +//define terraform outputs diff --git a/tests/policy-integration-tests/.test-template/main-bad-terraform/providers.tf b/tests/policy-integration-tests/.test-template/main-bad-terraform/providers.tf new file mode 100644 index 0000000..633fc98 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-bad-terraform/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">=1.14.6" + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.5" + } + } +} +# Configure the Microsoft Azure Provider +provider "azapi" { + subscription_id = "" # specify the subscription where the resources will be deployed. +} diff --git a/tests/policy-integration-tests/.test-template/main-bad-terraform/variables.tf b/tests/policy-integration-tests/.test-template/main-bad-terraform/variables.tf new file mode 100644 index 0000000..2e3e7ac --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-bad-terraform/variables.tf @@ -0,0 +1 @@ +//define variables diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/data.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/data.tf new file mode 100644 index 0000000..f6af5ea --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/data.tf @@ -0,0 +1,16 @@ +//Get existing resources. change as required +data "azapi_resource" "vnet_rg" { + type = "Microsoft.Resources/resourceGroups@2025-04-01" + name = local.vnetResourceGroupName +} +data "azapi_resource" "vnet" { + type = "Microsoft.Network/virtualNetworks@2024-10-01" + name = local.vnetName + parent_id = data.azapi_resource.vnet_rg.id +} + +data "azapi_resource" "pe_subnet" { + type = "Microsoft.Network/virtualNetworks/subnets@2024-10-01" + name = local.peSubnetName + parent_id = data.azapi_resource.vnet.id +} diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/local.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/local.tf new file mode 100644 index 0000000..832f9d2 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/local.tf @@ -0,0 +1,11 @@ +//read the configuration files and define required variables. +//You can add more variables based on your needs. +locals { + globalconfig = jsondecode(replace(file("${path.module}/../../.shared/policy_integration_test_config.jsonc"), "/\\/\\/[^\\n]*/", "")) + localconfig = jsondecode(file("${path.module}/../config.json")) + subName = local.localconfig.testSubscription + vnetName = local.globalconfig.subscriptions[local.subName].vNet + vnetResourceGroupName = local.globalconfig.subscriptions[local.subName].networkResourceGroup + peSubnetName = local.globalconfig.subscriptions[local.subName].peSubnet + location = local.localconfig.location +} diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/main.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/main.tf new file mode 100644 index 0000000..8444624 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/main.tf @@ -0,0 +1 @@ +//Define terraform resources diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/outputs.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/outputs.tf new file mode 100644 index 0000000..1a99a1f --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/outputs.tf @@ -0,0 +1,2 @@ +// Define terraform outputs. +// These outputs will be used in the test diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/providers.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/providers.tf new file mode 100644 index 0000000..adc7da0 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">=1.14.6" + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.5" + } + } +} +# Configure the Microsoft Azure Provider +provider "azapi" { + subscription_id = "" // specify the subscription where the resources will be deployed. +} diff --git a/tests/policy-integration-tests/.test-template/main-test-terraform/variables.tf b/tests/policy-integration-tests/.test-template/main-test-terraform/variables.tf new file mode 100644 index 0000000..2e3e7ac --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main-test-terraform/variables.tf @@ -0,0 +1 @@ +//define variables diff --git a/tests/policy-integration-tests/.test-template/main.bad.bicep b/tests/policy-integration-tests/.test-template/main.bad.bicep new file mode 100644 index 0000000..7a94149 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main.bad.bicep @@ -0,0 +1,16 @@ +metadata itemDisplayName = 'Test Template for xxx' +metadata description = 'This template deploys the testing resource for xxx.' +metadata summary = 'Deploys test xxx resources that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') + +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'xxx3' diff --git a/tests/policy-integration-tests/.test-template/main.good.bicep b/tests/policy-integration-tests/.test-template/main.good.bicep new file mode 100644 index 0000000..4975278 --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main.good.bicep @@ -0,0 +1,16 @@ +metadata itemDisplayName = 'Test Template for xxxx' +metadata description = 'This template deploys the testing resource for xxxx.' +metadata summary = 'Deploys test xxxx resources that should comply with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') + +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'xxx3' diff --git a/tests/policy-integration-tests/.test-template/main.test.bicep b/tests/policy-integration-tests/.test-template/main.test.bicep new file mode 100644 index 0000000..8e177af --- /dev/null +++ b/tests/policy-integration-tests/.test-template/main.test.bicep @@ -0,0 +1,30 @@ +metadata itemDisplayName = 'Test Template for xxx' +metadata description = 'This template deploys the testing resource for xxx.' +metadata summary = 'Deploys test xxx resources.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +//Define required variables from the configuration files - change these based on your requirements +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var subName = localConfig.testSubscription +var vnetResourceGroup = globalConfig.subscriptions[subName].networkResourceGroup +var vnetName = globalConfig.subscriptions[subName].vNet +var peSubnetName = globalConfig.subscriptions[subName].peSubnet +var resourceSubnetName = globalConfig.subscriptions[subName].resourceSubnet + +var serviceShort = 'xxx1' //use this to form the name of the resources deployed by this template. This is helpful to identify the resource in the portal and also useful if you want to have a policy that targets specific resources by name. For example, if you have a policy that audits whether storage accounts have secure transfer enabled, you can set serviceShort to 'st' and then in the policy definition, you can target resources with name starting with 'st' to only audit the storage accounts deployed by this test template. + +// ============ // +// resources // +// ============ // + +// ============ // +// outputs // +// ============ // +//Specify the outputs that are required for the test diff --git a/tests/policy-integration-tests/.test-template/tests.ps1 b/tests/policy-integration-tests/.test-template/tests.ps1 new file mode 100644 index 0000000..c1cbaae --- /dev/null +++ b/tests/policy-integration-tests/.test-template/tests.ps1 @@ -0,0 +1,44 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region defining tests +<# +The following policy definitions are tested:. + - List the policy definitions being tested here. +#> + +#define tests +$tests = @() + +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/README.md b/tests/policy-integration-tests/README.md new file mode 100644 index 0000000..db77f2c --- /dev/null +++ b/tests/policy-integration-tests/README.md @@ -0,0 +1,164 @@ +# Azure Policy Integration Tests + +## Overview + +This directory contains integration tests for Azure Policy assignments. Each subfolder (except `.shared` and `test-template`) represents a test case targeting specific policy definitions or initiatives. + +The `test-template` folder provides a starting point for creating new tests. Copy it, customise the configuration and templates, then define your test assertions. + +## Developing a New Test + +### 1. Copy the test template + +Copy the `test-template` folder and rename it to reflect the policy or scenario being tested (e.g. `storage-account`, `key-vault`). + +### 2. Update the local configuration (`config.json`) + +Edit `config.json` in the new folder. The file supports the following properties: + +| Property | Required | Description | +| :------- | :------: | :---------- | +| `policyAssignmentIds` | Yes | Array of policy assignment resource IDs to evaluate during testing. These policy assignments are the pre-requisites for the test. Test will not start until they have been initially evaluated after creation. | +| `testName` | Yes | A short name for the test case. Used in test titles and output file names. | +| `assignmentName` | No | The name of the primary policy assignment being tested. | +| `testSubscription` | Yes | The name of the subscription (as defined in the global config `subscriptions` map) to deploy test resources into. | +| `testResourceGroup` | No | The resource group name for testing. If specified, a `$script:testResourceGroupId` variable is calculated. | +| `testManagementGroup` | No | The management group name that are used to form the policy assignment scope in the `tests.ps1` script. | +| `location` | No | Azure region for resource deployments. | +| `tagsForResourceGroup` | No | Boolean. Whether to apply tags to the test resource group. Defaults to `false`. | +| `removeTestResourceGroup` | Yes | Boolean. Whether to remove the test resource group after the test run. Defaults to `true`. | + +You can also add custom properties (e.g. `diagSettingsAssignmentName`) and access them in `tests.ps1` as `$script:LocalConfig_`. + +### 3. Define Bicep and/or Terraform templates + +Depending on the policy effects you intend to test, create one or more of the following: + +| File / Directory | Purpose | +|---|---| +| `main.test.bicep` | Bicep template that deploys test resources for `Audit`, `AuditIfNotExists`, `Append`, `Modify`, or `DeployIfNotExists` policies. | +| `main.bad.bicep` | Bicep template for What-If deployments that are **expected to violate** `Deny` policies. | +| `main.good.bicep` | Bicep template for What-If deployments that are **expected to comply** with policies. | +| `main-test-terraform/` | Terraform configuration that deploys test resources (equivalent to `main.test.bicep`). | +| `main-bad-terraform/` | Terraform configuration **expected to violate** `Deny` or `Audit` policies via the Policy Violation API. | +| `main-good-terraform/` | Terraform configuration **expected to comply** with policies via the Policy Violation API. | + +Templates can load configuration values from the global config (`../.shared/policy_integration_test_config.jsonc`) and the local config (`config.json`). See the template files in `test-template` for examples. + +### 4. Write the test script (`tests.ps1`) + +The `tests.ps1` template has a pre-populated generic section (`#region generic sections for all tests`) that loads configuration and sets up variables. **Do not modify this section.** + +Populate the `#region test specific configuration and tests` section with your test assertions. Build an array of test configuration objects using the helper functions from the `AzResourceTest` module (e.g. `New-ARTPropertyCountTestConfig`, `New-ARTResourceExistenceTestConfig`, `New-ARTPolicyStateTestConfig`), then pass them to `Test-ARTResourceConfiguration`. + +### 5. Delete the `.testignore` file + +Remove the `.testignore` file from your new test folder so the test pipeline includes it in automated runs. + +## Variables Available to Tests + +After the generic initialisation section in `tests.ps1` runs, the following script-scoped variables are available for use in the `#region test specific configuration and tests` section. + +### Configuration Variables + +All properties from the global and local config files are loaded as script-scoped variables with a prefix: + +- **Global config** properties are prefixed with `GlobalConfig_`, e.g. `$script:GlobalConfig_deploymentPrefix` +- **Local config** properties are prefixed with `LocalConfig_`, e.g. `$script:LocalConfig_testResourceGroup` + +### Pre-calculated Variables + +| Variable | Description | +| :------- | :---------- | +| `$script:token` | Azure OAuth token for the Azure control plane API endpoint (`https://management.azure.com/`). | +| `$script:testSubscriptionId` | The subscription ID for testing, resolved from the subscription name in the local config against the global config. | +| `$script:testSubscriptionConfig` | The full subscription configuration object for the test subscription as defined in the global config. | +| `$script:testResourceGroupId` | The resource group resource ID for testing. Set only if `testResourceGroup` is defined in the local config. | +| `$script:bicepDeploymentResult` | The result object from the test Bicep template deployment (see below for schema). | +| `$script:bicepDeploymentOutputs` | The outputs from the Bicep template deployment. Empty `PSCustomObject` if no Bicep template was deployed. | +| `$script:bicepProvisioningState` | The provisioning state from the Bicep template deployment. `$null` if no Bicep template was deployed. | +| `$script:terraformDeploymentResult` | The result object from the test Terraform template deployment (see below for schema). | +| `$script:terraformDeploymentOutputs` | The outputs from the Terraform template deployment. Empty `PSCustomObject` if no Terraform template was deployed. | +| `$script:terraformProvisioningState` | The provisioning state from the Terraform template deployment. `$null` if no Terraform template was deployed. | +| `$script:testTitle` | Pester test title, set to `" Configuration Test"`. | +| `$script:contextTitle` | Pester context title, set to `" Configuration"`. | +| `$script:testSuiteName` | Test suite name for output, set to the `testName` from the local config. | +| `$script:outputFilePath` | File path for the test result output XML file. | +| `$script:whatIfComplyBicepTemplatePath` | Absolute path to the Bicep template for What-If deployments expected to **comply** with the policy. | +| `$script:whatIfViolateBicepTemplatePath` | Absolute path to the Bicep template for What-If deployments expected to **violate** the policy. | +| `$script:testTerraformDirectoryPath` | Absolute path to the directory containing the Terraform configuration used for testing. | +| `$script:terraformBackendStateFileDirectory` | Directory path for the Terraform backend state file (`/tf-state`). | +| `$script:terraformViolateDirectoryPath` | Absolute path to the Terraform configuration expected to **violate** deny or audit policies. | +| `$script:terraformComplyDirectoryPath` | Absolute path to the Terraform configuration expected to **comply** with the policy. | +| `$env:ARM_SUBSCRIPTION_ID` | Environment variable for Terraform authentication, set to the test subscription ID. | + +### `$script:bicepDeploymentResult` Schema + +When **no** Bicep templates were deployed: + +| Property | Description | +|---|---| +| `bicepRemoveTestResourceGroup` | Boolean indicating whether to remove the test resource group. | +| `bicepTestSubscriptionId` | The subscription ID for testing. | +| `bicepTestResourceGroup` | The resource group name for testing. | + +When a Bicep template **is** deployed, the object also includes: + +| Property | Description | +|---|---| +| `bicepDeploymentOutputs` | The outputs from the Bicep template deployment. | +| `bicepProvisioningState` | The provisioning state from the Bicep template deployment. | +| `bicepDeploymentTarget` | The deployment target, usable for policy evaluation via the Policy Insights API or Policy Violation API. | + +### `$script:terraformDeploymentResult` Schema + +When **no** Terraform templates were deployed: + +| Property | Description | +|---|---| +| `terraformDeployment` | `false` — no Terraform deployment was performed. | + +When a Terraform template **is** deployed: + +| Property | Description | +|---|---| +| `terraformDeployment` | `true` — Terraform deployment was performed. | +| `terraformDeploymentOutputs` | The outputs from the Terraform template deployment. | +| `terraformProvisioningState` | The provisioning state from the Terraform template deployment. | + +## Prerequisites + +- Policy assignments under test are already created and have completed initial evaluation in the target Azure environment. +- Azure PowerShell modules installed: `Az.Accounts`, `Az.PolicyInsights`, `Az.Resources`. +- PowerShell 7.0 or later. +- The `AzResourceTest` PowerShell module installed. +- Bicep CLI installed (if using Bicep templates). +- Terraform installed (if using Terraform templates). +- Azure CLI installed and signed in (if using Terraform templates). +- Signed in to Azure via `Connect-AzAccount`. +- The executing identity has permissions to deploy resources in the target subscription and at least `Reader` on the tenant root management group. + +## Running the Tests + +Tests are executed automatically by the Azure Policy integration test pipelines in Azure DevOps. Each test folder (that does not contain a `.testignore` file) is picked up and run as part of the pipeline. + +## Folder Structure + +``` +tests/policy-integration-tests/ +├── .shared/ # Shared config and initiation script +│ ├── policy_integration_test_config.jsonc # Global test configuration +│ └── initiate-test.ps1 # Test initialisation script +├── test-template/ # Template for new tests — copy this +│ ├── .testignore # Marker to exclude from pipeline runs +│ ├── config.json # Local test configuration +│ ├── main.test.bicep # Bicep template for resource deployment +│ ├── main.good.bicep # Bicep template expected to comply +│ ├── main.bad.bicep # Bicep template expected to violate +│ ├── main-test-terraform/ # Terraform config for resource deployment +│ ├── main-bad-terraform/ # Terraform config expected to violate +│ └── tests.ps1 # Test script +├── acr/ # Example: ACR policy tests +├── ... # Other test cases +└── README.md # This file +``` diff --git a/tests/policy-integration-tests/acr/config.json b/tests/policy-integration-tests/acr/config.json new file mode 100755 index 0000000..b42e174 --- /dev/null +++ b/tests/policy-integration-tests/acr/config.json @@ -0,0 +1,14 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-pedns", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "ACR", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-acr-001", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "peDNSAssignmentName": "pa-d-pedns", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/acr/main.test.bicep b/tests/policy-integration-tests/acr/main.test.bicep new file mode 100755 index 0000000..12009cc --- /dev/null +++ b/tests/policy-integration-tests/acr/main.test.bicep @@ -0,0 +1,126 @@ +metadata itemDisplayName = 'Test Template for Container Registry' +metadata description = 'This template deploys the testing resource for Container Registry.' +metadata summary = 'Deploys test Container Registry resource.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +//var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'acr1' +var acrName = 'acr${namePrefix}${serviceShort}01' +var routeTableName = 'rt-${namePrefix}-${serviceShort}-01' +var vnetName = 'vnet-${namePrefix}-${serviceShort}-01' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' +var peName = 'pe-${namePrefix}${serviceShort}-01' + +var groupName = 'registry' + +resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-01-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '443' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + access: 'Allow' + priority: 200 + direction: 'Inbound' + } + } + ] + } +} + +resource routeTable 'Microsoft.Network/routeTables@2024-01-01' = { + name: routeTableName + location: location + properties: { + disableBgpRoutePropagation: true + } +} + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + subnets: [ + { + name: 'Subnet-1' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: networkSecurityGroup.id + } + routeTable: { + id: routeTable.id + } + } + } + ] + } +} + +resource acr 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: acrName + location: location + sku: { + name: 'Premium' + } + properties: { + adminUserEnabled: false + anonymousPullEnabled: false + publicNetworkAccess: 'Disabled' + networkRuleBypassOptions: 'None' + networkRuleSet: { + defaultAction: 'Deny' + } + policies: { + exportPolicy: { status: 'disabled' } + } + } +} + +resource pe 'Microsoft.Network/privateEndpoints@2024-01-01' = { + name: peName + location: location + properties: { + privateLinkServiceConnections: [ + { + name: peName + properties: { + groupIds: [ + groupName + ] + privateLinkServiceId: acr.id + } + } + ] + subnet: { + id: virtualNetwork.properties.subnets[0].id + } + } +} + +output name string = acr.name +output resourceId string = acr.id +output location string = acr.location +output privateEndpointResourceId string = pe.id diff --git a/tests/policy-integration-tests/acr/tests.ps1 b/tests/policy-integration-tests/acr/tests.ps1 new file mode 100755 index 0000000..613bf18 --- /dev/null +++ b/tests/policy-integration-tests/acr/tests.ps1 @@ -0,0 +1,100 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +Test cases: +- PEDNS-012: Configure a private DNS Zone ID for Azure Container Registry (DeployIfNotExists) +- DS-003: Deploy Diagnostic Settings for Container Registry to Log Analytics workspace. (DeployIfNotExists) +- TAG-005: Inherit the tag from the Subscription to Resource Group if missing (appid) +- TAG-006: Inherit the tag from the Subscription to Resource Group if missing (dataclass) +- TAG-007: Inherit the tag from the Subscription to Resource Group if missing (owner) +- TAG-008: Inherit the tag from the Subscription to Resource Group if missing (supportteam) +- TAG-018: Inherit the tag from the Subscription to Resource Group if missing (environment) +- TAG-009: Inherit the tag from the Resource Group to Resources if missing (appid) +- TAG-010: Inherit the tag from the Resource Group to Resources if missing (dataclass) +- TAG-011: Inherit the tag from the Resource Group to Resources if missing (owner) +- TAG-012: Inherit the tag from the Resource Group to Resources if missing (supportteam) +- TAG-019: Inherit the tag from the Resource Group to Resources if missing (environment) +#> + +#Parse deployment outputs +$resourceId = $script:bicepDeploymentOutputs.resourceId.value +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$peDNSPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_peDNSAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $resourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$privateEndpointResourceId = $script:bicepDeploymentOutputs.privateEndpointResourceId.value +$privateEndpointPrivateDNSZoneGroupId = '{0}{1}' -f $privateEndpointResourceId, $script:GlobalConfig_privateEndpointPrivateDNSZoneGroupIdSuffix + +#define tests +$tests = @() + +#region Modify / Append Policies + +#TAG-005 rg-inherit-tag-from-sub (SolutionID) +$tests += New-ARTPropertyCountTestConfig 'TAG-005: Resource Group Should have appid tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.appid' 'equals' 1 + +#TAG-006 rg-inherit-tag-from-sub (dataclass) +$tests += New-ARTPropertyCountTestConfig 'TAG-006: Resource Group Should have dataclass tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.dataclass' 'equals' 1 + +#TAG-007 rg-inherit-tag-from-sub (owner) +$tests += New-ARTPropertyCountTestConfig 'TAG-007: Resource Group Should have owner tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.owner' 'equals' 1 + +#TAG-008 rg-inherit-tag-from-sub (supportteam) +$tests += New-ARTPropertyCountTestConfig 'TAG-008: Resource Group Should have supportteam tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.supportteam' 'equals' 1 + +#TAG-018 rg-inherit-tag-from-sub (environment) +$tests += New-ARTPropertyCountTestConfig 'TAG-018: Resource Group Should have environment tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.environment' 'equals' 1 + +#TAG-009 all-inherit-tag-from-rg (SolutionID) +$tests += New-ARTPropertyCountTestConfig 'TAG-009: Resource Should have appid tag' $script:token $resourceId 'tags.appid' 'equals' 1 + +#TAG-010 all-inherit-tag-from-rg (dataclass) +$tests += New-ARTPropertyCountTestConfig 'TAG-010: Resource Should have dataclass tag' $script:token $resourceId 'tags.dataclass' 'equals' 1 + +#TAG-011 all-inherit-tag-from-rg (owner) +$tests += New-ARTPropertyCountTestConfig 'TAG-011: Resource Should have owner tag' $script:token $resourceId 'tags.owner' 'equals' 1 + +#TAG-012 all-inherit-tag-from-rg (supportteam) +$tests += New-ARTPropertyCountTestConfig 'TAG-012: Resource Should have supportteam tag' $script:token $resourceId 'tags.supportteam' 'equals' 1 + +#TAG-019 all-inherit-tag-from-rg (environment) +$tests += New-ARTPropertyCountTestConfig 'TAG-019: Resource Should have environment tag' $script:token $resourceId 'tags.environment' 'equals' 1 + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'DS-003: Deploy Diagnostic Settings for Container Registry to Log Analytics workspace.' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-003: Diagnostic Settings Policy Must Be Compliant' $script:token $resourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-003' +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-012: Private DNS Record for Azure Container Registry PE must exist' $script:token $privateEndpointPrivateDNSZoneGroupId 'exists' $script:GlobalConfig_privateDNSZoneGroupAPIVersion +$tests += New-ARTPolicyStateTestConfig 'PEDNS-012: Private DNS Record Policy Must Be Compliant' $script:token $privateEndpointResourceId $peDNSPolicyAssignmentId 'Compliant' 'PEDNS-012' +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/event-hub/config.json b/tests/policy-integration-tests/event-hub/config.json new file mode 100644 index 0000000..418b6e5 --- /dev/null +++ b/tests/policy-integration-tests/event-hub/config.json @@ -0,0 +1,16 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-pedns", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-eh", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "EventHub", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-eh-001", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "peDNSAssignmentName": "pa-d-pedns", + "eventHubAssignmentName": "pa-d-eh", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/event-hub/main.bad.bicep b/tests/policy-integration-tests/event-hub/main.bad.bicep new file mode 100644 index 0000000..bcf26ed --- /dev/null +++ b/tests/policy-integration-tests/event-hub/main.bad.bicep @@ -0,0 +1,43 @@ +metadata itemDisplayName = 'Test Template for Event Hub' +metadata description = 'This template deploys the testing resource for Event Hub.' +metadata summary = 'Deploys test event hub resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var subName = localConfig.testSubscription +var vnetResourceGroup = globalConfig.subscriptions[subName].networkResourceGroup +var vnetName = globalConfig.subscriptions[subName].vNet +var peSubnetName = globalConfig.subscriptions[subName].peSubnet + +// define template specific variables +var serviceShort = 'eh3' +var eventHubName = 'eh${namePrefix}${serviceShort}03' + +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = { + name: vnetName + scope: az.resourceGroup(vnetResourceGroup) + + resource peSubnet 'subnets' existing = { name: peSubnetName } +} + +resource eventHub 'Microsoft.EventHub/namespaces@2025-05-01-preview' = { + name: eventHubName + location: location + sku: { + name: 'Premium' + } + properties: { + disableLocalAuth: false // This should violate EH-001 - Event Hub Namespace should have Local Authentication disabled + publicNetworkAccess: 'Enabled' // This should violate EH-003 - Disable Public Network Access + minimumTlsVersion: '1.0' // This should violate EH-002 - Event Hub must have minimum TLS version configured as per standard + } +} +output name string = eventHub.name +output resourceId string = eventHub.id +output location string = eventHub.location diff --git a/tests/policy-integration-tests/event-hub/main.good.bicep b/tests/policy-integration-tests/event-hub/main.good.bicep new file mode 100644 index 0000000..faf2432 --- /dev/null +++ b/tests/policy-integration-tests/event-hub/main.good.bicep @@ -0,0 +1,44 @@ +metadata itemDisplayName = 'Test Template for Event Hub' +metadata description = 'This template deploys the testing resource for Event Hub.' +metadata summary = 'Deploys test Event Hub resource that should be compliant with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var subName = localConfig.testSubscription +var vnetResourceGroup = globalConfig.subscriptions[subName].networkResourceGroup +var vnetName = globalConfig.subscriptions[subName].vNet +var peSubnetName = globalConfig.subscriptions[subName].peSubnet + +// define template specific variables +var serviceShort = 'eh' +var eventHubName = 'eh${namePrefix}${serviceShort}02' + +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = { + name: vnetName + scope: az.resourceGroup(vnetResourceGroup) + + resource peSubnet 'subnets' existing = { name: peSubnetName } +} + +resource eventHub 'Microsoft.EventHub/namespaces@2025-05-01-preview' = { + name: eventHubName + location: location + sku: { + name: 'Premium' // This should NOT violate EH-004 - Event Hub Namespace should have SKUs that support Private Links + } + properties: { + disableLocalAuth: true // This should comply with EH-001 - Event Hub Namespace should have Local Authentication disabled + publicNetworkAccess: 'Disabled' // This should comply with EH-003 - Disable Public Network Access + minimumTlsVersion: '1.2' // This should comply with EH-002 - Event Hub must have minimum TLS version configured as per standard + } +} + +output name string = eventHub.name +output resourceId string = eventHub.id +output location string = eventHub.location diff --git a/tests/policy-integration-tests/event-hub/main.test.bicep b/tests/policy-integration-tests/event-hub/main.test.bicep new file mode 100644 index 0000000..bb7025c --- /dev/null +++ b/tests/policy-integration-tests/event-hub/main.test.bicep @@ -0,0 +1,83 @@ +metadata itemDisplayName = 'Test Template for Event Hub' +metadata description = 'This template deploys the testing resource for Event Hub.' +metadata summary = 'Deploys test storage account resource.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +//var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var subName = localConfig.testSubscription +var vnetResourceGroup = globalConfig.subscriptions[subName].networkResourceGroup +var vnetName = globalConfig.subscriptions[subName].vNet +var peSubnetName = globalConfig.subscriptions[subName].peSubnet + +// define template specific variables +var serviceShort = 'eh1' +var eventHubName = 'eh${namePrefix}${serviceShort}01' +var eventHubNoPEName = 'eh${namePrefix}${serviceShort}02' + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' existing = { + name: vnetName + scope: az.resourceGroup(vnetResourceGroup) + + resource peSubnet 'subnets' existing = { name: peSubnetName } +} + +module eventHub 'br/public:avm/res/event-hub/namespace:0.14.1' = { + name: '${uniqueString(deployment().name, location)}-test-${serviceShort}' + params: { + name: eventHubName + disableLocalAuth: true + managedIdentities: { + systemAssigned: true + } + minimumTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + skuName: 'Premium' + zoneRedundant: false + networkRuleSets: { + defaultAction: 'Deny' + ipRules: [] + publicNetworkAccess: 'Disabled' + } + privateEndpoints: [ + { + name: 'pe-${eventHubName}-eh' + service: 'namespace' + subnetResourceId: vnet::peSubnet.id + } + ] + } +} + +module eventHubNoPe 'br/public:avm/res/event-hub/namespace:0.14.1' = { + name: '${uniqueString(deployment().name, location)}-test-NoPe-${serviceShort}' + params: { + name: eventHubNoPEName + disableLocalAuth: true + managedIdentities: { + systemAssigned: true + } + minimumTlsVersion: '1.2' + publicNetworkAccess: 'Disabled' + skuName: 'Premium' + zoneRedundant: false + networkRuleSets: { + defaultAction: 'Deny' + ipRules: [] + publicNetworkAccess: 'Disabled' + } + } +} + +output name string = eventHub.outputs.name +output resourceId string = eventHub.outputs.resourceId +output privateEndpointResourceId string = eventHub.outputs.privateEndpoints[0].resourceId +output eventHubNoPEName string = eventHubNoPe.outputs.name +output eventHubNoPEResourceId string = eventHubNoPe.outputs.resourceId +output location string = eventHub.outputs.location diff --git a/tests/policy-integration-tests/event-hub/tests.ps1 b/tests/policy-integration-tests/event-hub/tests.ps1 new file mode 100755 index 0000000..c5360f5 --- /dev/null +++ b/tests/policy-integration-tests/event-hub/tests.ps1 @@ -0,0 +1,87 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +Test cases in scope: +- EH-001: Restrict Event Hub Local Authentication (Deny) +- EH-002: Event Hub must have the minimum TLS version configured as per standard - (Deny) +- EH-003: Event Hub Restrict Public Network Access - (Deny) +- EH-004: Event Hub should use CMK Encryption - (Audit) +- EH-005: Event Hub namespaces should use private link - (AuditIfNotExists) +- PEDENS-007: Configure a private dns zone id for Event Hub - (DeployIfNotExists) +- DS-032: custom policy - eh-config-diag-logs - Configure Diagnostic Setting for Event Hub - (DeployIfNotExists) +#> + +#Parse deployment outputs +$resourceId = $script:bicepDeploymentOutputs.resourceId.value +$eventHubNoPeResourceId = $script:bicepDeploymentOutputs.eventHubNoPEResourceId.value +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$peDNSPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_peDNSAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $resourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$ehPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_eventHubAssignmentName`$" } +$privateEndpointResourceId = $script:bicepDeploymentOutputs.privateEndpointResourceId.value +$privateEndpointPrivateDNSZoneGroupId = '{0}{1}' -f $privateEndpointResourceId, $script:GlobalConfig_privateEndpointPrivateDNSZoneGroupIdSuffix + +$violatingPolicies = @( + @{ + policyAssignmentId = $ehPolicyAssignmentId + policyDefinitionReferenceId = 'EH-001' + } + @{ + policyAssignmentId = $ehPolicyAssignmentId + policyDefinitionReferenceId = 'EH-002' + } + @{ + policyAssignmentId = $ehPolicyAssignmentId + policyDefinitionReferenceId = 'EH-003' + } +) +#define tests +$tests = @() + +#region Audit Policies +$tests += New-ARTPolicyStateTestConfig 'EH-004: Event Hub Namespace use CMK encryption' $script:token $resourceId $ehPolicyAssignmentId 'NonCompliant' 'EH-004' +$tests += New-ARTPolicyStateTestConfig 'EH-005: Event Hub Namespace should use Private Endpoint' $script:token $eventHubNoPeResourceId $ehPolicyAssignmentId 'NonCompliant' 'EH-005' + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'DS-022: Deploy Diagnostic Settings for Container Registry to Log Analytics workspace.' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-022: Diagnostic Settings Policy Must Be Compliant' $script:token $resourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-022' +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-007: Private DNS Record for Azure Container Registry PE must exist' $script:token $privateEndpointPrivateDNSZoneGroupId 'exists' $script:GlobalConfig_privateDNSZoneGroupAPIVersion +$tests += New-ARTPolicyStateTestConfig 'PEDNS-007: Private DNS Record Policy Must Be Compliant' $script:token $privateEndpointResourceId $peDNSPolicyAssignmentId 'Compliant' 'PEDNS-007' + +#Deny policies (testing both positive and negative scenarios) +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy abiding deployment should succeed' $script:token $script:whatIfComplyBicepTemplatePath $script:bicepDeploymentResult.bicepDeploymentTarget 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:bicepDeploymentResult.bicepDeploymentTarget 'Failed' $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/key-vault/README.md b/tests/policy-integration-tests/key-vault/README.md new file mode 100644 index 0000000..e65b61b --- /dev/null +++ b/tests/policy-integration-tests/key-vault/README.md @@ -0,0 +1,27 @@ +# Policy Integration Test - Sample Test Cases for Key Vault + +## Introduction + +This folder contains a sample test case for Azure Key Vault related policies. + +The test case is designed to test the following policy assignments: + +| Policy Assignment Name | Policy Assignment Scope | Description | +| :-------------------- | :--------------------- | :---------- | +| `pa-d-kv` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for the Azure Key Vault initiative | +| `pa-d-tags` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for resource tags initiative | +| `pa-d-pedns` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for Azure Private Endpoint DNS Records Policy Initiative (deploy DNS records for Private Endpoints) | +| `pa-d-diag-settings` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for Azure Diagnostic Settings Policy Initiative (deploy diagnostic settings for all applicable Azure resources) | + + +The following policies are in scope for testing: + +| Policy Assignment | Policy Reference ID | Policy Name | Policy Effect | +| :---------------- | :---------------- | :------------ | :------------ | +| `pa-d-tags` | `TAG-006` | Resource Group inherit the 'dataclass' tag from subscription | `Modify` | +| `pa-d-tags` | `TAG-007` | Resource Group inherit the 'owner' tag from subscription | `Modify` | +| `pa-d-kv` | `KV-002` | Key Vault should have purge protection enabled | `Modify` | +| `pa-d-kv` | `KV-003` | Key Vault permission model should be configured to use Azure RBAC | `Deny` | +| `pa-d-kv` | `KV-004` | Azure Key Vault should disable public network access | `Audit` | +| `pa-d-pedns` | `PEDNS-005` | Private DNS Record for Key Vault PE must exist | `DeployIfNotExists` | +| `pa-d-diag-settings` | `DS-029` | Diagnostic Settings for Key Vault Must Be Configured | `DeployIfNotExists` | diff --git a/tests/policy-integration-tests/key-vault/config.json b/tests/policy-integration-tests/key-vault/config.json new file mode 100644 index 0000000..3c72b9f --- /dev/null +++ b/tests/policy-integration-tests/key-vault/config.json @@ -0,0 +1,15 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-key-vault", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-pedns", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "KeyVault", + "assignmentName": "pa-d-key-vault", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-kv-001", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "peDNSAssignmentName": "pa-d-pedns", + "location": "australiaeast", + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/key-vault/main.bad.bicep b/tests/policy-integration-tests/key-vault/main.bad.bicep new file mode 100644 index 0000000..f67f976 --- /dev/null +++ b/tests/policy-integration-tests/key-vault/main.bad.bicep @@ -0,0 +1,51 @@ +metadata itemDisplayName = 'Test Template for Key Vault' +metadata description = 'This template deploys the testing resource for Key Vault.' +metadata summary = 'Deploys test key vault resource that should violate some policy assignments.' + +// ========== // +// Parameters // +// ========== // + +@description('Optional. Get current time stamp. This is used to generate unique name for key vault. DO NOT provide a value.') +param now string = utcNow() + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'kv2' + +//Key vault name must container a random string so it's unique for each test deployment. +//This is required because soft delete and purge protection is enabled. You cannot re-use the same KV name after deletion until the purge protection period has passed. +var keyVaultName = 'kv-${namePrefix}-${serviceShort}01' + +resource kv 'Microsoft.KeyVault/vaults@2025-05-01' = { + location: location + name: keyVaultName + tags: tags + properties: { + sku: { + family: 'A' + name: 'premium' + } + publicNetworkAccess: 'Enabled' + enableRbacAuthorization: false + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + ipRules: [] + virtualNetworkRules: [] + } + } +} + +output name string = kv.name +output resourceId string = kv.id +output location string = kv.location diff --git a/tests/policy-integration-tests/key-vault/main.good.bicep b/tests/policy-integration-tests/key-vault/main.good.bicep new file mode 100644 index 0000000..29b97c2 --- /dev/null +++ b/tests/policy-integration-tests/key-vault/main.good.bicep @@ -0,0 +1,55 @@ +metadata itemDisplayName = 'Test Template for Key Vault' +metadata description = 'This template deploys the testing resource for Key Vault.' +metadata summary = 'Deploys test key vault resource that should comply with all policy assignments.' + +// ========== // +// Parameters // +// ========== // + +@description('Optional. Get current time stamp. This is used to generate unique name for key vault. DO NOT provide a value.') +param now string = utcNow() + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +var keyVaultNameSuffix = substring((uniqueString(now, location)), 0, 5) +// define template specific variables +var serviceShort = 'kv3' + +//Key vault name must contain a random string so it's unique for each test deployment. +//This is required because soft delete and purge protection is enabled. You cannot re-use the same KV name after deletion until the purge protection period has passed. +var keyVaultName = 'kv-${namePrefix}-${serviceShort}${keyVaultNameSuffix}01' + +resource kv 'Microsoft.KeyVault/vaults@2025-05-01' = { + name: keyVaultName + location: location + tags: tags + properties: { + sku: { + family: 'A' + name: 'premium' + } + tenantId: subscription().tenantId + enableSoftDelete: true + enablePurgeProtection: true //should comply with KV-002 + enableRbacAuthorization: true //Should comply with KV-003 + publicNetworkAccess: 'Disabled' //Should comply with KV-004 + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Deny' + ipRules: [] + virtualNetworkRules: [] + } + } +} + +output name string = kv.name +output resourceId string = kv.id +output location string = kv.location diff --git a/tests/policy-integration-tests/key-vault/main.test.bicep b/tests/policy-integration-tests/key-vault/main.test.bicep new file mode 100644 index 0000000..241d004 --- /dev/null +++ b/tests/policy-integration-tests/key-vault/main.test.bicep @@ -0,0 +1,90 @@ +metadata itemDisplayName = 'Test Template for Key Vault' +metadata description = 'This template deploys the testing resource for Key Vault.' +metadata summary = 'Deploys test key vault resource.' + +// ========== // +// Parameters // +// ========== // + +@description('Optional. Get current time stamp. This is used to generate unique name for key vault. DO NOT provide a value.') +param now string = utcNow() + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var subName = localConfig.testSubscription +var vnetResourceGroup = globalConfig.subscriptions[subName].networkResourceGroup +var vnetName = globalConfig.subscriptions[subName].vNet +var peSubnetName = globalConfig.subscriptions[subName].peSubnet +var resourceSubnetName = globalConfig.subscriptions[subName].resourceSubnet +var keyVaultNameSuffix = substring((uniqueString(now, location)), 0, 5) + +// define template specific variables +var serviceShort = 'kv1' + +//Key vault name must container a random string so it's unique for each test deployment. +//This is required because soft delete and purge protection is enabled. You cannot re-use the same KV name after deletion until the purge protection period has passed. +var keyVaultName = 'kv-${namePrefix}-${serviceShort}${keyVaultNameSuffix}01' +var peName = 'pe-${keyVaultName}' + +resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = { + name: vnetName + scope: az.resourceGroup(vnetResourceGroup) + + resource peSubnet 'subnets' existing = { name: peSubnetName } + resource resourceSubnet 'subnets' existing = { name: resourceSubnetName } +} +resource kv 'Microsoft.KeyVault/vaults@2025-05-01' = { + name: keyVaultName + location: location + tags: tags + properties: { + sku: { + family: 'A' + name: 'premium' + } + tenantId: subscription().tenantId + enableSoftDelete: true + enablePurgeProtection: true + enableRbacAuthorization: true + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Deny' + } + } +} + +resource pe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: peName + location: location + properties: { + privateLinkServiceConnections: [ + { + name: peName + properties: { + groupIds: [ + 'vault' + ] + privateLinkServiceId: kv.id + } + } + ] + subnet: { + id: vnet::peSubnet.id + } + } +} + +output name string = kv.name +output resourceId string = kv.id +output location string = kv.location +output privateEndpointName string = peName +output privateEndpointResourceId string = pe.id +output resourceGroupId string = resourceGroup().id diff --git a/tests/policy-integration-tests/key-vault/tests.ps1 b/tests/policy-integration-tests/key-vault/tests.ps1 new file mode 100644 index 0000000..16bbd07 --- /dev/null +++ b/tests/policy-integration-tests/key-vault/tests.ps1 @@ -0,0 +1,89 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +The following policy definitions are tested: + - Resource Group inherit the 'dataclass' tag from subscription + - Resource Group inherit the 'owner' tag from subscription + - Azure Key Vault should disable public network access (Audit) + - Key Vault should have purge protection enabled (Modify) + - Private DNS Record for Key Vault PE must exist (DeployIfNotExists) + - Diagnostic Settings for Key Vault Must Be Configured (DeployIfNotExists) + - KeyVault permission model should be configured to use Azure RBAC (Deny) +#> + +#Parse Deployment outputs +$resourceId = $script:bicepDeploymentOutputs.resourceId.Value +$resourceName = ($resourceId -split ('/'))[-1] +$privateEndpointResourceId = $script:bicepDeploymentOutputs.privateEndpointResourceId.value +#prepare other variables needed for tests +$privateDNSSubscriptionId = $script:GlobalConfig_subscriptions.$script:GlobalConfig_privateDNSSubscription.id +$privateDNSResourceGroup = $script:GlobalConfig_privateDNSResourceGroup +$keyVaultPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_assignmentName`$" } +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$peDNSPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_peDNSAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $resourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$kvPrivateDNSARecordId = "/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.Network/privateDnsZones/privatelink.vaultcore.azure.net/A/{2}" -f $privateDNSSubscriptionId, $privateDNSResourceGroup, $resourceName + +#define violating deny policies +$violatingPolicies = @( + @{ + policyAssignmentId = $keyVaultPolicyAssignmentId + policyDefinitionReferenceId = 'KV-003' #KV-003: KeyVault permission model should be configured to use Azure RBAC + } +) + +#define tests +$tests = @() +#Modify / Append Policies + +#TAG-006 rg-inherit-tag-from-sub (dataclass) +$tests += New-ARTPropertyCountTestConfig 'TAG-006: Resource Group Should have dataclass tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.dataclass' 'equals' 1 +$tests += New-ARTPropertyCountTestConfig 'TAG-007: Resource Group Should have owner tag' $script:token $script:bicepDeploymentResult.bicepDeploymentTarget 'tags.owner' 'equals' 1 +$tests += New-ARTPropertyValueTestConfig 'KV-002: Key Vault should have purge protection enabled' $script:token $resourceId 'boolean' 'properties.enablePurgeProtection' 'equals' $true + +#Audit / AuditIfNotExists policies +$tests += New-ARTPolicyStateTestConfig 'KV-004: Azure Key Vault should disable public network access' $script:token $resourceId $keyVaultPolicyAssignmentId 'NonCompliant' 'KV-004' + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-005: Private DNS Record for Key Vault PE must exist' $script:token $kvPrivateDNSARecordId 'exists' +$tests += New-ARTPolicyStateTestConfig 'PEDNS-005: Private DNS Record Policy Must Be Compliant' $script:token $privateEndpointResourceId $peDNSPolicyAssignmentId 'Compliant' 'PEDNS-005' +$tests += New-ARTResourceExistenceTestConfig 'DS-029: Diagnostic Settings for Key Vault Must Be Configured' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-029: Diagnostic Settings Policy Must Be Compliant' $script:token $resourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-029' + +#Deny policies (testing both positive and negative scenarios) +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy abiding deployment should succeed' $script:token $script:whatIfComplyBicepTemplatePath $script:bicepDeploymentResult.bicepDeploymentTarget 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:bicepDeploymentResult.bicepDeploymentTarget 'Failed' $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/monitor/README.md b/tests/policy-integration-tests/monitor/README.md new file mode 100644 index 0000000..2c3b48c --- /dev/null +++ b/tests/policy-integration-tests/monitor/README.md @@ -0,0 +1,24 @@ +# Policy Integration Test - Sample Test Cases for Azure Monitor + +## Introduction + +This folder contains a sample test case for Azure Monitor related policies. + +The test case is designed to test the following policy assignments: + +| Policy Assignment Name | Policy Assignment Scope | Description | +| :-------------------- | :--------------------- | :---------- | +| `pa-d-monitor` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for the Azure Monitor initiative | + +The following policies are in scope for testing: + +| Policy Assignment | Policy Reference ID | Policy Name | Policy Effect | +| :---------------- | :---------------- | :------------ | :------------ | +| `pa-d-monitor` | MON-001 | Restrict Azure Monitor Action Group Send Email Notification to External Email Addresses | Deny | +| `pa-d-monitor` | MON-002 | Restrict Azure Monitor Action Group Send SMS Notification to Unauthorized country codes | Deny | +| `pa-d-monitor` | MON-003 | Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Azure Automation or not on the Allowed List | Deny | +| `pa-d-monitor` | MON-004 | Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Event Hubs or not on the Allowed List | Deny | +| `pa-d-monitor` | MON-005 | Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Function Apps or not on the Allowed List | Deny | +| `pa-d-monitor` | MON-006 | Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Logic Apps or not on the Allowed List | Deny | +| `pa-d-monitor` | MON-007 | Restrict Azure Monitor Action Group Trigger Actions to Webhooks that are not on the Allowed List | Deny | +| `pa-d-monitor` | MON-008 | Restrict Azure Monitor Action Group Trigger Actions to Webhooks that are not using HTTPS | Deny | diff --git a/tests/policy-integration-tests/monitor/config.json b/tests/policy-integration-tests/monitor/config.json new file mode 100644 index 0000000..eefb6fb --- /dev/null +++ b/tests/policy-integration-tests/monitor/config.json @@ -0,0 +1,14 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-monitor", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-tags" + ], + "testName": "AzureMonitor", + "assignmentName": "pa-d-monitor", + "testSubscription": "sub-d-lz-corp-01", + "crossSubActionResourceSubscription": "sub-d-lz-online-01", + "testResourceGroup": "rg-ae-d-policy-test-mon-001", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/monitor/main.bad.bicep b/tests/policy-integration-tests/monitor/main.bad.bicep new file mode 100644 index 0000000..956e555 --- /dev/null +++ b/tests/policy-integration-tests/monitor/main.bad.bicep @@ -0,0 +1,95 @@ +metadata itemDisplayName = 'Test Template for Azure Monitor' +metadata description = 'This template deploys the testing resource for azure monitor.' +metadata summary = 'Deploys test azure monitor resources that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') + +var location = localConfig.location +var namePrefix = globalConfig.namePrefix +var crossSubActionResourceSubscriptionName = localConfig.crossSubActionResourceSubscription +var crossSubActionResourceSubscription = globalConfig.subscriptions[crossSubActionResourceSubscriptionName] +var crossSubActionResourceSubscriptionId = crossSubActionResourceSubscription.id + +// define template specific variables +var serviceShort = 'mon3' +var actionGroupName = 'ag-${namePrefix}-${serviceShort}-01' +var actionGroupShortName = 'agtest03' +//Dummy cross subscription resource Ids. Don't have to be real, they will be used for ARM what-if deployment validation +var dummyCrossSubAutomationAccountId = '/subscriptions/${crossSubActionResourceSubscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Automation/automationAccounts/automationAccount1' +var dummyCrossSubAutomationAccountWebhookId = '/subscriptions/${crossSubActionResourceSubscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Automation/automationAccounts/automationAccount1/webhooks/alert1' +var dummyCrossSubFunctionAppId = '/subscriptions/${crossSubActionResourceSubscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Web/sites/functionApp1' +var dummyCrossSubLogicAppId = '/subscriptions/${crossSubActionResourceSubscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Logic/workflows/logicApp1' +var dummyCrossSubEventHubId = '/subscriptions/${crossSubActionResourceSubscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.EventHub/namespaces/eventHub1' +resource actionGroup 'Microsoft.Insights/actionGroups@2024-10-01-preview' = { + name: actionGroupName + location: 'global' + properties: { + groupShortName: actionGroupShortName + enabled: true + smsReceivers: [ + { + name: 'sms1' + countryCode: '7' //Country code for Russia, should violate policy MON-002 + phoneNumber: '2345678901' + } + ] + emailReceivers: [ + { + emailAddress: 'test.user1@outlook.com' //violate policy MON-001 + name: 'email1' + useCommonAlertSchema: true + } + ] + automationRunbookReceivers: [ + { + name: 'runbook1' + automationAccountId: dummyCrossSubAutomationAccountId //violate policy MON-003 + webhookResourceId: dummyCrossSubAutomationAccountWebhookId + runbookName: 'runbookName1' + useCommonAlertSchema: true + isGlobalRunbook: false + } + ] + eventHubReceivers: [ + { + name: 'eventHub1' + eventHubName: 'eventHub1' + eventHubNameSpace: dummyCrossSubEventHubId //violate policy MON-004 + subscriptionId: crossSubActionResourceSubscriptionId + useCommonAlertSchema: true + } + ] + azureFunctionReceivers: [ + { + name: 'function1' + functionAppResourceId: dummyCrossSubFunctionAppId //violate policy MON-005 + functionName: 'functionName1' + httpTriggerUrl: 'https://function1.com' + useCommonAlertSchema: true + } + ] + logicAppReceivers: [ + { + name: 'logicApp1' + resourceId: dummyCrossSubLogicAppId //violate policy MON-006 + useCommonAlertSchema: true + callbackUrl: 'https://logicApp1.com' + } + ] + webhookReceivers: [ + { + name: 'webhook1' + serviceUri: 'http://webhookuri1.com' //violate policy MON-007 and MON-008 + useCommonAlertSchema: true + } + ] + } +} +output name string = actionGroup.name +output resourceId string = actionGroup.id +output location string = actionGroup.location diff --git a/tests/policy-integration-tests/monitor/main.good.bicep b/tests/policy-integration-tests/monitor/main.good.bicep new file mode 100644 index 0000000..e02f6e0 --- /dev/null +++ b/tests/policy-integration-tests/monitor/main.good.bicep @@ -0,0 +1,90 @@ +metadata itemDisplayName = 'Test Template for Azure Monitor' +metadata description = 'This template deploys the testing resource for azure monitor.' +metadata summary = 'Deploys test azure monitor resources that should comply with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') + +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'mon3' +var actionGroupName = 'ag-${namePrefix}-${serviceShort}-01' +var actionGroupShortName = 'agtest02' +//Dummy cross subscription resource Ids. Don't have to be real, they will be used for ARM what-if deployment validation +var dummyAutomationAccountId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Automation/automationAccounts/automationAccount1' +var dummyAutomationAccountWebhookId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Automation/automationAccounts/automationAccount1/webhooks/alert1' +var dummyFunctionAppId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Web/sites/functionApp1' +var dummyLogicAppId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.Logic/workflows/logicApp1' +var dummyEventHubId = '/subscriptions/${subscription().subscriptionId}/resourceGroups/rg-${namePrefix}-mon3-01/providers/Microsoft.EventHub/namespaces/eventHub1' +resource actionGroup 'Microsoft.Insights/actionGroups@2024-10-01-preview' = { + name: actionGroupName + location: 'global' + properties: { + groupShortName: actionGroupShortName + enabled: true + smsReceivers: [ + { + name: 'sms1' + countryCode: '61' //comply with policy MON-002 + phoneNumber: '491570006' //comply with policy MON-002 + } + ] + emailReceivers: [ + { + emailAddress: 'test.user1@contoso.com' //comply with policy MON-001 + name: 'email1' + useCommonAlertSchema: true + } + ] + automationRunbookReceivers: [ + { + name: 'runbook1' + automationAccountId: dummyAutomationAccountId //comply with policy MON-003 + webhookResourceId: dummyAutomationAccountWebhookId + runbookName: 'runbookName1' + useCommonAlertSchema: true + isGlobalRunbook: false + } + ] + eventHubReceivers: [ + { + name: 'eventHub1' + eventHubName: 'eventHub1' + eventHubNameSpace: dummyEventHubId //comply with policy MON-004 + subscriptionId: subscription().id + useCommonAlertSchema: true + } + ] + azureFunctionReceivers: [ + { + name: 'function1' + functionAppResourceId: dummyFunctionAppId //comply with policy MON-005 + functionName: 'functionName1' + httpTriggerUrl: 'https://function1.com' + useCommonAlertSchema: true + } + ] + logicAppReceivers: [ + { + name: 'logicApp1' + resourceId: dummyLogicAppId //comply with policy MON-006 + useCommonAlertSchema: true + callbackUrl: 'https://logicApp1.com' + } + ] + webhookReceivers: [ + { + name: 'webhook1' + serviceUri: 'https://webhookuri.com' //comply with policy MON-007 and MON-008 + useCommonAlertSchema: true + } + ] + } +} +output name string = actionGroup.name +output resourceId string = actionGroup.id +output location string = actionGroup.location diff --git a/tests/policy-integration-tests/monitor/tests.ps1 b/tests/policy-integration-tests/monitor/tests.ps1 new file mode 100644 index 0000000..6d52de2 --- /dev/null +++ b/tests/policy-integration-tests/monitor/tests.ps1 @@ -0,0 +1,192 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +The following policy definitions are tested via both What-If deployment and Poilcy Restriction API. +In real world scenario, select one of the two methods as this is for demonstration purpose to show the flexibility of the test framework in supporting different test methods. + - MON-001: Restrict Azure Monitor Action Group Send Email Notification to External Email Addresses (Deny) + - MON-002: Restrict Azure Monitor Action Group Send SMS Notification to Unauthorized country codes (Deny) + - MON-003: Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Azure Automation or not on the Allowed List (Deny) + - MON-004: Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Event Hubs or not on the Allowed List (Deny) + - MON-005: Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Function Apps or not on the Allowed List (Deny) + - MON-006: Restrict Azure Monitor Action Group Trigger Actions to Cross-Subscription Logic Apps or not on the Allowed List (Deny) + - MON-007: Restrict Azure Monitor Action Group Trigger Actions to Webhooks that are not on the Allowed List (Deny) + - MON-008: Restrict Azure Monitor Action Group Trigger Actions to Webhooks that are not using HTTPS (Deny) +#> + +$actionGroupName = 'ag01' +$monitorAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_assignmentName`$" } +$actionGroupViolatingResourceContent = @{ + properties = @{ + smsReceivers = @( + @{ + countryCode = '7' #Country code for Russia, should violate policy MON-002 + phoneNumber = '2345678901' + } + ) + emailReceivers = @( + @{ + emailAddress = 'test.user1@outlook.com' #violate policy MON-001 + } + ) + automationRunbookReceivers = @( + @{ + automationAccountId = '/subscriptions/62740b7e-8b53-4411-a353-14e023983d78/resourceGroups/rg-mon3-01/providers/Microsoft.Automation/automationAccounts/automationAccount1/webhooks/alert1' #violate policy MON-003 + } + ) + eventHubReceivers = @( + @{ + eventHubNameSpace = '/subscriptions/62740b7e-8b53-4411-a353-14e023983d78/resourceGroups/rg-mon3-01/providers/Microsoft.EventHub/namespaces/eventHub1' #violate policy MON-004 + } + ) + azureFunctionReceivers = @( + @{ + functionAppResourceId = '/subscriptions/62740b7e-8b53-4411-a353-14e023983d78/resourceGroups/rg-mon3-01/providers/Microsoft.Web/sites/functionApp1' #violate policy MON-005 + } + ) + logicAppReceivers = @( + @{ + resourceId = '/subscriptions/62740b7e-8b53-4411-a353-14e023983d78/resourceGroups/rg-mon3-01/providers/Microsoft.Logic/workflows/logicApp1' #violate policy MON-006 + } + ) + webhookReceivers = @( + @{ + serviceUri = 'http://webhookuri1.com' #violate policy MON-007 and MON-008 + } + ) + } +} | ConvertTo-Json -Depth 99 +Write-Output "Action Group Violating Resource Content: `n $actionGroupViolatingResourceContent" +$actionGroupViolatingResourceConfig = @{ + resourceName = $actionGroupName + resourceType = 'Microsoft.Insights/actionGroups' + apiVersion = '2024-10-01-preview' + resourceContent = $actionGroupViolatingResourceContent + location = 'global' + includeAuditEffect = $true +} +$policyRestrictionViolatingPolicies = @( + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-001' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-002' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-003' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-004' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-005' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-006' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-007' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-008' + resourceReference = $actionGroupName + policyEffect = 'Deny' + } +) + +$whatIfViolatingPolicies = @( + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-001' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-002' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-003' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-004' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-005' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-006' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-007' + } + @{ + policyAssignmentId = $monitorAssignmentId + policyDefinitionReferenceId = 'MON-008' + } +) + +#define tests +$tests = @() + +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy abiding deployment should succeed' $script:token $script:whatIfComplyBicepTemplatePath $script:testResourceGroupId 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:testResourceGroupId 'Failed' $whatIfViolatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTArmPolicyRestrictionTestConfig -testName 'Action Group Configuration should violate deny policies' -token $script:token -deploymentTargetResourceId $script:testResourceGroupId -resourceConfig $actionGroupViolatingResourceConfig -policyViolation $policyRestrictionViolatingPolicies +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/network-security-group/config.json b/tests/policy-integration-tests/network-security-group/config.json new file mode 100644 index 0000000..09a822d --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/config.json @@ -0,0 +1,14 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-nsg", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "NSG", + "testSubscription": "sub-d-lz-corp-01", + "nsgAssignmentName": "pa-d-nsg", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "testResourceGroup": "rg-ae-d-policy-test-nsg-001", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/network-security-group/main.bad.nsg.bicep b/tests/policy-integration-tests/network-security-group/main.bad.nsg.bicep new file mode 100644 index 0000000..bc12788 --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/main.bad.nsg.bicep @@ -0,0 +1,96 @@ +metadata itemDisplayName = 'Test Template for Network Security Group' +metadata description = 'This template deploys the testing resource for Network Security Group.' +metadata summary = 'Deploys test network security group resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'nsg3' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'Allow-SSH-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '22' + destinationPortRange: '22' + sourceAddressPrefix: 'AzureCloud' //should violate policy NSG-003 + destinationAddressPrefix: 'VirtualNetwork' + priority: 200 + direction: 'Inbound' + } + } + { + name: 'Allow-RDP-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '3389' + destinationPortRange: '3389' + sourceAddressPrefix: 'VirtualNetwork' + destinationAddressPrefix: 'VirtualNetwork' + priority: 210 + direction: 'Inbound' + } + } + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + priority: 220 + direction: 'Inbound' + } + } + { + name: 'Allow-HTTPS-Outbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'Storage.WestUS' //should violate policy NSG-004 + priority: 200 + direction: 'Outbound' + } + } + { + name: 'Deny-Outbound-SQL' + properties: { + access: 'Deny' + protocol: 'Tcp' + sourcePortRange: '1443' + destinationPortRange: '1433' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureCloud' + priority: 4000 + direction: 'Outbound' + } + } + ] + } +} + +output name string = nsg.name +output resourceId string = nsg.id +output location string = nsg.location diff --git a/tests/policy-integration-tests/network-security-group/main.bad.nsg.rule.bicep b/tests/policy-integration-tests/network-security-group/main.bad.nsg.rule.bicep new file mode 100644 index 0000000..d470a8b --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/main.bad.nsg.rule.bicep @@ -0,0 +1,54 @@ +metadata itemDisplayName = 'Test Template for Network Security Group Rule' +metadata description = 'This template deploys the testing resource for Network Security Group Rule.' +metadata summary = 'Deploys test network security group rule resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var existingServiceShort = 'nsg1' +var existingNsgName = 'nsg-${namePrefix}-${existingServiceShort}-01' + +resource existingNsg 'Microsoft.Network/networkSecurityGroups@2024-01-01' existing = { + name: existingNsgName +} + +resource inboundRule1 'Microsoft.Network/networkSecurityGroups/securityRules@2024-01-01' = { + name: 'Allow-SQL-Inbound' + parent: existingNsg + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '1433' + destinationPortRange: '*' + sourceAddressPrefix: 'AzureCloud' //should violate policy NSG-003 + destinationAddressPrefix: 'VirtualNetwork' + priority: 300 + direction: 'Inbound' + } +} + +resource outboundRule1 'Microsoft.Network/networkSecurityGroups/securityRules@2024-01-01' = { + name: 'Allow-SSH-Outbound' + parent: existingNsg + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '22' + destinationPortRange: '22' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'Storage.WestUS' //should violate policy NSG-004 + priority: 300 + direction: 'Outbound' + } +} + +output inboundRuleName string = inboundRule1.name +output outboundRuleName string = outboundRule1.name +output inboundRuleResourceId string = inboundRule1.id +output outboundRuleResourceId string = outboundRule1.id +output location string = existingNsg.location diff --git a/tests/policy-integration-tests/network-security-group/main.good.nsg.bicep b/tests/policy-integration-tests/network-security-group/main.good.nsg.bicep new file mode 100644 index 0000000..680b6bc --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/main.good.nsg.bicep @@ -0,0 +1,96 @@ +metadata itemDisplayName = 'Test Template for Network Security Group' +metadata description = 'This template deploys the testing resource for Network Security Group.' +metadata summary = 'Deploys test network security group resource that should be complaint with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'nsg2' +var newNsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: newNsgName + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'Allow-SSH-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '22' + destinationPortRange: '22' + sourceAddressPrefix: 'VirtualNetwork' //should be compliant with policy NSG-003 + destinationAddressPrefix: 'VirtualNetwork' + priority: 200 + direction: 'Inbound' + } + } + { + name: 'Allow-RDP-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '3389' + destinationPortRange: '3389' + sourceAddressPrefix: 'VirtualNetwork' + destinationAddressPrefix: 'VirtualNetwork' + priority: 210 + direction: 'Inbound' + } + } + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + priority: 220 + direction: 'Inbound' + } + } + { + name: 'Allow-HTTPS-Outbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureLoadBalancer' //should be compliant with policy NSG-004 + priority: 200 + direction: 'Outbound' + } + } + { + name: 'Deny-Outbound-SQL' + properties: { + access: 'Deny' + protocol: 'Tcp' + sourcePortRange: '1443' + destinationPortRange: '1433' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureCloud' + priority: 4000 + direction: 'Outbound' + } + } + ] + } +} + +output name string = nsg.name +output resourceId string = nsg.id +output location string = nsg.location diff --git a/tests/policy-integration-tests/network-security-group/main.good.nsg.rule.bicep b/tests/policy-integration-tests/network-security-group/main.good.nsg.rule.bicep new file mode 100644 index 0000000..44fc699 --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/main.good.nsg.rule.bicep @@ -0,0 +1,54 @@ +metadata itemDisplayName = 'Test Template for Network Security Group Rule' +metadata description = 'This template deploys the testing resource for Network Security Group Rule.' +metadata summary = 'Deploys test network security group rule resource that should be complaint with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var existingServiceShort = 'nsg1' +var existingNsgName = 'nsg-${namePrefix}-${existingServiceShort}-01' + +resource existingNsg 'Microsoft.Network/networkSecurityGroups@2024-01-01' existing = { + name: existingNsgName +} + +resource inboundRule1 'Microsoft.Network/networkSecurityGroups/securityRules@2024-01-01' = { + name: 'Allow-SQL-Inbound' + parent: existingNsg + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '1433' + destinationPortRange: '*' + sourceAddressPrefix: 'VirtualNetwork' //should be compliant with policy NSG-003 + destinationAddressPrefix: 'VirtualNetwork' + priority: 400 + direction: 'Inbound' + } +} + +resource outboundRule1 'Microsoft.Network/networkSecurityGroups/securityRules@2024-01-01' = { + name: 'Allow-SSH-Outbound' + parent: existingNsg + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '22' + destinationPortRange: '22' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureLoadBalancer' //should be compliant with policy NSG-004 + priority: 400 + direction: 'Outbound' + } +} + +output inboundRuleName string = inboundRule1.name +output outboundRuleName string = outboundRule1.name +output inboundRuleResourceId string = inboundRule1.id +output outboundRuleResourceId string = outboundRule1.id +output location string = existingNsg.location diff --git a/tests/policy-integration-tests/network-security-group/main.test.bicep b/tests/policy-integration-tests/network-security-group/main.test.bicep new file mode 100644 index 0000000..e603dea --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/main.test.bicep @@ -0,0 +1,83 @@ +metadata itemDisplayName = 'Test Template for Network Security Group' +metadata description = 'This template deploys the testing resource for Network Security Group.' +metadata summary = 'Deploys test network security group resource.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var tags = globalConfig.tags +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'nsg1' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'Allow-SSH-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '22' + destinationPortRange: '22' + sourceAddressPrefix: 'VirtualNetwork' //should be compliant with policy NSG-003 + destinationAddressPrefix: 'VirtualNetwork' + priority: 200 + direction: 'Inbound' + } + } + { + name: 'Allow-RDP-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '3389' + destinationPortRange: '3389' + sourceAddressPrefix: 'VirtualNetwork' + destinationAddressPrefix: 'VirtualNetwork' + priority: 210 + direction: 'Inbound' + } + } + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + priority: 220 + direction: 'Inbound' + } + } + { + name: 'Deny-Outbound-SQL' + properties: { + access: 'Deny' + protocol: 'Tcp' + sourcePortRange: '1443' + destinationPortRange: '1433' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'AzureCloud' //should be compliant with policy NSG-004 + priority: 4000 + direction: 'Outbound' + } + } + ] + } +} + +output name string = nsg.name +output resourceId string = nsg.id +output location string = nsg.location diff --git a/tests/policy-integration-tests/network-security-group/tests.ps1 b/tests/policy-integration-tests/network-security-group/tests.ps1 new file mode 100644 index 0000000..5c9e984 --- /dev/null +++ b/tests/policy-integration-tests/network-security-group/tests.ps1 @@ -0,0 +1,69 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +#variables +$resourceId = $script:bicepDeploymentOutputs.resourceId.value +$whatIfFailedTemplatePathNsg = join-path $PSScriptRoot 'main.bad.nsg.bicep' +$whatIfFailedTemplatePathNsgRule = join-path $PSScriptRoot 'main.bad.nsg.rule.bicep' +$whatIfSuccessTemplatePathNsg = join-path $PSScriptRoot 'main.good.nsg.bicep' +$whatIfSuccessTemplatePathNsgRule = join-path $PSScriptRoot 'main.good.nsg.rule.bicep' +$nsgAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_nsgAssignmentName`$" } +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $resourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix + +$violatingPolicies = @( + @{ + policyAssignmentId = $nsgAssignmentId + policyDefinitionReferenceId = 'NSG-003' + } + @{ + policyAssignmentId = $nsgAssignmentId + policyDefinitionReferenceId = 'NSG-004' + } +) + +#define tests +$tests = @() + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'DS-038: Diagnostic Settings Must Be Configured' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-038: Diagnostic Settings Policy Must Be Compliant' $script:token $resourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-038' + +#Deny policies +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy violating deployment for NSG should fail' -token $script:token -templateFilePath $whatIfFailedTemplatePathNsg -deploymentTargetResourceId $script:bicepDeploymentResult.bicepDeploymentTarget -requiredWhatIfStatus 'Failed' -policyViolation $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy violating deployment for NSG Rules should fail' -token $script:token -templateFilePath $whatIfFailedTemplatePathNsgRule -deploymentTargetResourceId $script:bicepDeploymentResult.bicepDeploymentTarget -requiredWhatIfStatus 'Failed' -policyViolation $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy abiding deployment for NSG should succeed' -token $script:token -templateFilePath $whatIfSuccessTemplatePathNsg -deploymentTargetResourceId $script:bicepDeploymentResult.bicepDeploymentTarget -requiredWhatIfStatus 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy abiding deployment for NSG Rule should succeed' -token $script:token -templateFilePath $whatIfSuccessTemplatePathNsgRule -deploymentTargetResourceId $script:bicepDeploymentResult.bicepDeploymentTarget -requiredWhatIfStatus 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/postgresql/config.json b/tests/policy-integration-tests/postgresql/config.json new file mode 100644 index 0000000..61dcf41 --- /dev/null +++ b/tests/policy-integration-tests/postgresql/config.json @@ -0,0 +1,12 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-postgresql" + ], + "testName": "PostgreSQL", + "assignmentName": "pa-d-postgresql", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-postgresql-001", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/postgresql/main.bad.bicep b/tests/policy-integration-tests/postgresql/main.bad.bicep new file mode 100644 index 0000000..96e3488 --- /dev/null +++ b/tests/policy-integration-tests/postgresql/main.bad.bicep @@ -0,0 +1,148 @@ +metadata itemDisplayName = 'Test Template for PostgreSQL' +metadata description = 'This template deploys the testing resource for PostgreSQL.' +metadata summary = 'Deploys test PostgreSQL resources.' + +@description('Optional. The password to leverage for the login.') +@secure() +param password string = newGuid() +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') + +//Define required variables from the configuration files - change these based on your requirements +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +var serviceShort = 'pgs4' //use this to form the name of the resources deployed by this template. This is helpful to identify the resource in the portal and also useful if you want to have a policy that targets specific resources by name. For example, if you have a policy that audits whether storage accounts have secure transfer enabled, you can set serviceShort to 'st' and then in the policy definition, you can target resources with name starting with 'st' to only audit the storage accounts deployed by this test template. +var postgreSqlName = 'psql-${namePrefix}${serviceShort}01' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-02' +var virtualNetworkName = 'vnet-${namePrefix}-${serviceShort}-01' +var routeTableName = 'rt-${namePrefix}-${serviceShort}-01' +var managedIdentityName = 'mi-${namePrefix}-${serviceShort}-01' + +// ============ // +// resources // +// ============ // + +var addressPrefix = '10.100.0.0/16' + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: managedIdentityName + location: location +} + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + sourcePortRange: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 200 + } + } + ] + } +} +resource privateDNSZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: '${serviceShort}.postgres.database.azure.com' + location: 'global' + + resource virtualNetworkLinks 'virtualNetworkLinks@2024-06-01' = { + name: '${virtualNetwork.name}-vnetlink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} +resource routeTable 'Microsoft.Network/routeTables@2025-05-01' = { + name: routeTableName + location: location + properties: { + routes: [] + } +} +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: virtualNetworkName + location: location + + properties: { + addressSpace: { + addressPrefixes: [ + addressPrefix + ] + } + subnets: [ + { + name: 'subnet1' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 0) + networkSecurityGroup: { + id: nsg.id + } + routeTable: { + id: routeTable.id + } + delegations: [ + { + name: 'Microsoft.DBforPostgreSQL.flexibleServers' + properties: { + serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' + } + } + ] + } + } + ] + } +} + +resource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2026-01-01-preview' = { + name: postgreSqlName + location: location + sku: { + name: 'Standard_D2s_v3' + tier: 'GeneralPurpose' + } + + properties: { + createMode: 'Default' + administratorLogin: 'adminUserName' + administratorLoginPassword: password + authConfig: { + activeDirectoryAuth: 'Disabled' // this should violate policy PSG-001: A Microsoft Entra administrator should be provisioned for PostgreSQL servers + passwordAuth: 'Enabled' + } + network: {} // this should violate policy PGS-002: Public network access should be disabled for PostgreSQL flexible servers + } +} + +// ============ // +// outputs // +// ============ // +output defaultSubnetResourceId string = virtualNetwork.properties.subnets[0].id +output virtualNetworkResourceId string = virtualNetwork.id +output privateDnsZoneResourceId string = privateDNSZone.id +output routeTableResourceId string = routeTable.id +output managedIdentityResourceId string = managedIdentity.id +output managedIdentityClientId string = managedIdentity.properties.clientId +output managedIdentityPrincipalId string = managedIdentity.properties.principalId +output name string = postgresql.name +output resourceId string = postgresql.id +output location string = postgresql.location diff --git a/tests/policy-integration-tests/postgresql/main.good.bicep b/tests/policy-integration-tests/postgresql/main.good.bicep new file mode 100644 index 0000000..5fcc587 --- /dev/null +++ b/tests/policy-integration-tests/postgresql/main.good.bicep @@ -0,0 +1,145 @@ +metadata itemDisplayName = 'Test Template for PostgreSQL' +metadata description = 'This template deploys the testing resource for PostgreSQL.' +metadata summary = 'Deploys test PostgreSQL resources that should comply with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') + +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'pgs2' +var postgreSqlName = 'psql-${namePrefix}${serviceShort}01' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-02' +var virtualNetworkName = 'vnet-${namePrefix}-${serviceShort}-01' +var routeTableName = 'rt-${namePrefix}-${serviceShort}-01' +var managedIdentityName = 'mi-${namePrefix}-${serviceShort}-01' + +// ============ // +// resources // +// ============ // + +var addressPrefix = '10.100.0.0/16' + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: managedIdentityName + location: location +} + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + sourcePortRange: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 200 + } + } + ] + } +} +resource privateDNSZone 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: '${serviceShort}.postgres.database.azure.com' + location: 'global' + + resource virtualNetworkLinks 'virtualNetworkLinks@2024-06-01' = { + name: '${virtualNetwork.name}-vnetlink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} +resource routeTable 'Microsoft.Network/routeTables@2025-05-01' = { + name: routeTableName + location: location + properties: { + routes: [] + } +} +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: virtualNetworkName + location: location + + properties: { + addressSpace: { + addressPrefixes: [ + addressPrefix + ] + } + subnets: [ + { + name: 'subnet1' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 0) + networkSecurityGroup: { + id: nsg.id + } + routeTable: { + id: routeTable.id + } + delegations: [ + { + name: 'Microsoft.DBforPostgreSQL.flexibleServers' + properties: { + serviceName: 'Microsoft.DBforPostgreSQL/flexibleServers' + } + } + ] + } + } + ] + } +} + +resource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2026-01-01-preview' = { + name: postgreSqlName + location: location + sku: { + name: 'Standard_D2s_v3' + tier: 'GeneralPurpose' + } + properties: { + createMode: 'Default' + authConfig: { + activeDirectoryAuth: 'Enabled' + passwordAuth: 'Disabled' + } // this should comply with policy PSG-001: A Microsoft Entra administrator should be provisioned for PostgreSQL servers + network: { + delegatedSubnetResourceId: virtualNetwork.properties.subnets[0].id + privateDnsZoneArmResourceId: privateDNSZone.id + } // this should comply with policy PGS-002: Public network access should be disabled for PostgreSQL flexible servers + } +} + +// ============ // +// outputs // +// ============ // +output defaultSubnetResourceId string = virtualNetwork.properties.subnets[0].id +output virtualNetworkResourceId string = virtualNetwork.id +output privateDnsZoneResourceId string = privateDNSZone.id +output routeTableResourceId string = routeTable.id +output managedIdentityResourceId string = managedIdentity.id +output managedIdentityClientId string = managedIdentity.properties.clientId +output managedIdentityPrincipalId string = managedIdentity.properties.principalId +output name string = postgresql.name +output resourceId string = postgresql.id +output location string = postgresql.location diff --git a/tests/policy-integration-tests/postgresql/tests.ps1 b/tests/policy-integration-tests/postgresql/tests.ps1 new file mode 100644 index 0000000..3832437 --- /dev/null +++ b/tests/policy-integration-tests/postgresql/tests.ps1 @@ -0,0 +1,64 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region defining tests +<# +The following policy definitions are tested:. + - PGS-001: Azure PostgreSQL flexible server should have Microsoft Entra Only Authentication enabled (Deny) + - PGS-002: Public network access should be disabled for PostgreSQL flexible servers (Deny) +#> + +$policyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_assignmentName`$" } + +$violatingPolicies = @( + @{ + policyAssignmentId = $policyAssignmentId + policyDefinitionReferenceId = 'PGS-001' + } + @{ + policyAssignmentId = $policyAssignmentId + policyDefinitionReferenceId = 'PGS-002' + } +) + +#define tests +$tests = @() + +#AuditIfNotExists policies + +#Deny policies +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy abiding deployment should succeed' $script:token $script:whatIfComplyBicepTemplatePath $script:testResourceGroupId 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:testResourceGroupId 'Failed' $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry + +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/private-endpoint/config.json b/tests/policy-integration-tests/private-endpoint/config.json new file mode 100644 index 0000000..65ac883 --- /dev/null +++ b/tests/policy-integration-tests/private-endpoint/config.json @@ -0,0 +1,12 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV-LandingZones/providers/Microsoft.Authorization/policyAssignments/pa-d-pe-lz" + ], + "testName": "PE", + "peAssignmentName": "pa-d-pe-lz", + "testSubscription": "sub-d-lz-online-01", + "testResourceGroup": "rg-ae-d-policy-test-pe-001", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/private-endpoint/main.bad.bicep b/tests/policy-integration-tests/private-endpoint/main.bad.bicep new file mode 100644 index 0000000..a9be42d --- /dev/null +++ b/tests/policy-integration-tests/private-endpoint/main.bad.bicep @@ -0,0 +1,108 @@ +metadata itemDisplayName = 'Test Template for Azure Private Endpoint' +metadata description = 'This template deploys the testing resource for Azure Private Endpoint.' +metadata summary = 'Deploys test Azure Private Endpoint resources that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +var vnetName = 'vnet-${namePrefix}${serviceShort}01' +var amplsSubnetName = 'sn-ampls' +var vnetAddressPrefix = '10.200.0.0/16' +var amplsSubnetPrefix = '10.200.0.0/24' + +// define template specific variables +var serviceShort = 'ampls1' +var privateLinkScopeName = 'ampls-${namePrefix}${serviceShort}01' +var privateLinkScopePrivateEndpointName = 'pe-${privateLinkScopeName}' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: '*' + destinationAddressPrefix: 'VirtualNetwork' + priority: 200 + direction: 'Inbound' + } + } + ] + } +} + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + vnetAddressPrefix + ] + } + } + + resource amplsSubnet 'subnets' = { + name: amplsSubnetName + properties: { + addressPrefix: amplsSubnetPrefix + networkSecurityGroup: { + id: nsg.id + } + } + } +} + +resource ampls 'microsoft.insights/privateLinkScopes@2021-07-01-preview' = { + name: privateLinkScopeName + location: 'global' + properties: { + accessModeSettings: { + ingestionAccessMode: 'PrivateOnly' + queryAccessMode: 'Open' + } + } +} + +resource amplsPe 'Microsoft.Network/privateEndpoints@2024-05-01' = { + name: privateLinkScopePrivateEndpointName + location: location + properties: { + subnet: { + id: vnet::amplsSubnet.id + } + privateLinkServiceConnections: [ + { + name: '${last(split(ampls.id, '/'))}-azuremonitor' + properties: { + privateLinkServiceId: ampls.id + groupIds: [ + 'azuremonitor' //should violate policy PE-002: groupId 'azuremonitor' for AMPLS private endpoint is not allowed + ] + } + } + ] + } +} + +// ---------- Outputs ---------- +output name string = ampls.name +output resourceId string = ampls.id +output location string = ampls.location +output vnetResourceId string = vnet.id +output privateEndpointName string = privateLinkScopePrivateEndpointName +output privateEndpointResourceId string = amplsPe.id +output resourceGroupId string = resourceGroup().id diff --git a/tests/policy-integration-tests/private-endpoint/tests.ps1 b/tests/policy-integration-tests/private-endpoint/tests.ps1 new file mode 100644 index 0000000..e114ef2 --- /dev/null +++ b/tests/policy-integration-tests/private-endpoint/tests.ps1 @@ -0,0 +1,54 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +Test cases: +- P-PE-02: AMPLS Private Endpoint is not allowed (Deny) +#> + +#variables + +$pePolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_peAssignmentName`$" } +$amplsViolatingPolicies = @( + @{ + policyAssignmentId = $pePolicyAssignmentId + policyDefinitionReferenceId = 'PE-002' + } +) + +#define tests +$tests = @() +$tests += New-ARTWhatIfDeploymentTestConfig 'E-002: AMPLS Private Endpoints Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:testResourceGroupId 'Failed' $amplsViolatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/storage-account/config.json b/tests/policy-integration-tests/storage-account/config.json new file mode 100644 index 0000000..eec8c82 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/config.json @@ -0,0 +1,14 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-storage", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-pedns", + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "StorageAccount", + "storageAccountAssignmentName": "pa-d-storage", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "peDNSAssignmentName": "pa-d-pedns", + "testSubscription": "sub-d-lz-corp-01", + "location": "australiaeast", + "removeTestResourceGroup": false +} diff --git a/tests/policy-integration-tests/storage-account/main-bad-terraform/main.tf b/tests/policy-integration-tests/storage-account/main-bad-terraform/main.tf new file mode 100644 index 0000000..907ddf7 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-bad-terraform/main.tf @@ -0,0 +1,33 @@ +resource "azapi_resource" "rg" { + type = "Microsoft.Resources/resourceGroups@2025-04-01" + name = var.resource_group_name + location = var.location +} + +resource "azapi_resource" "storage_account" { + type = "Microsoft.Storage/storageAccounts@2025-06-01" + name = var.storage_account_name + location = var.location + parent_id = azapi_resource.rg.id + body = { + sku = { + name = "Standard_LRS" + } + tags = { + application = "policy-integration-tests" + } + kind = "StorageV2" + properties = { + minimumTlsVersion = "TLS1_1" # Should violate policy STG-010 + allowSharedKeyAccess = true # Should violate policy STG-007 + allowCrossTenantReplication = true # Should violate policy STG-006 + allowedCopyScope = "" # Should violate policy STG-012 + publicNetworkAccess = "Enabled" # Should violate policy STG-009 + supportsHttpsTrafficOnly = false # Should violate policy STG-008 + networkAcls = { + bypass = "AzureServices" + defaultAction = "Allow" + } + } + } +} diff --git a/tests/policy-integration-tests/storage-account/main-bad-terraform/outputs.tf b/tests/policy-integration-tests/storage-account/main-bad-terraform/outputs.tf new file mode 100644 index 0000000..728b2c4 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-bad-terraform/outputs.tf @@ -0,0 +1,14 @@ +output "storage_account_name" { + value = azapi_resource.storage_account.name +} +output "storage_account_id" { + value = azapi_resource.storage_account.id +} + +output "resource_group_name" { + value = azapi_resource.rg.name +} + +output "resource_group_id" { + value = azapi_resource.rg.id +} diff --git a/tests/policy-integration-tests/storage-account/main-bad-terraform/providers.tf b/tests/policy-integration-tests/storage-account/main-bad-terraform/providers.tf new file mode 100644 index 0000000..9422570 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-bad-terraform/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">=1.14.6" + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.5" + } + } +} +# Configure the Microsoft Azure Provider +provider "azapi" { + #subscription_id = "dc2d72b7-a48d-45e8-91cc-81193ecc659b" +} diff --git a/tests/policy-integration-tests/storage-account/main-bad-terraform/variables.tf b/tests/policy-integration-tests/storage-account/main-bad-terraform/variables.tf new file mode 100644 index 0000000..06dc7e6 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-bad-terraform/variables.tf @@ -0,0 +1,14 @@ +variable "resource_group_name" { + type = string + default = "rg-ae-d-policy-test-storage-002" +} + +variable "storage_account_name" { + type = string + default = "sataoaztesttf02" +} + +variable "location" { + type = string + default = "australiaeast" +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/data.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/data.tf new file mode 100644 index 0000000..7a40590 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/data.tf @@ -0,0 +1,15 @@ +data "azapi_resource" "vnet_rg" { + type = "Microsoft.Resources/resourceGroups@2025-04-01" + name = local.vnetResourceGroupName +} +data "azapi_resource" "vnet" { + type = "Microsoft.Network/virtualNetworks@2024-10-01" + name = local.vnetName + parent_id = data.azapi_resource.vnet_rg.id +} + +data "azapi_resource" "pe_subnet" { + type = "Microsoft.Network/virtualNetworks/subnets@2024-10-01" + name = local.peSubnetName + parent_id = data.azapi_resource.vnet.id +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/local.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/local.tf new file mode 100644 index 0000000..ee3d86b --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/local.tf @@ -0,0 +1,9 @@ +locals { + globalconfig = jsondecode(replace(replace(file("${path.module}/../../.shared/policy_integration_test_config.jsonc"), "/\\/\\/[^\\n]*/", ""), "/,\\s*(\\]|\\})/", "$1")) + localconfig = jsondecode(file("${path.module}/../config.json")) + subName = local.localconfig.testSubscription + vnetName = local.globalconfig.subscriptions[local.subName].vNet + vnetResourceGroupName = local.globalconfig.subscriptions[local.subName].networkResourceGroup + peSubnetName = local.globalconfig.subscriptions[local.subName].peSubnet + location = local.localconfig.location +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/main.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/main.tf new file mode 100644 index 0000000..0427cf1 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/main.tf @@ -0,0 +1,54 @@ +resource "azapi_resource" "rg" { + type = "Microsoft.Resources/resourceGroups@2024-07-01" + name = var.resource_group_name + location = local.location +} + +resource "azapi_resource" "storage_account" { + type = "Microsoft.Storage/storageAccounts@2024-01-01" + name = var.storage_account_name + location = local.location + parent_id = azapi_resource.rg.id + body = { + sku = { + name = "Standard_LRS" + } + kind = "StorageV2" + properties = { + minimumTlsVersion = "TLS1_2" + allowSharedKeyAccess = false + allowCrossTenantReplication = false + allowedCopyScope = "AAD" + publicNetworkAccess = "Disabled" + supportsHttpsTrafficOnly = true + networkAcls = { + bypass = "AzureServices" + defaultAction = "Deny" + } + } + } +} + +resource "azapi_resource" "storage_account_blob_pe" { + type = "Microsoft.Network/privateEndpoints@2024-07-01" + name = var.storage_account_blob_pe_name + location = local.location + parent_id = azapi_resource.rg.id + body = { + properties = { + customNetworkInterfaceName = var.storage_account_blob_pe_nic_name + privateLinkServiceConnections = [ + { + name = "blob-pls-connection" + properties = { + privateLinkServiceId = azapi_resource.storage_account.id + groupIds = ["blob"] + } + } + ] + subnet = { + id = data.azapi_resource.pe_subnet.id + } + } + } +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/outputs.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/outputs.tf new file mode 100644 index 0000000..0f738d8 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/outputs.tf @@ -0,0 +1,22 @@ +output "storage_account_name" { + value = azapi_resource.storage_account.name +} +output "storage_account_id" { + value = azapi_resource.storage_account.id +} + +output "resource_group_name" { + value = azapi_resource.rg.name +} + +output "resource_group_id" { + value = azapi_resource.rg.id +} + +output "storage_account_blob_pe_name" { + value = azapi_resource.storage_account_blob_pe.name +} + +output "storage_account_blob_pe_id" { + value = azapi_resource.storage_account_blob_pe.id +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/providers.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/providers.tf new file mode 100644 index 0000000..95f38b2 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">=1.14.6" + required_providers { + azapi = { + source = "azure/azapi" + version = "~> 2.5" + } + } +} +# Configure the Microsoft Azure Provider +provider "azapi" { + #åsubscription_id = "dc2d72b7-a48d-45e8-91cc-81193ecc659b" +} diff --git a/tests/policy-integration-tests/storage-account/main-test-terraform/variables.tf b/tests/policy-integration-tests/storage-account/main-test-terraform/variables.tf new file mode 100644 index 0000000..2c4d04d --- /dev/null +++ b/tests/policy-integration-tests/storage-account/main-test-terraform/variables.tf @@ -0,0 +1,17 @@ +variable "resource_group_name" { + type = string + default = "rg-ae-d-policy-test-storage-001" +} + +variable "storage_account_blob_pe_nic_name" { + type = string + default = "nic_pe-sataoaztesttf01-blob-01" +} +variable "storage_account_blob_pe_name" { + type = string + default = "pe-sataoaztesttf01-blob-01" +} +variable "storage_account_name" { + type = string + default = "sataoaztesttf01" +} diff --git a/tests/policy-integration-tests/storage-account/tests.ps1 b/tests/policy-integration-tests/storage-account/tests.ps1 new file mode 100644 index 0000000..6ee34b9 --- /dev/null +++ b/tests/policy-integration-tests/storage-account/tests.ps1 @@ -0,0 +1,106 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +$storageAccountId = $script:terraformDeploymentOutputs.storage_account_id.value +$storageAccountName = ($storageAccountId -split ('/'))[-1] +$privateEndpointResourceId = $script:terraformDeploymentOutputs.storage_account_blob_pe_id.value +Write-Verbose "Storage Account Id: $storageAccountId" -verbose +$privateDNSSubscriptionId = $script:GlobalConfig_subscriptions.$script:GlobalConfig_privateDNSSubscription.id +$privateDNSResourceGroup = $script:GlobalConfig_privateDNSResourceGroup +$storagePolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_storageAccountAssignmentName`$" } +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$peDNSPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_peDNSAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $storageAccountId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$blobPrivateDNSARecordId = "/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.Network/privateDnsZones/privatelink.blob.core.windows.net/A/{2}" -f $privateDNSSubscriptionId, $privateDNSResourceGroup, $storageAccountName + +$violatingPolicies = @( + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-006' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-007' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Audit' + } + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-009' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-010' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-012' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Deny' + } + @{ + policyAssignmentId = $storagePolicyAssignmentId + policyDefinitionReferenceId = 'STG-008' + resourceReference = 'azapi_resource.storage_account' + policyEffect = 'Deny' + } + +) +#define tests +$tests = @() + +#Modify / Append Policies +#TAG-010 all-inherit-tag-from-rg (dataclass) +$tests += New-ARTPropertyCountTestConfig 'TAG-010: Resource Should have dataclass tag' $script:token $storageAccountId 'tags.dataclass' 'equals' 1 + +#TAG-011 all-inherit-tag-from-rg (owner) +$tests += New-ARTPropertyCountTestConfig 'TAG-011: Resource Should have owner tag' $script:token $storageAccountId 'tags.owner' 'equals' 1 + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'DS-052: Diagnostic Settings Must Be Configured' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-052: Diagnostic Settings Policy Must Be Compliant' $script:token $storageAccountId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-052' + +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-002: Private DNS Record for Storage Blob must exist' $script:token $blobPrivateDNSARecordId 'exists' +$tests += New-ARTPolicyStateTestConfig 'PEDNS-002: Private DNS Record Policy Must Be Compliant' $script:token $privateEndpointResourceId $peDNSPolicyAssignmentId 'Compliant' 'PEDNS-002' + +#Deny policies +$tests += New-ARTTerraformPolicyRestrictionTestConfig -testName 'Violating Audit and Deny Policies should be detected from test Terraform template' -token $script:token -terraformDirectory $script:terraformViolateDirectoryPath -policyViolation $violatingPolicies +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/tags/README.md b/tests/policy-integration-tests/tags/README.md new file mode 100644 index 0000000..cc27d9f --- /dev/null +++ b/tests/policy-integration-tests/tags/README.md @@ -0,0 +1,30 @@ +# Policy Integration Test - Sample Test Cases for Azure Resource Tags + +## Introduction + +This folder contains a sample test case for Azure Resource Tags related policies. + +The test case is designed to test the following policy assignments: + +| Policy Assignment Name | Policy Assignment Scope | Description | +| :-------------------- | :--------------------- | :---------- | +| `pa-d-tags` | `/providers/Microsoft.Management/managementGroups/CONTOSO-DEV` | Policy Assignment for resource tags initiative | + +The following policies are in scope for testing: + +| Policy Assignment | Policy Reference ID | Policy Name | Policy Effect | +| :---------------- | :---------------- | :------------ | :------------ | +| `pa-d-tags` | `TAG-001` | Subscription Should have required tag (appid) | `Deny` | +| `pa-d-tags` | `TAG-002` | Subscription Should have required tag value (dataclass) | `Deny` | +| `pa-d-tags` | `TAG-003` | Subscription Should have required tag (owner) | `Deny` | +| `pa-d-tags` | `TAG-004` | Subscription Should have required tag (supportteam) | `Deny` | +| `pa-d-tags` | `TAG-005` | Inherit the tag from the Subscription to Resource Group if missing (appid) | `Modify` | +| `pa-d-tags` | `TAG-006` | Inherit the tag from the Subscription to Resource Group if missing (dataclass) | `Modify` | +| `pa-d-tags` | `TAG-007` | Inherit the tag from the Subscription to Resource Group if missing (owner) | `Modify` | +| `pa-d-tags` | `TAG-008` | Inherit the tag from the Subscription to Resource Group if missing (supportteam) | `Modify` | +| `pa-d-tags` | `TAG-013` | Resource Group Should have required tag value for dataclass tag | `Deny` | +| `pa-d-tags` | `TAG-014` | Resource Should have required tag value for dataclass tag | `Deny` | +| `pa-d-tags` | `TAG-015` | Subscription Should have required tag value for environment tag | `Deny` | +| `pa-d-tags` | `TAG-016` | Resource Group Should have required tag value for environment tag | `Deny` | +| `pa-d-tags` | `TAG-017` | Resource Should have required tag value for environment tag | `Deny` | +| `pa-d-tags` | `TAG-018` | Inherit the tag from the Subscription to Resource Group if missing (environment) | `Modify` | diff --git a/tests/policy-integration-tests/tags/config.json b/tests/policy-integration-tests/tags/config.json new file mode 100644 index 0000000..0547731 --- /dev/null +++ b/tests/policy-integration-tests/tags/config.json @@ -0,0 +1,14 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-DEV/providers/Microsoft.Authorization/policyAssignments/pa-d-tags" + ], + "testName": "tags", + "assignmentName": "pa-d-tags", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-tags-001", + "location": "australiaeast", + "whatIfViolateBicepTemplateForRGName": "main.bad.rg.bicep", + "whatIfViolateBicepTemplateForResourcesName": "main.bad.resource.bicep", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/tags/main.bad.resource.bicep b/tests/policy-integration-tests/tags/main.bad.resource.bicep new file mode 100644 index 0000000..fc9d9a8 --- /dev/null +++ b/tests/policy-integration-tests/tags/main.bad.resource.bicep @@ -0,0 +1,35 @@ +metadata itemDisplayName = 'Test Template for Tagging Policy Assignment (Resource)' +metadata description = 'This template deploys the testing resource for Tagging Policy Assignment.' +metadata summary = 'Deploys test a resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location + +var namePrefix = globalConfig.namePrefix +var tags = { + dataclass: 'official-internal' //this should violate the policy TAG-014: Resource Should have required tag value (dataclass). 'official-internal' is not one of the allowed values + environment: 'hell' //this should violate the policy TAG-017: Resource Should have required tag value (environment). 'hell' is not one of the allowed values +} +// define template specific variables +var serviceShort = 'tag' +var uamiName = 'uami-${namePrefix}-${serviceShort}-01' + +resource uami 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: uamiName + location: location + tags: tags +} + +@description('The ID of the resource created.') +output resourceId string = uami.id + +@description('The name of the resource.') +output name string = uami.name + +@description('The location of the resource.') +output location string = uami.location diff --git a/tests/policy-integration-tests/tags/main.bad.rg.bicep b/tests/policy-integration-tests/tags/main.bad.rg.bicep new file mode 100644 index 0000000..e8774de --- /dev/null +++ b/tests/policy-integration-tests/tags/main.bad.rg.bicep @@ -0,0 +1,36 @@ +targetScope = 'subscription' +metadata itemDisplayName = 'Test Template for Tagging Policy Assignment (Resource Group)' +metadata description = 'This template deploys the testing resource for Tagging Policy Assignment.' +metadata summary = 'Deploys test a resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location + +var namePrefix = globalConfig.namePrefix +var tags = { + dataclass: 'official-internal' //this should violate the policy TAG-013: Resource Group Should have required tag value (dataclass). 'official-internal' is not one of the allowed values + environment: 'hell' //this should violate the policy TAG-016: Resource Group Should have required tag value (environment). 'hell' is not one of the allowed values +} +// define template specific variables +var serviceShort = 'tag' +var rgName = 'rg-${namePrefix}-${serviceShort}-01' + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: rgName + location: location + tags: tags +} + +@description('The ID of the resource group created.') +output resourceId string = resourceGroup.id + +@description('The name of the resource group.') +output name string = resourceGroup.name + +@description('The location of the resource group.') +output location string = resourceGroup.location diff --git a/tests/policy-integration-tests/tags/main.test.bicep b/tests/policy-integration-tests/tags/main.test.bicep new file mode 100644 index 0000000..06dabf7 --- /dev/null +++ b/tests/policy-integration-tests/tags/main.test.bicep @@ -0,0 +1,26 @@ +targetScope = 'subscription' +metadata itemDisplayName = 'Test Template for Tags' +metadata description = 'This template deploys the testing resource for Tags.' +metadata summary = 'Deploys test resource for testing tagging policies.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location + +var namePrefix = globalConfig.namePrefix +// define template specific variables +var serviceShort = 'tag1' +var rgName = 'rg-${namePrefix}-${serviceShort}-01' + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: rgName + location: location +} + +output name string = resourceGroup.name +output resourceId string = resourceGroup.id +output location string = resourceGroup.location diff --git a/tests/policy-integration-tests/tags/tests.ps1 b/tests/policy-integration-tests/tags/tests.ps1 new file mode 100644 index 0000000..b9e124d --- /dev/null +++ b/tests/policy-integration-tests/tags/tests.ps1 @@ -0,0 +1,145 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region test specific configuration and tests +<# +Test cases: +- TAG-001: Subscription Should have appid tag (deny) +- TAG-002: Subscription Should have dataclass tag with allowed value (deny) +- TAG-003: Subscription Should have owner tag (deny) +- TAG-004: Subscription Should have supportteam tag (deny) +- TAG-005: Inherit the tag from the Subscription to Resource Group if missing (appid) +- TAG-006: Inherit the tag from the Subscription to Resource Group if missing (dataclass) +- TAG-007: Inherit the tag from the Subscription to Resource Group if missing (owner) +- TAG-008: Inherit the tag from the Subscription to Resource Group if missing (supportteam) +- TAG-013: Resource Group Should have required tag value for dataclass tag (deny) +- TAG-014: Resource Should have required tag value for dataclass tag (deny) +- TAG-015: Subscription Should have required tag value for environment tag (deny) +- TAG-016: Resource Group Should have required tag value for environment tag (deny) +- TAG-017: Resource Should have required tag value for environment tag (deny) +- TAG-018: Inherit the tag from the Subscription to Resource Group if missing (environment) +#> + +#variables + +#Parse deployment outputs +$resourceId = $script:bicepDeploymentOutputs.resourceId.Value #Deployed resource group + +#This test case uses non-standard bicep template names for what-if validation because it requires 2 separate templates. The template file names are defined in local config in this case. +$resourceWhatIfFailedTemplatePath = join-path $PSScriptRoot $script:LocalConfig_whatIfViolateBicepTemplateForResourcesName +$rgWhatIfFailedTemplatePath = join-path $PSScriptRoot $script:LocalConfig_whatIfViolateBicepTemplateForRGName + +$testSubscriptionResourceId = '/subscriptions/{0}' -f $script:testSubscriptionId +$taggingAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_assignmentName`$" } + +$subViolatingPolicies = @( + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-001' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-002' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-003' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-004' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-015' + } +) + +$rgViolatingPolicies = @( + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-013' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-016' + } +) + +$resourceViolatingPolicies = @( + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-014' + }, + @{ + policyAssignmentId = $taggingAssignmentId + policyDefinitionReferenceId = 'TAG-017' + } +) + + +#test sub update +$subViolatingTags = @{ + appid1 = '10207' #this should violate the policy TAG-001: Subscription Should have required tag (SolutionID) + owner1 = 'platform-team' #this should violate the policy TAG-003: Subscription Should have required tag (owner) + dataclass = 'official-internal' #this should violate the policy TAG-002: Subscription Should have required tag value (dataclass). 'official-internal' is not one of the allowed values + supportteam1 = 'platform-team' #this should violate the policy TAG-004: Subscription Should have required tag (supportteam) + environment = "hell" #this should violate the policy TAG-015: Subscription Should have required tag value (environment). 'hell' is not one of the allowed values +} + +$subTagUpdateTestResponse = updateAzResourceTags -resourceId $testSubscriptionResourceId -token $script:token -tags $subViolatingTags -revertBack $true +$subTagUpdatePolicyActualViolations = ($subTagUpdateTestResponse.content | ConvertFrom-Json -depth 10).error.additionalInfo | Where-Object { $_.type -ieq 'policyviolation' } + +#define tests +$tests = @() +$tests += New-ARTManualWhatIfTestConfig -testName 'Subscription Tagging Policy violating update should fail' -actualPolicyViolation $subTagUpdatePolicyActualViolations -desiredPolicyViolation $subViolatingPolicies +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Resource Group Tagging Policy violating deployment should fail' -token $script:token -templateFilePath $rgWhatIfFailedTemplatePath -deploymentTargetResourceId $testSubscriptionResourceId -requiredWhatIfStatus 'Failed' -policyViolation $rgViolatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry -azureLocation $script:LocalConfig_location +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Resource Tagging Policy violating deployment should fail' -token $script:token -templateFilePath $resourceWhatIfFailedTemplatePath -deploymentTargetResourceId $resourceId -requiredWhatIfStatus 'Failed' -policyViolation $resourceViolatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry + +#Modify / Append Policies +#TAG-005 rg-inherit-tag-from-sub (appid) +$tests += New-ARTPropertyCountTestConfig 'TAG-005: Resource Group Should have appid tag' $script:token $resourceId 'tags.appid' 'equals' 1 + +#TAG-006 rg-inherit-tag-from-sub (dataclass) +$tests += New-ARTPropertyCountTestConfig 'TAG-006: Resource Group Should have dataclass tag' $script:token $resourceId 'tags.dataclass' 'equals' 1 + +#TAG-007 rg-inherit-tag-from-sub (owner) +$tests += New-ARTPropertyCountTestConfig 'TAG-007: Resource Group Should have owner tag' $script:token $resourceId 'tags.owner' 'equals' 1 + +#TAG-008 rg-inherit-tag-from-sub (supportteam) +$tests += New-ARTPropertyCountTestConfig 'TAG-008: Resource Group Should have supportteam tag' $script:token $resourceId 'tags.supportteam' 'equals' 1 + +#TAG-018 rg-inherit-tag-from-sub (environment) +$tests += New-ARTPropertyCountTestConfig 'TAG-018: Resource Group Should have environment tag' $script:token $resourceId 'tags.environment' 'equals' 1 +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/virtual-network/config.json b/tests/policy-integration-tests/virtual-network/config.json new file mode 100644 index 0000000..0fde4c3 --- /dev/null +++ b/tests/policy-integration-tests/virtual-network/config.json @@ -0,0 +1,15 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-dev/providers/Microsoft.Authorization/policyAssignments/pa-d-vnet", + "/providers/Microsoft.Management/managementGroups/CONTOSO-dev/providers/microsoft.authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "VNet", + "vnetAssignmentName": "pa-d-vnet", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-vnet-001", + "location": "australiaeast", + "location2": "australiasoutheast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/virtual-network/main.bad.bicep b/tests/policy-integration-tests/virtual-network/main.bad.bicep new file mode 100644 index 0000000..21aed8f --- /dev/null +++ b/tests/policy-integration-tests/virtual-network/main.bad.bicep @@ -0,0 +1,78 @@ +metadata itemDisplayName = 'Test Template for Virtual Network' +metadata description = 'This template deploys the testing resource for Virtual Network.' +metadata summary = 'Deploys test virtual network resource that should violate some policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'vnet3' + +var vnetName = 'vnet-${namePrefix}-${serviceShort}-01' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + sourcePortRange: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 200 + } + } + ] + } +} + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.200.0.0/16' + ] + } + } + + resource subnet1 'subnets' = { + name: 'subnet1' + properties: { + addressPrefix: '10.200.1.0/24' + //networkSecurityGroup: { + // id: nsg.id //this should violate the policy VNET-002: Subnets should be associated with a Network Security Group + //} + } + } + resource gatewaySubnet 'subnets' = { + name: 'GatewaySubnet' + properties: { + addressPrefix: '10.200.250.0/24' + networkSecurityGroup: { + id: nsg.id //this should violate the policy VNET-001: Gateway Subnet should not have Network Security Group associated + } + } + } +} + +output name string = vnet.name +output resourceId string = vnet.id +output nsgResourceId string = nsg.id +output nsgName string = nsg.name +output location string = location diff --git a/tests/policy-integration-tests/virtual-network/main.good.bicep b/tests/policy-integration-tests/virtual-network/main.good.bicep new file mode 100644 index 0000000..351193c --- /dev/null +++ b/tests/policy-integration-tests/virtual-network/main.good.bicep @@ -0,0 +1,77 @@ +metadata itemDisplayName = 'Test Template for Virtual Network' +metadata description = 'This template deploys the testing resource for Virtual Network.' +metadata summary = 'Deploys test virtual network resource that should be compliant with all policy assignments.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'vnet2' + +var vnetName = 'vnet-${namePrefix}-${serviceShort}-01' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + priority: 200 + direction: 'Inbound' + } + } + ] + } +} + +resource vnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.100.0.0/16' + ] + } + } + + resource subnet1 'subnets' = { + name: 'subnet1' + properties: { + addressPrefix: '10.100.1.0/24' + networkSecurityGroup: { + id: nsg.id //this should comply with the policy VNET-002: Subnets should be associated with a Network Security Group + } + } + } + resource gatewaySubnet 'subnets' = { + name: 'GatewaySubnet' + properties: { + addressPrefix: '10.100.250.0/24' + //networkSecurityGroup: { + // id: nsg.id //this comply with the policy VNET-001: Gateway Subnet should not have Network Security Group associated + //} + } + } +} + +output name string = vnet.name +output resourceId string = vnet.id +output nsgResourceId string = nsg.id +output nsgName string = nsg.name +output location string = location diff --git a/tests/policy-integration-tests/virtual-network/main.test.bicep b/tests/policy-integration-tests/virtual-network/main.test.bicep new file mode 100644 index 0000000..5112553 --- /dev/null +++ b/tests/policy-integration-tests/virtual-network/main.test.bicep @@ -0,0 +1,118 @@ +metadata itemDisplayName = 'Test Template for Virtual Network' +metadata description = 'This template deploys the testing resource for Virtual Network.' +metadata summary = 'Deploys test virtual network resource.' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var location2 = localConfig.location2 +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'vnet1' +var aeVnetName = 'vnet-${namePrefix}-${serviceShort}-01' +var aeNsgName = 'nsg-${namePrefix}-${serviceShort}-01' + +var aseVnetName = 'vnet-${namePrefix}-${serviceShort}-02' +var aseNsgName = 'nsg-${namePrefix}-${serviceShort}-02' + +//NSGs +resource aeNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: aeNsgName + location: location + properties: { + securityRules: [ + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + priority: 200 + direction: 'Inbound' + } + } + ] + } +} + +resource aseNsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: aseNsgName + location: location2 + properties: { + securityRules: [ + { + name: 'Allow-HTTPS-Inbound' + properties: { + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '443' + destinationPortRange: '443' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + priority: 200 + direction: 'Inbound' + } + } + ] + } +} + +//vnets +resource aeVnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: aeVnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + } + + resource subnet 'subnets' = { + name: 'subnet1' + properties: { + addressPrefix: '10.0.1.0/24' + networkSecurityGroup: { + id: aeNsg.id + } + } + } +} + +resource aseVnet 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: aseVnetName + location: location2 + properties: { + addressSpace: { + addressPrefixes: [ + '10.1.0.0/16' + ] + } + } + + resource subnet 'subnets' = { + name: 'subnet1' + properties: { + addressPrefix: '10.1.1.0/24' + networkSecurityGroup: { + id: aseNsg.id + } + } + } +} + +output name string = aeVnet.name +output resourceId string = aeVnet.id +output location string = location +output aseVNetName string = aseVnet.name +output aseVNetResourceId string = aseVnet.id +output location2 string = location2 diff --git a/tests/policy-integration-tests/virtual-network/tests.ps1 b/tests/policy-integration-tests/virtual-network/tests.ps1 new file mode 100644 index 0000000..d3dbf54 --- /dev/null +++ b/tests/policy-integration-tests/virtual-network/tests.ps1 @@ -0,0 +1,93 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region defining tests +<# +Test cases: +- VNET-001: Gateway Subnet should not have Network Security Group associated (Deny) +- VNET-002: Subnets should be associated with a Network Security Group (Deny) +- VNET-003: Configure virtual networks to enable vnet flow log and traffic analytics (Australia East) (DeployIfNotExists) +- VNET-004: Configure virtual networks to enable vnet flow log and traffic analytics (Australia Southeast) (DeployIfNotExists) +- DS-058: Configure Diagnostic logging for Virtual Network (DeployIfNotExists) +#> +#variables +#Australia East VNet +$resourceId = $script:bicepDeploymentOutputs.resourceId.value +$resourceName = $script:bicepDeploymentOutputs.name.value + +#Australia Southeast VNet +$aseVNetResourceName = $script:bicepDeploymentOutputs.aseVNetName.value +$aseVNetResourceId = $script:bicepDeploymentOutputs.aseVNetResourceId.value + +$vnetPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_vnetAssignmentName`$" } +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } +$diagnosticSettingsId = "{0}{1}" -f $resourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$aeVnetFlowLogId = '/subscriptions/{0}/resourceGroups/NetworkWatcherRG/providers/microsoft.network/networkwatchers/networkWatcher_australiaeast/flowlogs/{1}-flowlog' -f $script:testSubscriptionId, $resourceName +$aseVnetFlowLogId = '/subscriptions/{0}/resourceGroups/NetworkWatcherRG/providers/microsoft.network/networkwatchers/networkWatcher_australiasoutheast/flowlogs/{1}-flowlog' -f $script:testSubscriptionId, $aseVNetResourceName + +#define violating deny policies +$violatingPolicies = @( + @{ + policyAssignmentId = $vnetPolicyAssignmentId + policyDefinitionReferenceId = 'VNET-001' + } + @{ + policyAssignmentId = $vnetPolicyAssignmentId + policyDefinitionReferenceId = 'VNET-002' + } +) +#define tests +$tests = @() + +#DeployIfNotExists Policies +# DS-020 vnet-config-diag-logs +$tests += New-ARTResourceExistenceTestConfig 'DS-058: Diagnostic Settings for VNet Must Be Configured' $script:token $diagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-058: Diagnostic Settings Policy Must Be Compliant' $script:token $resourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-058' + +#VNet Flow logs (Australia East) +$tests += New-ARTPropertyValueTestConfig -testName 'VNET-003: VNet Flow Log must be enabled in Australia East' -token $script:token -resourceId $aeVnetFlowLogId -property 'properties.enabled' -valueType 'boolean' -condition 'equals' -value $true -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPropertyCountTestConfig -testName 'VNET-003: VNet Flow Log (Australia East) Must Be Configured to use Log Analytics Workspace' -token $script:token -resourceId $aeVnetFlowLogId -property 'properties.flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration.workspaceResourceId' -condition 'equals' -count 1 -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPropertyValueTestConfig -testName 'VNET-003: VNet Flow Log (Australia East) Traffic Analytics must be enabled' -token $script:token -resourceId $aeVnetFlowLogId -property 'properties.flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration.enabled' -valueType 'boolean' -condition 'equals' -value $true -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPolicyStateTestConfig 'VNET-003: VNet Flow Log Policy (Australia East) Must Be Compliant' $script:token $resourceId $vnetPolicyAssignmentId 'Compliant' 'VNET-003' + +#VNet Flow logs (Australia Southeast) +$tests += New-ARTPropertyValueTestConfig -testName 'VNET-004: VNet Flow Log must be enabled in Australia Southeast' -token $script:token -resourceId $aseVnetFlowLogId -property 'properties.enabled' -valueType 'boolean' -condition 'equals' -value $true -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPropertyCountTestConfig -testName 'VNET-004: VNet Flow Log (Australia Southeast) Must Be Configured to use Log Analytics Workspace' -token $script:token -resourceId $aseVnetFlowLogId -property 'properties.flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration.workspaceResourceId' -condition 'equals' -count 1 -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPropertyValueTestConfig -testName 'VNET-004: VNet Flow Log (Australia Southeast) Traffic Analytics must be enabled' -token $script:token -resourceId $aseVnetFlowLogId -property 'properties.flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration.enabled' -valueType 'boolean' -condition 'equals' -value $true -apiVersion $script:GlobalConfig_vnetFlowLogApiVersion +$tests += New-ARTPolicyStateTestConfig 'VNET-004: VNet Flow Log Policy (Australia Southeast) Must Be Compliant' $script:token $aseVNetResourceId $vnetPolicyAssignmentId 'Compliant' 'VNET-004' + +#Deny policies +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy abiding deployment should succeed' $script:token $script:whatIfComplyBicepTemplatePath $script:testResourceGroupId 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig 'Policy violating deployment should fail' $script:token $script:whatIfViolateBicepTemplatePath $script:testResourceGroupId 'Failed' $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion diff --git a/tests/policy-integration-tests/web/config.json b/tests/policy-integration-tests/web/config.json new file mode 100644 index 0000000..5d052e4 --- /dev/null +++ b/tests/policy-integration-tests/web/config.json @@ -0,0 +1,22 @@ +{ + "policyAssignmentIds": [ + "/providers/Microsoft.Management/managementGroups/CONTOSO-dev/providers/Microsoft.Authorization/policyAssignments/pa-d-web", + "/providers/Microsoft.Management/managementGroups/CONTOSO-dev/providers/Microsoft.Authorization/policyAssignments/pa-d-pedns", + "/providers/Microsoft.Management/managementGroups/CONTOSO-dev/providers/microsoft.authorization/policyAssignments/pa-d-diag-settings" + ], + "testName": "AppServices", + "testSubscription": "sub-d-lz-corp-01", + "testResourceGroup": "rg-ae-d-policy-test-web-001", + "additionalResourceGroups": { + "crossSubPe": { + "subscription": "sub-d-lz-online-01", + "resourceGroup": "rg-ae-d-pol-test-web-cross-sub-pe-01" + } + }, + "AppServicesAssignmentName": "pa-d-web", + "diagSettingsAssignmentName": "pa-d-diag-settings", + "peDNSAssignmentName": "pa-d-pedns", + "location": "australiaeast", + "tagsForResourceGroup": false, + "removeTestResourceGroup": true +} diff --git a/tests/policy-integration-tests/web/main.bad.bicep b/tests/policy-integration-tests/web/main.bad.bicep new file mode 100644 index 0000000..1bc6d0c --- /dev/null +++ b/tests/policy-integration-tests/web/main.bad.bicep @@ -0,0 +1,162 @@ +metadata itemDisplayName = 'Test Template for App Services' +metadata description = 'This template deploys the testing resource for App Services.' +metadata summary = 'Deploys test App Services resource that should violate some policy assignments' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'web3' +var functionAppName = 'fa-${namePrefix}-${serviceShort}-01' +var functionAppServerFarmName = 'sf-fa-${namePrefix}-${serviceShort}-01' +var webAppServerFarmName = 'sf-web-${namePrefix}-${serviceShort}-01' +var webAppName = 'web-${namePrefix}-${serviceShort}-01' +var appSlotName = 'slot1' + +resource functionAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: functionAppServerFarmName + location: location + kind: 'functionapp,linux' + sku: { + name: 'Y1' //this should violate policy WEB-009: App Service apps should use a SKU that supports private link + tier: 'Dynamic' //this should violate policy WEB-009: App Service apps should use a SKU that supports private link + } + properties: { + reserved: true + } +} + +resource webAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: webAppServerFarmName + location: location + kind: 'app,linux' + sku: { + name: 'P1v3' + tier: 'PremiumV3' + family: 'Pv3' + capacity: 3 + size: 'P1v3' + } + properties: { + reserved: true + } +} +module webApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-web-${serviceShort}' + params: { + name: webAppName + location: location + enableTelemetry: false + kind: 'app,linux' + outboundVnetRouting: { + applicationTraffic: false //This should violate policy WEB-005: App Service apps should enable outbound non-RFC 1918 traffic to Azure Virtual Network + } + + serverFarmResourceId: webAppServerFarm.id + siteConfig: { + use32BitWorkerProcess: false + ftpsState: 'FtpsOnly' + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} +resource webAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${webAppName}/${appSlotName}' + dependsOn: [ + webApp + ] + location: location + kind: 'app,linux' + properties: { + serverFarmId: webAppServerFarm.id + httpsOnly: false //This should violate policy WEB-002: App Service and Function apps should only be accessible over HTTPS + publicNetworkAccess: 'Disabled' + } +} +resource webAppConfig 'Microsoft.Web/sites/config@2025-03-01' = { + name: '${webAppName}/web' + dependsOn: [ + webApp + ] + properties: { + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: '7c0aba23-7e7c-4dfd-95b4-c2d3fa5770d1' //dummy guid + openIdIssuer: 'https://login.microsoftonline.com/v2.0/cd3dacc7-d202-4d5d-9b13-59eced0d34a0/' //dummy guid + } + } //This should violate policy WEB-003: Function apps should only use approved identity providers for authentication + } + } +} +module functionApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-func-${serviceShort}' + params: { + name: functionAppName + location: location + kind: 'functionapp,linux' + serverFarmResourceId: functionAppServerFarm.id + publicNetworkAccess: 'Enabled' //This should violate policy WEB-010: Public network access should be disabled for App Services and Function Apps + httpsOnly: false //This should violate policy WEB-002: App Service and Function apps should only be accessible over HTTPS + outboundVnetRouting: { + contentShareTraffic: false //This should violate policy WEB-006: App Service and Function apps should route configuration traffic over the virtual network + } + configs: [ + { + name: 'appsettings' + properties: { + AzureFunctionsJobHost__logging__logLevel__default: 'Trace' + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: 'powershell' + WEBSITE_RUN_FROM_PACKAGE: '0' + WEBSITE_ENABLE_SYNC_UPDATE_SITE: 'true' + } + } + ] + siteConfig: { + use32BitWorkerProcess: false + powerShellVersion: '7.4' + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} + +resource functionAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${functionAppName}/${appSlotName}' + dependsOn: [ + functionApp + ] + location: location + kind: 'functionapp,linux' + properties: { + serverFarmId: functionAppServerFarm.id + publicNetworkAccess: 'Enabled' //This should violate policy WEB-011: Public network access should be disabled for App Service and Function App slots + httpsOnly: false //This should violate policy WEB-001: App Service and function app slots should only be accessible over HTTPS + outboundVnetRouting: { + contentShareTraffic: false //This should violate policy WEB-008: Function app slots should route configuration traffic over the virtual network + applicationTraffic: false //This should violate policy WEB-007: Function apps should route configuration traffic over the virtual network + } + } +} + +output webAppName string = webApp.outputs.name +output webAppServerFarmResourceId string = webAppServerFarm.id +output webAppResourceId string = webApp.outputs.resourceId +output functionAppName string = functionApp.outputs.name +output functionAppServerFarmResourceId string = functionAppServerFarm.id +output functionAppResourceId string = functionApp.outputs.resourceId +output location string = functionApp.outputs.location +output resourceGroupId string = resourceGroup().id diff --git a/tests/policy-integration-tests/web/main.good.bicep b/tests/policy-integration-tests/web/main.good.bicep new file mode 100644 index 0000000..ed75401 --- /dev/null +++ b/tests/policy-integration-tests/web/main.good.bicep @@ -0,0 +1,253 @@ +metadata itemDisplayName = 'Test Template for App Services' +metadata description = 'This template deploys the testing resource for App Services.' +metadata summary = 'Deploys test App Services resource that should comply with all policy assignments' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'as2' +var functionAppName = 'fa-${namePrefix}-${serviceShort}-01' +var functionAppServerFarmName = 'sf-fa-${namePrefix}-${serviceShort}-01' +var webAppServerFarmName = 'sf-web-${namePrefix}-${serviceShort}-01' +var webAppName = 'web-${namePrefix}-${serviceShort}-01' +var appSlotName = 'slot1' + +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' +var virtualNetworkName = 'vnet-${namePrefix}-${serviceShort}-01' +var routeTableName = 'rt-${namePrefix}-${serviceShort}-01' +var peName = 'pe-${functionAppName}' +var addressPrefix = '10.1.0.0/16' + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + sourcePortRange: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 200 + } + } + ] + } +} +resource routeTable 'Microsoft.Network/routeTables@2025-05-01' = { + name: routeTableName + location: location + properties: { + routes: [] + } +} +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: virtualNetworkName + location: location + + properties: { + addressSpace: { + addressPrefixes: [ + addressPrefix + ] + } + subnets: [ + { + name: 'defaultSubnet' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 0) + networkSecurityGroup: { + id: nsg.id + } + routeTable: { + id: routeTable.id + } + } + } + { + name: 'webAppSubnet' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 1) + networkSecurityGroup: { + id: nsg.id + } + routeTable: { + id: routeTable.id + } + delegations: [ + { + name: 'Microsoft.Web.serverFarms' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } + } + ] + } +} + +resource functionAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: functionAppServerFarmName + location: location + kind: 'functionapp,linux' + sku: { + name: 'P1v3' //this should comply with policy WEB-009: App Service apps should use a SKU that supports private link + tier: 'PremiumV3' //this should comply policy WEB-009: App Service apps should use a SKU that supports private link + family: 'Pv3' + capacity: 3 + size: 'P1v3' + } + properties: { + reserved: true + } +} + +module functionApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-func-${serviceShort}' + params: { + name: functionAppName + location: location + kind: 'functionapp,linux' + serverFarmResourceId: functionAppServerFarm.id + publicNetworkAccess: 'Disabled' //This should comply with policy WEB-010 + httpsOnly: true //This should comply with policy WEB-002 + configs: [ + { + name: 'appsettings' + properties: { + AzureFunctionsJobHost__logging__logLevel__default: 'Trace' + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: 'powershell' + WEBSITE_RUN_FROM_PACKAGE: '0' + WEBSITE_ENABLE_SYNC_UPDATE_SITE: 'true' + } + } + ] + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + siteConfig: { + use32BitWorkerProcess: false + powerShellVersion: '7.4' + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} + +resource functionAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${functionAppName}/${appSlotName}' + dependsOn: [ + functionApp + ] + location: location + kind: 'functionapp,linux' + properties: { + serverFarmId: functionAppServerFarm.id + httpsOnly: true //This should comply with policy WEB-001 + publicNetworkAccess: 'Disabled' //This should comply with policy WEB-011 + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + } +} + +resource webAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: webAppServerFarmName + location: location + kind: 'app,linux' + sku: { + name: 'P1v3' + tier: 'PremiumV3' + family: 'Pv3' + capacity: 3 + size: 'P1v3' + } + properties: { + reserved: true + } +} + +module webApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-web-${serviceShort}' + params: { + name: webAppName + location: location + enableTelemetry: false + kind: 'app,linux' + serverFarmResourceId: webAppServerFarm.id + publicNetworkAccess: 'Disabled' //This should comply with policy WEB-010 + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + siteConfig: { + use32BitWorkerProcess: false + ftpsState: 'FtpsOnly' + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} +resource webAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${webAppName}/${appSlotName}' + dependsOn: [ + webApp + ] + location: location + kind: 'app,linux' + properties: { + serverFarmId: webAppServerFarm.id + httpsOnly: true //This should comply with policy WEB-002 + publicNetworkAccess: 'Disabled' + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + } +} + +resource webAppConfig 'Microsoft.Web/sites/config@2025-03-01' = { + name: '${webAppName}/web' + dependsOn: [ + webApp + ] + properties: { + identityProviders: {} //This should comply with policy WEB-003: Function apps should only use approved identity providers for authentication + } +} +output webAppName string = webApp.outputs.name +output webAppServerFarmResourceId string = webAppServerFarm.id +output webAppResourceId string = webApp.outputs.resourceId +output functionAppName string = functionApp.outputs.name +output functionAppServerFarmResourceId string = functionAppServerFarm.id +output functionAppResourceId string = functionApp.outputs.resourceId +output location string = functionApp.outputs.location +output resourceGroupId string = resourceGroup().id +output privateEndpointName string = peName +output privateEndpoints array = functionApp.outputs.privateEndpoints diff --git a/tests/policy-integration-tests/web/main.test.bicep b/tests/policy-integration-tests/web/main.test.bicep new file mode 100644 index 0000000..ae96d9f --- /dev/null +++ b/tests/policy-integration-tests/web/main.test.bicep @@ -0,0 +1,415 @@ +metadata itemDisplayName = 'Test Template for App Service and Function Apps' +metadata description = 'This template deploys the testing resource for App Service and Function Apps.' +metadata summary = 'Deploys test App Service and Function Apps resources' + +// ============ // +// variables // +// ============ // +// Load the configuration file +var globalConfig = loadJsonContent('../.shared/policy_integration_test_config.jsonc') +var localConfig = loadJsonContent('config.json') +var location = localConfig.location +var namePrefix = globalConfig.namePrefix + +// define template specific variables +var serviceShort = 'web1' +var functionAppName = 'fa-${namePrefix}-${serviceShort}-01' +var functionAppServerFarmName = 'sf-fa-${namePrefix}-${serviceShort}-01' +var webAppServerFarmName = 'sf-web-${namePrefix}-${serviceShort}-01' +var webAppName = 'web-${namePrefix}-${serviceShort}-01' + +var appSlotName = 'slot1' +var webAppSlotPeName = 'pe-${webAppName}-${appSlotName}' +var nsgName = 'nsg-${namePrefix}-${serviceShort}-01' +var virtualNetworkName = 'vnet-${namePrefix}-${serviceShort}-01' + +var functionAppPeName = 'pe-${functionAppName}' +var webAppPeName = 'pe-${webAppName}' + +var addressPrefix = '10.0.0.0/16' + +//Cross sub PE +var crossSubPeSubName = localConfig.additionalResourceGroups.crossSubPe.subscription +var crossSubPeSubId = globalConfig.subscriptions[crossSubPeSubName].id +var crossSubPeVnetResourceGroup = globalConfig.subscriptions[crossSubPeSubName].networkResourceGroup +var crossSubPeVnetName = globalConfig.subscriptions[crossSubPeSubName].vNet +var crossSubPeSubnetName = globalConfig.subscriptions[crossSubPeSubName].peSubnet +var crossSubPeResourceGroup = localConfig.additionalResourceGroups.crossSubPe.resourceGroup + +var crossSubPeWebAppServerFarmName = 'sf-web-${namePrefix}-${serviceShort}-02' +var crossSubPeWebAppName = 'web-${namePrefix}-${serviceShort}-02' +var crossSubPeWebAppPeName = 'pe-${crossSubPeWebAppName}' + +resource crossSubPeRg 'Microsoft.Resources/resourceGroups@2025-04-01' existing = { + scope: subscription(crossSubPeSubId) + name: crossSubPeResourceGroup +} + +resource crossSubPeVNetRg 'Microsoft.Resources/resourceGroups@2025-04-01' existing = { + name: crossSubPeVnetResourceGroup + scope: subscription(crossSubPeSubId) +} + +resource crossSubPeVNet 'Microsoft.Network/virtualNetworks@2025-05-01' existing = { + name: crossSubPeVnetName + scope: crossSubPeVNetRg + resource peSubnet 'subnets' existing = { name: crossSubPeSubnetName } +} + +resource nsg 'Microsoft.Network/networkSecurityGroups@2025-05-01' = { + name: nsgName + location: location + properties: { + securityRules: [ + { + name: 'AllowHTTPSInbound' + properties: { + access: 'Allow' + description: 'Allow HTTPS Inbound on TCP port 443' + protocol: 'Tcp' + sourceAddressPrefix: 'virtualNetwork' + destinationAddressPrefix: '*' + sourcePortRange: '*' + destinationPortRange: '443' + direction: 'Inbound' + priority: 200 + } + } + ] + } +} + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2025-05-01' = { + name: virtualNetworkName + location: location + + properties: { + addressSpace: { + addressPrefixes: [ + addressPrefix + ] + } + subnets: [ + { + name: 'defaultSubnet' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 0) + networkSecurityGroup: { + id: nsg.id + } + } + } + { + name: 'functionAppSubnet' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 1) + networkSecurityGroup: { + id: nsg.id + } + delegations: [ + { + name: 'Microsoft.Web.serverFarms' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } + } + { + name: 'webAppSubnet1' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 2) + networkSecurityGroup: { + id: nsg.id + } + + delegations: [ + { + name: 'Microsoft.Web.serverFarms' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } + } + { + name: 'webAppSubnet2' + properties: { + addressPrefix: cidrSubnet(addressPrefix, 24, 3) + networkSecurityGroup: { + id: nsg.id + } + delegations: [ + { + name: 'Microsoft.Web.serverFarms' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } + } + ] + } +} + +resource functionAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: functionAppServerFarmName + location: location + kind: 'functionapp,linux' + sku: { + name: 'P1v3' + tier: 'PremiumV3' + family: 'Pv3' + capacity: 3 + size: 'P1v3' + } + properties: { + reserved: true + } +} + +resource webAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: webAppServerFarmName + location: location + kind: 'app,linux' + sku: { + name: 'P1v3' + tier: 'PremiumV3' + family: 'Pv3' + capacity: 3 + size: 'P1v3' + } + properties: { + reserved: true + } +} + +resource crossSubPeWebAppServerFarm 'Microsoft.Web/serverfarms@2025-03-01' = { + name: crossSubPeWebAppServerFarmName + location: location + kind: 'app,linux' + sku: { + name: 'S1' + tier: 'Standard' + family: 'S' + capacity: 1 + size: 'S1' + } + properties: { + reserved: true + } +} + +module functionApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-func-${serviceShort}' + params: { + name: functionAppName + location: location + enableTelemetry: false + kind: 'functionapp,linux' + serverFarmResourceId: functionAppServerFarm.id + virtualNetworkSubnetResourceId: virtualNetwork.properties.subnets[1].id + clientCertEnabled: true + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + privateEndpoints: [ + { + name: functionAppPeName + subnetResourceId: virtualNetwork.properties.subnets[0].id + service: 'sites' + } + ] + configs: [ + { + name: 'appsettings' + properties: { + AzureFunctionsJobHost__logging__logLevel__default: 'Trace' + FUNCTIONS_EXTENSION_VERSION: '~4' + FUNCTIONS_WORKER_RUNTIME: 'powershell' + WEBSITE_RUN_FROM_PACKAGE: '0' + WEBSITE_ENABLE_SYNC_UPDATE_SITE: 'true' + } + } + ] + siteConfig: { + use32BitWorkerProcess: false + powerShellVersion: '7.4' + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} + +resource functionAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${functionAppName}/${appSlotName}' + dependsOn: [ + functionApp + ] + location: location + kind: 'functionapp,linux' + properties: { + serverFarmId: functionAppServerFarm.id + httpsOnly: true + publicNetworkAccess: 'Disabled' + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + clientCertEnabled: true + } +} + +module webApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-web-${serviceShort}' + params: { + name: webAppName + location: location + enableTelemetry: false + kind: 'app,linux' + serverFarmResourceId: webAppServerFarm.id + virtualNetworkSubnetResourceId: virtualNetwork.properties.subnets[2].id + clientCertEnabled: true + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + privateEndpoints: [ + { + name: webAppPeName + subnetResourceId: virtualNetwork.properties.subnets[0].id + service: 'sites' + } + ] + siteConfig: { + use32BitWorkerProcess: false + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} + +resource webAppSlot 'Microsoft.Web/sites/slots@2025-03-01' = { + name: '${webAppName}/${appSlotName}' + dependsOn: [ + webApp + ] + location: location + kind: 'app,linux' + properties: { + serverFarmId: webAppServerFarm.id + httpsOnly: true + publicNetworkAccess: 'Disabled' + clientCertEnabled: true + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + } +} + +resource webAppSlotPe 'Microsoft.Network/privateEndpoints@2025-05-01' = { + name: webAppSlotPeName + location: location + dependsOn: [ + webAppSlot + ] + properties: { + subnet: { + id: virtualNetwork.properties.subnets[0].id + } + privateLinkServiceConnections: [ + { + name: 'sites-${appSlotName}' + properties: { + privateLinkServiceId: webApp.outputs.resourceId + groupIds: [ + 'sites-${appSlotName}' + ] + } + } + ] + } +} + +module crossSubPeWebApp 'br/public:avm/res/web/site:0.22.0' = { + name: '${uniqueString(deployment().name, location)}-test-cross-sub-pe-web-${serviceShort}' + params: { + name: crossSubPeWebAppName + location: location + enableTelemetry: false + kind: 'app,linux' + serverFarmResourceId: crossSubPeWebAppServerFarm.id + virtualNetworkSubnetResourceId: virtualNetwork.properties.subnets[3].id + clientCertEnabled: true + publicNetworkAccess: 'Disabled' + outboundVnetRouting: { + imagePullTraffic: true + contentShareTraffic: true + applicationTraffic: true + } + siteConfig: { + use32BitWorkerProcess: false + alwaysOn: true + } + managedIdentities: { + systemAssigned: true + } + } +} + +module crossSubPeWebAppPrivateEndpoint 'br/public:avm/res/network/private-endpoint:0.12.0' = { + name: '${uniqueString(deployment().name, location)}-cross-sub-web-pe-${serviceShort}' + scope: crossSubPeRg + dependsOn: [ + crossSubPeRg + ] + params: { + name: crossSubPeWebAppPeName + subnetResourceId: crossSubPeVNet::peSubnet.id + location: location + enableTelemetry: false + privateLinkServiceConnections: [ + { + name: '${crossSubPeWebAppName}-1' + properties: { + groupIds: [ + 'sites' + ] + privateLinkServiceId: crossSubPeWebApp.outputs.resourceId + } + } + ] + } +} + +output name string = functionApp.outputs.name +output resourceId string = functionApp.outputs.resourceId +output webAppName string = webApp.outputs.name +output webAppResourceId string = webApp.outputs.resourceId +output webAppServerFarmResourceId string = webAppServerFarm.id +output functionAppServerFarmResourceId string = functionAppServerFarm.id +output functionAppSlotResourceId string = functionAppSlot.id +output location string = functionApp.outputs.location +output resourceGroupId string = resourceGroup().id +output functionAppPrivateEndpointName string = functionAppPeName +output functionAppPrivateEndpoints array = functionApp.outputs.privateEndpoints +output webAppPrivateEndpointName string = webAppPeName +output webAppPrivateEndpoints array = webApp.outputs.privateEndpoints +output appSlotName string = appSlotName +output webAppSlotResourceId string = webAppSlot.id +output webAppSlotPrivateEndpointName string = webAppSlotPeName +output webAppSlotPrivateEndpointResourceId string = webAppSlotPe.id +output crossSubPeWebAppResourceId string = crossSubPeWebApp.outputs.resourceId diff --git a/tests/policy-integration-tests/web/tests.ps1 b/tests/policy-integration-tests/web/tests.ps1 new file mode 100644 index 0000000..9d6e8af --- /dev/null +++ b/tests/policy-integration-tests/web/tests.ps1 @@ -0,0 +1,158 @@ +#region generic sections for all tests +#Requires -Modules Az.Accounts, Az.PolicyInsights, Az.Resources +#Requires -Version 7.0 + +using module AzResourceTest + +$helperFunctionScriptPath = (resolve-path -relativeBasePath $PSScriptRoot -path '../../../scripts/pipelines/helper/helper-functions.ps1').Path + +#load helper +. $helperFunctionScriptPath + +#Run initiate-test script to set environment variables for test configuration and deployment +$globalConfigFilePath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/policy_integration_test_config.jsonc').Path +$TestDirectory = $PSScriptRoot +Write-Output "Initiating test with global config file: $globalConfigFilePath and test directory: $TestDirectory" +$initiateTestScriptPath = (resolve-path -RelativeBasePath $PSScriptRoot -path '../.shared/initiate-test.ps1').Path +. $initiateTestScriptPath -globalConfigFilePath $globalConfigFilePath -TestDirectory $TestDirectory + +# Refer to the ../README.md for details on the expected variables to be set by the initiate-test script and the structure of those variables. +#endregion + +#region defining tests +<# +Test cases: +- DS-026: Configure Diagnostic Settings for Function App (DeployIfNotExists) +- DS-062: Configure Diagnostic Settings for App Services (DeployIfNotExists) +- PEDNS-006: Configure Private DNS Record for Web App Private Endpoint (DeployIfNotExists) +- PEDNS-015: Configure Private DNS Record for Web App Slots Private Endpoint (DeployIfNotExists) +- WEB-001: App Service and function app slots should only be accessible over HTTPS (Deny) +- WEB-002: App Service and Function apps should only be accessible over HTTPS (Deny) +- WEB-003: Function apps should only use approved identity providers for authentication (Deny) +- WEB-004: Prevent cross-subscription Private Link for App Services and Function Apps (Audit) +- WEB-005: Function apps should route application traffic over the virtual network (Deny) +- WEB-006: App Service and Function apps should route configuration traffic over the virtual network (Deny) +- WEB-007: Function apps should route configuration traffic over the virtual network (Deny) +- WEB-008: Function app slots should route configuration traffic over the virtual network (Deny) +- WEB-009: App Service apps should use a SKU that supports private link (Deny) +- WEB-010: Public network access should be disabled for App Services and Function Apps (Deny) +- WEB-011: Public network access should be disabled for App Service and Function App slots (Deny) +#> + +#Parse deployment outputs +$functionAppResourceId = $script:bicepDeploymentOutputs.resourceId.value + +$crossSubPeWebAppResourceId = $script:bicepDeploymentOutputs.crossSubPeWebAppResourceId.value +$webAppResourceId = $script:bicepDeploymentOutputs.webAppResourceId.value +$functionAppPrivateEndpointName = $script:bicepDeploymentOutputs.functionAppPrivateEndpointName.value +$functionAppPrivateEndpoints = $script:bicepDeploymentOutputs.functionAppPrivateEndpoints.value +$webAppPrivateEndpointName = $script:bicepDeploymentOutputs.webAppPrivateEndpointName.value +$webAppPrivateEndpoints = $script:bicepDeploymentOutputs.webAppPrivateEndpoints.value + +#function app +$functionAppPrivateEndpoint = $functionAppPrivateEndpoints | where-object { $_.name -ieq $functionAppPrivateEndpointName } +$functionAppPrivateEndpointResourceId = $functionAppPrivateEndpoint.resourceId +$functionAppPrivateEndpointPrivateDNSZoneGroupId = '{0}{1}' -f $functionAppPrivateEndpointResourceId, $script:GlobalConfig_privateEndpointPrivateDNSZoneGroupIdSuffix +$crossSubPePrivateEndpointConnectionResourceId = $(getResourceViaARMAPI -token $script:token -resourceId "$crossSubPeWebAppResourceId/privateEndpointConnections" -apiVersion $script:GlobalConfig_appServicesAPIVersion).value[0].id +$functionAppDiagnosticSettingsId = "{0}{1}" -f $functionAppResourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix + +#web app +$webAppPrivateEndpoint = $webAppPrivateEndpoints | where-object { $_.name -ieq $webAppPrivateEndpointName } +$webAppPrivateEndpointResourceId = $webAppPrivateEndpoint.resourceId +$webAppPrivateEndpointPrivateDNSZoneGroupId = '{0}{1}' -f $webAppPrivateEndpointResourceId, $script:GlobalConfig_privateEndpointPrivateDNSZoneGroupIdSuffix +$webAppSlotPrivateEndpointResourceId = $script:bicepDeploymentOutputs.webAppSlotPrivateEndpointResourceId.value +$webAppSlotPrivateEndpointPrivateDNSZoneGroupId = '{0}{1}' -f $webAppSlotPrivateEndpointResourceId, $script:GlobalConfig_privateEndpointPrivateDNSZoneGroupIdSuffix +$AppServicesDiagnosticSettingsId = "{0}{1}" -f $webAppResourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix +$CrossSubPeAppServicesDiagnosticSettingsId = "{0}{1}" -f $crossSubPeWebAppResourceId, $script:GlobalConfig_diagnosticSettingsIdSuffix + +#policy assignment ids +$appServicesPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_AppServicesAssignmentName`$" } +$diagSettingsPolicyAssignmentId = $script:LocalConfig_policyAssignmentIds | Where-Object { $_ -imatch "$script:LocalConfig_diagSettingsAssignmentName`$" } + +$violatingPolicies = @( + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-001' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-002' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-003' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-005' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-006' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-007' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-008' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-009' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-010' + } + @{ + policyAssignmentId = $appServicesPolicyAssignmentId + policyDefinitionReferenceId = 'WEB-011' + } +) + +#define tests +$tests = @() + +#Audit / AuditIfNotExists Policies + +$tests += New-ARTPolicyStateTestConfig 'WEB-004: Azure App Service and Function Apps with Cross subscription PE must be non-compliant with policy Prevent cross-subscription Private Link for Azure App Service' $script:token $crossSubPePrivateEndpointConnectionResourceId $appServicesPolicyAssignmentId 'NonCompliant' 'WEB-004' + +#DeployIfNotExists Policies +$tests += New-ARTResourceExistenceTestConfig 'DS-062: Premium SKU App Services Diagnostic Settings Must Be Configured' $script:token $AppServicesDiagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-062: Premium SKU App Services Diagnostic Settings Policy Must Be Compliant' $script:token $webAppResourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-062' + +$tests += New-ARTResourceExistenceTestConfig 'DS-062: Standard SKU App Services Diagnostic Settings Must Be Configured' $script:token $CrossSubPeAppServicesDiagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-062: Standard SKU App Services Diagnostic Settings Policy Must Be Compliant' $script:token $crossSubPeWebAppResourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-062' + +$tests += New-ARTResourceExistenceTestConfig 'DS-062: Premium SKU App Services Diagnostic Settings Must Be Configured' $script:token $AppServicesDiagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-062: Premium SKU App Services Diagnostic Settings Policy Must Be Compliant' $script:token $webAppResourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-062' + +$tests += New-ARTResourceExistenceTestConfig 'DS-062: Standard SKU App Services Diagnostic Settings Must Be Configured' $script:token $CrossSubPeAppServicesDiagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-062: Standard SKU App Services Diagnostic Settings Policy Must Be Compliant' $script:token $crossSubPeWebAppResourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-062' + +$tests += New-ARTResourceExistenceTestConfig 'DS-026: Function App Diagnostic Settings Must Be Configured' $script:token $functionAppDiagnosticSettingsId 'exists' $script:GlobalConfig_diagnosticSettingsAPIVersion +$tests += New-ARTPolicyStateTestConfig 'DS-026: Function App Diagnostic Settings Policy Must Be Compliant' $script:token $functionAppResourceId $diagSettingsPolicyAssignmentId 'Compliant' 'DS-026' + +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-006: Private DNS Record for Function App PE must exist' $script:token $functionAppPrivateEndpointPrivateDNSZoneGroupId 'exists' $script:GlobalConfig_privateDNSZoneGroupAPIVersion +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-006: Private DNS Record for Web App PE must exist' $script:token $webAppPrivateEndpointPrivateDNSZoneGroupId 'exists' $script:GlobalConfig_privateDNSZoneGroupAPIVersion +$tests += New-ARTResourceExistenceTestConfig 'PEDNS-015: Private DNS Record for Web App Slot PE must exist' $script:token $webAppSlotPrivateEndpointPrivateDNSZoneGroupId 'exists' $script:GlobalConfig_privateDNSZoneGroupAPIVersion + +#Deny policies +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy violating deployment should fail' -token $script:token -templateFilePath $script:whatIfViolateBicepTemplatePath -deploymentTargetResourceId $script:testResourceGroupId -requiredWhatIfStatus 'Failed' -policyViolation $violatingPolicies -maxRetry $script:GlobalConfig_whatIfMaxRetry +$tests += New-ARTWhatIfDeploymentTestConfig -testName 'Policy abiding deployment should succeed' -token $script:token -templateFilePath $script:whatIfComplyBicepTemplatePath -deploymentTargetResourceId $script:testResourceGroupId -requiredWhatIfStatus 'Succeeded' -maxRetry $script:GlobalConfig_whatIfMaxRetry +#endregion + +#region Invoke tests - do not modify +$params = @{ + tests = $tests + testTitle = $script:testTitle + contextTitle = $script:contextTitle + testSuiteName = $script:testSuiteName + OutputFile = $script:outputFilePath + OutputFormat = $script:GlobalConfig_testOutputFormat +} +Test-ARTResourceConfiguration @params + +#endregion From b69ce6cc423d17272043e7cbdbc29b11849e29fd Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Fri, 3 Apr 2026 23:34:10 +1100 Subject: [PATCH 02/22] Update policy definitions and effects for PostgreSQL and resource restrictions --- policyInitiatives/polset-diag-settings.json | 2 +- policyInitiatives/polset-pedns.json | 2 +- policyInitiatives/polset-postgresql.json | 107 ++---------------- .../polset-resource-restriction.json | 28 +++++ policyInitiatives/polset-vnet.json | 2 +- 5 files changed, 41 insertions(+), 100 deletions(-) diff --git a/policyInitiatives/polset-diag-settings.json b/policyInitiatives/polset-diag-settings.json index b657126..7109179 100644 --- a/policyInitiatives/polset-diag-settings.json +++ b/policyInitiatives/polset-diag-settings.json @@ -481,7 +481,7 @@ "DS-038_Effect": { "type": "string", "metadata": { - "displayName": "DS-038 Effect: Configure Diagnostic Setting for ANetwork Security Groups", + "displayName": "DS-038 Effect: Configure Diagnostic Setting for Network Security Groups", "description": "Enable or disable the execution of the policy" }, "allowedValues": [ diff --git a/policyInitiatives/polset-pedns.json b/policyInitiatives/polset-pedns.json index 07cb004..dc945de 100755 --- a/policyInitiatives/polset-pedns.json +++ b/policyInitiatives/polset-pedns.json @@ -560,7 +560,7 @@ }, { "policyDefinitionReferenceId": "PEDNS-015", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deploy-pe-dns-records-single-dns-zone-all-locations", + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deploy-pe-dns-records-single-zone-all-regions-match-groupid", "parameters": { "Effect": { "value": "[parameters('PEDNS-015_Effect')]" diff --git a/policyInitiatives/polset-postgresql.json b/policyInitiatives/polset-postgresql.json index 3c84932..821154b 100644 --- a/policyInitiatives/polset-postgresql.json +++ b/policyInitiatives/polset-postgresql.json @@ -13,31 +13,7 @@ "PGS-001_Effect": { "type": "String", "metadata": { - "displayName": "PGS-001 Effect: Connection throttling should be enabled for PostgreSQL database servers", - "description": "Enable or disable the execution of the policy" - }, - "allowedValues": [ - "AuditIfNotExists", - "Disabled" - ], - "defaultValue": "AuditIfNotExists" - }, - "PGS-002_Effect": { - "type": "String", - "metadata": { - "displayName": "PGS-002 Effect: Private endpoint should be enabled for PostgreSQL servers", - "description": "Enable or disable the execution of the policy" - }, - "allowedValues": [ - "AuditIfNotExists", - "Disabled" - ], - "defaultValue": "AuditIfNotExists" - }, - "PGS-003_Effect": { - "type": "String", - "metadata": { - "displayName": "PGS-003 Effect: Public network access should be disabled for PostgreSQL flexible servers", + "displayName": "PGS-001 Effect: Azure PostgreSQL flexible server should have Microsoft Entra Only Authentication enabled", "description": "Enable or disable the execution of the policy" }, "allowedValues": [ @@ -45,25 +21,12 @@ "Deny", "Disabled" ], - "defaultValue": "Audit" - }, - "PGS-004_Effect": { - "type": "String", - "metadata": { - "displayName": "PGS-004 Effect: Enforce SSL connection should be enabled for PostgreSQL database servers", - "description": "Enable or disable the execution of the policy" - }, - "allowedValues": [ - "Audit", - "Deny", - "Disabled" - ], - "defaultValue": "Audit" + "defaultValue": "Deny" }, - "PGS-005_Effect": { + "PGS-002_Effect": { "type": "String", "metadata": { - "displayName": "PGS-005 Effect: Enforce SSL connection should be enabled for PostgreSQL database servers", + "displayName": "PGS-002 Effect: Public network access should be disabled for PostgreSQL flexible servers", "description": "Enable or disable the execution of the policy" }, "allowedValues": [ @@ -76,25 +39,13 @@ }, "policyDefinitionGroups": [ { - "name": "ISO27001-2013_A.10.1.1", - "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.10.1.1" - }, - { - "name": "ISO27001-2013_A.12.3.1", - "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.12.3.1" + "name": "ISO27001-2013_A.9.2.3", + "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.9.2.3" }, { "name": "ISO27001-2013_A.13.1.2", "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.13.1.2" }, - { - "name": "ISO27001-2013_A.13.2.1", - "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.13.1.4" - }, - { - "name": "ISO27001-2013_A.14.1.2", - "additionalMetadataId": "/providers/Microsoft.PolicyInsights/policyMetadata/ISO27001-2013_A.14.1.2" - }, { "name": "CB-AZ-011" } @@ -102,66 +53,28 @@ "policyDefinitions": [ { "policyDefinitionReferenceId": "PGS-001", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-audit-postgresql-connection-throttling", + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deny-postgresql-flexible-servers-entraid-admin", "parameters": { "effect": { "value": "[parameters('PGS-001_Effect')]" } }, "groupNames": [ - "ISO27001-2013_A.13.2.1" + "ISO27001-2013_A.9.2.3" ] }, { "policyDefinitionReferenceId": "PGS-002", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-audit-postgresql-private-endpoint", + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deny-postgresql-flexible-servers-public-network-access", "parameters": { "effect": { "value": "[parameters('PGS-002_Effect')]" } }, "groupNames": [ - "ISO27001-2013_A.10.1.1", - "ISO27001-2013_A.14.1.2", + "ISO27001-2013_A.13.1.2", "CB-AZ-011" ] - }, - { - "policyDefinitionReferenceId": "PGS-003", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-audit-postgresql-geo-redundant-backup", - "parameters": { - "effect": { - "value": "[parameters('PGS-003_Effect')]" - } - }, - "groupNames": [ - "ISO27001-2013_A.12.3.1" - ] - }, - { - "policyDefinitionReferenceId": "PGS-004", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-audit-postgresql-ssl", - "parameters": { - "effect": { - "value": "[parameters('PGS-004_Effect')]" - } - }, - "groupNames": [ - "ISO27001-2013_A.10.1.1", - "ISO27001-2013_A.14.1.2" - ] - }, - { - "policyDefinitionReferenceId": "PGS-005", - "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deny-postgresql-flexible-servers-public-network-access", - "parameters": { - "effect": { - "value": "[parameters('PGS-005_Effect')]" - } - }, - "groupNames": [ - "ISO27001-2013_A.13.1.2" - ] } ] } diff --git a/policyInitiatives/polset-resource-restriction.json b/policyInitiatives/polset-resource-restriction.json index 275c55a..59a432f 100644 --- a/policyInitiatives/polset-resource-restriction.json +++ b/policyInitiatives/polset-resource-restriction.json @@ -74,6 +74,19 @@ "Disabled" ], "defaultValue": "Deny" + }, + "RR-006_Effect": { + "type": "String", + "metadata": { + "displayName": "RR-006 Effect: Azure Database for PostgreSQL Server", + "description": "Enable or disable the execution of the policy" + }, + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "defaultValue": "Deny" } }, "policyDefinitionGroups": [ @@ -157,6 +170,21 @@ "groupNames": [ "ISO27001-2013_A.13.1.1" ] + }, + { + "policyDefinitionReferenceId": "RR-006", + "policyDefinitionId": "{policyLocationResourceId}/providers/Microsoft.Authorization/policyDefinitions/pol-deny-resource-type", + "parameters": { + "effect": { + "value": "[parameters('RR-006_Effect')]" + }, + "disallowedResourceType": { + "value": "Microsoft.DBforPostgreSQL/servers" + } + }, + "groupNames": [ + "ISO27001-2013_A.13.1.1" + ] } ] } diff --git a/policyInitiatives/polset-vnet.json b/policyInitiatives/polset-vnet.json index a1aab73..ac3e8f2 100644 --- a/policyInitiatives/polset-vnet.json +++ b/policyInitiatives/polset-vnet.json @@ -232,7 +232,7 @@ "value": "NetworkWatcherRG" }, "networkWatcherName": { - "value": "NetworkWatcher_australiaeast" + "value": "NetworkWatcher_australiasoutheast" }, "workspaceResourceId": { "value": "[parameters('vnetFlowLogWorkspaceResourceId')]" From 3055ff1dc96c55c730773a9b2316e37c6b47bfa0 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Fri, 3 Apr 2026 23:37:43 +1100 Subject: [PATCH 03/22] Add policy integration test scripts and AES key generation - Created `pipeline-initiate-policy-integration-tests.ps1` to initiate policy integration tests based on configuration. - Developed `pipeline-map-policy-integration-test-cases.ps1` to map required test cases based on modified files in pull requests. - Introduced `pipeline-policy-int-test-compliance-scan.ps1` for triggering policy compliance scans using Azure CLI. - Added `newAesKey.ps1` script to generate AES encryption keys and initialization vectors. --- scripts/pipelines/helper/helper-functions.ps1 | 61 +- .../helper/resource-removal-helper.ps1 | 903 ++++++++++++++++++ .../helper/terraform-helper-functions.ps1 | 187 ++++ scripts/pipelines/pipeline-install-bicep.ps1 | 17 +- ...eate-pipeline-variables-from-json-file.ps1 | 54 ++ ...-delete-policy-test-deployed-resources.ps1 | 114 +++ ...destroy-policy-test-terraform-template.ps1 | 242 +++++ ...line-deploy-policy-test-bicep-template.ps1 | 328 +++++++ ...get-policy-assignment-compliance-state.ps1 | 210 ++++ .../pipeline-get-sub-directories.ps1 | 125 +++ .../pipeline-get-test-config.ps1 | 236 +++++ ...line-initiate-policy-integration-tests.ps1 | 149 +++ ...line-map-policy-integration-test-cases.ps1 | 574 +++++++++++ ...peline-policy-int-test-compliance-scan.ps1 | 75 ++ .../policy-integration-test/newAesKey.ps1 | 34 + 15 files changed, 3302 insertions(+), 7 deletions(-) create mode 100644 scripts/pipelines/helper/resource-removal-helper.ps1 create mode 100644 scripts/pipelines/helper/terraform-helper-functions.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 create mode 100644 scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1 create mode 100644 scripts/support/policy-integration-test/newAesKey.ps1 diff --git a/scripts/pipelines/helper/helper-functions.ps1 b/scripts/pipelines/helper/helper-functions.ps1 index c6f1ad4..1b5042b 100644 --- a/scripts/pipelines/helper/helper-functions.ps1 +++ b/scripts/pipelines/helper/helper-functions.ps1 @@ -113,7 +113,7 @@ Function getTestConfig { [ValidateScript({ Test-Path $_ -PathType Leaf })][string]$TestConfigFilePath ) - $testConfig = Get-Content $TestConfigFilePath | ConvertFrom-Json + $testConfig = Get-Content $TestConfigFilePath | ConvertFrom-Json -Depth 99 -AsHashtable $testConfig } @@ -158,6 +158,9 @@ function updateAzResourceTags { [Parameter(Mandatory = $true, HelpMessage = 'Specify the resource Id.')] [ValidateNotNullOrEmpty()][string]$resourceId, + [Parameter(Mandatory = $true, HelpMessage = 'Azure OAuth token.')] + [ValidateNotNullOrEmpty()][string]$token, + [Parameter(Mandatory = $true, HelpMessage = 'Specify the new resource tags.')] [hashtable]$tags, @@ -165,7 +168,6 @@ function updateAzResourceTags { [bool]$revertBack = $false ) $uri = 'https://management.azure.com/subscriptions/{0}/providers/Microsoft.Resources/tags/default?api-version=2021-04-01' -f $testSubscriptionId - $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText $headers = @{ 'Authorization' = "Bearer $token" 'Content-Type' = 'application/json' @@ -215,11 +217,14 @@ function getResourceViaARMAPI { [CmdletBinding()] Param( [Parameter(Mandatory = $true)][string]$resourceId, - [Parameter(Mandatory = $true)][string]$apiVersion + [Parameter(Mandatory = $true)][string]$apiVersion, + [Parameter(Mandatory = $false)][string]$token ) $uri = "https://management.azure.com{0}?api-version={1}" -f $resourceId, $apiVersion Write-Verbose "[$(getCurrentUTCString)]: Trying getting resource via the Resource provider API endpoint '$uri'" -Verbose - $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText + if (-not $PSBoundParameters.ContainsKey('token')) { + $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText + } $headers = @{ 'Authorization' = "Bearer $token" } @@ -244,11 +249,14 @@ function newResourceGroupViaARMAPI { [Parameter(Mandatory = $true)][string]$resourceGroupName, [Parameter(Mandatory = $true)][string]$location, [Parameter(Mandatory = $false)][hashtable]$tags, + [Parameter(Mandatory = $false)][string]$token, [Parameter(Mandatory = $false)][string]$apiVersion = '2021-04-01' ) $uri = "https://management.azure.com/subscriptions/{0}/resourceGroups/{1}?api-version={2}" -f $subscriptionId, $resourceGroupName, $apiVersion Write-Verbose "[$(getCurrentUTCString)]: Trying creating resource group via the Resource provider API endpoint '$uri'" -Verbose - $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText + if (-not $PSBoundParameters.ContainsKey('token')) { + $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText + } $headers = @{ 'Authorization' = "Bearer $token" 'Content-Type' = 'application/json' @@ -570,3 +578,46 @@ function decryptStuff { if ($decryptor) { $decryptor.Dispose() } } } + +function sanitisePSScript { + [CmdletBinding()] + [OutputType([string])] + Param ( + [parameter(Mandatory = $true)] + [string]$scriptPath + ) + $scriptContent = Get-Content -Path $scriptPath -Raw + $tokens = [System.Management.Automation.PSParser]::Tokenize($scriptContent, [ref]$null) + $commentTokens = $tokens | Where-Object { $_.Type -eq 'Comment' } + + # Remove comments in reverse order to preserve character positions + foreach ($token in ($commentTokens | Sort-Object { $_.Start } -Descending)) { + $scriptContent = $scriptContent.Remove($token.Start, $token.Length) + } + + # Remove resulting blank lines + $scriptContent = ($scriptContent -split "`r?`n" | Where-Object { $_.Trim().Length -gt 0 }) -join [Environment]::NewLine + $scriptContent +} + +function findCommandInScript { + [CmdletBinding()] + [OutputType([bool])] + Param ( + [parameter(Mandatory = $true)] + [string]$scriptContent, + + [parameter(Mandatory = $true)] + [string[]]$commands + ) + $found = $false + $tokens = [System.Management.Automation.PSParser]::Tokenize($scriptContent, [ref]$null) + $usedCommands = ($tokens | Where-Object { $_.Type -eq 'Command' }).Content.tolower() | Select-Object -Unique + foreach ($command in $commands) { + if ($command.tolower() -in $usedCommands) { + $found = $true + break + } + } + return $found +} diff --git a/scripts/pipelines/helper/resource-removal-helper.ps1 b/scripts/pipelines/helper/resource-removal-helper.ps1 new file mode 100644 index 0000000..246c3fe --- /dev/null +++ b/scripts/pipelines/helper/resource-removal-helper.ps1 @@ -0,0 +1,903 @@ +#get deployment operation filtered to the 'create' provisioning operation +Function getDeploymentOperation { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string] $deploymentId, + + [Parameter(Mandatory = $true)] + [string]$apiToken + ) + $url = "https://management.azure.com/$deploymentId/operations?api-version=2021-04-01" + $headers = @{ + 'Authorization' = "Bearer $apiToken" + 'Content-Type' = 'application/json' + } + Write-Verbose "Getting deployment operation for deployment via url $url" -verbose + $request = invoke-webrequest -Uri $url -Method Get -Headers $headers -ErrorAction SilentlyContinue + if ($request.StatusCode -ge 200 -and $request.StatusCode -le 299) { + $result = ($request.Content | ConvertFrom-Json).value + } else { + Write-Verbose "Unable to get deployment operation for deployment $deploymentId via url $url. Error: $($request.Content). Status Code: $($request.StatusCode)" + $result = $null + } + #Filter on ProvisioningOperation and only return 'Create' + $filteredResult = $result | where-object { $_.properties.provisioningOperation -ieq 'create' } + $filteredResult +} + +#Get all deployments that match a given deployment name in a given scope +function getDeploymentTargetResourceListInner { + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $deploymentId, + + [Parameter(Mandatory = $true)] + [string] $apiToken + ) + + $resultSet = [System.Collections.ArrayList]@() + + ############################################## + # Get all deployment children based on scope # + ############################################## + if ($deploymentScope -imatch '/resourceGroups/') { + $resourceGroupName = $deploymentId.split( '/ resourceGroups/')[1].Split('/')[0] + Write-Verbose "Resource Group scoped deployment. Resource Group Name: $resourceGroupName" + if (Get-AzResourceGroup -Name $resourceGroupName -ErrorAction 'SilentlyContinue') { + #[array]$deploymentTargets = (Get-AzResourceGroupDeploymentOperation -DeploymentName $name -ResourceGroupName $resourceGroupName).TargetResource | Where-Object { $_ -ne $null } + [array]$deploymentTargets = (getDeploymentOperation -deploymentId $deploymentId -apiToken $apiToken).properties.targetResource.id | Where-Object { $_ -ne $null } + } else { + # In case the resource group itself was already deleted, there is no need to try and fetch deployments from it + # In case we already have any such resources in the list, we should remove them + [array]$resultSet = $resultSet | Where-Object { $_ -notmatch "/resourceGroups/$resourceGroupName/" } + } + } else { + $deploymentTargets = (getDeploymentOperation -deploymentId $deploymentId -apiToken $apiToken).properties.targetResource.id | Where-Object { $_ -ne $null } + } + + ########################### + # Manage nested resources # + ########################### + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -notmatch '/deployments/' } )) { + Write-Verbose ('Found deployed resource [{0}]' -f $deployment) + [array]$resultSet += $deployment + } + + ############################# + # Manage nested deployments # + ############################# + foreach ($deployment in ($deploymentTargets | Where-Object { $_ -match '/deployments/' } )) { + [array]$resultSet += getDeploymentTargetResourceListInner -deploymentId $deployment -apiToken $apiToken + } + + return $resultSet +} + +#Get all deployments that match a given deployment name in a given scope using a retry mechanic +function getDeploymentTargetResourceList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $deploymentId, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryLimit = 40, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryInterval = 60 + ) + $searchRetryCount = 1 + #Get ARM REST API token + $apiToken = ConvertFrom-SecureString (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token -AsPlainText + #write-verbose "api token: $apiToken" -verbose + + + do { + $innerInputObject = @{ + deploymentId = $deploymentId + apiToken = $apiToken + ErrorAction = 'SilentlyContinue' + } + [array]$targetResources = getDeploymentTargetResourceListInner @innerInputObject + if ($targetResources) { + break + } + Write-Verbose ('No deployment found by name [{0}] in scope [{1}]. Retrying in [{2}] seconds [{3}/{4}]' -f $name, $scope, $searchRetryInterval, $searchRetryCount, $searchRetryLimit) -Verbose + Start-Sleep $searchRetryInterval + $searchRetryCount++ + } while ($searchRetryCount -le $searchRetryLimit) + + if (-not $targetResources) { + throw "No deployment target resources found for [$name]" + } + + return $targetResources +} + +#Get diagnostic settings for a given resource +function getDiagnosticSettingsResources { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true, HelpMessage = 'Specify the resource id of the resource.')] + [string]$ResourceId + ) + $url = "https://management.azure.com/{0}/providers/microsoft.insights/diagnosticSettings?api-version=2021-05-01-preview" -f $ResourceId + $apiToken = ConvertFrom-SecureString (Get-AzAccessToken -ResourceUrl 'https://management.azure.com/').Token -AsPlainText + $headers = @{ + 'Authorization' = "Bearer $apiToken" + 'Content-Type' = 'application/json' + } + Write-Verbose "Getting diagnostic settings for resource via url $url" -verbose + try { + $request = invoke-webrequest -Uri $url -Method Get -Headers $headers -ErrorAction SilentlyContinue + if ($request.StatusCode -ge 200 -and $request.StatusCode -le 299) { + $result = ($request.Content | ConvertFrom-Json).value.id + } else { + Write-Verbose "Unable to get diagnostic settings for resource $ResourceId via url $url. Error: $($request.Content). Status Code: $($request.StatusCode)" + $result = $null + } + } catch { + Write-Verbose "Unable to get diagnostic settings for resource $ResourceId via url $url. Error: $_" + $result = $null + } + if ($result) { + $result + } +} + +# Order the given resources as per the provided ordered resource type list +function getOrderedResourcesList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [hashtable[]] $ResourcesToOrder, + + [Parameter(Mandatory = $false)] + [string[]] $Order = @() + ) + + # Going from back to front of the list to stack in the correct order + for ($orderIndex = ($order.Count - 1); $orderIndex -ge 0; $orderIndex--) { + $searchItem = $order[$orderIndex] + if ($elementsContained = $resourcesToOrder | Where-Object { $_.type -eq $searchItem }) { + $resourcesToOrder = @() + $elementsContained + ($resourcesToOrder | Where-Object { $_.type -ne $searchItem }) + } + } + + return $resourcesToOrder +} + +#Remove any artifacts that remain of the given resource +function invokeResourcePostRemoval { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $true)] + [string] $Type + ) + + switch ($Type) { + 'Microsoft.AppConfiguration/configurationStores' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceName = Split-Path $ResourceId -Leaf + + # Fetch service in soft-delete + $getPath = '/subscriptions/{0}/providers/Microsoft.AppConfiguration/deletedConfigurationStores?api-version=2021-10-01-preview' -f $subscriptionId + $getRequestInputObject = @{ + Method = 'GET' + Path = $getPath + } + $softDeletedConfigurationStore = ((Invoke-AzRestMethod @getRequestInputObject).Content | ConvertFrom-Json).value | Where-Object { $_.properties.configurationStoreId -eq $ResourceId } + + if ($softDeletedConfigurationStore) { + # Purge service + $purgePath = '/subscriptions/{0}/providers/Microsoft.AppConfiguration/locations/{1}/deletedConfigurationStores/{2}/purge?api-version=2021-10-01-preview' -f $subscriptionId, $softDeletedConfigurationStore.properties.location, $resourceName + $purgeRequestInputObject = @{ + Method = 'POST' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('App Configuration Store with ID [{0}]' -f $softDeletedConfigurationStore.properties.configurationStoreId), 'Purge')) { + $response = Invoke-AzRestMethod @purgeRequestInputObject + if ($response.StatusCode -ne 200) { + throw ('Purge of resource [{0}] failed with error code [{1}]' -f $ResourceId, $response.StatusCode) + } + } + } + break + } + 'Microsoft.KeyVault/vaults' { + $resourceName = Split-Path $ResourceId -Leaf + + $matchingKeyVault = Get-AzKeyVault -InRemovedState | Where-Object { $_.resourceId -eq $ResourceId } + if ($matchingKeyVault -and -not $matchingKeyVault.EnablePurgeProtection) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('Key Vault with ID [{0}]' -f $matchingKeyVault.Id), 'Purge')) { + try { + $null = Remove-AzKeyVault -ResourceId $matchingKeyVault.Id -InRemovedState -Force -Location $matchingKeyVault.Location -ErrorAction 'Stop' + } catch { + if ($_.Exception.Message -like '*DeletedVaultPurge*') { + Write-Warning ('Purge protection for key vault [{0}] enabled. Skipping. Scheduled purge date is [{1}]' -f $resourceName, $matchingKeyVault.ScheduledPurgeDate) + } else { + throw $_ + } + } + } + } + break + } + 'Microsoft.CognitiveServices/accounts' { + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + $matchingAccount = Get-AzCognitiveServicesAccount -InRemovedState | Where-Object { $_.AccountName -eq $resourceName } + if ($matchingAccount) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('Cognitive services account with ID [{0}]' -f $matchingAccount.Id), 'Purge')) { + $null = Remove-AzCognitiveServicesAccount -InRemovedState -Force -Location $matchingAccount.Location -ResourceGroupName $resourceGroupName -Name $matchingAccount.AccountName + } + } + break + } + 'Microsoft.ApiManagement/service' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceName = Split-Path $ResourceId -Leaf + + # Fetch service in soft-delete + $getPath = '/subscriptions/{0}/providers/Microsoft.ApiManagement/deletedservices?api-version=2021-08-01' -f $subscriptionId + $getRequestInputObject = @{ + Method = 'GET' + Path = $getPath + } + $softDeletedService = ((Invoke-AzRestMethod @getRequestInputObject).Content | ConvertFrom-Json).value | Where-Object { $_.properties.serviceId -eq $ResourceId } + + if ($softDeletedService) { + # Purge service + $purgePath = '/subscriptions/{0}/providers/Microsoft.ApiManagement/locations/{1}/deletedservices/{2}?api-version=2020-06-01-preview' -f $subscriptionId, $softDeletedService.location, $resourceName + $purgeRequestInputObject = @{ + Method = 'DELETE' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess(('API management service with ID [{0}]' -f $softDeletedService.properties.serviceId), 'Purge')) { + $null = Invoke-AzRestMethod @purgeRequestInputObject + } + } + break + } + 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems' { + # Remove protected VM + # Required if e.g. a VM was listed in an RSV and only that VM is removed + $vaultId = $ResourceId.split('/backupFabrics/')[0] + $resourceName = Split-Path $ResourceId -Leaf + $softDeleteStatus = (Get-AzRecoveryServicesVaultProperty -VaultId $vaultId).SoftDeleteFeatureState + if ($softDeleteStatus -ne 'Disabled') { + if ($PSCmdlet.ShouldProcess(('Soft-delete on RSV [{0}]' -f $vaultId), 'Set')) { + $null = Set-AzRecoveryServicesVaultProperty -VaultId $vaultId -SoftDeleteFeatureState 'Disable' + } + } + + $backupItemInputObject = @{ + BackupManagementType = 'AzureVM' + WorkloadType = 'AzureVM' + VaultId = $vaultId + Name = $resourceName + } + if ($backupItem = Get-AzRecoveryServicesBackupItem @backupItemInputObject -ErrorAction 'SilentlyContinue') { + Write-Verbose (' [-] Removing Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $vaultId) -Verbose + + if ($backupItem.DeleteState -eq 'ToBeDeleted') { + if ($PSCmdlet.ShouldProcess('Soft-deleted backup data removal', 'Undo')) { + $null = Undo-AzRecoveryServicesBackupItemDeletion -Item $backupItem -VaultId $vaultId -Force + } + } + + if ($PSCmdlet.ShouldProcess(('Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $vaultId), 'Remove')) { + $null = Disable-AzRecoveryServicesBackupProtection -Item $backupItem -VaultId $vaultId -RemoveRecoveryPoints -Force + } + } + + # Undo a potential soft delete state change + $null = Set-AzRecoveryServicesVaultProperty -VaultId $vaultId -SoftDeleteFeatureState $softDeleteStatus.TrimEnd('d') + break + } + ### CODE LOCATION: Add custom post-removal operation here + } +} +#Remove the given resource(s) +function removeResourceListInner { + + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $false)] + [Hashtable[]] $ResourcesToRemove = @() + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + + } + + process { + $resourcesToRemove | ForEach-Object { Write-Verbose ('- Remove [{0}]' -f $_.resourceId) -Verbose } + $resourcesToRetry = @() + $processedResources = @() + Write-Verbose '----------------------------------' -Verbose + + foreach ($resource in $resourcesToRemove) { + $resourceName = Split-Path $resource.resourceId -Leaf + $alreadyProcessed = $processedResources.count -gt 0 ? (($processedResources | Where-Object { $resource.resourceId -like ('{0}*' -f $_) }).Count -gt 0) : $false + + if ($alreadyProcessed) { + # Skipping + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: Its parent resource was already processed' -f $resourceName, $resource.type) -Verbose + [array]$processedResources += $resource.resourceId + [array]$resourcesToRetry = $resourcesToRetry | Where-Object { $_.resourceId -notmatch $resource.resourceId } + } else { + Write-Verbose ('[-] Removing resource [{0}] of type [{1}]' -f $resourceName, $resource.type) -Verbose + try { + if ($PSCmdlet.ShouldProcess(('Resource [{0}]' -f $resource.resourceId), 'Remove')) { + invokeResourceRemoval -Type $resource.type -ResourceId $resource.resourceId + } + + # If we removed a parent remove its children + [array]$processedResources += $resource.resourceId + [array]$resourcesToRetry = $resourcesToRetry | Where-Object { $_.resourceId -notmatch $resource.resourceId } + } catch { + Write-Warning ('[!] Removal moved back for retry. Reason: [{0}]' -f $_.Exception.Message) + [array]$resourcesToRetry += $resource + } + } + + # We want to purge resources even if they were not explicitly removed because they were 'alreadyProcessed' + if ($PSCmdlet.ShouldProcess(('Post-resource-removal for [{0}]' -f $resource.resourceId), 'Execute')) { + invokeResourcePostRemoval -Type $resource.type -ResourceId $resource.resourceId + } + } + Write-Verbose '----------------------------------' -Verbose + return $resourcesToRetry + } + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} +#Remove all resources in the provided array from Azure +function removeResourceList { + + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory = $false)] + [PSObject[]] $ResourcesToRemove = @(), + + [Parameter(Mandatory = $false)] + [int] $RemovalRetryLimit = 3, + + [Parameter(Mandatory = $false)] + [int] $RemovalRetryInterval = 15 + ) + + $removalRetryCount = 1 + $resourcesToRetry = $resourcesToRemove + + do { + if ($PSCmdlet.ShouldProcess(("[{0}] Resource(s) with a maximum of [$removalRetryLimit] attempts." -f (($resourcesToRetry -is [array]) ? $resourcesToRetry.Count : 1)), 'Remove')) { + $resourcesToRetry = removeResourceListInner -ResourcesToRemove $resourcesToRetry + } else { + removeResourceListInner -ResourcesToRemove $resourcesToRemove -WhatIf + } + + if (-not $resourcesToRetry) { + break + } + Write-Verbose ('Retry removal of remaining [{0}] resources. Waiting [{1}] seconds. Round [{2}|{3}]' -f (($resourcesToRetry -is [array]) ? $resourcesToRetry.Count : 1), $removalRetryInterval, $removalRetryCount, $removalRetryLimit) + $removalRetryCount++ + Start-Sleep $removalRetryInterval + } while ($removalRetryCount -le $removalRetryLimit) + + if ($resourcesToRetry.Count -gt 0) { + throw ('The removal failed for resources [{0}]' -f ((Split-Path $resourcesToRetry.resourceId -Leaf) -join ', ')) + } else { + Write-Verbose 'The removal completed successfully' + } +} + + +#Format the provide resource IDs into objects of resourceID, name & type +function getResourceIdsAsFormattedObjectList { + + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [string[]] $ResourceIds = @() + ) + + $formattedResources = [System.Collections.ArrayList]@() + + # If any resource is deployed at a resource group level, we store all resources in this resource group in this array. Essentially it's a cache. + $allResourceGroupResources = @() + + foreach ($resourceId in $resourceIds) { + + $idElements = $resourceId.Split('/') + + switch ($idElements.Count) { + { $PSItem -eq 5 } { + if ($idElements[3] -eq 'managementGroups') { + # management-group level management group (e.g. '/providers/Microsoft.Management/managementGroups/testMG') + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[2, 3] -join '/' + } + } else { + # subscription level resource group (e.g. '/subscriptions//resourceGroups/myRG') + $formattedResources += @{ + resourceId = $resourceId + type = 'Microsoft.Resources/resourceGroups' + } + } + break + } + { $PSItem -eq 6 } { + # subscription-level resource group + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[4, 5] -join '/' + } + break + } + { $PSItem -eq 7 } { + if (($resourceId.Split('/'))[3] -ne 'resourceGroups') { + # subscription-level resource + $formattedResources += @{ + resourceId = $resourceId + type = $idElements[4, 5] -join '/' + } + } else { + # resource group-level + if ($allResourceGroupResources.Count -eq 0) { + $allResourceGroupResources = Get-AzResource -ResourceGroupName $resourceGroupName -Name '*' + } + $expandedResources = $allResourceGroupResources | Where-Object { $_.ResourceId.startswith($resourceId) } + $expandedResources = $expandedResources | Sort-Object -Descending -Property { $_.ResourceId.Split('/').Count } + foreach ($resource in $expandedResources) { + $formattedResources += @{ + resourceId = $resource.ResourceId + type = $resource.Type + } + } + } + break + } + { $PSItem -ge 8 } { + # child-resource level + # Find the last resource type reference in the resourceId. + # E.g. Microsoft.Automation/automationAccounts/provider/Microsoft.Authorization/roleAssignments/... returns the index of 'Microsoft.Authorization' + $indexOfResourceType = $idElements.IndexOf(($idElements -like 'Microsoft.**')[-1]) + $type = $idElements[$indexOfResourceType, ($indexOfResourceType + 1)] -join '/' + + # Concat rest of resource type along the ID + $partCounter = $indexOfResourceType + 1 + while (-not ($partCounter + 2 -gt $idElements.Count - 1)) { + $type += ('/{0}' -f $idElements[($partCounter + 2)]) + $partCounter = $partCounter + 2 + } + + $formattedResources += @{ + resourceId = $resourceId + type = $type + } + break + } + Default { + throw "Failed to process resource ID [$resourceId]" + } + } + } + + return $formattedResources +} + +#Gets resource locks on a resource or a specific resource lock. +function invokeResourceLockRetrieval { + [OutputType([System.Management.Automation.PSCustomObject])] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $false)] + [string] $Type = '' + ) + if ($Type -eq 'Microsoft.Authorization/locks') { + $lockName = ($ResourceId -split '/')[-1] + $lockScope = ($ResourceId -split '/providers/Microsoft.Authorization/locks')[0] + return Get-AzResourceLock -LockName $lockName -Scope $lockScope -ErrorAction SilentlyContinue + } else { + return Get-AzResourceLock -Scope $ResourceId -ErrorAction SilentlyContinue + } +} + +#Remove a specific resource +function invokeResourceRemoval { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $true)] + [string] $Type + ) + # Remove unhandled resource locks, for cases when the resource + # collection is incomplete, usually due to previous removal failing. + if ($PSCmdlet.ShouldProcess("Possible locks on resource with ID [$ResourceId]", 'Handle')) { + invokeResourceLockRemoval -ResourceId $ResourceId -Type $Type + } + + switch ($Type) { + 'Microsoft.Insights/diagnosticSettings' { + $parentResourceId = $ResourceId.Split('/providers/{0}' -f $Type)[0] + $resourceName = Split-Path $ResourceId -Leaf + if ($PSCmdlet.ShouldProcess("Diagnostic setting [$resourceName]", 'Remove')) { + $null = Remove-AzDiagnosticSetting -ResourceId $parentResourceId -Name $resourceName + } + break + } + 'Microsoft.Authorization/locks' { + if ($PSCmdlet.ShouldProcess("Lock with ID [$ResourceId]", 'Remove')) { + invokeResourceLockRemoval -ResourceId $ResourceId -Type $Type + } + break + } + 'Microsoft.KeyVault/vaults/keys' { + $resourceName = Split-Path $ResourceId -Leaf + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: It is handled by different logic.' -f $resourceName, $Type) -Verbose + # Also, we don't want to accidently remove keys of the dependency key vault + break + } + 'Microsoft.KeyVault/vaults/accessPolicies' { + $resourceName = Split-Path $ResourceId -Leaf + Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: It is handled by different logic.' -f $resourceName, $Type) -Verbose + break + } + 'Microsoft.ServiceBus/namespaces/authorizationRules' { + if ((Split-Path $ResourceId '/')[-1] -eq 'RootManageSharedAccessKey') { + Write-Verbose ('[/] Skipping resource [RootManageSharedAccessKey] of type [{0}]. Reason: The Service Bus''s default authorization key cannot be removed' -f $Type) -Verbose + } else { + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + } + break + } + 'Microsoft.Compute/diskEncryptionSets' { + # Pre-Removal + # ----------- + # Remove access policies on key vault + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + $diskEncryptionSet = Get-AzDiskEncryptionSet -Name $resourceName -ResourceGroupName $resourceGroupName + $keyVaultResourceId = $diskEncryptionSet.ActiveKey.SourceVault.Id + $keyVaultName = Split-Path $keyVaultResourceId -Leaf + $objectId = $diskEncryptionSet.Identity.PrincipalId + + if ($PSCmdlet.ShouldProcess(('Access policy [{0}] from key vault [{1}]' -f $objectId, $keyVaultName), 'Remove')) { + $null = Remove-AzKeyVaultAccessPolicy -VaultName $keyVaultName -ObjectId $objectId + } + + # Actual removal + # -------------- + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + break + } + 'Microsoft.RecoveryServices/vaults/backupstorageconfig' { + # Not a 'resource' that can be removed, but represents settings on the RSV. The config is deleted with the RSV + break + } + 'Microsoft.Authorization/roleAssignments' { + $idElem = $ResourceId.Split('/') + $scope = $idElem[0..($idElem.Count - 5)] -join '/' + $roleAssignmentsOnScope = Get-AzRoleAssignment -Scope $scope + $null = $roleAssignmentsOnScope | Where-Object { $_.RoleAssignmentId -eq $ResourceId } | Remove-AzRoleAssignment + break + } + 'Microsoft.RecoveryServices/vaults' { + # Pre-Removal + # ----------- + # Remove protected VMs + if ((Get-AzRecoveryServicesVaultProperty -VaultId $ResourceId).SoftDeleteFeatureState -ne 'Disabled') { + if ($PSCmdlet.ShouldProcess(('Soft-delete on RSV [{0}]' -f $ResourceId), 'Set')) { + $null = Set-AzRecoveryServicesVaultProperty -VaultId $ResourceId -SoftDeleteFeatureState 'Disable' + } + } + + $backupItems = Get-AzRecoveryServicesBackupItem -BackupManagementType 'AzureVM' -WorkloadType 'AzureVM' -VaultId $ResourceId + foreach ($backupItem in $backupItems) { + Write-Verbose ('Removing Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $ResourceId) -Verbose + + if ($backupItem.DeleteState -eq 'ToBeDeleted') { + if ($PSCmdlet.ShouldProcess('Soft-deleted backup data removal', 'Undo')) { + $null = Undo-AzRecoveryServicesBackupItemDeletion -Item $backupItem -VaultId $ResourceId -Force + } + } + + if ($PSCmdlet.ShouldProcess(('Backup item [{0}] from RSV [{1}]' -f $backupItem.Name, $ResourceId), 'Remove')) { + $null = Disable-AzRecoveryServicesBackupProtection -Item $backupItem -VaultId $ResourceId -RemoveRecoveryPoints -Force + } + } + + # Actual removal + # -------------- + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + break + } + 'Microsoft.OperationalInsights/workspaces' { + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + # Force delete workspace (cannot be recovered) + if ($PSCmdlet.ShouldProcess("Log Analytics Workspace [$resourceName]", 'Remove')) { + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + $null = Remove-AzOperationalInsightsWorkspace -ResourceGroupName $resourceGroupName -Name $resourceName -Force -ForceDelete + } + break + } + 'Microsoft.MachineLearningServices/workspaces' { + $subscriptionId = $ResourceId.Split('/')[2] + $resourceGroupName = $ResourceId.Split('/')[4] + $resourceName = Split-Path $ResourceId -Leaf + + # Purge service + $purgePath = '/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.MachineLearningServices/workspaces/{2}?api-version=2023-06-01-preview&forceToPurge=true' -f $subscriptionId, $resourceGroupName, $resourceName + $purgeRequestInputObject = @{ + Method = 'DELETE' + Path = $purgePath + } + Write-Verbose ('[*] Purging resource [{0}] of type [{1}]' -f $resourceName, $Type) -Verbose + if ($PSCmdlet.ShouldProcess("Machine Learning Workspace [$resourceName]", 'Purge')) { + $purgeResource = Invoke-AzRestMethod @purgeRequestInputObject + if ($purgeResource.StatusCode -notlike '2*') { + $responseContent = $purgeResource.Content | ConvertFrom-Json + throw ('{0} : {1}' -f $responseContent.error.code, $responseContent.error.message) + } + + # Wait for workspace to be purged. If it is not purged it has a chance of being soft-deleted via RG deletion (not purged) + # The consecutive deployments will fail because it is not purged. + $retryCount = 0 + $retryLimit = 240 + $retryInterval = 15 + do { + $retryCount++ + if ($retryCount -ge $retryLimit) { + Write-Warning (' [!] Workspace [{0}] was not purged after {1} seconds. Continuing with resource removal.' -f $resourceName, ($retryCount * $retryInterval)) + break + } + Write-Verbose (' [⏱️] Waiting {0} seconds for workspace to be purged.' -f $retryInterval) -Verbose + Start-Sleep -Seconds $retryInterval + $workspace = Get-AzMLWorkspace -Name $resourceName -ResourceGroupName $resourceGroupName -SubscriptionId $subscriptionId -ErrorAction SilentlyContinue + $workspaceExists = $workspace.count -gt 0 + } while ($workspaceExists) + } + break + } + ### CODE LOCATION: Add custom removal action here + Default { + if ($PSCmdlet.ShouldProcess("Resource with ID [$ResourceId]", 'Remove')) { + $null = Remove-AzResource -ResourceId $ResourceId -Force -ErrorAction 'Stop' + } + } + } +} + +#Remove resource locks from a resource or a specific resource lock. +function invokeResourceLockRemoval { + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $ResourceId, + + [Parameter(Mandatory = $false)] + [string] $Type, + + [Parameter(Mandatory = $false)] + [int] $RetryLimit = 10, + + [Parameter(Mandatory = $false)] + [int] $RetryInterval = 10 + ) + + + $resourceLock = invokeResourceLockRetrieval -ResourceId $ResourceId -Type $Type + + $isLocked = $resourceLock.count -gt 0 + if (-not $isLocked) { + return + } + + $resourceLock | ForEach-Object { + Write-Warning (' [-] Removing lock [{0}] on [{1}] of type [{2}].' -f $_.Name, $_.ResourceName, $_.ResourceType) + if ($PSCmdlet.ShouldProcess(('Lock [{0}] on resource [{1}] of type [{2}].' -f $_.Name, $_.ResourceName, $_.ResourceType ), 'Remove')) { + $null = $_ | Remove-AzResourceLock -Force + } + } + + $retryCount = 0 + do { + $retryCount++ + if ($retryCount -ge $RetryLimit) { + Write-Warning (' [!] Lock was not removed after {1} seconds. Continuing with resource removal.' -f ($retryCount * $RetryInterval)) + break + } + Write-Verbose ' [⏱️] Waiting for lock to be removed.' -Verbose + Start-Sleep -Seconds $RetryInterval + + # Rechecking the resource locks to see if they have been removed. + $resourceLock = invokeResourceLockRetrieval -ResourceId $ResourceId -Type $Type + $isLocked = $resourceLock.count -gt 0 + } while ($isLocked) + + Write-Verbose (' [-] [{0}] resource lock(s) removed.' -f $resourceLock.count) -Verbose +} + +#Invoke the removal of all resources created from an ARM deployment (Only subscription and resource group level deployments are supported) +function removeDeployment { + + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(Mandatory = $true)] + [string] $deploymentId, + + [Parameter(Mandatory = $false)] + [string[]] $RemovalSequence = @(), + + [Parameter(Mandatory = $false)] + [int] $SearchRetryLimit = 40, + + [Parameter(Mandatory = $false)] + [int] $SearchRetryInterval = 60 + ) + + begin { + Write-Debug ('{0} entered' -f $MyInvocation.MyCommand) + } + + process { + #extract subscription Id from deploymentId + $regex = '(?im)^\/subscriptions\/([0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12})\/\S+' + $match = Select-string -InputObject $deploymentId -Pattern $regex + $subscriptionId = $match.Matches.Groups[1].Value + # Fetch deployments + # ================= + + #Get deployed Resources + Write-Verbose "[$(getCurrentUTCString)]: Getting deployed resources for deployment id: $deploymentId" -Verbose + $deployedTargetResources = getDeploymentTargetResourceList -deploymentId $deploymentId + + #Also get the diagnostic settings resources for each detected resource. Diagnostic Settings must be removed before the resource can be removed. + #More info: https://learn.microsoft.com/en-us/azure/azure-monitor/essentials/diagnostic-settings + Write-Verbose "[$(getCurrentUTCString)]: Getting diagnostic settings resources for the detected resources" -Verbose + $diagnosticSettings = @() + foreach ($resource in $resources) { + $diagnosticSettings += getDiagnosticSettingsResources -ResourceId $resource + } + + Write-Verbose "[$(getCurrentUTCString)]: Detected Diagnostic Settings created outside of the deployment:" -Verbose + foreach ($item in $diagnosticSettings) { + if ($deployedTargetResources -notcontains $item) { + Write-Verbose " - $item" -Verbose + $deployedTargetResources += $item + } + } + + Write-Verbose "[$(getCurrentUTCString)]: Resources to be deleted:" -Verbose + foreach ($resource in $deployedTargetResources) { + Write-Verbose " - $resource" -Verbose + } + + + if ($deployedTargetResources.Count -eq 0) { + throw 'No deployment target resources found.' + } + + [array] $deployedTargetResources = $deployedTargetResources | Select-Object -Unique + + Write-Verbose ('Total number of deployment target resources after fetching deployments [{0}]' -f $deployedTargetResources.Count) -Verbose + + # Pre-Filter & order items + # ======================== + $rawTargetResourceIdsToRemove = $deployedTargetResources | Sort-Object -Property { $_.Split('/').Count } -Descending | Select-Object -Unique + Write-Verbose ('Total number of deployment target resources after pre-filtering (duplicates) & ordering items [{0}]' -f $rawTargetResourceIdsToRemove.Count) -Verbose + + # Format items + # ============ + [array] $resourcesToRemove = getResourceIdsAsFormattedObjectList -ResourceIds $rawTargetResourceIdsToRemove + Write-Verbose ('Total number of deployment target resources after formatting items [{0}]' -f $resourcesToRemove.Count) -Verbose + + if ($resourcesToRemove.Count -eq 0) { + return + } + + # Filter resources + # ================ + + # Resource IDs in the below list are ignored by the removal + $resourceIdsToIgnore = @( + '/subscriptions/{0}/resourceGroups/NetworkWatcherRG' -f $subscriptionId + ) + + # Resource IDs starting with a prefix in the below list are ignored by the removal + $resourceIdPrefixesToIgnore = @( + '/subscriptions/{0}/providers/Microsoft.Security/autoProvisioningSettings/' -f $subscriptionId + '/subscriptions/{0}/providers/Microsoft.Security/deviceSecurityGroups/' -f $subscriptionId + '/subscriptions/{0}/providers/Microsoft.Security/iotSecuritySolutions/' -f $subscriptionId + '/subscriptions/{0}/providers/Microsoft.Security/pricings/' -f $subscriptionId + '/subscriptions/{0}/providers/Microsoft.Security/securityContacts/' -f $subscriptionId + '/subscriptions/{0}/providers/Microsoft.Security/workspaceSettings/' -f $subscriptionId + ) + [regex] $ignorePrefix_regex = '(?i)^(' + (($resourceIdPrefixesToIgnore | ForEach-Object { [regex]::escape($_) }) -join '|') + ')' + + + if ($resourcesToIgnore = $resourcesToRemove | Where-Object { $_.resourceId -in $resourceIdsToIgnore -or $_.resourceId -match $ignorePrefix_regex }) { + Write-Verbose 'Resources excluded from removal:' -Verbose + $resourcesToIgnore | ForEach-Object { Write-Verbose ('- Ignore [{0}]' -f $_.resourceId) -Verbose } + } + + [array] $resourcesToRemove = $resourcesToRemove | Where-Object { $_.resourceId -notin $resourceIdsToIgnore -and $_.resourceId -notmatch $ignorePrefix_regex } + Write-Verbose ('Total number of deployments after filtering all dependency resources [{0}]' -f $resourcesToRemove.Count) -Verbose + + # Order resources + # =============== + [array] $resourcesToRemove = getOrderedResourcesList -ResourcesToOrder $resourcesToRemove -Order $RemovalSequence + Write-Verbose ('Total number of deployments after final ordering of resources [{0}]' -f $resourcesToRemove.Count) -Verbose + + # Remove resources + # ================ + if ($resourcesToRemove.Count -gt 0) { + if ($PSCmdlet.ShouldProcess(('[{0}] resources' -f (($resourcesToRemove -is [array]) ? $resourcesToRemove.Count : 1)), 'Remove')) { + removeResourceList -ResourcesToRemove $resourcesToRemove + } + } else { + Write-Verbose 'Found [0] resources to remove' + } + } + + end { + Write-Debug ('{0} exited' -f $MyInvocation.MyCommand) + } +} + +function removeAzureResource { + [CmdletBinding()] + Param ( + [Parameter(Mandatory = $true, HelpMessage = 'Specify the resource id.')] + [string]$resourceId, + [Parameter(Mandatory = $true, HelpMessage = 'Specify the ARM API version.')] + [string]$apiVersion + ) + Write-Output " - Deleting resource '$resourceId'" + $uri = 'https://management.azure.com{0}?api-version={1}' -f $resourceId, $apiVersion + $token = ConvertFrom-SecureString (Get-AzAccessToken).token -AsPlainText + $headers = @{ + 'Authorization' = "Bearer $token" + } + Write-Verbose "Deleting Resource $resourceId via the REST API using URI '$uri'." -Verbose + $response = Invoke-WebRequest -Uri $uri -Method DELETE -Headers $headers + If ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) { + Write-Output " - Response Code is '$($response.StatusCode)'." + } else { + Write-Error " - Failed to delete resource '$resourceId'." + Write-Error " - Response Code is '$($response.StatusCode)'." + Write-Error " - Response: $($response.Content)" + } +} diff --git a/scripts/pipelines/helper/terraform-helper-functions.ps1 b/scripts/pipelines/helper/terraform-helper-functions.ps1 new file mode 100644 index 0000000..ec0e4dd --- /dev/null +++ b/scripts/pipelines/helper/terraform-helper-functions.ps1 @@ -0,0 +1,187 @@ +#function to create a Terraform backend configuration file for local backend +function setTfLocalBackend { + [CmdletBinding()] + [OutputType([string])] + param ( + [parameter(Mandatory = $true, HelpMessage = "Path to the Terraform template directory.")] + [string]$path, + + [parameter(Mandatory = $false, HelpMessage = "The path to the Terraform state file that to be configured in the backend config.")] + [string]$localPath = "./terraform.tfstate", + + [parameter(Mandatory = $false, HelpMessage = "The path to non-default workspaces that to be configured in the backend config.")] + [AllowEmptyString()][AllowNull()] + [string]$localWorkspaceDir + ) + + $backendConfig = "terraform {`n" + $backendConfig += " backend `local` {`n" + $backendConfig += " path = `"$localPath`"`n" + if ($localWorkspaceDir) { + $backendConfig += " workspace_dir = `"$localWorkspaceDir`"`n" + } + $backendConfig += " }`n" + $backendConfig += "}`n" + Write-Verbose "Backend configuration content:" -Verbose + Write-Verbose $backendConfig -Verbose + $backendConfig | Out-File -FilePath $path -Encoding utf8 + Write-Verbose "Terraform backend configuration file created '$path'. It sets local path to '$localPath' and workspace directory to '$localWorkspaceDir'." +} + +#Function to check if Terraform is initialized +function isTFInitialized { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string]$path + ) + $isInit = $true + $tfLockFile = Join-Path -Path $path -ChildPath ".terraform.lock.hcl" + $tfChildDir = Join-Path -Path $path -ChildPath ".terraform" + if ( -not (Test-Path -Path $tfLockFile -PathType Leaf)) { + $isInit = $false + } + if ( -not (Test-Path -Path $tfChildDir -PathType Container)) { + $isInit = $false + } + $isInit +} + +#Function to find the .tfvars file in the specified path +function findTFVarsFile { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$path + ) + $tfVarsFile = Get-ChildItem -Path $path -Filter "*.tfvars" -Recurse -ErrorAction SilentlyContinue + if ($tfVarsFile) { + return $tfVarsFile.name + } else { + Write-Verbose "No .tfvars file found in the specified path: $path." -verbose + return $null + } +} + +#Function to apply terraform template +function applyDestroyTF { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, HelpMessage = "Path to the Terraform template directory.")] + [validateScript({ Test-Path $_ -PathType Container })] + [string]$tfPath, + + [Parameter(Mandatory = $true, HelpMessage = "Terraform action (apply or destroy).")] + [ValidateSet('apply', 'destroy')] + [string]$tfAction, + + [parameter(Mandatory = $false, HelpMessage = "Path to the Terraform template file that contains the backend configuration.")] + [ValidateNotNullOrEmpty()] + [string]$backendConfigFileName = 'backend.tf', + + [parameter(Mandatory = $false, HelpMessage = "The path to the Terraform state file that to be configured in the backend config.")] + [string]$localBackendPath = "./terraform.tfstate", + + [parameter(Mandatory = $false, HelpMessage = "The path to non-default workspaces that to be configured in the backend config.")] + [AllowEmptyString()][AllowNull()] + [string]$localBackendWorkspaceDir + ) + $backendConfigFilePath = join-path $tfPath $backendConfigFileName + Write-Verbose "Create Terraform backend configuration file at '$backendConfigFilePath'." -verbose + $localBackendParams = @{ + path = $backendConfigFilePath + localPath = $localBackendPath + } + if ($localBackendWorkspaceDir) { + $localBackendParams.add('localWorkspaceDir', $localBackendWorkspaceDir) + } + setTfLocalBackend @localBackendParams + + Write-Verbose "Finding .tfvars file in the specified path: $tfPath." -verbose + $tfVarsFile = findTFVarsFile -path $tfPath + # If multiple .tfvars files are found, throw an error + if ($tfVarsFile.Count -gt 1) { + Write-Error "Multiple .tfvars files found in the specified path: $path. Please specify a single .tfvars file." + Exit 1 + } else { + Write-Verbose "Number of .tfvars files found: $($tfVarsFile.Count):" -verbose + foreach ($file in $tfVarsFile) { + Write-Verbose " - '$file'" -verbose + } + } + + Write-Verbose "Make sure the Terraform template is initialized in the path: $tfPath." -verbose + $currentDir = Get-Location + if (-not (isTFInitialized -path $tfPath)) { + Write-Verbose "Terraform is not initialized in the specified path: $tfPath. Trying to initialize..." + try { + Set-Location -Path $tfPath + terraform init -input=false + $exitCode = $? + if ($exitCode -ne $true) { + #set the location back to the original + Set-Location -Path $currentDir + Write-Error "Terraform initialization failed in the specified path: $tfPath. Please check the output for details." + Exit 1 + } + #set the location back to the original + Set-Location -Path $currentDir + + } catch { + Write-Error "Failed to initialize Terraform in the specified path: $tfPath. Error: $_. Try manually running 'terraform init' in the directory." + #set the location back to the original + Set-Location -Path $currentDir + Exit 1 + } + } else { + Write-Verbose "Terraform is already initialized in the specified path: $tfPath." + } + # Run the Terraform command + Set-Location -Path $tfPath + if ($tfVarsFile) { + Write-Verbose "Running Terraform Plan using .tfvars file: $tfVarsFile" -verbose + terraform plan --var-file="$tfVarsFile" + Write-Verbose "Running Terraform $tfAction using .tfvars file: $tfVarsFile" -verbose + terraform $tfAction --var-file="$tfVarsFile" -auto-approve + } else { + Write-Verbose "Running Terraform Plan without variables." -verbose + terraform plan + Write-Verbose "No .tfvars file found. Running Terraform $tfAction without variables." -verbose + terraform $tfAction -auto-approve + } + $script:tfExitCode = $? + Set-Location -Path $currentDir + if ($script:tfExitCode -ne $true) { + Write-Error "Terraform $tfAction failed in the specified path: $tfPath. Please check the output for details." + } +} + +# Function to sanitize the terraform project and uninitialize it +function uninitializeTFProject { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, HelpMessage = "Path to the Terraform template directory.")] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string]$tfPath + ) + + #Check if the the terraform project is initialized + if (-not (isTFInitialized -path $tfPath)) { + Write-Verbose "Terraform project at '$tfPath' is not initialized. No need to uninitialize." -Verbose + return + } else { + Write-Verbose "Terraform project at '$tfPath' is initialized. Proceeding to uninitialize." -Verbose + $tfLockFile = Join-Path -Path $tfPath -ChildPath ".terraform.lock.hcl" + $tfChildDir = Join-Path -Path $tfPath -ChildPath ".terraform" + if ( Test-Path -Path $tfLockFile -PathType Leaf) { + Write-Verbose "[$(getCurrentUTCString)]: Removing Terraform lock file at '$tfLockFile'." -Verbose + Remove-Item -Path $tfLockFile -Force | Out-Null + } + if (Test-Path -Path $tfChildDir -PathType Container) { + Write-Verbose "[$(getCurrentUTCString)]: Removing Terraform child directory at '$tfChildDir'." -Verbose + Remove-Item -Path $tfChildDir -Recurse -Force | Out-Null + } + } +} diff --git a/scripts/pipelines/pipeline-install-bicep.ps1 b/scripts/pipelines/pipeline-install-bicep.ps1 index 88f2379..767409d 100644 --- a/scripts/pipelines/pipeline-install-bicep.ps1 +++ b/scripts/pipelines/pipeline-install-bicep.ps1 @@ -118,7 +118,20 @@ Function InstallBicepWindows { #endregion #region main -$os = $env:AGENT_OS +$helperFunctionScriptPath = join-path $PSScriptRoot 'helper' 'helper-functions.ps1' +#load helper +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType +if ($runtimePlatform -ieq 'Azure DevOps') { + $os = $env:AGENT_OS +} elseif ($runtimePlatform -ieq 'GitHub Actions') { + $os = $env:RUNNER_OS +} else { + Write-Error "Unsupported pipeline runtime platform: $runtimePlatform" + exit 1 +} + $isSelfHostedAgent = [boolean]$([int]$env:AGENT_ISSELFHOSTED) if ($isSelfHostedAgent) { Write-Output "Self-hosted agent detected. Skip Bicep installation and make sure desired version is installed manually on the agent Computer." @@ -126,7 +139,7 @@ if ($isSelfHostedAgent) { } if ($os -eq 'Linux') { InstallBicepLinux -DesiredVersion $DesiredVersion -} elseif ($os -eq 'Windows_NT') { +} elseif ($os -eq 'Windows_NT' -or $os -eq 'Windows') { InstallBicepWindows -DesiredVersion $DesiredVersion } else { Write-Error "Unsupported OS: $os" diff --git a/scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1 new file mode 100644 index 0000000..7aa1cb0 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-create-pipeline-variables-from-json-file.ps1 @@ -0,0 +1,54 @@ +<# +============================================================= +AUTHOR: Tao Yang +DATE: 22/07/2024 +NAME: pipeline-create-pipeline-variables-from-json-file.ps1 +VERSION: 1.0.0 +COMMENT: Create pipeline variables from a json file +============================================================= +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType 'Leaf' })] + [string]$jsonFilePath, + + [parameter(Mandatory = $false)] + [string]$overallJsonVariableName +) +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' + +#load helper +. $helperFunctionScriptPath +$runtimePlatform = getPipelineType +#Read Json file and create a hashtable +Write-Verbose "Parsing Json file: $jsonFilePath" +$ht = Get-Content -Path $jsonFilePath -raw | ConvertFrom-Json -AsHashtable + +foreach ($item in $ht.GetEnumerator()) { + if ($item.Value -is [System.Array]) { + $itemValue = $item.Value | ConvertTo-Json -Compress -AsArray + } else { + $itemValue = $item.Value + } + Write-Verbose "Creating pipeline variable $($item.Key) with value '$itemValue'" -verbose + if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f $item.Key, $itemValue) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f $item.Key, $itemValue) + } elseif ($runtimePlatform -ieq 'github actions') { + write-output "complianceScanSubNames=$complianceScanSubNames" >> $env:GITHUB_OUTPUT + Write-Output $('{0}={1}' -f $item.Key, $itemValue) >> $env:GITHUB_OUTPUT + } + +} + +if ($PSBoundParameters.ContainsKey('overallJsonVariableName')) { + $overallJsonValue = Get-Content -Path $jsonFilePath -raw | convertFrom-Json -depth 99 | ConvertTo-Json -Compress -depth 99 + Write-Verbose "Creating pipeline variable $overallJsonVariableName with overall json content" -verbose + if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f $overallJsonVariableName, $overallJsonValue) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f $overallJsonVariableName, $overallJsonValue) + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output $('{0}={1}' -f $overallJsonVariableName, $overallJsonValue) >> $env:GITHUB_OUTPUT + } +} diff --git a/scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1 new file mode 100644 index 0000000..9b4f2f7 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-delete-policy-test-deployed-resources.ps1 @@ -0,0 +1,114 @@ +#Requires -Modules Az.Resources +#Requires -Version 7.0 + +<# +=================================================================== +AUTHOR: Tao Yang +DATE: 24/06/2024 +NAME: pipeline-delete-policy-test-deployed-resources.ps1 +VERSION: 1.0.0 +COMMENT: Delete resources deployed by policy integration tests +=================================================================== +#> +#variables +$bicepDeploymentResult = $env:bicepDeploymentResult | ConvertFrom-Json -depth 99 +$deploymentId = $bicepDeploymentResult.bicepDeploymentId ?? $null +$deploymentTarget = $bicepDeploymentResult.bicepDeploymentTarget ?? $null +$deploymentScope = $bicepDeploymentResult.bicepDeploymentScope ?? $null +$additionalResourceGroups = $bicepDeploymentResult.bicepAdditionalResourceGroups ?? $null +$removeTestResourceGroup = $bicepDeploymentResult.bicepRemoveTestResourceGroup +$testSubscriptionId = $bicepDeploymentResult.bicepTestSubscriptionId +$testResourceGroup = $bicepDeploymentResult.bicepTestResourceGroup ?? $null + +Write-Verbose "Deployment Id: $deploymentId" -verbose +Write-Verbose "Deployment Target: $deploymentTarget" -verbose +Write-Verbose "Deployment Scope: $deploymentScope" -verbose +Write-Verbose "Test Subscription Id: $testSubscriptionId" -verbose +Write-Verbose "Test Resource Group: $testResourceGroup" -verbose +if ($testResourceGroup -ne $null) { + $resourceGroupId = "/subscriptions/$testSubscriptionId/resourceGroups/$testResourceGroup" + Write-Verbose "Test Resource Group Id: $resourceGroupId" -verbose +} +Write-Verbose "Remove Test Resource Group: $removeTestResourceGroup" -verbose +If ($null -ne $additionalResourceGroups) { + Write-Verbose "Additional Resource Groups:" -verbose + foreach ($resourceGroup in $additionalResourceGroups) { + Write-Verbose " - $resourceGroup" -verbose + } +} else { + Write-Verbose "No additional resource groups found from deployment output." -verbose +} + +$resourceGroupApiVersion = '2021-04-01' +# The removal sequence is a general order-recommendation +$removalSequence = @( + 'Microsoft.Authorization/locks', + 'Microsoft.Authorization/roleAssignments', + 'Microsoft.Insights/diagnosticSettings', + 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups', + 'Microsoft.Network/privateEndpoints', + 'Microsoft.Network/azureFirewalls', + 'Microsoft.Network/virtualHubs', + 'Microsoft.Network/virtualWans', + 'Microsoft.OperationsManagement/solutions', + 'Microsoft.OperationalInsights/workspaces/linkedServices', + 'Microsoft.OperationalInsights/workspaces', + 'Microsoft.KeyVault/vaults', + 'Microsoft.Authorization/policyExemptions', + 'Microsoft.Authorization/policyAssignments', + 'Microsoft.Authorization/policySetDefinitions', + 'Microsoft.Authorization/policyDefinitions' + 'Microsoft.Sql/managedInstances', + 'Microsoft.MachineLearningServices/workspaces', + 'Microsoft.Resources/resourceGroups', + 'Microsoft.Compute/virtualMachines' +) + +#Load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +$removalHelperScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'resource-removal-helper.ps1' + +. $helperFunctionScriptPath +. $removalHelperScriptPath + +if ($deploymentId.length -gt 0) { + Write-Verbose "Removing resources deployed by deployment id '$deploymentId'..." -verbose + try { + removeDeployment -deploymentId $deploymentId -removalSequence $removalSequence + } catch { + Write-Verbose "Failed to remove resources deployed by deployment id '$deploymentId'. Error: $_" -verbose + } + +} else { + Write-Verbose "Deployment Id is not provided. Nothing to be removed." -Verbose +} + +If ($removeTestResourceGroup -eq 'true') { + Write-Verbose "The Local Configuration explicitly specified to remove the test resource group." -verbose + $resourceGroup = getResourceViaARMAPI -resourceId $resourceGroupId -apiVersion $resourceGroupApiVersion -errorAction SilentlyContinue + if ($null -eq $resourceGroup) { + Write-Verbose "The test resource group '$resourceGroupId' does not exist." -verbose + } else { + Write-Verbose "Removing test resource group '$resourceGroupId'..." -verbose + removeAzureResource -resourceId $resourceGroupId -apiVersion $resourceGroupApiVersion + } +} else { + Write-Verbose "The Local Configuration did not explicitly specify to delete the test resource group." -verbose +} + +#Delete additional resource groups if specified +if ($null -ne $additionalResourceGroups) { + Write-Verbose "Removing additional resource groups..." -verbose + foreach ($resourceGroupId in $additionalResourceGroups) { + #check if the resource group exists + $resourceGroup = getResourceViaARMAPI -resourceId $resourceGroupId -apiVersion $resourceGroupApiVersion -errorAction SilentlyContinue + if ($null -eq $resourceGroup) { + Write-Verbose " - The additional resource group '$resourceGroupId' does not exist." -verbose + } else { + Write-Verbose " - Removing additional resource group '$resourceGroupId'..." -verbose + removeAzureResource -resourceId $resourceGroupId -apiVersion $resourceGroupApiVersion + } + } +} else { + Write-Verbose "No additional resource groups found to remove." -verbose +} diff --git a/scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1 new file mode 100644 index 0000000..1b14f1a --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-deploy-destroy-policy-test-terraform-template.ps1 @@ -0,0 +1,242 @@ +<# +================================================================================== +AUTHOR: Tao Yang +DATE: 15/07/2025 +NAME: pipeline-deploy-destroy-policy-test-terraform-template.ps1 +VERSION: 1.0.0 +COMMENT: Deploy or destroy test Terraform template for policy integration testing +================================================================================== +#> +[CmdletBinding()] +Param ( + [Parameter(Mandatory = $true, HelpMessage = 'Specify the Test configuration path.')] + [string]$testConfigFilePath, + + [Parameter(Mandatory = $true, HelpMessage = 'Required. Specify the Terraform file path.')] + [ValidateNotNullOrEmpty()] + [string]$terraformPath, + + [parameter(Mandatory = $false, HelpMessage = "Name of the Terraform template file that contains the backend configuration.")] + [string]$tfBackendConfigFileName = 'backend.tf', + + [parameter(Mandatory = $true, HelpMessage = "Required. The path to the Terraform state file that to be configured in the backend config.")] + [string]$tfBackendStateFileDirectory, + + [parameter(Mandatory = $false, HelpMessage = "Optional. The file name for the unencrypted terraform state file.")] + [ValidateNotNullOrEmpty()] + [string]$tfStateFileName = 'terraform_state.tfstate', + + [parameter(Mandatory = $false, HelpMessage = "Optional. The file name for the encrypted terraform state file.")] + [ValidateNotNullOrEmpty()] + [string]$tfEncryptedStateFileName = 'terraform_state.enc', + + [parameter(Mandatory = $false, HelpMessage = "Optional. The file name for the deployment result file.")] + [ValidateNotNullOrEmpty()] + [string]$deploymentResultFileName = 'result.json', + + [parameter(Mandatory = $false, HelpMessage = "Optional. he path to non-default workspaces that to be configured in the backend config.")] + [AllowEmptyString()][AllowNull()] + [string]$tfWorkspaceDir, + + [Parameter(Mandatory = $true, HelpMessage = "Terraform action (apply or destroy).")] + [ValidateSet('apply', 'destroy')] + [string]$tfAction, + + [Parameter(Mandatory = $false, HelpMessage = "Un-initialize Terraform after terraform apply or destroy.")] + [ValidateSet('true', 'false')] + [string]$uninitializeTerraform = 'false', + + [parameter(Mandatory = $false, HelpMessage = "Optional. The AES encryption key used to encrypt the Terraform state file.")] + [string]$aesEncryptionKey, + + [parameter(Mandatory = $false, HelpMessage = "Optional. The AES encryption initialization vector used to encrypt the Terraform state file.")] + [string]$aesIV +) + +#region functions +function createResultFile { + param ( + [Parameter(Mandatory = $false)] + [string]$fileName = 'result.json', + + [Parameter(Mandatory = $true)] + [string]$directory, + + [Parameter(Mandatory = $true)] + [boolean]$terraformDeployment, + + [Parameter(Mandatory = $false)] + [string]$provisioningState, + + [Parameter(Mandatory = $false)] + [string]$deploymentOutputs + ) + $result = @{ + terraformDeployment = $terraformDeployment + } + if ($provisioningState) { + $result.add('terraformProvisioningState', $provisioningState) + } + if ($deploymentOutputs) { + $result.add('terraformDeploymentOutputs', $deploymentOutputs) + } + $result | ConvertTo-Json -Depth 99 | Out-File -FilePath (Join-Path -Path $directory -ChildPath $fileName) -Encoding utf8 +} +#endregion + +#region main +#load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +. $helperFunctionScriptPath +$runtimePlatform = getPipelineType +#Get the test config +$gitRoot = Get-GitRoot +$testGlobalConfigFilePath = join-path $gitRoot 'tests' 'policy-integration-tests' '.shared' 'policy_integration_test_config.jsonc' +Write-Verbose "Loading Global Test configuration from ''$testGlobalConfigFilePath'..." -verbose +$globalTestConfig = getTestConfig -TestConfigFilePath $testGlobalConfigFilePath +Write-Verbose "Loading Local Test configuration from ''$testConfigFilePath'..." -verbose +$localTestConfig = getTestConfig -TestConfigFilePath $testConfigFilePath +$testSubName = $localTestConfig.testSubscription +$testSubId = $globalTestConfig.subscriptions.$testSubName.id + +#Set the environment variable 'ARM_SUBSCRIPTION_ID' to the test subscription id so that terraform can pick it up for authentication +$env:ARM_SUBSCRIPTION_ID = $testSubId +#Check if the terraform directory exists +if (-not (Test-Path -Path $terraformPath )) { + Write-Output "The specified Terraform path '$terraformPath' does not exist. 'Terraform $tfAction' Skipped." + if ($tfAction -eq 'apply') { + #create empty pipeline variable for terraformDeploymentOutputs + $deploymentOutputs = '{}' + if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setvariable variable=terraformDeploymentOutputs]$deploymentOutputs" + Write-Output "##vso[task.setvariable variable=terraformDeploymentOutputs;isOutput=true]$deploymentOutputs}" + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "terraformDeploymentOutputs=$deploymentOutputs" >> $env:GITHUB_OUTPUT + } + #create an empty folder for the artifact so the publish artifact task does not fail + + if (-not (Test-Path -Path $tfBackendStateFileDirectory)) { + New-Item -Path $tfBackendStateFileDirectory -ItemType Directory -Force | Out-Null + } + #create result file + createResultFile -fileName $deploymentResultFileName -directory $tfBackendStateFileDirectory -terraformDeployment $false + } + exit +} +#Get the test config +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +$tfHelperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'terraform-helper-functions.ps1' + +#load helper functions +. $helperFunctionScriptPath +. $tfHelperFunctionScriptPath + +#Convert the uninitializeTerraform parameter to boolean +$uninitializeTerraform = [bool]::Parse($uninitializeTerraform) +$tfBackendStateFilePath = join-path -Path $tfBackendStateFileDirectory -ChildPath $tfStateFileName +$tfEncryptedBackendStateFilePath = join-path -Path $tfBackendStateFileDirectory -ChildPath $tfEncryptedStateFileName +#apply or destroy terraform template +if ($tfAction -ieq 'apply') { + if (-not (Test-Path -Path $tfBackendStateFileDirectory)) { + New-Item -Path $tfBackendStateFileDirectory -ItemType Directory -Force | Out-Null + } + + Write-Verbose "[$(getCurrentUTCString)]: Applying Terraform template at '$terraformPath'." -Verbose +} else { + if ($aesEncryptionKey -and $aesIV) { + Write-Verbose "[$(getCurrentUTCString)]: Decrypting Terraform state file at '$tfEncryptedBackendStateFilePath'." -Verbose + $tfStateFile = Get-item -Path $tfEncryptedBackendStateFilePath -ErrorAction Stop + $tfStateFileDir = $tfStateFile.DirectoryName + $decryptedFileName = "$($tfStateFile.BaseName).decrypted.tfstate" + $decryptedBackupFileName = "$($tfStateFile.BaseName).decrypted.tfstate.backup" + $decryptedFilePath = Join-Path -Path $tfStateFileDir -ChildPath $decryptedFileName + $decryptedBackupFilePath = Join-Path -Path $tfStateFileDir -ChildPath $decryptedBackupFileName + decryptStuff -InputFilePath $tfEncryptedBackendStateFilePath -OutputFilePath $decryptedFilePath -AESKey $aesEncryptionKey -AESIV $aesIV + $tfBackendStateFilePath = $decryptedFilePath + } + Write-Verbose "[$(getCurrentUTCString)]: Destroying resources previously created by Terraform template at '$terraformPath'." -Verbose +} + +$params = @{ + tfPath = $terraformPath + tfAction = $tfAction + backendConfigFileName = $tfBackendConfigFileName + localBackendPath = $tfBackendStateFilePath +} +if ($tfWorkspaceDir.length -gt 0) { + $params.add('localBackendWorkspaceDir', $tfWorkspaceDir) +} +applyDestroyTF @params + +#remove the backend config file if it exists +$backendConfigFilePath = join-path $terraformPath $tfBackendConfigFileName +if (Test-Path -Path $backendConfigFilePath -PathType Leaf) { + Write-Verbose "[$(getCurrentUTCString)]: Removing backend configuration file at '$backendConfigFilePath'." -Verbose + Remove-Item -Path $backendConfigFilePath -Force -ErrorAction SilentlyContinue +} else { + Write-Verbose "[$(getCurrentUTCString)]: Backend configuration file '$backendConfigFilePath' does not exist, skipping removal." -Verbose +} + +#If terraform apply, parse the terraform output and store as the pipeline variable +if ($tfAction -eq 'apply') { + $provisioningState = $script:tfExitCode ? 'Succeeded' : 'Failed' + + Write-Verbose "[$(getCurrentUTCString)]: Parsing Terraform output." -Verbose + $tfState = Get-Content -path $tfBackendStateFilePath -raw | ConvertFrom-Json -depth 99 + $deploymentOutputs = $tfState.outputs | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii' -Compress + createResultFile -fileName $deploymentResultFileName -directory $tfBackendStateFileDirectory -terraformDeployment $true -provisioningState $provisioningState -deploymentOutputs $deploymentOutputs + if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setvariable variable=terraformDeploymentOutputs]$deploymentOutputs" + Write-Output "##vso[task.setvariable variable=terraformDeploymentOutputs;isOutput=true]$deploymentOutputs" + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "terraformDeploymentOutputs=$deploymentOutputs" >> $env:GITHUB_OUTPUT + } + $tfStateFile = Get-item -Path $tfBackendStateFilePath -ErrorAction Stop + $tfStateFileDir = $tfStateFile.DirectoryName + + #encrypt the Terraform state file after terraform apply if AES key and IV are provided + if ($aesEncryptionKey -and $aesIV) { + Write-Verbose "[$(getCurrentUTCString)]: Encrypting Terraform state file at '$tfBackendStateFilePath'." -Verbose + encryptStuff -InputFilePath $tfBackendStateFilePath -OutputFilePath $tfEncryptedBackendStateFilePath -AESKey $aesEncryptionKey -AESIV $aesIV + Write-Verbose "[$(getCurrentUTCString)]: Delete original terraform state file at '$tfBackendStateFilePath'." -Verbose + Remove-Item -Path $tfBackendStateFilePath -Force -ErrorAction SilentlyContinue + if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setVariable variable=tfStateFileName]$tfEncryptedStateFileName" + Write-Output "##vso[task.setVariable variable=tfStateFileName;isOutput=true]$tfEncryptedStateFileName" + Write-Output "##vso[task.setVariable variable=tfStateFilePath]$tfEncryptedBackendStateFilePath" + Write-Output "##vso[task.setVariable variable=tfStateFilePath;isOutput=true]$tfEncryptedBackendStateFilePath" + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "tfStateFileName=$tfEncryptedStateFileName" >> $env:GITHUB_OUTPUT + Write-Output "tfStateFilePath=$tfEncryptedBackendStateFilePath" >> $env:GITHUB_OUTPUT + } + } else { + $tfStateFileName = $tfStateFile.Name + if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setVariable variable=tfStateFileName]$tfStateFileName" + Write-Output "##vso[task.setVariable variable=tfStateFileName;isOutput=true]$tfStateFileName" + Write-Output "##vso[task.setVariable variable=tfStateFilePath]$tfBackendStateFilePath" + Write-Output "##vso[task.setVariable variable=tfStateFilePath;isOutput=true]$tfBackendStateFilePath" + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "tfStateFileName=$tfStateFileName" >> $env:GITHUB_OUTPUT + Write-Output "tfStateFilePath=$tfBackendStateFilePath" >> $env:GITHUB_OUTPUT + } + } +} + +#remove the decrypted state file if it exists +if ($tfAction -eq 'destroy' -and $decryptedFilePath) { + Write-Verbose "[$(getCurrentUTCString)]: Removing decrypted Terraform state file at '$decryptedFilePath'." -Verbose + Remove-Item -Path $decryptedFilePath -Force -ErrorAction SilentlyContinue + if ((Test-Path -Path $decryptedBackupFilePath -PathType Leaf)) { + Write-Verbose "[$(getCurrentUTCString)]: Removing decrypted Terraform state backup file at '$decryptedBackupFilePath'." -Verbose + Remove-Item -Path $decryptedBackupFilePath -Force -ErrorAction SilentlyContinue + } +} + +if ($uninitializeTerraform) { + Write-Verbose "[$(getCurrentUTCString)]: Uninitializing Terraform at '$terraformPath'." -Verbose + uninitializeTFProject -tfPath $terraformPath +} + +Write-Output "Done." +#endregion diff --git a/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 new file mode 100644 index 0000000..79590d8 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 @@ -0,0 +1,328 @@ +#Requires -Modules Az.Resources +#Requires -Version 7.0 + +<# +=================================================================== +AUTHOR: Tao Yang +DATE: 23/05/2024 +NAME: pipeline-deploy-policy-test-bicep-template.ps1 +VERSION: 1.0.0 +COMMENT: Deploy test bicep template for policy integration testing +=================================================================== +#> +[CmdletBinding()] +Param ( + [Parameter(Mandatory = $true, HelpMessage = 'Specify the Bicep file path.')] + [string]$BicepFilePath, + + [Parameter(Mandatory = $true, HelpMessage = 'Specify the Test configuration path.')] + [string]$testConfigFilePath, + + [parameter(Mandatory = $false)] + [Int64]$BuildNumber = $(Get-Random -Minimum 100000 -Maximum 999999), + + [parameter(Mandatory = $false, HelpMessage = 'Maximum deployment retry attempt.')] + [ValidateRange(2, 5)] + [int]$maxRetry = 3, + + [parameter(Mandatory = $false)] + [string]$bicepModuleSubscriptionId = '', + + [parameter(Mandatory = $false)] + [string]$deploymentResultFilePath = '' +) + +#load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType +#Get the test config +$additionalResourceGroups = @() +$gitRoot = Get-GitRoot +$testGlobalConfigFilePath = join-path $gitRoot 'tests' 'policy-integration-tests' '.shared' 'policy_integration_test_config.jsonc' +Write-Verbose "Loading Global Test configuration from ''$testGlobalConfigFilePath'..." -verbose +$globalTestConfig = getTestConfig -TestConfigFilePath $testGlobalConfigFilePath +$tags = $globalTestConfig.tags | ConvertTo-Json | ConvertFrom-Json -AsHashTable +$resourceGroupApiVersion = $globalTestConfig.resourceGroupApiVersion +Write-Verbose "Loading Local Test configuration from ''$testConfigFilePath'..." -verbose +$localTestConfig = getTestConfig -TestConfigFilePath $testConfigFilePath +$testLocation = $localTestConfig.location +$testSubName = $localTestConfig.testSubscription +$testSubId = $globalTestConfig.subscriptions.$testSubName.id + +if ($localTestConfig.ContainsKey('removeTestResourceGroup')) { + $removeTestResourceGroup = $localTestConfig.removeTestResourceGroup +} else { + $removeTestResourceGroup = $false +} +try { + $testResourceGroupName = $localTestConfig.testResourceGroup +} catch { + Write-Verbose "Test resource group is not specified." -verbose +} + +Write-Verbose "Test Subscription Id: $testSubId" -verbose + +if ($localTestConfig.ContainsKey('tagsForResourceGroup')) { + $tagsForResourceGroup = $localTestConfig.tagsForResourceGroup +} else { + $tagsForResourceGroup = $false +} + +#Create deployment result artifacts +If ($deploymentResultFilePath -ne '') { + Write-Verbose " - Deployment result will be saved to '$deploymentResultFilePath'..." -verbose + If (!(test-path $deploymentResultFilePath)) { + Write-Output "[$(getCurrentUTCString)]: Creating the deployment result file '$deploymentResultFilePath'..." + New-Item -Path $deploymentResultFilePath -ItemType File -Force | Out-Null + } + $deploymentResult = [ordered]@{ + bicepRemoveTestResourceGroup = $removeTestResourceGroup + bicepTestSubscriptionId = $testSubId + } + if ($testResourceGroupName -ne '') { + $deploymentResult.Add('bicepTestResourceGroup', $testResourceGroupName) + } +} +#create the test resource group if it's specified in the local config file and doesn't exist +if ($testResourceGroupName) { + $testResourceGroupResourceId = '/subscriptions/{0}/resourceGroups/{1}' -f $testSubId, $testResourceGroupName + $existingTestResourceGroup = getResourceViaARMAPI -ResourceId $testResourceGroupResourceId -apiVersion $resourceGroupApiVersion + if (!($existingTestResourceGroup)) { + if ($tagsForResourceGroup) { + Write-Output "[$(getCurrentUTCString)]: Resource group '$testResourceGroupName' doesn't exist. Creating the resource group '$testResourceGroupName' with predefined tags..." + $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion-Tag $tags + } else { + Write-Output "[$(getCurrentUTCString)]: Resource group '$testResourceGroupName' doesn't exist. Creating the resource group '$testResourceGroupName' without any tags..." + $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion + } + + } else { + Write-Output "[$(getCurrentUTCString)]: Resource group '$testResourceGroupName' already exists." + } +} + +# deploy test bicep template if it exists +if (Test-path $BicepFilePath -PathType Leaf) { + if ($bicepModuleSubscriptionId -ne '') { + Write-Output "[$(getCurrentUTCString)]: Set Az Context to the bicep module subscription '$bicepModuleSubscriptionId'." + set-AzContext -subscriptionId $bicepModuleSubscriptionId + } + #Make sure the bicep file is valid + Write-Output "[$(getCurrentUTCString)]: Validating the bicep file..." + $isValidBicep = validateBicep -BicepFilePath $BicepFilePath + + if (!$isValidBicep) { + Throw "The bicep file is not valid. Exiting..." + exit 1 + } + + #Get Bicep template deployment scope + $templateScope = getTemplateScope -BicepFilePath $BicepFilePath + + Write-Verbose "[$(getCurrentUTCString)]: Test Template Deployment Subscription Name: $testSubName" -verbose + Write-Verbose "[$(getCurrentUTCString)]: Test Template Deployment Subscription Id: $testSubId" -verbose + Write-Verbose "[$(getCurrentUTCString)]: Test Template Deployment Location: $testLocation" -verbose + if ($templateScope -ieq 'resourcegroup') { + $testResourceGroupName = $localTestConfig.testResourceGroup + Write-Verbose "[$(getCurrentUTCString)]: Test Template Deployment Resource Group Name: $testResourceGroupName" -verbose + } + + $deploymentPrefix = $globalTestConfig.deploymentPrefix + $templateName = (Split-Path -Path (Split-Path $BicepFilePath -Parent) -LeafBase).replace('-', '') + $randomString = -join ((65..90) + (97..122) | Get-Random -Count 5 | % { [char]$_ }) + $deploymentNamePrefix = "$($deploymentPrefix)-$($templateName)-$($randomString)-$($BuildNumber)" + + #Create additional resource groups if specified in the test config + if ($localTestConfig.additionalResourceGroups) { + Write-Output "[$(getCurrentUTCString)]: Creating additional resource groups..." + + $rgReferenceNames = $localTestConfig.additionalResourceGroups.Keys + foreach ($rg in $rgReferenceNames) { + $resourceGroupName = ($localTestConfig.additionalResourceGroups.$rg).resourceGroup + $subscriptionName = ($localTestConfig.additionalResourceGroups.$rg).subscription + $subscriptionId = $globalTestConfig.subscriptions.$subscriptionName.id + $rgResourceId = '/subscriptions/{0}/resourceGroups/{1}' -f $subscriptionId, $resourceGroupName + Write-Verbose "Checking if the resource group '$rgResourceId' exists..." -verbose + $existingRg = getResourceViaARMAPI -ResourceId $rgResourceId -apiVersion $resourceGroupApiVersion + if ($existingRg) { + Write-Verbose "[$(getCurrentUTCString)]: Resource group '$rgResourceId' already exists. Skipping creation." -verbose + $additionalResourceGroups += $existingRg.id + } else { + Write-Output "[$(getCurrentUTCString)]: Resource group '$rgResourceId' doesn't exist. Creating in location '$testLocation'..." + #create the resource group using ARM REST API directly so we don't have to change the subscription in the Az context + $additionalResourceGroups += newResourceGroupViaARMAPI -subscriptionId $subscriptionId -resourceGroupName $resourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion + } + } + Write-Verbose "[$(getCurrentUTCString)]: Additional resource groups:" -verbose + Foreach ($rg in $additionalResourceGroups) { + Write-Verbose " - $rg" -verbose + } + } + #Create deployment result hastable to store the deployment result + If ($deploymentResultFilePath -ne '') { + $deploymentResult.Add('bicepDeploymentScope', $templateScope) + $deploymentResult.Add('bicepAdditionalResourceGroups', $additionalResourceGroups) + } + #Deploy the bicep template + $deployParams = @{ + templateFile = $BicepFilePath + AsJob = $true + name = $deploymentNamePrefix + } + + Write-Verbose "[$(getCurrentUTCString)]: Deploying the bicep template..." -verbose + $subscription = Get-AzSubscription -SubscriptionId $testSubId + Set-AzContext -SubscriptionId $testSubId + switch ($templateScope) { + 'subscription' { + $deployParams.Add('location', $testLocation) + $deploymentJob = New-AzDeployment @deployParams + $deploymentTarget = '/subscriptions/{0}' -f $subscription.Id + } + 'resourcegroup' { + $deployParams.Add('resourceGroupName', $testResourceGroupName) + $deployParams.add('Mode', "Incremental") + $deploymentTarget = $testResourceGroupResourceId + } + default { + Throw "The template scope '$templateScope' is not supported. Only Subscription and ResourceGroup level deployments are supported for Azure Policy test templates." + Exit 1 + } + } + If ($deploymentResultFilePath -ne '') { + #Save deployment target to the deployment result file + $deploymentResult.Add('bicepDeploymentTarget', $deploymentTarget) + } + + #create template deployment and retry if failed + $retryCount = 0 + $retryAfterSeconds = 15 + $deploymentSuccessful + Do { + try { + $retryCount++ + $deploymentName = "$deploymentNamePrefix-$retryCount" + $deployParams.name = $deploymentName + Write-Verbose "[$(getCurrentUTCString)]: Deployment Attempt $retryCount`: Create $templateScope scope deployment job '$deploymentName'." -verbose + switch ($templateScope) { + 'subscription' { + $deploymentJob = New-AzDeployment @deployParams + } + 'resourcegroup' { + $deploymentJob = New-AzResourceGroupDeployment @deployParams + } + } + Write-Verbose "[$(getCurrentUTCString)]: Wait for the $templateScope scope deployment job to complete..." -verbose + $wait = $deploymentJob | Wait-Job + $deployResult = $deploymentJob | Receive-Job + $provisioningState = $deployResult.ProvisioningState + if ($provisioningState -ieq 'succeeded') { + $deploymentSuccessful = $true + Write-Verbose "[$(getCurrentUTCString)]: The $templateScope scope deployment job Completed with provisioning state: '$provisioningState'." -verbose + } else { + Write-Verbose "[$(getCurrentUTCString)]: The $templateScope scope deployment job provisioning state: '$provisioningState'." -verbose + } + } Catch { + Write-Verbose "[$(getCurrentUTCString)]: Error occurred while deploying the test bicep template." + Write-Verbose "[$(getCurrentUTCString)]: Error: $_" -verbose + if ($retryCount -le $maxRetry) { + Write-Verbose "[$(getCurrentUTCString)]: Will retry after $retryAfterSeconds seconds." -verbose + Start-Sleep -Seconds $retryAfterSeconds + } else { + Write-Verbose "[$(getCurrentUTCString)]: Max retry count reached. Will not retry." -verbose + } + } + } until ($retryCount -ge $maxRetry -or $deploymentSuccessful -eq $true) + + #retrieve deployment Id + If ($templateScope -ieq 'subscription') { + $deploymentId = $deployResult.Id + $deploymentId = '/subscriptions/{0}/providers/Microsoft.Resources/deployments/{1}' -f $testSubId, $deploymentName + } else { + $resourceGroupId = (Get-AzResourceGroup -Name $testResourceGroupName).ResourceId + $deploymentId = '{0}/providers/Microsoft.Resources/deployments/{1}' -f $resourceGroupId, $deploymentName + } + + Write-Output "[$(getCurrentUTCString)]: Deployment Id: $deploymentId" + Write-Output "[$(getCurrentUTCString)]: Deployment Name: $deploymentName" + Write-Output "[$(getCurrentUTCString)]: Deployment Provisioning State: $provisioningState" + Write-Output "[$(getCurrentUTCString)]: Deployment Target: $deploymentTarget" + Write-Output "[$(getCurrentUTCString)]: Template Scope: $templateScope" + + #create pipeline variables for deployment + if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setVariable variable=bicepDeploymentName]$deploymentName" + Write-Output "##vso[task.setVariable variable=bicepDeploymentName;isOutput=true]$deploymentName" + + Write-Output "##vso[task.setVariable variable=provisioningState]$provisioningState" + Write-Output "##vso[task.setVariable variable=provisioningState;isOutput=true]$provisioningState" + + Write-Output "##vso[task.setVariable variable=bicepDeploymentId]$deploymentId" + Write-Output "##vso[task.setVariable variable=bicepDeploymentId;isOutput=true]$deploymentId" + + Write-Output "##vso[task.setVariable variable=deploymentTarget]$deploymentTarget" + Write-Output "##vso[task.setVariable variable=deploymentTarget;isOutput=true]$deploymentTarget" + + Write-Output "##vso[task.setVariable variable=bicepDeploymentScope]$templateScope" + Write-Output "##vso[task.setVariable variable=bicepDeploymentScope;isOutput=true]$templateScope" + } elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "deploymentName=$deploymentName" >> $env:GITHUB_OUTPUT + Write-Output "provisioningState=$provisioningState" >> $env:GITHUB_OUTPUT + Write-Output "bicepDeploymentId=$deploymentId" >> $env:GITHUB_OUTPUT + Write-Output "deploymentTarget=$deploymentTarget" >> $env:GITHUB_OUTPUT + Write-Output "bicepDeploymentScope=$templateScope" >> $env:GITHUB_OUTPUT + } + + If ($deploymentResultFilePath -ne '') { + #save the deployment name and provisioning state to the deployment result file + if ($deploymentName -ne '') { + $deploymentResult.Add('bicepDeploymentId', $deploymentId) + $deploymentResult.Add('bicepDeploymentName', $deploymentName) + } + if ($provisioningState -ne '') { + $deploymentResult.Add('bicepProvisioningState', $provisioningState) + } + } + if ($deploymentSuccessful -ne $true) { + #Still save the applicable deployment details to the file if specified + $deploymentResult | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii' | Out-File -Path $deploymentResultFilePath -Force + Throw "Failed to deploy test template after $maxRetry attempts." + Exit 1 + } + + #Get deployment outputs for the successful deployment + $deploymentOutputs = $deployResult.Outputs | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii' + + if ($($deployResult.Outputs)) { + $deploymentOutputs = $deployResult.Outputs | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii' -Compress + + Write-Output "[$(getCurrentUTCString)]: Saving Deployment Outputs..." + Write-Output "[$(getCurrentUTCString)]: Deployment Outputs: $($deployResult.Outputs | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii')" + #Save deployment outputs + If ($deploymentResultFilePath -ne '') { + Write-Verbose " - Saving deployment outputs to '$deploymentResultFilePath'..." -verbose + $deploymentResult.Add('bicepDeploymentOutputs', $deploymentOutputs) + } + } else { + Write-Output "[$(getCurrentUTCString)]: No Deployment Outputs found." + } +} else { + Write-Output "The bicep file '$BicepFilePath' does not exist. Deployment skipped." +} +#Always create deploymentOutputs and deploymentId pipeline variable even if there are no outputs So it can be used in the next steps +if ($runtimePlatform -ieq 'azure devops') { + Write-Output "##vso[task.setVariable variable=bicepDeploymentOutputs]$deploymentOutputs" + Write-Output "##vso[task.setVariable variable=bicepDeploymentOutputs;isOutput=true]$deploymentOutputs" +} elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "bicepDeploymentOutputs=$deploymentOutputs" >> $env:GITHUB_OUTPUT +} + +#Save deployment result to file if specified +If ($deploymentResultFilePath -ne '') { + Write-Output "Saving deployment result to '$deploymentResultFilePath'..." + $deploymentResult | ConvertTo-Json -depth 99 -EnumsAsString -EscapeHandling 'EscapeNonAscii' | Out-File -Path $deploymentResultFilePath -Force +} + +Write-Output "Done." diff --git a/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 new file mode 100644 index 0000000..a841e4a --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 @@ -0,0 +1,210 @@ +#Requires -Module @{ModuleName="Az.ResourceGraph"; ModuleVersion="0.10.0"} +<# +============================================================================== +AUTHOR: Tao Yang +DATE: 25/06/2024 +NAME: pipeline-get-policy-assignment-compliance-state.ps1 +VERSION: 1.0.0 +COMMENT: Get policy assignment compliacen status using Azure Resource Graph +============================================================================== +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory = $true, ParameterSetName = 'ByPolicyAssignmentId')] + [string[]]$policyAssignmentId, + + [Parameter(Mandatory = $true, ParameterSetName = 'ByPolicyConfigFile')] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string]$configFilePath, + + [parameter(Mandatory = $false)] + [ValidateSet('true', 'false')] + [string]$wait = 'true', + + [parameter(Mandatory = $false)] + [ValidateRange(5, 30)] + [int]$maximumWaitMinutes = 20 +) + +#region functions +function getComplianceState { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string[]]$policyAssignmentId + ) + $resourceSearchScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'pipeline-resource-search.ps1' + + #build ARG query + $arrResourceId = @() + foreach ($item in $policyAssignmentId) { + $arrResourceId += "'$item'" + } + $strResourceId = $arrResourceId -join ',' + #convert resourceId string to all lower case because the resourceId returned in ARG query is all in lower case + $strResourceId = $strResourceId.ToLower() + $ARGQuery = @" +PolicyResources +| where type =~ 'Microsoft.PolicyInsights/PolicyStates' +| extend complianceState = tostring(properties.complianceState) +| extend + resourceId = tostring(properties.resourceId), + policyAssignmentId = tolower(tostring(properties.policyAssignmentId)), + policyAssignmentScope = tostring(properties.policyAssignmentScope), + policyAssignmentName = tostring(properties.policyAssignmentName), + policyDefinitionId = tostring(properties.policyDefinitionId), + policyDefinitionReferenceId = tostring(properties.policyDefinitionReferenceId), + stateWeight = iff(complianceState == 'NonCompliant', int(300), iff(complianceState == 'Compliant', int(200), iff(complianceState == 'Conflict', int(100), iff(complianceState == 'Exempt', int(50), int(0))))) +| where policyAssignmentId in ($strResourceId) +| summarize max(stateWeight) by resourceId, policyAssignmentId, policyAssignmentScope, policyAssignmentName +| summarize counts = count() by policyAssignmentId, policyAssignmentScope, max_stateWeight, policyAssignmentName +| summarize overallStateWeight = max(max_stateWeight), +nonCompliantCount = sumif(counts, max_stateWeight == 300), +compliantCount = sumif(counts, max_stateWeight == 200), +conflictCount = sumif(counts, max_stateWeight == 100), +exemptCount = sumif(counts, max_stateWeight == 50) by policyAssignmentId, policyAssignmentScope, policyAssignmentName +| extend totalResources = todouble(nonCompliantCount + compliantCount + conflictCount + exemptCount) +| extend compliancePercentage = iff(totalResources == 0, todouble(100), 100 * todouble(compliantCount + exemptCount) / totalResources) +| project policyAssignmentName, policyAssignmentId, scope = policyAssignmentScope, +complianceState = iff(overallStateWeight == 300, 'noncompliant', iff(overallStateWeight == 200, 'compliant', iff(overallStateWeight == 100, 'conflict', iff(overallStateWeight == 50, 'exempt', 'notstarted')))), +compliancePercentage, +compliantCount, +nonCompliantCount, +conflictCount, +exemptCount +"@ + + Write-Verbose "Searching policy assignments compliance state using ARG query:" -verbose + Write-Verbose $argQuery -Verbose + $complianceState = & $resourceSearchScriptPath -ScopeType 'tenant' -customQuery $argQuery | ConvertFrom-Json + $complianceState +} + +function getPolicAssignment { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string[]]$policyAssignmentId + ) + $resourceSearchScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'pipeline-resource-search.ps1' + + #build ARG query + $arrResourceId = @() + foreach ($item in $policyAssignmentId) { + $arrResourceId += "'$item'" + } + $strResourceId = $arrResourceId -join ',' + #convert resourceId string to all lower case because the resourceId returned in ARG query is all in lower case + $strResourceId = $strResourceId.ToLower() + $assignmentARGQuery = @" +PolicyResources +| where type =~ 'Microsoft.Authorization/PolicyAssignments' +| extend policyAssignmentId = tolower(id) +| where policyAssignmentId in ($strResourceId) +"@ + + Write-Verbose "Searching policy assignments using ARG query:" -verbose + Write-Verbose $assignmentARGQuery -Verbose + $assignments = & $resourceSearchScriptPath -ScopeType 'tenant' -customQuery $assignmentARGQuery | ConvertFrom-Json + $assignments +} +#endregion + +#region main +#load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType + +if ($PSCmdlet.ParameterSetName -eq 'ByPolicyConfigFile') { + $config = Get-Content $configFilePath -Raw | ConvertFrom-Json + $policyAssignmentId = $config.policyAssignmentIds +} + +$bAllPolicyAssignmentIdValid = $true +Write-Verbose "Make sure all policy assignments exist" -verbose +$assignments = getPolicAssignment -policyAssignmentId $policyAssignmentId +foreach ($item in $policyAssignmentId) { + if ($assignments.policyAssignmentId -notcontains $item) { + Write-Warning " - Policy assignment '$item' does not exist." -ErrorAction Continue + $bAllPolicyAssignmentIdValid = $false + } else { + Write-Verbose " - Policy assignment '$item' exists." -verbose + } +} + +If ($bAllPolicyAssignmentIdValid -eq $false) { + Write-Error "Not all policy assignments exist. Exiting..." + Exit -1 +} + +if ($wait -eq 'true') { + $waitStartTime = Get-Date + $waitMinutesForNewAssignments = 5 + $waitEndTime = $waitStartTime.AddMinutes($maximumWaitMinutes) + Do { + $shouldWait = $false + $complianceState = getComplianceState -policyAssignmentId $policyAssignmentId + Write-Verbose "$($complianceState.Count) of $($policyAssignmentId.count) policy assignments compliance state returned from ARG query." -Verbose + #Policy compliance state cannot be queries if there are no existing targetted resources in the assignment scope. + #In this case, we will check the initial creation date (createdOn metadata) of the policy assignment and make sure we wait for at least 5 minutes from the initial creation date + #If the policy assignment is previously created and the updated by the pipeline execution, the updatedOn metadata is ignored. + Foreach ($assignment in $assignments) { + $initialCreationDate = [datetime]::Parse($assignment.properties.metadata.createdOn) + $assignmentComplianceState = $complianceState | Where-Object { $_.policyAssignmentId -ieq $assignment.policyAssignmentId } + $utcNow = (Get-Date).ToUniversalTime() + $timeDifference = New-TimeSpan -Start $initialCreationDate -End $utcNow + if (!$assignmentComplianceState) { + Write-Verbose "Policy assignment '$($assignment.policyAssignmentId)' does not have any compliance state returned from ARG query. Waiting for at least 5 minutes from the initial creation date." -Verbose + if ($($timeDifference.TotalMinutes -lt $waitMinutesForNewAssignments)) { + Write-Verbose " - Initial creation date: $($initialCreationDate)" -Verbose + Write-Verbose " - Current date (UTC): $utcNow" -Verbose + Write-Verbose " - Waiting for 5 minutes from the initial creation date..." -Verbose + $shouldWait = $true + } else { + Write-Verbose " - Initial creation date: $($initialCreationDate)" -Verbose + Write-Verbose " - Current date (UTC): $utcNow" -Verbose + Write-Verbose " - Not need to wait..." -Verbose + } + } elseif ($assignmentComplianceState -ieq 'notstarted') { + Write-Verbose "Policy assignment '$($assignment.policyAssignmentId)' has not started compliance scan. Waiting for it to complete compliance scan" -Verbose + $shouldWait = $true + } + } + if ($shouldWait -eq $true) { + if ((Get-Date) -gt $waitEndTime) { + Write-Error "Maximum wait time of $maximumWaitMinutes minutes reached. Exiting..." -ErrorAction Stop + exit 1 + } else { + Write-Verbose "Waiting for 1 minute before querying the compliance state again..." -Verbose + Start-Sleep -Seconds 60 + } + } + + } Until($shouldWait -eq $false) +} else { + $complianceState = getComplianceState -policyAssignmentId $policyAssignmentId +} + +Write-Output "Policy Assignment Compliance State:" +Foreach ($item in $complianceState) { + Write-Output "Policy Assignment ID: $($item.policyAssignmentId)" + Write-Output " - Name: $($item.policyAssignmentName)" + Write-Output " - Compliance State: $($item.complianceState)" + Write-Output " - Compliance Percentage: $($item.compliancePercentage)" + Write-Output " - Compliant Count: $($item.compliantCount)" + Write-Output " - Non-Compliant Count: $($item.nonCompliantCount)" + Write-Output " - Conflict Count: $($item.conflictCount)" + Write-Output " - Exempt Count: $($item.exemptCount)" +} + +if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable=ComplianceState;isOutput=true]{0}' -f ($complianceState | ConvertTo-Json -Compress)) + Write-Output ('##vso[task.setVariable variable=ComplianceState]{0}' -f ($complianceState | ConvertTo-Json -Compress)) +} elseif ($runtimePlatform -ieq 'github actions') { + Write-Output ('ComplianceState={0}' -f ($complianceState | ConvertTo-Json -Compress)) >> $env:GITHUB_OUTPUT +} + + +#endregion diff --git a/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 new file mode 100644 index 0000000..d8ecdc7 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 @@ -0,0 +1,125 @@ +<# +================================================================= +AUTHOR: Tao Yang +DATE: 23/10/2024 +NAME: pipeline-get-sub-directories.ps1 +VERSION: 1.1.0 +COMMENT: Get all sub directories in the specified directory +================================================================= +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType 'Container' })] + [string]$directory, + + [parameter(Mandatory = $false)] + [string]$ignoreFileName = '.testignore', + + [parameter(Mandatory = $false)] + [string]$includedDirectory = '', + + [parameter(Mandatory = $false)] + [validateSet('true', 'false')] + [string]$skip = 'false' #can't be boolean because pipeline can only pass string +) + +#region functions +function getSubDir { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$directory, + + [parameter(Mandatory = $true)] + [string]$ignoreFileName, + + [parameter(Mandatory = $false)] + [string]$includedDirectory + ) + Write-Verbose "Searching for the directory '$includedDirectory' in '$directory' that doesn't contain '$ignoreFileName'." -Verbose + if ($includedDirectory.length -gt 0) { + #get the specific directory + $includedDirs = ($includedDirectory -split ',') | ForEach-Object { $_.Trim() } + $subDirectories = Get-ChildItem -Path $directory -Directory | where-object { $includedDirs -contains $_.Name } | where-object { -not (get-ChildItem $_.FullName -File -Filter $ignoreFileName -Force) } + if (!$subDirectories) { + throw "the '$includedDirectory' is not found in '$directory'." + } + } else { + Write-Verbose "Get all sub directories in '$directory'." -Verbose + $subDirectories = Get-ChildItem -Path $directory -Directory | where-object { -not (get-ChildItem $_.FullName -File -Filter $ignoreFileName -Force) } + } + $subDirectories +} +#endregion +#region main +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' + +#load helper +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType +$subDirectoryTable = [Ordered]@{} +If ($skip -ieq 'true') { + Write-Output "The skip test parameter is set to true. no tests required." +} else { + Write-Verbose "directory: $directory" -Verbose + + #check if the included directory is specified as * which means all sub directories + if ($includedDirectory -eq '*') { + $includedDirectory = '' + } + $includedDirectory = $includedDirectory.trim() + $ignoreFileName = $ignoreFileName.trim() + + $i = 1 + + $subDirectories = getSubDir -directory $directory -ignoreFileName $ignoreFileName -includedDirectory $includedDirectory + Write-Verbose "Found $($subDirectories.Count) sub directories in '$directory'." -Verbose + if ($subDirectories) { + Foreach ($folder in $subDirectories) { + $relativePath = Get-GitRelativeFilePath -path $folder.FullName + $key = "$($folder.Name)" + $subDirectoryTable[$key] += @{ + matrixSubDirName = $folder.Name + matrixSubDirRelativePath = $relativePath + matrixSubDirFullPath = $folder.FullName + matrixKey = $key + } + $i++ + } + Write-Output "Found $($subDirectoryTable.Count) sub directories in '$directory'." + $subDirectoryTable.GetEnumerator() | ForEach-Object { + Write-Output "Directory Name: $($_.value.matrixSubDirName), Relative Path: $($_.value.matrixSubDirRelativePath)" + } + } else { + Write-Error "no sub directory in '$directory." + Exit 1 + } +} +#Create pipeline output variables +if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'SubDirCount', $($subDirectories.Count)) + #Output Hashtable to ADO Pipeline as a Variable. + Write-Output ('##vso[task.setVariable variable=SubDirectories;isOutput=true]{0}' -f ($subDirectoryTable | ConvertTo-Json -Compress)) + Write-Output "SubDirCount: $($subDirectories.Count)" + Write-Output "SubDirectories: $($subDirectoryTable | ConvertTo-Json -Compress)" +} elseif ($runtimePlatform -ieq 'github actions') { + # Convert ordered hashtable to array for GitHub Actions matrix compatibility + $subDirectoryArray = @($subDirectoryTable.Values) + if ($subDirectoryArray.Count -eq 0) { + $subDirectories_json = "[]" + } elseif ($subDirectoryArray.Count -eq 1) { + $subDirectories_json = '[' + ($subDirectoryArray | ConvertTo-Json -Depth 5 -Compress) + ']' + } else { + $subDirectories_json = $subDirectoryArray | ConvertTo-Json -Depth 5 -Compress + } + Add-Content -Path $env:GITHUB_OUTPUT -Value "SubDirCount=$($subDirectories.Count)" + Add-Content -Path $env:GITHUB_OUTPUT -Value "SubDirectories=$subDirectories_json" + Write-Output "SubDirCount: $($subDirectories.Count)" + Write-Output "SubDirectories: $subDirectories_json" +} else { + Write-Output "SubDirCount: $($subDirectories.Count)" + Write-Output "SubDirectories: $($subDirectoryTable | ConvertTo-Json)" +} +#endregion diff --git a/scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1 new file mode 100644 index 0000000..a6d1fd0 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-get-test-config.ps1 @@ -0,0 +1,236 @@ +<# +=================================================================== +AUTHOR: Tao Yang +DATE: 30/03/2026 +NAME: pipeline-get-test-config.ps1 +VERSION: 2.0.0 +COMMENT: Get test configurations for policy integration test cases +=================================================================== +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType 'Container' })] + [string]$directory, + + [parameter(Mandatory = $false)] + [string]$ignoreFileName = '.testignore', + + [parameter(Mandatory = $false)] + [string]$includedDirectory = ' ', + + [parameter(Mandatory = $true)] + [int]$policyComplianceStateDelay, + + [parameter(Mandatory = $true)] + [int]$appendModifyDelay, + + [parameter(Mandatory = $true)] + [int]$DINEDelay, + + [parameter(Mandatory = $false)] + [ValidateSet('true', 'false')] + [string]$skip = 'false', #can't be boolean because pipeline can only pass string + + [parameter(Mandatory = $false)] + [string]$testLocalConfigFileName = 'config.json', + + [parameter(Mandatory = $false)] + [string]$testScriptName = 'tests.ps1' +) + +#region functions +function getSubDir { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$directory, + + [parameter(Mandatory = $true)] + [string]$ignoreFileName, + + [parameter(Mandatory = $false)] + [string]$includedDirectory + ) + if ($includedDirectory.length -gt 0) { + #get the specific directory + Write-Verbose "Searching for the directory '$includedDirectory' in '$directory' that doesn't contain '$ignoreFileName'." -Verbose + $includedDirs = $includedDirectory -split ',' | ForEach-Object { $_.Trim() } + $subDirectories = Get-ChildItem -Path $directory -Directory | where-object { $includedDirs -contains $_.Name } | where-object { -not (get-ChildItem $_.FullName -File -Filter $ignoreFileName -Force) } + if (!$subDirectories) { + throw "the '$includedDirectory' is not found in '$directory'." + } + } else { + Write-Verbose "Get all sub directories in '$directory'." -Verbose + $subDirectories = Get-ChildItem -Path $directory -Directory | where-object { -not (get-ChildItem $_.FullName -File -Filter $ignoreFileName -Force) } + } + $subDirectories +} + +function getConfigForTestCase { + [OutputType([hashtable])] + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [int]$policyComplianceStateDelay, + + [parameter(Mandatory = $true)] + [int]$appendModifyDelay, + + [parameter(Mandatory = $true)] + [int]$DINEDelay, + + [parameter(Mandatory = $true)] + [string]$directory, + + [parameter(Mandatory = $true)] + [string]$testLocalConfigFileName, + + [parameter(Mandatory = $true)] + [string]$testScriptName + ) + $runComplianceScan = $false + + $testLocalConfigFilePath = join-path $directory $testLocalConfigFileName + $testScriptPath = join-path $directory $testScriptName + if (Test-Path $testScriptPath -PathType 'Leaf') { + $sanitisedTestScriptContent = sanitisePSScript -scriptPath $testScriptPath + } else { + Write-Warning "The test script '$testScriptName' is not found in directory '$directory'." + $sanitisedTestScriptContent = '' + } + if (Test-Path $testLocalConfigFilePath -PathType 'Leaf') { + try { + $testLocalConfig = Get-Content -Path $testLocalConfigFilePath -Raw | ConvertFrom-Json -Depth 10 + $testSubscription = $testLocalConfig.testSubscription + Write-Verbose " - Test Subscription: '$testSubscription'." -Verbose + $testAppendModifyPolicies = findCommandInScript -scriptContent $sanitisedTestScriptContent -commands $script:AppendModifyPolicyTestCommands + $testDeployIfNotExistsPolicies = findCommandInScript -scriptContent $sanitisedTestScriptContent -commands $script:DeployIfNotExistPolicyTestCommands + $testPolicyComplianceState = findCommandInScript -scriptContent $sanitisedTestScriptContent -commands $script:AuditTestCommands + $testDenyPolicies = findCommandInScript -scriptContent $sanitisedTestScriptContent -commands $script:DenyPolicyTestCommands + $testPolicyRestrictionAPIs = findCommandInScript -scriptContent $sanitisedTestScriptContent -commands $script:PolicyRestrictionAPICommands + Write-Verbose " - Test Append/Modify Policies: '$testAppendModifyPolicies'." -Verbose + Write-Verbose " - Test DeployIfNotExists Policies: '$testDeployIfNotExistsPolicies'." -Verbose + Write-Verbose " - Test Policy Compliance State: '$testPolicyComplianceState'." -Verbose + Write-Verbose " - Test Deny Policies: '$testDenyPolicies'." -Verbose + Write-Verbose " - Test Deny or Audit policies using Policy Restriction APIs: '$testPolicyRestrictionAPIs'." -Verbose + + #Calculate the maximum wait time for append, modify and DINE policies + $waitTimeMinute = 0 + if ($testAppendModifyPolicies) { + $waitTimeMinute = $appendModifyDelay + } + if ($testDeployIfNotExistsPolicies) { + if ($waitTimeMinute) { + $waitTimeMinute = [math]::Max($waitTimeMinute, $DINEDelay) + } else { + $waitTimeMinute = $DINEDelay + } + } + if ($testPolicyComplianceState) { + $runComplianceScan = $true + if ($waitTimeMinute) { + $waitTimeMinute = [math]::Max($waitTimeMinute, $policyComplianceStateDelay) + } else { + $waitTimeMinute = $policyComplianceStateDelay + } + } + } catch { + Write-Warning "$_.Exception.Message" + } + } else { + Write-Warning "The test local config file '$testLocalConfigFilePath' is not found." + } + + @{ + waitTimeMinute = $waitTimeMinute + runComplianceScan = $runComplianceScan + testSubscription = $testSubscription + } +} +#endregion +#region main +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' + +#load helper +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType +#Different Command names for different policy effect tests +$script:DenyPolicyTestCommands = @('New-ARTWhatIfDeploymentTestConfig', 'New-ARTManualWhatIfTestConfig') +$script:AppendModifyPolicyTestCommands = @('New-ARTPropertyCountTestConfig', 'New-ARTPropertyValueTestConfig') +$script:DeployIfNotExistPolicyTestCommands = @('New-ARTResourceExistenceTestConfig') +$script:AuditTestCommands = @('New-ARTPolicyStateTestConfig') +$script:PolicyRestrictionAPICommands = @('New-ARTArmPolicyRestrictionTestConfig', 'New-ARTTerraformPolicyRestrictionTestConfig') +$testDelayStartMinutes = 0 +$complianceScanSubNames = @() +If ($skip -ieq 'true') { + Write-Output "The skip test parameter is set to true. no tests required." +} else { + Write-Verbose "directory: $directory" -Verbose + + #check if the included directory is specified as * which means all sub directories + if ($includedDirectory -eq '*') { + $includedDirectory = '' + } + $includedDirectory = $includedDirectory.trim() + $ignoreFileName = $ignoreFileName.trim() + + $subDirectories = getSubDir -directory $directory -ignoreFileName $ignoreFileName -includedDirectory $includedDirectory + $runComplianceScan = $false + if ($subDirectories) { + Foreach ($folder in $subDirectories) { + Write-Verbose "Checking test configuration for test case in directory '$($folder.name)'..." -verbose + $getTestConfigParams = @{ + directory = $folder.FullName + testLocalConfigFileName = $testLocalConfigFileName + testScriptName = $testScriptName + policyComplianceStateDelay = $policyComplianceStateDelay + appendModifyDelay = $appendModifyDelay + DINEDelay = $DINEDelay + } + $testConfig = getConfigForTestCase @getTestConfigParams + Write-Verbose " - Test delay Start value for test '$($folder.name)' is $($testConfig.waitTimeMinute)." -verbose + Write-Verbose " - Test '$($folder.name)' requirement for the compliance scan is $($testConfig.runComplianceScan)." -verbose + if ($testConfig.waitTimeMinute -gt $testDelayStartMinutes) { + $testDelayStartMinutes = $testConfig.waitTimeMinute + } + if ($testConfig.runComplianceScan -eq $true) { + $runComplianceScan = $true + $complianceScanSubNames += $testConfig.testSubscription + } + + } + } else { + Write-Error "no sub directory in '$directory." + Exit 1 + } +} +#deduplicate the compliance scan subscription names +$complianceScanSubNames = $complianceScanSubNames | Select-Object -Unique +$complianceScanSubNames = $(ConvertTo-Json -InputObject $complianceScanSubNames -Compress) +Write-Verbose "Run Compliance Scan: $runComplianceScan." -verbose + +if ($runComplianceScan) { + Write-Verbose "The subscription names that require compliance scan are: $complianceScanSubNames." -verbose +} +#Output Hashtable to ADO Pipeline as a Variable. +Write-Verbose "Delay start for all tests will be $testDelayStartMinutes minutes." -verbose +Write-Verbose "Run Compliance Scan: $($testConfig.runComplianceScan)" -verbose +if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'testDelayStartMinutes', $testDelayStartMinutes) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f 'testDelayStartMinutes', $testDelayStartMinutes) + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'runComplianceScan', $runComplianceScan) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f 'runComplianceScan', $runComplianceScan) + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'complianceScanSubNames', $complianceScanSubNames) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f 'complianceScanSubNames', $complianceScanSubNames) +} elseif ($runtimePlatform -ieq 'github actions') { + Add-Content -Path $env:GITHUB_OUTPUT -Value "testDelayStartMinutes=$testDelayStartMinutes" + Add-Content -Path $env:GITHUB_OUTPUT -Value "runComplianceScan=$runComplianceScan" + Add-Content -Path $env:GITHUB_OUTPUT -Value "complianceScanSubNames=$complianceScanSubNames" +} else { + Write-Output "testDelayStartMinutes: $testDelayStartMinutes" + Write-Output "runComplianceScan: $runComplianceScan" + Write-Output "complianceScanSubNames: $complianceScanSubNames" +} +#endregion diff --git a/scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1 new file mode 100644 index 0000000..5785c27 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-initiate-policy-integration-tests.ps1 @@ -0,0 +1,149 @@ +<# +========================================================== +AUTHOR: Tao Yang +DATE: 18/07/2024 +NAME: pipeline-initiate-policy-integration-tests.ps1 +VERSION: 1.0.0 +COMMENT: Initiate policy integration tests +========================================================== +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType 'Container' })] + [string]$testDirectory, + + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string]$testConfigFilePath +) +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' + +#load helper +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType + +#read global configuration for policy integration test +$config = Get-Content $testConfigFilePath | ConvertFrom-Json +try { + $testBicepTemplateName = $config.testBicepTemplateName + $testTerraformDirectoryName = $config.testTerraformDirectoryName + $testTerraformStateFileName = $config.testTerraformStateFileName + $testTerraformEncryptedStateFileName = $config.testTerraformEncryptedStateFileName + $testLocalConfigFileName = $config.testLocalConfigFileName + $initialEvalMaximumWaitTime = $config.initialEvalMaximumWaitTime + $testScriptName = $config.testScriptName + $testOutputFilePrefix = $config.testOutputFilePrefix + $testOutputFormat = $config.testOutputFormat + $testBicepDeploymentOutputArtifactPrefix = $config.testBicepDeploymentOutputArtifactPrefix + $testTerraformDeploymentOutputArtifactPrefix = $config.testTerraformDeploymentOutputArtifactPrefix + $testDeploymentOutputFileName = $config.testDeploymentOutputFileName + $waitTimeForPolicyComplianceStateAfterDeployment = $config.waitTimeForPolicyComplianceStateAfterDeployment + $waitTimeForAppendModifyPoliciesAfterDeployment = $config.waitTimeForAppendModifyPoliciesAfterDeployment + $waitTimeForDeployIfNotExistsPoliciesAfterDeployment = $config.waitTimeForDeployIfNotExistsPoliciesAfterDeployment +} catch { + write-error $_.Exception.Message + exit 1 +} + +#check if there are any tests that need to deploy test Bicep templates +$bBicepDeploymentRequired = $false +$tests = get-childitem -Path $testDirectory -Directory +if ($tests) { + foreach ($t in $tests) { + if (test-path $(join-path $t.FullName $testBicepTemplateName)) { + Write-Verbose "Test $($t.name) contains '$testBicepTemplateName' that needs to be deployed." -verbose + $bBicepDeploymentRequired = $true + } + } +} + +#create pipeline output variables +Write-Verbose "Setting pipeline output variables..." -verbose +Write-Verbose " - Setting testBicepTemplateName to '$testBicepTemplateName'..." -verbose +Write-Verbose " - Setting testTerraformDirectoryName to '$testTerraformDirectoryName'..." -verbose +Write-Verbose " - Setting testTerraformStateFileName to '$testTerraformStateFileName'..." -verbose +Write-Verbose " - Setting testTerraformEncryptedStateFileName to '$testTerraformEncryptedStateFileName'..." -verbose +Write-Verbose " - Setting testLocalConfigFileName to '$testLocalConfigFileName'..." -verbose +Write-Verbose " - Setting initialEvalMaximumWaitTime to '$initialEvalMaximumWaitTime'..." -verbose +Write-Verbose " - Setting initialEvalMaximumWaitTime to '$initialEvalMaximumWaitTime'..." -verbose +Write-Verbose " -- Setting testScriptName to '$testScriptName'..." -verbose +Write-Verbose " - Setting testOutputFilePrefix to '$testOutputFilePrefix'..." -verbose +Write-Verbose " - Setting testOutputFormat to '$testOutputFormat'..." -verbose +Write-Verbose " - Setting testBicepDeploymentOutputArtifactPrefix to '$testBicepDeploymentOutputArtifactPrefix'..." -verbose +Write-Verbose " - Setting testTerraformDeploymentOutputArtifactPrefix to '$testTerraformDeploymentOutputArtifactPrefix'..." -verbose +Write-Verbose " - Setting testDeploymentOutputFileName to '$testDeploymentOutputFileName'..." -verbose +Write-Verbose " - Setting bicepDeploymentRequired to '$bBicepDeploymentRequired'..." -verbose +Write-Verbose " - Setting waitTimeForPolicyComplianceStateAfterDeployment to '$waitTimeForPolicyComplianceStateAfterDeployment'..." -verbose +Write-Verbose " - Setting waitTimeForAppendModifyPoliciesAfterDeployment to '$waitTimeForAppendModifyPoliciesAfterDeployment'..." -verbose +Write-Verbose " - Setting waitTimeForDeployIfNotExistsPoliciesAfterDeployment to '$waitTimeForDeployIfNotExistsPoliciesAfterDeployment'..." -verbose + +if ($runtimePlatform -ieq 'azure devops') { + + Write-Output ('##vso[task.setVariable variable=testBicepTemplateName;isOutput=true]{0}' -f $testBicepTemplateName) + Write-Output ('##vso[task.setVariable variable=testBicepTemplateName]{0}' -f $testBicepTemplateName) + + Write-Output ('##vso[task.setVariable variable=testTerraformDirectoryName;isOutput=true]{0}' -f $testTerraformDirectoryName) + Write-Output ('##vso[task.setVariable variable=testTerraformDirectoryName]{0}' -f $testTerraformDirectoryName) + + Write-Output ('##vso[task.setVariable variable=testTerraformStateFileName;isOutput=true]{0}' -f $testTerraformStateFileName) + Write-Output ('##vso[task.setVariable variable=testTerraformStateFileName]{0}' -f $testTerraformStateFileName) + + Write-Output ('##vso[task.setVariable variable=testTerraformEncryptedStateFileName;isOutput=true]{0}' -f $testTerraformEncryptedStateFileName) + Write-Output ('##vso[task.setVariable variable=testTerraformEncryptedStateFileName]{0}' -f $testTerraformEncryptedStateFileName) + + Write-Output ('##vso[task.setVariable variable=testLocalConfigFileName;isOutput=true]{0}' -f $testLocalConfigFileName) + Write-Output ('##vso[task.setVariable variable=testLocalConfigFileName]{0}' -f $testLocalConfigFileName) + + Write-Output ('##vso[task.setVariable variable=initialEvalMaximumWaitTime;isOutput=true]{0}' -f $initialEvalMaximumWaitTime) + Write-Output ('##vso[task.setVariable variable=initialEvalMaximumWaitTime]{0}' -f $initialEvalMaximumWaitTime) + + Write-Output ('##vso[task.setVariable variable=testScriptName;isOutput=true]{0}' -f $testScriptName) + Write-Output ('##vso[task.setVariable variable=testScriptName]{0}' -f $testScriptName) + + Write-Output ('##vso[task.setVariable variable=testOutputFilePrefix;isOutput=true]{0}' -f $testOutputFilePrefix) + Write-Output ('##vso[task.setVariable variable=testOutputFilePrefix]{0}' -f $testOutputFilePrefix) + + Write-Output ('##vso[task.setVariable variable=testOutputFormat;isOutput=true]{0}' -f $testOutputFormat) + Write-Output ('##vso[task.setVariable variable=testOutputFormat]{0}' -f $testOutputFormat) + + Write-Output ('##vso[task.setVariable variable=testBicepDeploymentOutputArtifactPrefix;isOutput=true]{0}' -f $testBicepDeploymentOutputArtifactPrefix) + Write-Output ('##vso[task.setVariable variable=testBicepDeploymentOutputArtifactPrefix]{0}' -f $testBicepDeploymentOutputArtifactPrefix) + + Write-Output ('##vso[task.setVariable variable=testTerraformDeploymentOutputArtifactPrefix;isOutput=true]{0}' -f $testTerraformDeploymentOutputArtifactPrefix) + Write-Output ('##vso[task.setVariable variable=testTerraformDeploymentOutputArtifactPrefix]{0}' -f $testTerraformDeploymentOutputArtifactPrefix) + + Write-Output ('##vso[task.setVariable variable=testDeploymentOutputFileName;isOutput=true]{0}' -f $testDeploymentOutputFileName) + Write-Output ('##vso[task.setVariable variable=testDeploymentOutputFileName]{0}' -f $testDeploymentOutputFileName) + + Write-Output ('##vso[task.setVariable variable=bicepDeploymentRequired;isOutput=true]{0}' -f $bBicepDeploymentRequired) + Write-Output ('##vso[task.setVariable variable=bicepDeploymentRequired]{0}' -f $bBicepDeploymentRequired) + + Write-Output ('##vso[task.setVariable variable=waitTimeForPolicyComplianceStateAfterDeployment;isOutput=true]{0}' -f $waitTimeForPolicyComplianceStateAfterDeployment) + Write-Output ('##vso[task.setVariable variable=waitTimeForPolicyComplianceStateAfterDeployment]{0}' -f $waitTimeForPolicyComplianceStateAfterDeployment) + + Write-Output ('##vso[task.setVariable variable=waitTimeForAppendModifyPoliciesAfterDeployment;isOutput=true]{0}' -f $waitTimeForAppendModifyPoliciesAfterDeployment) + Write-Output ('##vso[task.setVariable variable=waitTimeForAppendModifyPoliciesAfterDeployment]{0}' -f $waitTimeForAppendModifyPoliciesAfterDeployment) + + Write-Output ('##vso[task.setVariable variable=waitTimeForDeployIfNotExistsPoliciesAfterDeployment;isOutput=true]{0}' -f $waitTimeForDeployIfNotExistsPoliciesAfterDeployment) + Write-Output ('##vso[task.setVariable variable=waitTimeForDeployIfNotExistsPoliciesAfterDeployment]{0}' -f $waitTimeForDeployIfNotExistsPoliciesAfterDeployment) +} elseif ($runtimePlatform -ieq 'github actions') { + Write-Output "testBicepTemplateName=$testBicepTemplateName" >> $env:GITHUB_OUTPUT + Write-Output "testTerraformDirectoryName=$testTerraformDirectoryName" >> $env:GITHUB_OUTPUT + Write-Output "testTerraformStateFileName=$testTerraformStateFileName" >> $env:GITHUB_OUTPUT + Write-Output "testTerraformEncryptedStateFileName=$testTerraformEncryptedStateFileName" >> $env:GITHUB_OUTPUT + Write-Output "testLocalConfigFileName=$testLocalConfigFileName" >> $env:GITHUB_OUTPUT + Write-Output "initialEvalMaximumWaitTime=$initialEvalMaximumWaitTime" >> $env:GITHUB_OUTPUT + Write-Output "testScriptName=$testScriptName" >> $env:GITHUB_OUTPUT + Write-Output "testOutputFilePrefix=$testOutputFilePrefix" >> $env:GITHUB_OUTPUT + Write-Output "testOutputFormat=$testOutputFormat" >> $env:GITHUB_OUTPUT + Write-Output "testBicepDeploymentOutputArtifactPrefix=$testBicepDeploymentOutputArtifactPrefix" >> $env:GITHUB_OUTPUT + Write-Output "testTerraformDeploymentOutputArtifactPrefix=$testTerraformDeploymentOutputArtifactPrefix" >> $env:GITHUB_OUTPUT + Write-Output "testDeploymentOutputFileName=$testDeploymentOutputFileName" >> $env:GITHUB_OUTPUT + Write-Output "bicepDeploymentRequired=$bBicepDeploymentRequired" >> $env:GITHUB_OUTPUT + Write-Output "waitTimeForPolicyComplianceStateAfterDeployment=$waitTimeForPolicyComplianceStateAfterDeployment" >> $env:GITHUB_OUTPUT + Write-Output "waitTimeForAppendModifyPoliciesAfterDeployment=$waitTimeForAppendModifyPoliciesAfterDeployment" >> $env:GITHUB_OUTPUT + Write-Output "waitTimeForDeployIfNotExistsPoliciesAfterDeployment=$waitTimeForDeployIfNotExistsPoliciesAfterDeployment" >> $env:GITHUB_OUTPUT + +} diff --git a/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 new file mode 100644 index 0000000..e0cb2a1 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 @@ -0,0 +1,574 @@ +<# +====================================================================================== +AUTHOR: Tao Yang +DATE: 29/03/2026 +NAME: pipeline-map-policy-integration-test-cases.ps1 +VERSION: 2.0.0 +COMMENT: Map required policy integration test cases based on modified files in the PR +====================================================================================== +#> +<# +.SYNOPSIS +Determine which policy integration test cases need to be executed based on the modified files in the PR. + +.DESCRIPTION +This script uses git diff command to determine the modified files in the PR. + +It then checks if the modified files are in the ignored files list. + +If the modified files are not in the ignored files list, it checks if the modified files are in the global test paths. + +If any of the modified files are in the global test paths, all test cases will be executed. + +If the modified files are not in the global test paths, it will check if the modified files are in the policy definition, initiative, assignment parameter files folder as well as the folders for individual policy integration test. + +If the modified files are in the individual test paths, it will determine the required test cases based on the modified files. + +.PARAMETER testConfigFilePath +Mandatory. The path of the policy integration test global configuration json file. + +.PARAMETER targetGitBranch +Optional. The name of the target git branch. The default value is 'main'. + +.PARAMETER testCaseDir +Optional. the path to the policy integration test cases. The default value is 'tests/policy-integration-tests' + +.EXAMPLE +./pipeline-map-policy-integration-test-cases.ps1 -testConfigFilePath '..\..\..\tests\policy-integration-tests\.shared\policy_integration_test_config.jsonc' + +Extract the deployment parameter information from the json exemption parameter file path. +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string]$testConfigFilePath, + + [parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$targetGitBranch = 'main', + + [parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$testCaseDir = 'tests/policy-integration-tests' +) + +#region functions +#Function for reading the policy integration test global configuration file (supports JSONC with comments) +function getTestConfig { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$testConfigFilePath + ) + $rawContent = Get-Content -Path $testConfigFilePath -Raw + $testConfig = $rawContent | ConvertFrom-Json -Depth 99 + $testConfig +} + +#function to get the name of the current checked out branch (borrowed from CARML) +function getGitBranchName { + [CmdletBinding()] + param () + + # Get branch name from Git + $BranchName = git branch --show-current + + # If git could not get name, try GitHub variable + if ([string]::IsNullOrEmpty($BranchName) -and (Test-Path env:GITHUB_REF_NAME)) { + $BranchName = $env:GITHUB_REF_NAME + } + + # If git could not get name, try Azure DevOps variable + if ([string]::IsNullOrEmpty($BranchName) -and (Test-Path env:BUILD_SOURCEBRANCHNAME)) { + $BranchName = $env:BUILD_SOURCEBRANCHNAME + } + + return $BranchName +} + +#Function for searching definition from all initiatives in the policy initiatives path +function getInitiativesFromDefinition { + [OutputType([system.array])] + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$definitionName, + + [parameter(Mandatory = $true)] + [string]$policyInitiativesPath + ) + $initiatives = @() + #Get all initiatives in policy initiatives path + + $initiativeFiles = Get-childItem -Path $policyInitiativesPath -Filter '*.json' -Recurse + + Foreach ($file in $initiativeFiles) { + $initiative = Get-Content $file.FullName -raw | ConvertFrom-Json -Depth 99 + $memberPolicies = $initiative.properties.policyDefinitions + foreach ($memberPolicy in $memberPolicies) { + if ($definitionName -ieq $memberPolicy.policyDefinitionId.split("/")[-1]) { + Write-Verbose " - Policy Definition '$definitionName' is a member of the initiative '$($initiative.name)' defined in '$($file.FullName)'." -verbose + $initiatives += $($initiative.name) + break + } + } + } + return , $initiatives +} +#function to iterate PSObjects properties based on the property name and path +function getPropertyValue { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [object]$object, + + [parameter(Mandatory = $true)] + [string]$propertyName + ) + $propertyValue = $object + $propertyNames = $propertyName -split '\.' + foreach ($name in $propertyNames) { + $propertyValue = $propertyValue.$name + } + $propertyValue +} + +#Function for reading the parameters from the parameter file of the policy assignment +function getAssignmentFromConfigurationFile { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$filePath + ) + $fileContent = Get-Content -Path $filePath -Raw + + #Read parameters from parameter file + $json = (ConvertFrom-Json $fileContent -Depth 99).policyAssignment + $json +} + +#Function for getting the test cases that are impacted by the policy assignment based on the 'policyAssignmentIds' defined in the test config file +function getTestCasesFromAssignment { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$assignmentName, + + [parameter(Mandatory = $true)] + [string]$policyIntegrationTestsPath + ) + $testsInScope = @() + $testCases = Get-ChildItem -path $policyIntegrationTestsPath -Depth 1 -Directory + + foreach ($testCase in $testCases) { + $testConfigFile = join-Path -Path $testCase.FullName -ChildPath 'config.json' -Resolve + $testConfig = Get-Content -Path $testConfigFile -Raw | ConvertFrom-Json -Depth 99 + $policyAssignmentIds = $testConfig.policyAssignmentIds + foreach ($policyAssignmentId in $policyAssignmentIds) { + $assignmentNameFromTestConfig = $policyAssignmentId.split("/")[-1] + if ($assignmentNameFromTestConfig -ieq $assignmentName) { + Write-Verbose " - Test case '$($testCase.Name)' is required for assignment '$assignmentName'." -Verbose + $testsInScope += $testCase.Name + break + } + } + } + return , $testsInScope +} + +#Function for getting the assignments that are directly assigning the policy definition or initiative by reading the policy assignment bicep parameter files +function getAssignmentsFromInitiativeOrDefinition { + [OutputType([System.Array])] + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$definitionName, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentsPath, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentConfigurationJsonPathForAssignmentName, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentConfigurationJsonPathForPolicyDefinitionId + ) + $assignments = @() + #Get all initiatives in policy initiatives path + + $assignmentFiles = Get-childItem -Path $policyAssignmentsPath -Recurse + foreach ($file in $assignmentFiles) { + $json = getAssignmentFromConfigurationFile -filePath $file.FullName + $PolicyDefinitionId = getPropertyValue -object $json -propertyName $policyAssignmentConfigurationJsonPathForPolicyDefinitionId + if ($definitionName -ieq $PolicyDefinitionId.split("/")[-1]) { + $policyAssignmentName = getPropertyValue -object $json -propertyName $policyAssignmentConfigurationJsonPathForAssignmentName + Write-Verbose "[$(getCurrentUTCString)]: Policy Assignment '$policyAssignmentName' defined in '$($file.name)' is used to assign definition / initiative '$definitionName'." + $assignments += $policyAssignmentName + } + + } + + return , $assignments +} + +#Function for getting the required test cases based on the modified files that are in the policy definition, initiative, assignment parameter files folder as well as the folders for individual policy integration test +function getRequiredTestCases { + [CmdletBinding()] + param ( + [parameter(ParameterSetName = 'definition', Mandatory = $true)] + [string]$changeFilePath, + + [parameter(Mandatory = $true)] + [string]$policyInitiativesPath, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentsPath, + + [parameter(Mandatory = $true)] + [string]$gitRoot, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentConfigurationJsonPathForAssignmentName, + + [parameter(Mandatory = $true)] + [string]$policyAssignmentConfigurationJsonPathForPolicyDefinitionId + ) + $resolvedRelativeFilePath = Resolve-Path -Path $changeFilePath -RelativeBasePath $gitRoot + $assignments = new-object -TypeName System.Collections.ArrayList + $requiredTestCases = new-object -TypeName System.Collections.ArrayList + $changeType = '' + if (isFileInPath -filePath $changeFilePath -paths $policyDefinitionsPath -gitRoot $gitRoot) { + Write-Verbose " - File '$changeFilePath' is in the policy definitions path" -Verbose + $changeType = 'definition' + } elseif (isFileInPath -filePath $changeFilePath -paths $policyInitiativesPath -gitRoot $gitRoot) { + Write-Verbose " - File '$changeFilePath' is in the policy initiatives path" -Verbose + $changeType = 'initiative' + } elseif (isFileInPath -filePath $changeFilePath -paths $policyAssignmentsPath -gitRoot $gitRoot) { + Write-Verbose " - File '$changeFilePath' is in the policy Assignments path" -Verbose + $changeType = 'assignment' + } elseif (isFileInPath -filePath $changeFilePath -paths $policyIntegrationTestsPath -gitRoot $gitRoot) { + Write-Verbose " - File '$changeFilePath' is in the policy Integration Tests path" -Verbose + $changeType = 'integrationTest' + } + $policyAssignmentsResolvedPath = Resolve-Path -Path $policyAssignmentsPath -RelativeBasePath $gitRoot + $policyIntegrationTestsResolvedPath = Resolve-Path -Path $policyIntegrationTestsPath -RelativeBasePath $gitRoot + $policyInitiativesResolvedPath = Resolve-Path -Path $policyInitiativesPath -RelativeBasePath $gitRoot + $baseParams = @{ + policyAssignmentsPath = $policyAssignmentsResolvedPath + policyAssignmentConfigurationJsonPathForAssignmentName = $policyAssignmentConfigurationJsonPathForAssignmentName + policyAssignmentConfigurationJsonPathForPolicyDefinitionId = $policyAssignmentConfigurationJsonPathForPolicyDefinitionId + } + Switch ($changeType) { + 'definition' { + $definition = Get-Content -path $resolvedRelativeFilePath -raw | ConvertFrom-Json -Depth 99 + $definitionName = $definition.name + Write-Verbose " - policy definition name: $definitionName" -Verbose + Write-Verbose " - find all initiatives that definition '$definitionName' is a member of." -Verbose + $initiatives = getInitiativesFromDefinition -definitionName $definitionName -policyInitiativesPath $policyInitiativesResolvedPath + Write-Verbose " - Number of Policy Initiatives found: $($initiatives.count)" -Verbose + + foreach ($initiative in $initiatives) { + #look for all assignments that are directly assigning the initiative + Write-Verbose " - look for all assignments that are directly assigning the initiative '$initiative'." -Verbose + foreach ($assignment in $(getAssignmentsFromInitiativeOrDefinition @baseParams -definitionName $initiative)) { + if (!$assignments.Contains($assignment)) { + $assignments.add($assignment) | Out-Null + } + } + } + #look for all assignments that are directly assigning the definition + Write-Verbose " - look for all assignments that are directly assigning the definition '$definitionName'." -Verbose + foreach ($assignment in $(getAssignmentsFromInitiativeOrDefinition @baseParams -definitionName $definitionName)) { + if (!$assignments.Contains($assignment)) { + $assignments.add($assignment) | Out-Null + } + } + } + 'initiative' { + $initiative = Get-Content -path $resolvedRelativeFilePath -raw | ConvertFrom-Json -Depth 99 + $initiativeName = $initiative.name + Write-Verbose " - policy initiative name: $initiativeName" -Verbose + + Write-Verbose " - look for all assignments that are directly assigning the initiative '$initiativeName'." -Verbose + foreach ($assignment in $(getAssignmentsFromInitiativeOrDefinition @baseParams -definitionName $initiativeName)) { + if (!$assignments.Contains($assignment)) { + $assignments.add($assignment) | Out-Null + } + } + } + 'assignment' { + $parametersJson = getAssignmentFromConfigurationFile -filePath $resolvedRelativeFilePath + $assignmentName = getPropertyValue -object $parametersJson -propertyName $policyAssignmentConfigurationJsonPathForAssignmentName + Write-Verbose " - policy assignment name: $assignmentName" -Verbose + + $assignments.add($assignmentName) | Out-Null + + } + 'integrationTest' { + $testName = (Get-Item -Path $resolvedRelativeFilePath).Directory.Name + + if (!$requiredTestCases.Contains($testName)) { + $requiredTestCases.add($testName) | Out-Null + } + } + + } + Write-Verbose " - Number of Policy Assignments found: $($assignments.count)" -Verbose + if ($assignments.count -gt 0) { + foreach ($assignment in $assignments) { + Write-Verbose " - look for policy integration tests that are required for assignment '$assignment'." -Verbose + $testCases = getTestCasesFromAssignment -assignmentName $assignment -policyIntegrationTestsPath $policyIntegrationTestsResolvedPath + foreach ($testCase in $testCases) { + if (!$requiredTestCases.Contains($testCase)) { + $requiredTestCases.add($testCase) | Out-Null + } + } + } + } + Write-Verbose " - Number of Policy Integration Tests found: $($requiredTestCases.count)" -Verbose + return , $requiredTestCases +} + +#Function to get modified files in a git repository based on the default branch +function getModifiedFiles { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$targetGitBranch, + + [parameter(Mandatory = $true)] + [string]$gitRoot + ) + $currentWorkingDiretory = $PWD + #the following commands must be executed when the working dir is in the git root + Write-verbose "[$(getCurrentUTCString)]: Setting working directory to $gitRoot" -Verbose + Set-Location -Path $gitRoot + $ModifiedFiles = new-object -TypeName System.Collections.ArrayList + #Current branch + $currentBranch = getGitBranchName + Write-Verbose "[$(getCurrentUTCString)]: Current branch: $currentBranch" -Verbose + #Firstly get the names of the modified files + if (($CurrentBranch -ieq $targetGitBranch)) { + Write-Verbose "[$(getCurrentUTCString)]: Gathering modified files from the pull request" -Verbose + $diffFiles = git diff --name-only --diff-filter=AM HEAD^ HEAD + } else { + Write-Verbose "[$(getCurrentUTCString)]: Gathering modified files between current branch and $targetGitBranch" -Verbose + $gitDiffCmd = "git diff --name-only --diff-filter=AM origin/$targetGitBranch" + $diffFiles = invoke-expression $gitDiffCmd + } + Write-Verbose "[$(getCurrentUTCString)]: Total Modified files: $($diffFiles.count)" -Verbose + #Secondly filter out the file changes that only consists white space and line changes + foreach ($item in $diffFiles) { + Write-Verbose "[$(getCurrentUTCString)]: Checking if '$item' has any content changes..." -Verbose + if (($CurrentBranch -ieq $targetGitBranch)) { + $gitDiffCmd = "git diff --ignore-all-space --ignore-blank-lines HEAD^ HEAD -- $item" + } else { + $gitDiffCmd = "git diff --ignore-all-space --ignore-blank-lines origin/$targetGitBranch -- $item" + } + $diff = invoke-expression $gitDiffCmd + if ($diff.length -gt 0) { + $ModifiedFiles.add($item) | Out-Null + } + } + #Set working directory back to the original + Write-Verbose "[$(getCurrentUTCString)]: Setting working directory back to $currentWorkingDiretory" -Verbose + Set-Location -Path $currentWorkingDiretory + Write-Verbose "[$(getCurrentUTCString)]: Total Modified files with content changes: $($ModifiedFiles.count)" -Verbose + return , $ModifiedFiles +} + +#Function to check if the file name matches the pattern +function fileNameMatch { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$fileName, + + [parameter(Mandatory = $true)] + [string[]]$patterns + ) + $bMatch = $false + foreach ($pattern in $patterns) { + #Write-Verbose " - Check if $fileName matches pattern $pattern" -Verbose + if ($fileName -like $pattern) { + $bMatch = $true + break + } + } + $bMatch +} + +#Function to check if the file is in the path +function isFileInPath { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [string]$filePath, + + [parameter(Mandatory = $true)] + [string[]]$paths, + + [parameter(Mandatory = $true)] + [string]$gitRoot + ) + $isInPath = $false + $resolvedRelativeFilePath = Resolve-Path -Path $filePath -RelativeBasePath $gitRoot -Relative + + foreach ($path in $paths) { + $relativePath = Resolve-Path -Path $path -RelativeBasePath $gitRoot -Relative + #Write-Verbose " - Check if $resolvedRelativeFilePath is in $relativePath" -Verbose + + $isInPath = $resolvedRelativeFilePath -like "$relativePath*" + if ($isInPath -eq $true) { + break + } + } + $isInPath +} +#endregion + +#region main + +#output variables +$requiredTestCases = new-object -TypeName System.Collections.ArrayList + +#load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.tostring() 'helper' 'helper-functions.ps1' +#load helper +. $helperFunctionScriptPath + +$runtimePlatform = getPipelineType +$gitRoot = Get-GitRoot +$gitVersion = git --version +Write-Verbose $gitVersion -Verbose +#Get test config +$testConfig = Get-Content -Path $testConfigFilePath -Raw | ConvertFrom-Json -Depth 99 +if ($null -eq $testConfig.testTriggers) { + Throw "The 'testTriggers' section is missing from the test configuration file '$testConfigFilePath'. Please ensure the file contains a valid 'TestTriggers' section." +} +$localTestConfigFileName = $testConfig.testLocalConfigFileName +#Get test ignored files +$testIgnoredFiles = $testConfig.testTriggers.IgnoredFiles +Write-Verbose "[$(getCurrentUTCString)]: Test ignored files:" -Verbose +Foreach ($ignoredFile in $testIgnoredFiles) { + Write-Verbose " - $ignoredFile" -Verbose +} + +#Get paths that will trigger all tests +$globalTestPaths = $testConfig.testTriggers.GlobalTestPaths +Write-Verbose "[$(getCurrentUTCString)]: File Paths that will trigger all tests:" -Verbose +Foreach ($globalTestPath in $globalTestPaths) { + Write-Verbose " - $globalTestPath" -Verbose +} + +#Get the policy assignment parameter json path for assignment name +$policyAssignmentConfigurationJsonPathForAssignmentName = $testConfig.testTriggers.policyAssignmentConfigurationJsonPathForAssignmentName +Write-Verbose "[$(getCurrentUTCString)]: Policy Assignment Configuration JSON path for Assignment Name: '$policyAssignmentConfigurationJsonPathForAssignmentName'." -verbose + +#Get the policy assignment parameter json path for policy definition ID +$policyAssignmentConfigurationJsonPathForPolicyDefinitionId = $testConfig.testTriggers.policyAssignmentConfigurationJsonPathForPolicyDefinitionId +Write-Verbose "[$(getCurrentUTCString)]: Policy Assignment Configuration JSON path for Assignment Policy Definition ID: '$policyAssignmentConfigurationJsonPathForPolicyDefinitionId'." -verbose + +#Get paths that will trigger specific tests +$policyDefinitionsPath = Resolve-Path -Path $testConfig.testTriggers.PolicyDefinitionsPath -RelativeBasePath $gitRoot -Relative +Write-Verbose "[$(getCurrentUTCString)]: Policy Definitions Path: '$policyDefinitionsPath'." -Verbose + +$policyInitiativesPath = Resolve-Path -Path $testConfig.testTriggers.PolicyInitiativesPath -RelativeBasePath $gitRoot -Relative +Write-Verbose "[$(getCurrentUTCString)]: Policy Initiatives Path: '$policyInitiativesPath'." -Verbose + +$policyAssignmentsPath = Resolve-Path -Path $testConfig.testTriggers.PolicyAssignmentsPath -RelativeBasePath $gitRoot -Relative +Write-Verbose "[$(getCurrentUTCString)]: Policy Assignments Path: '$policyAssignmentsPath'." -Verbose + +$policyIntegrationTestsPath = Resolve-Path -Path $testConfig.testTriggers.policyIntegrationTestsPath -RelativeBasePath $gitRoot -Relative +Write-Verbose "[$(getCurrentUTCString)]: Policy Integration Tests Path: '$policyIntegrationTestsPath'." + +#Get modified files +$modifiedFiles = getModifiedFiles -targetGitBranch $targetGitBranch -gitRoot $gitRoot +Write-Verbose "[$(getCurrentUTCString)]: Modified files:" -Verbose +Foreach ($file in $modifiedFiles) { + Write-Verbose " - $file" -Verbose +} + +#check if the modified files are in the ignored files +Write-Verbose "[$(getCurrentUTCString)]: Checking if the modified files are in the ignored files..." -Verbose +$ignoredFiles = @() +foreach ($file in $modifiedFiles) { + #Write-Verbose "Checking if file '$file' is in the ignored files..." -Verbose + $fileName = Split-Path -Path $file -Leaf + $isIgnored = fileNameMatch -fileName $fileName -patterns $testIgnoredFiles + if ($isIgnored) { + Write-Verbose " - File '$file' is in the ignored files" -Verbose + $ignoredFiles += $file + } else { + Write-Verbose " - File '$file' is not in the ignored files" -Verbose + } +} +foreach ($ignoredFile in $ignoredFiles) { + Write-Verbose "[$(getCurrentUTCString)]: Removing ignored file '$ignoredFile' from the modified files" -Verbose + $modifiedFiles.Remove($ignoredFile) +} +#Check if the modified files are in the global test paths +Write-Verbose "[$(getCurrentUTCString)]: Process all modified files." -Verbose +$i = 1 +Foreach ($file in $modifiedFiles) { + Write-Verbose " - [$i/$($modifiedFiles.count)] - '$file'" -Verbose + $isInGlobalTestPath = isFileInPath -filePath $file -paths $globalTestPaths -gitRoot $gitRoot + if ($isInGlobalTestPath) { + Write-Verbose " - File '$file' is in the global test paths. all tests will be executed. No need to process the rest of the modified files." -Verbose + $requiredTestCases.clear() + $requiredTestCases.add('*') | Out-Null + break + } else { + Write-Verbose " - File '$file' is not in the global test paths. Will Check if individual tests need to be executed." -Verbose + $getRequiredTestCasesParams = @{ + changeFilePath = $file + policyIntegrationTestsPath = $policyIntegrationTestsPath + policyInitiativesPath = $policyInitiativesPath + policyAssignmentsPath = $policyAssignmentsPath + gitRoot = $gitRoot + policyAssignmentConfigurationJsonPathForAssignmentName = $policyAssignmentConfigurationJsonPathForAssignmentName + policyAssignmentConfigurationJsonPathForPolicyDefinitionId = $policyAssignmentConfigurationJsonPathForPolicyDefinitionId + } + $testCases = getRequiredTestCases @getRequiredTestCasesParams + foreach ($testCase in $testCases) { + if (!$requiredTestCases.contains($testCase)) { + Write-Verbose " - Adding test case '$testCase' to the required test cases." -Verbose + $requiredTestCases.add($testCase) | Out-Null + } else { + Write-Verbose " - Test case '$testCase' is already added previously." -Verbose + } + } + + } + $i++ +} + +if ($requiredTestCases.Count -gt 0) { + $shouldSkipTest = $false + $strRequiredTestCases = $requiredTestCases -join "," +} else { + $shouldSkipTest = $true + $strRequiredTestCases = '_NONE_' +} +Write-Output "[$(getCurrentUTCString)]: Test Execution should be skipped: $shouldSkipTest" +Write-Output "[$(getCurrentUTCString)]: Required Test Cases:" +Foreach ($testCase in $requiredTestCases) { + Write-Output " - $testCase" +} +#convert $requiredTestCases to string +Write-Verbose "[$(getCurrentUTCString)]: Creating pipeline variable 'shouldSkipTest' with value '$shouldSkipTest'" -verbose +Write-Verbose "[$(getCurrentUTCString)]: Creating pipeline variable 'requiredTestCases' with value '$strRequiredTestCases'" -verbose + +if ($runtimePlatform -ieq 'azure devops') { + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'shouldSkipTest', $shouldSkipTest) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f 'shouldSkipTest', $shouldSkipTest) + + Write-Output ('##vso[task.setVariable variable={0};isOutput=true]{1}' -f 'requiredTestCases', $strRequiredTestCases) + Write-Output ('##vso[task.setVariable variable={0}]{1}' -f 'requiredTestCases', $strRequiredTestCases) +} elseif ($runtimePlatform -ieq 'github actions') { + write-output "shouldSkipTest=$shouldSkipTest" >> $env:GITHUB_OUTPUT + write-output "requiredTestCases=$strRequiredTestCases" >> $env:GITHUB_OUTPUT +} + +#endregion diff --git a/scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1 new file mode 100644 index 0000000..dca9452 --- /dev/null +++ b/scripts/pipelines/policy-integration-tests/pipeline-policy-int-test-compliance-scan.ps1 @@ -0,0 +1,75 @@ +<# +============================================================ +AUTHOR: Tao Yang +DATE: 26/03/2026 +NAME: pipeline-policy-int-test-compliance-scan.ps1 +VERSION: 2.0.0 +COMMENT: Kicking off policy compliance scan using Azure CLI +============================================================ +#> +[CmdletBinding()] +param ( + [parameter(Mandatory = $true)] + [ValidateScript({ Test-Path $_ -PathType 'Leaf' })] + [string]$testGlobalConfigFilePath, + + [parameter(Mandatory = $true)] + [string]$complianceScanSubNames +) + +#region functions +function getSubFromGlobalConfig { + [CmdletBinding()] + param ( + [parameter(Mandatory = $true)] + [hashtable]$globalConfig, + + [parameter(Mandatory = $true)] + [string]$subName + ) + $subsFromGlobalConfig = ($globalConfig.subscriptions) + Foreach ($item in $subsFromGlobalConfig.GetEnumerator()) { + if ($item.Key -ieq $subName) { + $sub = $item.Value + } + } + return $sub +} +#endregion + +#region main +#Read the global configuration file +$globalConfig = Get-Content $testGlobalConfigFilePath | ConvertFrom-Json -Depth 10 -AsHashtable + +#Get the list of subscriptions defined in the global Configuration file +$subsFromGlobalConfig = $globalConfig.subscriptions + +#List of subscriptions to run compliance scan +$subscriptions = ConvertFrom-Json -InputObject $complianceScanSubNames + +if ($subscriptions.Count -eq 0) { + Write-Output "No subscription is required to scan for policy compliance. Skip this step." +} else { + Write-Output "$($subscriptions.Count) unique subscriptions are required to scan for policy compliance." + foreach ($sub in $subscriptions) { + Write-Output " - $sub" + } + Write-Output "Start Policy Compliance Scan" + + foreach ($subName in $subscriptions) { + Write-Output " -- Get configuration for subscription '$subName' from tests global configuration file" + $sub = getSubFromGlobalConfig -globalConfig $globalConfig -subName $subName + if (!$sub) { + Write-Error "Subscription '$subName' not found in the global configuration file." + exit 1 + } else { + Write-Output " - Start Compliance Scan for subscription '$subName'('$($sub.id)') asynchronously." + } + + # use Azure CLI to start policy compliance scan because it supports async operation with --no-wait switch. This is not supported by Azure PowerShell + az policy state trigger-scan --subscription $sub.id --no-wait + } + + Write-output "Done." +} +#endregion diff --git a/scripts/support/policy-integration-test/newAesKey.ps1 b/scripts/support/policy-integration-test/newAesKey.ps1 new file mode 100644 index 0000000..2c6731a --- /dev/null +++ b/scripts/support/policy-integration-test/newAesKey.ps1 @@ -0,0 +1,34 @@ +<# +============================================ +AUTHOR: Tao Yang +DATE: 22/03/2026 +NAME: newAesKey.ps1 +VERSION: 1.0.0 +COMMENT: Generate AES encryption key and IV +============================================ +#> +[CmdletBinding()] +[OutputType([PSCustomObject])] +param ( + [Parameter(Mandatory = $false)] + [ValidateSet(128, 192, 256)] + [int]$KeySize = 256, + + [Parameter(Mandatory = $false)] + [string]$OutputFilePath +) +#load helper functions +$helperFunctionScriptPath = join-path (get-item $PSScriptRoot).parent.parent.tostring() 'pipelines' 'helper' 'helper-functions.ps1' +. $helperFunctionScriptPath + +$params = @{ + KeySize = $KeySize +} +if ($PSBoundParameters.ContainsKey('OutputFilePath')) { + $params.OutputFilePath = $OutputFilePath +} + +#Create AES key and IV +$aesKeyInfo = newAesKey @params + +$aesKeyInfo From 3e50ebcbe9271f946ece2d4565a50a344475f7dd Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 10:00:17 +1100 Subject: [PATCH 04/22] Add debug input option to workflow dispatch for policy files --- .github/workflows/policy-assignments.yml | 6 ++++++ .github/workflows/policy-definitions.yml | 7 ++++++- .github/workflows/policy-exemptions.yml | 6 ++++++ .github/workflows/policy-initiatives.yml | 6 ++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/policy-assignments.yml b/.github/workflows/policy-assignments.yml index 0d65758..02719b9 100644 --- a/.github/workflows/policy-assignments.yml +++ b/.github/workflows/policy-assignments.yml @@ -2,6 +2,11 @@ name: policy-assignments on: workflow_dispatch: # allows a manual run from the UI + inputs: + debug: + description: "Enable debug logging" + type: boolean + default: false workflow_run: workflows: ["policy-initiatives"] # name of the triggering workflow types: [completed] @@ -11,6 +16,7 @@ permissions: contents: read env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} variablesPath: "settings.yml" templateName: "PolicyAssign" templateFileDirectory: "bicep/templates/policyAssignments" diff --git a/.github/workflows/policy-definitions.yml b/.github/workflows/policy-definitions.yml index 0cc10e0..51ba7aa 100644 --- a/.github/workflows/policy-definitions.yml +++ b/.github/workflows/policy-definitions.yml @@ -2,11 +2,16 @@ name: policy-definitions on: workflow_dispatch: # allows a manual run from the UI - + inputs: + debug: + description: "Enable debug logging" + type: boolean + default: false permissions: contents: read env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} variablesPath: "settings.yml" templateName: "PolicyDef" templateFileDirectory: "bicep/templates/policyDefinitions" diff --git a/.github/workflows/policy-exemptions.yml b/.github/workflows/policy-exemptions.yml index e2c94df..9f6a571 100644 --- a/.github/workflows/policy-exemptions.yml +++ b/.github/workflows/policy-exemptions.yml @@ -2,6 +2,11 @@ name: policy-exemptions on: workflow_dispatch: # allows a manual run from the UI + inputs: + debug: + description: "Enable debug logging" + type: boolean + default: false workflow_run: workflows: ["policy-assignments"] # name of the triggering workflow types: [completed] @@ -10,6 +15,7 @@ on: permissions: contents: read env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} variablesPath: "settings.yml" templateName: "PolicyExemption" templateFileDirectory: "bicep/templates/policyExemptions" diff --git a/.github/workflows/policy-initiatives.yml b/.github/workflows/policy-initiatives.yml index e8e12f7..25e70be 100644 --- a/.github/workflows/policy-initiatives.yml +++ b/.github/workflows/policy-initiatives.yml @@ -2,6 +2,11 @@ name: policy-initiatives on: workflow_dispatch: # allows a manual run from the UI + inputs: + debug: + description: "Enable debug logging" + type: boolean + default: false workflow_run: workflows: ["policy-definitions"] # name of the triggering workflow types: [completed] @@ -11,6 +16,7 @@ permissions: contents: read env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} variablesPath: "settings.yml" templateName: "PolicySet" templateFileDirectory: "bicep/templates/policyInitiatives" From 616dc04569cc9f6577a8b91e44ad7de14192d05a Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 10:25:12 +1100 Subject: [PATCH 05/22] Upgrade actions/upload-artifact from v4 to v7 in multiple workflow templates --- .github/actions/templates/bicep-deployments/action.yml | 2 +- .../build-policy-assignment-and-exemption/action.yml | 4 ++-- .github/actions/templates/build-policy-def/action.yml | 2 +- .github/actions/templates/test-validate/action.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/templates/bicep-deployments/action.yml b/.github/actions/templates/bicep-deployments/action.yml index e890630..72af134 100644 --- a/.github/actions/templates/bicep-deployments/action.yml +++ b/.github/actions/templates/bicep-deployments/action.yml @@ -205,7 +205,7 @@ runs: # Upload Deployment Outputs Artifact - name: Upload Deployment Outputs Artifact if: inputs.publish-deployment-outputs == 'true' && steps.deploy.outputs.deploymentOutputPublished == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "${{ steps.deploy.outputs.deploymentName }}-Outputs" path: "${{ inputs.template-file-directory }}/Outputs/" diff --git a/.github/actions/templates/build-policy-assignment-and-exemption/action.yml b/.github/actions/templates/build-policy-assignment-and-exemption/action.yml index f4efd66..68ad45e 100644 --- a/.github/actions/templates/build-policy-assignment-and-exemption/action.yml +++ b/.github/actions/templates/build-policy-assignment-and-exemption/action.yml @@ -91,7 +91,7 @@ runs: Write-Output '::endgroup::' - name: Upload Bicep Template Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "${{ inputs.bicep-template-build-artifact-name }}" path: "${{ inputs.template-file-directory }}/${{ inputs.bicep-template-build-artifact-name }}" @@ -120,7 +120,7 @@ runs: - name: Upload Config File Artifact if: inputs.resource-type == 'assignment' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "${{ inputs.config-build-artifact-name }}" path: "${{ inputs.output-dir }}/${{ inputs.config-build-artifact-name }}" diff --git a/.github/actions/templates/build-policy-def/action.yml b/.github/actions/templates/build-policy-def/action.yml index 7406ef1..560e66a 100644 --- a/.github/actions/templates/build-policy-def/action.yml +++ b/.github/actions/templates/build-policy-def/action.yml @@ -64,7 +64,7 @@ runs: Write-Output '::endgroup::' - name: Upload Bicep Template Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: "${{ inputs.build-artifact-name }}" path: "${{ inputs.template-file-directory }}/${{ inputs.build-artifact-name }}" diff --git a/.github/actions/templates/test-validate/action.yml b/.github/actions/templates/test-validate/action.yml index 5f84b04..824e05f 100644 --- a/.github/actions/templates/test-validate/action.yml +++ b/.github/actions/templates/test-validate/action.yml @@ -288,7 +288,7 @@ runs: # Upload What-If Results Artifact - name: Upload What-If Results Artifact if: inputs.run-template-validation == 'true' && inputs.run-whatif-in-template-validation == 'true' && inputs.whatif-result-artifact-name != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ inputs.whatif-result-artifact-name }} path: "${{ inputs.template-file-directory }}/${{ inputs.whatif-result-artifact-name }}/" From 996ed6e250e3bf39a59dfd60d21b1fa007858f38 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 10:27:23 +1100 Subject: [PATCH 06/22] Upgrade azure/login action from v2 to v3 in Bicep deployment and validation workflows --- .github/actions/templates/bicep-deployments/action.yml | 2 +- .../templates/build-policy-assignment-and-exemption/action.yml | 2 +- .github/actions/templates/test-validate/action.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/templates/bicep-deployments/action.yml b/.github/actions/templates/bicep-deployments/action.yml index 72af134..f16591a 100644 --- a/.github/actions/templates/bicep-deployments/action.yml +++ b/.github/actions/templates/bicep-deployments/action.yml @@ -141,7 +141,7 @@ runs: # Azure Login - name: Azure Login - uses: azure/login@v2 + uses: azure/login@v3 with: creds: ${{ env.AZURE_CREDENTIALS }} enable-AzPSSession: true diff --git a/.github/actions/templates/build-policy-assignment-and-exemption/action.yml b/.github/actions/templates/build-policy-assignment-and-exemption/action.yml index 68ad45e..bb43582 100644 --- a/.github/actions/templates/build-policy-assignment-and-exemption/action.yml +++ b/.github/actions/templates/build-policy-assignment-and-exemption/action.yml @@ -57,7 +57,7 @@ runs: using: "composite" steps: - name: Azure Login - uses: Azure/login@v2 + uses: azure/login@v3 with: creds: ${{ env.AZURE_CREDENTIALS }} enable-AzPSSession: true diff --git a/.github/actions/templates/test-validate/action.yml b/.github/actions/templates/test-validate/action.yml index 824e05f..126f80c 100644 --- a/.github/actions/templates/test-validate/action.yml +++ b/.github/actions/templates/test-validate/action.yml @@ -199,7 +199,7 @@ runs: # Run Bicep Linter and Build - name: Azure Login if: inputs.run-psrule-tests == 'true' || inputs.run-bicep-linter == 'true' || inputs.run-template-validation == 'true' || inputs.run-whatif-in-template-validation == 'true' - uses: Azure/login@v2 + uses: azure/login@v3 with: creds: ${{ env.AZURE_CREDENTIALS }} enable-AzPSSession: true From 6fd4c0cfae102cfe9388749fa9c6d124d858b4a0 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 10:54:34 +1100 Subject: [PATCH 07/22] Remove obsolete policy exemption files and add new exemptions for LZ-Online subscriptions --- ...corp-sub-eh-001.json => pex-lz-online-sub-eh-001.json} | 8 ++++---- ...corp-sub-eh-003.json => pex-lz-online-sub-eh-003.json} | 8 ++++---- ...corp-sub-eh-001.json => pex-lz-online-sub-eh-001.json} | 8 ++++---- ...corp-sub-eh-003.json => pex-lz-online-sub-eh-003.json} | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) rename policyExemptions/dev/{pex-lz-corp-sub-eh-001.json => pex-lz-online-sub-eh-001.json} (73%) rename policyExemptions/dev/{pex-lz-corp-sub-eh-003.json => pex-lz-online-sub-eh-003.json} (73%) rename policyExemptions/prod/{pex-lz-corp-sub-eh-001.json => pex-lz-online-sub-eh-001.json} (74%) rename policyExemptions/prod/{pex-lz-corp-sub-eh-003.json => pex-lz-online-sub-eh-003.json} (74%) diff --git a/policyExemptions/dev/pex-lz-corp-sub-eh-001.json b/policyExemptions/dev/pex-lz-online-sub-eh-001.json similarity index 73% rename from policyExemptions/dev/pex-lz-corp-sub-eh-001.json rename to policyExemptions/dev/pex-lz-online-sub-eh-001.json index a841a11..07e4135 100644 --- a/policyExemptions/dev/pex-lz-corp-sub-eh-001.json +++ b/policyExemptions/dev/pex-lz-online-sub-eh-001.json @@ -1,9 +1,9 @@ { "$schema": "../policyExemption.schema.json", "policyExemption": { - "name": "pex-lz-corp-sub-eh-001", - "displayName": "Exempt LZ-Corp-Dev Subscription from Event Hub disable local auth restriction", - "description": "This is a test exemption for the sub-d-lz-corp-01 subscription.", + "name": "pex-lz-online-sub-eh-001", + "displayName": "Exempt LZ-Online-Dev Subscription from Event Hub disable local auth restriction", + "description": "This is a test exemption for the sub-d-lz-online-01 subscription.", "metadata": { "requestedBy": "Eric Cartman", "approvedBy": "Bart Simpson", @@ -31,5 +31,5 @@ } ] }, - "subscriptionId": "dc2d72b7-a48d-45e8-91cc-81193ecc659b" + "subscriptionId": "f793e6e7-9219-4d57-8582-89bb7be49bba" } diff --git a/policyExemptions/dev/pex-lz-corp-sub-eh-003.json b/policyExemptions/dev/pex-lz-online-sub-eh-003.json similarity index 73% rename from policyExemptions/dev/pex-lz-corp-sub-eh-003.json rename to policyExemptions/dev/pex-lz-online-sub-eh-003.json index d91f55a..b2bd8cd 100644 --- a/policyExemptions/dev/pex-lz-corp-sub-eh-003.json +++ b/policyExemptions/dev/pex-lz-online-sub-eh-003.json @@ -1,9 +1,9 @@ { "$schema": "../policyExemption.schema.json", "policyExemption": { - "name": "pex-lz-corp-sub-eh-003", - "displayName": "Exempt LZ-Corp-Dev Subscription from Event Hub Private Networking restriction", - "description": "This is a test exemption for the sub-d-lz-corp-01 subscription.", + "name": "pex-lz-online-sub-eh-003", + "displayName": "Exempt LZ-Online-Dev Subscription from Event Hub Private Networking restriction", + "description": "This is a test exemption for the sub-d-lz-online-01 subscription.", "metadata": { "requestedBy": "Eric Cartman", "approvedBy": "Bart Simpson", @@ -31,5 +31,5 @@ } ] }, - "subscriptionId": "dc2d72b7-a48d-45e8-91cc-81193ecc659b" + "subscriptionId": "f793e6e7-9219-4d57-8582-89bb7be49bba" } diff --git a/policyExemptions/prod/pex-lz-corp-sub-eh-001.json b/policyExemptions/prod/pex-lz-online-sub-eh-001.json similarity index 74% rename from policyExemptions/prod/pex-lz-corp-sub-eh-001.json rename to policyExemptions/prod/pex-lz-online-sub-eh-001.json index 0d407ad..2bfd8c0 100644 --- a/policyExemptions/prod/pex-lz-corp-sub-eh-001.json +++ b/policyExemptions/prod/pex-lz-online-sub-eh-001.json @@ -1,9 +1,9 @@ { "$schema": "../policyExemption.schema.json", "policyExemption": { - "name": "pex-lz-corp-sub-eh-001", - "displayName": "Exempt LZ-Corp Subscription from Event Hub disable local auth restriction", - "description": "This is a test exemption for the sub-d-lz-corp-01 subscription.", + "name": "pex-lz-online-sub-eh-001", + "displayName": "Exempt LZ-Online Subscription from Event Hub disable local auth restriction", + "description": "This is a test exemption for the sub-d-lz-online-01 subscription.", "metadata": { "requestedBy": "Eric Cartman", "approvedBy": "Bart Simpson", @@ -31,5 +31,5 @@ } ] }, - "subscriptionId": "0f1b7d98-c832-4d46-8a29-a0c63d54a2fa" + "subscriptionId": "e371edfc-8526-4d2d-9493-ca2d3f18f465" } diff --git a/policyExemptions/prod/pex-lz-corp-sub-eh-003.json b/policyExemptions/prod/pex-lz-online-sub-eh-003.json similarity index 74% rename from policyExemptions/prod/pex-lz-corp-sub-eh-003.json rename to policyExemptions/prod/pex-lz-online-sub-eh-003.json index bc29776..ddaed86 100644 --- a/policyExemptions/prod/pex-lz-corp-sub-eh-003.json +++ b/policyExemptions/prod/pex-lz-online-sub-eh-003.json @@ -1,9 +1,9 @@ { "$schema": "../policyExemption.schema.json", "policyExemption": { - "name": "pex-lz-corp-sub-eh-003", - "displayName": "Exempt LZ-Corp Subscription from Event Hub Private Networking restriction", - "description": "This is a test exemption for the sub-d-lz-corp-01 subscription.", + "name": "pex-lz-online-sub-eh-003", + "displayName": "Exempt LZ-Online Subscription from Event Hub Private Networking restriction", + "description": "This is a test exemption for the sub-d-lz-online-01 subscription.", "metadata": { "requestedBy": "Eric Cartman", "approvedBy": "Bart Simpson", @@ -31,5 +31,5 @@ } ] }, - "subscriptionId": "0f1b7d98-c832-4d46-8a29-a0c63d54a2fa" + "subscriptionId": "e371edfc-8526-4d2d-9493-ca2d3f18f465" } From d9a940a2eb07f7d5c5123089c14ff76c62a7c7fb Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 11:02:52 +1100 Subject: [PATCH 08/22] Upgrade actions/download-artifact from v4 to v8 in validation workflow --- .../action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/templates/validate-policy-assignment-and-exemption-config-syntax/action.yml b/.github/actions/templates/validate-policy-assignment-and-exemption-config-syntax/action.yml index d0cfe68..9ff078d 100644 --- a/.github/actions/templates/validate-policy-assignment-and-exemption-config-syntax/action.yml +++ b/.github/actions/templates/validate-policy-assignment-and-exemption-config-syntax/action.yml @@ -44,7 +44,7 @@ runs: steps: - name: Download Config File Artifact if: inputs.config-artifact-name != '' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: "${{ inputs.config-artifact-name }}" path: "${{ github.workspace }}/_artifacts/${{ inputs.config-artifact-name }}" From 738d4e3dfd1309a493e522ded6aff4d06879d386 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 11:08:02 +1100 Subject: [PATCH 09/22] Upgrade actions/download-artifact from v7 to v8 in Bicep deployment and validation workflows --- .github/actions/templates/bicep-deployments/action.yml | 6 +++--- .github/actions/templates/test-validate/action.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/templates/bicep-deployments/action.yml b/.github/actions/templates/bicep-deployments/action.yml index f16591a..a2e2f93 100644 --- a/.github/actions/templates/bicep-deployments/action.yml +++ b/.github/actions/templates/bicep-deployments/action.yml @@ -119,14 +119,14 @@ runs: # Download template file artifact if artifact name provided - name: Download Template File Artifact if: inputs.template-file-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.template-file-artifact-name }} path: ${{ inputs.template-file-directory }} - name: Download Additional Resource Artifact if: inputs.additional-resource-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.additional-resource-artifact-name }} path: "${{ inputs.additional-resource-directory }}" @@ -134,7 +134,7 @@ runs: # Download Parameter file artifact if artifact name provided - name: Download Parameter File Artifact if: inputs.parameter-file-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.parameter-file-artifact-name }} path: "${{ inputs.parameter-file-directory }}" diff --git a/.github/actions/templates/test-validate/action.yml b/.github/actions/templates/test-validate/action.yml index 126f80c..19c350b 100644 --- a/.github/actions/templates/test-validate/action.yml +++ b/.github/actions/templates/test-validate/action.yml @@ -133,7 +133,7 @@ runs: # Download Parameter file artifact if artifact name provided - name: Download Parameter File Artifact if: inputs.parameter-file-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.parameter-file-artifact-name }} path: "${{ inputs.template-file-directory }}/psrule-test/" @@ -162,14 +162,14 @@ runs: # Download template file artifact if artifact name provided - name: Download Template File Artifact if: inputs.template-file-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.template-file-artifact-name }} path: ${{ inputs.template-file-directory }} - name: Download Additional Resource Artifact if: inputs.additional-resource-artifact-name != '' - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: ${{ inputs.additional-resource-artifact-name }} path: "${{ inputs.additional-resource-directory }}" From 521e1a6f9be04af69d218c7773d502512927a3e4 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 11:19:12 +1100 Subject: [PATCH 10/22] Add debug logging input to PR Policy Assignment Environment Consistency Tests workflow --- .github/workflows/pr-policy-assignment-env-consistency.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-policy-assignment-env-consistency.yml b/.github/workflows/pr-policy-assignment-env-consistency.yml index 5626760..cb83988 100644 --- a/.github/workflows/pr-policy-assignment-env-consistency.yml +++ b/.github/workflows/pr-policy-assignment-env-consistency.yml @@ -14,8 +14,13 @@ on: - ".github/workflows/pr-policy-assignment-env-consistency.yml" - ".github/actions/templates/test-policy-assignment-env-consistency/**" workflow_dispatch: # allow manual trigger - + inputs: + debug: + description: "Enable debug logging" + type: boolean + default: false env: + ACTIONS_STEP_DEBUG: ${{ inputs.debug }} devConfigurationFilesDirectory: "policyAssignments/dev" prodConfigurationFilesDirectory: "policyAssignments/prod" From 7db26e7fee2f984e51b716c5f6ffc50fd3520a75 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 12:12:35 +1100 Subject: [PATCH 11/22] Update EH-002_Effect to Deny in both dev and prod policy assignments --- policyAssignments/dev/pa-d-eh.json | 2 +- policyAssignments/prod/pa-p-eh.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/policyAssignments/dev/pa-d-eh.json b/policyAssignments/dev/pa-d-eh.json index 4c257d9..1bcb117 100644 --- a/policyAssignments/dev/pa-d-eh.json +++ b/policyAssignments/dev/pa-d-eh.json @@ -14,7 +14,7 @@ "value": "Deny" }, "EH-002_Effect": { - "value": "Audit" + "value": "Deny" }, "EH-002_MinimumTlsVersion": { "value": "1.2" diff --git a/policyAssignments/prod/pa-p-eh.json b/policyAssignments/prod/pa-p-eh.json index 2378e7f..e1c62cd 100644 --- a/policyAssignments/prod/pa-p-eh.json +++ b/policyAssignments/prod/pa-p-eh.json @@ -14,7 +14,7 @@ "value": "Disabled" }, "EH-002_Effect": { - "value": "Audit" + "value": "Deny" }, "EH-002_MinimumTlsVersion": { "value": "1.2" From d3d4669367fb62d1967740865ba196185dc0b8e0 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 12:55:44 +1100 Subject: [PATCH 12/22] Refactor integration test workflows and scripts for improved clarity and functionality --- .../pol-int-test-detect-test-cases/action.yml | 10 ++++--- .../pol-int-test-get-sub-dir/action.yml | 13 +++++++--- .github/linters/.checkov.yml | 5 ++++ .../workflows/policy-integration-tests.yml | 26 ++++++++++++++++++- .github/workflows/pr-code-scan.yml | 2 +- .markdownlint.json | 2 +- .../helper/resource-removal-helper.ps1 | 2 +- ...line-deploy-policy-test-bicep-template.ps1 | 2 +- ...get-policy-assignment-compliance-state.ps1 | 2 +- tests/policy-integration-tests/README.md | 4 +-- 10 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 .github/linters/.checkov.yml diff --git a/.github/actions/templates/pol-int-test-detect-test-cases/action.yml b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml index 9643405..0f0b4b1 100644 --- a/.github/actions/templates/pol-int-test-detect-test-cases/action.yml +++ b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml @@ -38,6 +38,10 @@ runs: shell: pwsh run: | ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 ` - -testConfigFilePath "${{ inputs.test-config-file-path }}" ` - -targetGitBranch "${{ inputs.target-git-branch }}" ` - -testCaseDir "${{ inputs.test-case-dir }}" + -testConfigFilePath "${TestConfigFilePath}" ` + -targetGitBranch "${TargetGitBranch}" ` + -testCaseDir "${TestCaseDir}" + env: + TestConfigFilePath: ${{ inputs.test-config-file-path }} + TargetGitBranch: ${{ inputs.target-git-branch }} + TestCaseDir: ${{ inputs.test-case-dir }} diff --git a/.github/actions/templates/pol-int-test-get-sub-dir/action.yml b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml index 9eae478..7e72b45 100644 --- a/.github/actions/templates/pol-int-test-get-sub-dir/action.yml +++ b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml @@ -39,7 +39,12 @@ runs: shell: pwsh run: | ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 ` - -directory "${{ inputs.directory }}" ` - -ignoreFileName "${{ inputs.ignore-file-name }}" ` - -includedDirectory "${{ inputs.included-directory }}" ` - -skip "${{ inputs.skip }}" + -directory "${Directory }" ` + -ignoreFileName "${IgnoreFileName}" ` + -includedDirectory "${IncludedDirectory}" ` + -skip "${Skip}" + env: + Directory: ${{ inputs.directory }} + IgnoreFileName: ${{ inputs.ignore-file-name }} + IncludedDirectory: ${{ inputs.included-directory }} + Skip: ${{ inputs.skip }} diff --git a/.github/linters/.checkov.yml b/.github/linters/.checkov.yml new file mode 100644 index 0000000..787bbe2 --- /dev/null +++ b/.github/linters/.checkov.yml @@ -0,0 +1,5 @@ +skip-check: + - CKV_GHA_7 # Allow workflow_dispatch inputs for operational flexibility + +skip-path: + - tests/policy-integration-tests diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index 85d768b..62c3548 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -75,6 +75,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: List Environment Variables shell: pwsh run: "Get-ChildItem env:" @@ -103,6 +106,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + persist-credentials: false - name: Detect Test Cases id: detectTestCases uses: ./.github/actions/templates/pol-int-test-detect-test-cases @@ -130,6 +134,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Get Test Config id: getTestConfigsTask shell: pwsh @@ -137,13 +144,15 @@ jobs: ./${{ env.getTestConfigsScript }} ` -directory '${{ env.testDirectory }}' ` -ignoreFileName '${{ env.testIgnoreFileName }}' ` - -includedDirectory "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" ` + -includedDirectory "${IncludedDirectory}" ` -policyComplianceStateDelay ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} ` -appendModifyDelay ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} ` -DINEDelay ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} ` -testLocalConfigFileName '${{ needs.initiation.outputs.testLocalConfigFileName }}' ` -testScriptName '${{ needs.initiation.outputs.testScriptName }}' ` -skip ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} + env: + IncludedDirectory: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }} # ────────────────────────────────────────── # Get test case sub directories @@ -164,6 +173,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Get Sub Directories id: getSubDirs uses: ./.github/actions/templates/pol-int-test-get-sub-dir @@ -202,6 +214,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Azure Login uses: azure/login@v3 with: @@ -281,6 +296,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Azure Login uses: azure/login@v3 with: @@ -351,6 +369,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Azure Login uses: azure/login@v3 with: @@ -439,6 +460,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false - name: Azure Login uses: azure/login@v3 with: diff --git a/.github/workflows/pr-code-scan.yml b/.github/workflows/pr-code-scan.yml index 3e6ec9f..2030b7c 100644 --- a/.github/workflows/pr-code-scan.yml +++ b/.github/workflows/pr-code-scan.yml @@ -41,7 +41,7 @@ jobs: VALIDATE_BIOME_FORMAT: false VALIDATE_KUBERNETES_KUBECONFORM: false VALIDATE_JSCPD: false - FILTER_REGEX_EXCLUDE: '(bin/|obj/|\.vs/|node_modules/|\.vscode/)' + FILTER_REGEX_EXCLUDE: '(bin/|obj/|\.vs/|node_modules/|\.vscode/|tests/policy-integration-tests/\.test-template/)' LOG_LEVEL: ERROR VALIDATE_ARM: false IGNORE_GITIGNORED_FILES: true diff --git a/.markdownlint.json b/.markdownlint.json index 7cbe286..a0511af 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -20,7 +20,7 @@ "no-reversed-links": true, "no-multiple-blanks": false, "line-length": { - "line_length": 400, + "line_length": 500, "code_blocks": false, "tables": false, "headers": true diff --git a/scripts/pipelines/helper/resource-removal-helper.ps1 b/scripts/pipelines/helper/resource-removal-helper.ps1 index 246c3fe..73b3fbe 100644 --- a/scripts/pipelines/helper/resource-removal-helper.ps1 +++ b/scripts/pipelines/helper/resource-removal-helper.ps1 @@ -559,7 +559,7 @@ function invokeResourceRemoval { 'Microsoft.KeyVault/vaults/keys' { $resourceName = Split-Path $ResourceId -Leaf Write-Verbose ('[/] Skipping resource [{0}] of type [{1}]. Reason: It is handled by different logic.' -f $resourceName, $Type) -Verbose - # Also, we don't want to accidently remove keys of the dependency key vault + # Also, we don't want to accidentally remove keys of the dependency key vault break } 'Microsoft.KeyVault/vaults/accessPolicies' { diff --git a/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 index 79590d8..3567883 100644 --- a/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 +++ b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 @@ -160,7 +160,7 @@ if (Test-path $BicepFilePath -PathType Leaf) { Write-Verbose " - $rg" -verbose } } - #Create deployment result hastable to store the deployment result + #Create deployment result hashtable to store the deployment result If ($deploymentResultFilePath -ne '') { $deploymentResult.Add('bicepDeploymentScope', $templateScope) $deploymentResult.Add('bicepAdditionalResourceGroups', $additionalResourceGroups) diff --git a/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 index a841e4a..ace0c58 100644 --- a/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 +++ b/scripts/pipelines/policy-integration-tests/pipeline-get-policy-assignment-compliance-state.ps1 @@ -147,7 +147,7 @@ if ($wait -eq 'true') { $shouldWait = $false $complianceState = getComplianceState -policyAssignmentId $policyAssignmentId Write-Verbose "$($complianceState.Count) of $($policyAssignmentId.count) policy assignments compliance state returned from ARG query." -Verbose - #Policy compliance state cannot be queries if there are no existing targetted resources in the assignment scope. + #Policy compliance state cannot be queried if there are no existing targeted resources in the assignment scope. #In this case, we will check the initial creation date (createdOn metadata) of the policy assignment and make sure we wait for at least 5 minutes from the initial creation date #If the policy assignment is previously created and the updated by the pipeline execution, the updatedOn metadata is ignored. Foreach ($assignment in $assignments) { diff --git a/tests/policy-integration-tests/README.md b/tests/policy-integration-tests/README.md index db77f2c..5ef9a8b 100644 --- a/tests/policy-integration-tests/README.md +++ b/tests/policy-integration-tests/README.md @@ -19,7 +19,7 @@ Edit `config.json` in the new folder. The file supports the following properties | Property | Required | Description | | :------- | :------: | :---------- | | `policyAssignmentIds` | Yes | Array of policy assignment resource IDs to evaluate during testing. These policy assignments are the pre-requisites for the test. Test will not start until they have been initially evaluated after creation. | -| `testName` | Yes | A short name for the test case. Used in test titles and output file names. | +| `testName` | Yes | A short name for the test case. Used in test titles and output filenames. | | `assignmentName` | No | The name of the primary policy assignment being tested. | | `testSubscription` | Yes | The name of the subscription (as defined in the global config `subscriptions` map) to deploy test resources into. | | `testResourceGroup` | No | The resource group name for testing. If specified, a `$script:testResourceGroupId` variable is calculated. | @@ -144,7 +144,7 @@ Tests are executed automatically by the Azure Policy integration test pipelines ## Folder Structure -``` +```text tests/policy-integration-tests/ ├── .shared/ # Shared config and initiation script │ ├── policy_integration_test_config.jsonc # Global test configuration From 6228cde7a14487c41c10bf72bed487fff452fa99 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 13:24:35 +1100 Subject: [PATCH 13/22] Add concurrency settings and update linter configurations for policy integration tests --- .github/linters/{.checkov.yml => .checkov.yaml} | 0 .github/linters/.zizmor.yml | 1 + .github/workflows/policy-integration-tests.yml | 4 ++++ 3 files changed, 5 insertions(+) rename .github/linters/{.checkov.yml => .checkov.yaml} (100%) diff --git a/.github/linters/.checkov.yml b/.github/linters/.checkov.yaml similarity index 100% rename from .github/linters/.checkov.yml rename to .github/linters/.checkov.yaml diff --git a/.github/linters/.zizmor.yml b/.github/linters/.zizmor.yml index 1fab18e..9fb71d2 100644 --- a/.github/linters/.zizmor.yml +++ b/.github/linters/.zizmor.yml @@ -1,3 +1,4 @@ +severity: medium rules: unpinned-uses: disable: true diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index 62c3548..c678f21 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -23,6 +23,10 @@ on: type: boolean default: false +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: read From 2a0f043db8ba69c9d88aa9319c49474943d46381 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 13:52:54 +1100 Subject: [PATCH 14/22] Refactor policy integration test workflows to use environment variables for configuration parameters --- .github/linters/.zizmor.yml | 1 - .../workflows/policy-integration-tests.yml | 114 ++++++++++++------ 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/.github/linters/.zizmor.yml b/.github/linters/.zizmor.yml index 9fb71d2..1fab18e 100644 --- a/.github/linters/.zizmor.yml +++ b/.github/linters/.zizmor.yml @@ -1,4 +1,3 @@ -severity: medium rules: unpinned-uses: disable: true diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index c678f21..81d18b1 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -149,14 +149,20 @@ jobs: -directory '${{ env.testDirectory }}' ` -ignoreFileName '${{ env.testIgnoreFileName }}' ` -includedDirectory "${IncludedDirectory}" ` - -policyComplianceStateDelay ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} ` - -appendModifyDelay ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} ` - -DINEDelay ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} ` - -testLocalConfigFileName '${{ needs.initiation.outputs.testLocalConfigFileName }}' ` - -testScriptName '${{ needs.initiation.outputs.testScriptName }}' ` - -skip ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} + -policyComplianceStateDelay "${PolicyComplianceStateDelay}" ` + -appendModifyDelay "${AppendModifyDelay}" ` + -DINEDelay "${DINEDelay}" ` + -testLocalConfigFileName "${TestLocalConfigFileName}" ` + -testScriptName "${TestScriptName}" ` + -skip "${Skip}" env: IncludedDirectory: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }} + PolicyComplianceStateDelay: ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} + AppendModifyDelay: ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} + DINEDelay: ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} + TestLocalConfigFileName: ${{ needs.initiation.outputs.testLocalConfigFileName }} + TestScriptName: ${{ needs.initiation.outputs.testScriptName }} + Skip: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} # ────────────────────────────────────────── # Get test case sub directories @@ -188,11 +194,6 @@ jobs: ignore-file-name: "${{ env.testIgnoreFileName }}" included-directory: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" skip: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }}" - - name: Debug Outputs - shell: bash - run: | - echo "SubDirCount='${{ steps.getSubDirs.outputs.SubDirCount }}'" - echo 'SubDirectories=${{ steps.getSubDirs.outputs.SubDirectories }}' # ────────────────────────────────────────── # Deploy test resources (matrix) @@ -239,19 +240,28 @@ jobs: shell: pwsh run: | ./${{ env.waitPolicyInitialEvalScriptPath }} ` - -configFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -configFilePath '${ ConfigFilePath }' ` -wait 'true' ` - -maximumWaitMinutes ${{ needs.initiation.outputs.initialEvalMaximumWaitTime }} + -maximumWaitMinutes '${ MaximumWaitMinutes }' + env: + ConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" + MaximumWaitMinutes: "${{ needs.initiation.outputs.initialEvalMaximumWaitTime }}" - name: Deploy Test Bicep Template shell: pwsh run: | ./${{ env.testBicepDeploymentScriptPath }} ` - -BicepFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testBicepTemplateName }}' ` - -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` - -BuildNumber ${{ github.run_number }} ` - -maxRetry ${{ env.deploymentMaxRetry }} ` + -BicepFilePath '${BicepFilePath}' ` + -TestConfigFilePath '${TestConfigFilePath}' ` + -BuildNumber '${BuildNumber}' ` + -maxRetry '${MaxRetry}' ` -bicepModuleSubscriptionId '' ` - -deploymentResultFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' + -deploymentResultFilePath '${deploymentResultFilePath}' + env: + BicepFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testBicepTemplateName }}" + TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" + BuildNumber: ${{ github.run_number }} + MaxRetry: ${{ env.deploymentMaxRetry }} + deploymentResultFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: Publish Bicep Deployment Result Artifact if: always() uses: actions/upload-artifact@v7 @@ -262,17 +272,26 @@ jobs: shell: pwsh run: | ./${{ env.testTFDeploymentDestroyScriptPath }} ` - -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` - -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` - -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` + -TestConfigFilePath '${TestConfigFilePath}' ` + -terraformPath '${TerraformPath}' ` + -tfBackendConfigFileName '${TfBackendConfigFileName}' ` -tfAction 'apply' ` - -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}' ` - -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` - -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` - -deploymentResultFileName '${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -tfBackendStateFileDirectory '${TfBackendStateFileDirectory}' ` + -tfStateFileName '${TfStateFileName}' ` + -tfEncryptedStateFileName '${TfEncryptedStateFileName}' ` + -deploymentResultFileName '${DeploymentResultFileName}' ` -uninitializeTerraform 'true' ` -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` -aesIV '${{ secrets.AES_IV }}' + env: + TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" + TerraformPath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}" + TfBackendConfigFileName: "backend-${{ github.run_id }}.tf" + TfBackendStateFileDirectory: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}" + TfStateFileName: "${{ needs.initiation.outputs.testTerraformStateFileName }}" + TfEncryptedStateFileName: "${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}" + DeploymentResultFileName: "${{ needs.initiation.outputs.testDeploymentOutputFileName }}" + - name: Publish Terraform Deployment Result Artifact if: always() uses: actions/upload-artifact@v7 @@ -313,7 +332,9 @@ jobs: run: | ./${{ env.complianceScanScriptPath }} ` -testGlobalConfigFilePath '${{ env.testGlobalConfigFilePath }}' ` - -complianceScanSubNames '${{ needs.getTestConfigs.outputs.complianceScanSubNames }}' + -complianceScanSubNames '${ ComplianceScanSubNames }' + env: + ComplianceScanSubNames: ${{ needs.getTestConfigs.outputs.complianceScanSubNames }} # ────────────────────────────────────────── # Wait after template deployment for policy @@ -337,8 +358,8 @@ jobs: - name: "Wait ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} min for initial evaluation" shell: pwsh run: | - Write-Output "::group::Waiting ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} minutes for Initial Policy Evaluation" - $delayMinutes = '${{ needs.getTestConfigs.outputs.testDelayStartMinutes }}' + Write-Output "::group::Waiting ${{ env.TestDelayStartMinutes }} minutes for Initial Policy Evaluation" + $delayMinutes = '${{ env.TestDelayStartMinutes }}' $now = "$([DateTime]::UtcNow.ToString('u')) UTC" if ($delayMinutes -match '^\d+$' -and [int]$delayMinutes -gt 0) { Write-Output "[$now]: Waiting $delayMinutes minutes for policy evaluation..." @@ -347,6 +368,8 @@ jobs: Write-Output "[$now]: No delay required (value: '$delayMinutes')" } Write-Output '::endgroup::' + env: + TestDelayStartMinutes: ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} # ────────────────────────────────────────── # Execute test cases (matrix) @@ -412,15 +435,19 @@ jobs: shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -jsonFilePath '${ JsonFilePath }' ` -overallJsonVariableName 'bicepDeploymentResult' + env: + JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Parse Terraform Deployment Result - ${{ matrix.matrixSubDirName }}" id: parseTerraformDeploymentResult shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -jsonFilePath '${ JsonFilePath }' ` -overallJsonVariableName 'terraformDeploymentResult' + env: + JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Resource Test - ${{ matrix.matrixSubDirName }}" shell: pwsh env: @@ -428,8 +455,10 @@ jobs: terraformDeploymentResult: ${{ steps.parseTerraformDeploymentResult.outputs.terraformDeploymentResult }} outputFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testOutputFilePrefix }}-${{ matrix.matrixSubDirName }}.XML" outputFormat: ${{ needs.initiation.outputs.testOutputFormat }} + TestScriptName: ${{ needs.initiation.outputs.testScriptName }} run: | - ./${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testScriptName }} + ./${{ matrix.matrixSubDirRelativePath }}/${ TestScriptName } + - name: "Process Test Results - ${{ matrix.matrixSubDirName }}" if: always() uses: ./.github/actions/templates/parse-pester-results @@ -491,8 +520,10 @@ jobs: shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` + -jsonFilePath '${ JsonFilePath }' ` -overallJsonVariableName 'bicepDeploymentResult' + env: + JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Remove Bicep Test Resources - ${{ matrix.matrixSubDirName }}" if: >- steps.parseBicepDeploymentResult.outputs.bicepDeploymentId != '' || @@ -507,13 +538,20 @@ jobs: shell: pwsh run: | ./${{ env.testTFDeploymentDestroyScriptPath }} ` - -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` - -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` - -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` + -TestConfigFilePath '${ TestConfigFilePath }' ` + -terraformPath '${ TerraformPath }' ` + -tfBackendConfigFileName '${ TfBackendConfigFileName }' ` -tfAction 'destroy' ` - -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}' ` - -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` - -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` + -tfBackendStateFileDirectory '${ TfBackendStateFileDirectory }' ` + -tfStateFileName '${ TfStateFileName }' ` + -tfEncryptedStateFileName '${ TfEncryptedStateFileName }' ` -uninitializeTerraform 'true' ` -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` -aesIV '${{ secrets.AES_IV }}' + env: + TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" + TerraformPath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}" + TfBackendConfigFileName: "backend-${{ github.run_id }}.tf" + TfBackendStateFileDirectory: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" + TfStateFileName: "${{ needs.initiation.outputs.testTerraformStateFileName }}" + TfEncryptedStateFileName: "${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}" From ce3ec37b9ccb61a5bdd58666d427d8dca646e645 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 14:03:34 +1100 Subject: [PATCH 15/22] Fix variable interpolation in wait script for initial policy evaluation --- .github/workflows/policy-integration-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index 81d18b1..fa208e2 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -358,8 +358,8 @@ jobs: - name: "Wait ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} min for initial evaluation" shell: pwsh run: | - Write-Output "::group::Waiting ${{ env.TestDelayStartMinutes }} minutes for Initial Policy Evaluation" - $delayMinutes = '${{ env.TestDelayStartMinutes }}' + Write-Output "::group::Waiting ${TestDelayStartMinutes} minutes for Initial Policy Evaluation" + $delayMinutes = '${TestDelayStartMinutes}' $now = "$([DateTime]::UtcNow.ToString('u')) UTC" if ($delayMinutes -match '^\d+$' -and [int]$delayMinutes -gt 0) { Write-Output "[$now]: Waiting $delayMinutes minutes for policy evaluation..." From 914a0ef024a40c1d1a22ec4a6ce5d95a8d2014c9 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 14:15:53 +1100 Subject: [PATCH 16/22] Add .prettierignore to policy integration test configuration --- .../.shared/policy_integration_test_config.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc b/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc index 31de403..668a4cd 100644 --- a/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc +++ b/tests/policy-integration-tests/.shared/policy_integration_test_config.jsonc @@ -136,7 +136,8 @@ "*.nuspec", ".testignore", "LICENSE", - ".DS_Store" + ".DS_Store", + ".prettierignore" ] } } From 80b58ff1285fb71a4872ff2e799b5d88fc6c70ec Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 14:25:52 +1100 Subject: [PATCH 17/22] Refactor policy integration test workflows for improved clarity and functionality --- .github/linters/.zizmor.yml | 2 + .../workflows/policy-integration-tests.yml | 119 ++++++------------ 2 files changed, 40 insertions(+), 81 deletions(-) diff --git a/.github/linters/.zizmor.yml b/.github/linters/.zizmor.yml index 1fab18e..6fdfc5e 100644 --- a/.github/linters/.zizmor.yml +++ b/.github/linters/.zizmor.yml @@ -5,3 +5,5 @@ rules: disable: true secrets-outside-env: disable: true + template-injection: + disable: true diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index fa208e2..e04d4d6 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -1,8 +1,3 @@ -# ────────────────────────────────────────────────────────────── -# Policy Integration Tests -# Converted from Azure DevOps pipeline: -# .azuredevops/pipelines/validation/azure-pipelines-pr-policy-int-tests.yml -# ────────────────────────────────────────────────────────────── name: policy-integration-tests on: @@ -149,20 +144,14 @@ jobs: -directory '${{ env.testDirectory }}' ` -ignoreFileName '${{ env.testIgnoreFileName }}' ` -includedDirectory "${IncludedDirectory}" ` - -policyComplianceStateDelay "${PolicyComplianceStateDelay}" ` - -appendModifyDelay "${AppendModifyDelay}" ` - -DINEDelay "${DINEDelay}" ` - -testLocalConfigFileName "${TestLocalConfigFileName}" ` - -testScriptName "${TestScriptName}" ` - -skip "${Skip}" + -policyComplianceStateDelay ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} ` + -appendModifyDelay ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} ` + -DINEDelay ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} ` + -testLocalConfigFileName '${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -testScriptName '${{ needs.initiation.outputs.testScriptName }}' ` + -skip ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} env: IncludedDirectory: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }} - PolicyComplianceStateDelay: ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} - AppendModifyDelay: ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} - DINEDelay: ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} - TestLocalConfigFileName: ${{ needs.initiation.outputs.testLocalConfigFileName }} - TestScriptName: ${{ needs.initiation.outputs.testScriptName }} - Skip: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} # ────────────────────────────────────────── # Get test case sub directories @@ -194,6 +183,11 @@ jobs: ignore-file-name: "${{ env.testIgnoreFileName }}" included-directory: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" skip: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }}" + - name: Debug Outputs + shell: bash + run: | + echo "SubDirCount='${{ steps.getSubDirs.outputs.SubDirCount }}'" + echo 'SubDirectories=${{ steps.getSubDirs.outputs.SubDirectories }}' # ────────────────────────────────────────── # Deploy test resources (matrix) @@ -240,28 +234,19 @@ jobs: shell: pwsh run: | ./${{ env.waitPolicyInitialEvalScriptPath }} ` - -configFilePath '${ ConfigFilePath }' ` + -configFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` -wait 'true' ` - -maximumWaitMinutes '${ MaximumWaitMinutes }' - env: - ConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" - MaximumWaitMinutes: "${{ needs.initiation.outputs.initialEvalMaximumWaitTime }}" + -maximumWaitMinutes ${{ needs.initiation.outputs.initialEvalMaximumWaitTime }} - name: Deploy Test Bicep Template shell: pwsh run: | ./${{ env.testBicepDeploymentScriptPath }} ` - -BicepFilePath '${BicepFilePath}' ` - -TestConfigFilePath '${TestConfigFilePath}' ` - -BuildNumber '${BuildNumber}' ` - -maxRetry '${MaxRetry}' ` + -BicepFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testBicepTemplateName }}' ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -BuildNumber ${{ github.run_number }} ` + -maxRetry ${{ env.deploymentMaxRetry }} ` -bicepModuleSubscriptionId '' ` - -deploymentResultFilePath '${deploymentResultFilePath}' - env: - BicepFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testBicepTemplateName }}" - TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" - BuildNumber: ${{ github.run_number }} - MaxRetry: ${{ env.deploymentMaxRetry }} - deploymentResultFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" + -deploymentResultFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' - name: Publish Bicep Deployment Result Artifact if: always() uses: actions/upload-artifact@v7 @@ -272,26 +257,17 @@ jobs: shell: pwsh run: | ./${{ env.testTFDeploymentDestroyScriptPath }} ` - -TestConfigFilePath '${TestConfigFilePath}' ` - -terraformPath '${TerraformPath}' ` - -tfBackendConfigFileName '${TfBackendConfigFileName}' ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` + -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` -tfAction 'apply' ` - -tfBackendStateFileDirectory '${TfBackendStateFileDirectory}' ` - -tfStateFileName '${TfStateFileName}' ` - -tfEncryptedStateFileName '${TfEncryptedStateFileName}' ` - -deploymentResultFileName '${DeploymentResultFileName}' ` + -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}' ` + -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` + -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` + -deploymentResultFileName '${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` -uninitializeTerraform 'true' ` -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` -aesIV '${{ secrets.AES_IV }}' - env: - TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" - TerraformPath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}" - TfBackendConfigFileName: "backend-${{ github.run_id }}.tf" - TfBackendStateFileDirectory: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}" - TfStateFileName: "${{ needs.initiation.outputs.testTerraformStateFileName }}" - TfEncryptedStateFileName: "${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}" - DeploymentResultFileName: "${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - - name: Publish Terraform Deployment Result Artifact if: always() uses: actions/upload-artifact@v7 @@ -332,9 +308,7 @@ jobs: run: | ./${{ env.complianceScanScriptPath }} ` -testGlobalConfigFilePath '${{ env.testGlobalConfigFilePath }}' ` - -complianceScanSubNames '${ ComplianceScanSubNames }' - env: - ComplianceScanSubNames: ${{ needs.getTestConfigs.outputs.complianceScanSubNames }} + -complianceScanSubNames '${{ needs.getTestConfigs.outputs.complianceScanSubNames }}' # ────────────────────────────────────────── # Wait after template deployment for policy @@ -358,8 +332,8 @@ jobs: - name: "Wait ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} min for initial evaluation" shell: pwsh run: | - Write-Output "::group::Waiting ${TestDelayStartMinutes} minutes for Initial Policy Evaluation" - $delayMinutes = '${TestDelayStartMinutes}' + Write-Output "::group::Waiting ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} minutes for Initial Policy Evaluation" + $delayMinutes = '${{ needs.getTestConfigs.outputs.testDelayStartMinutes }}' $now = "$([DateTime]::UtcNow.ToString('u')) UTC" if ($delayMinutes -match '^\d+$' -and [int]$delayMinutes -gt 0) { Write-Output "[$now]: Waiting $delayMinutes minutes for policy evaluation..." @@ -368,8 +342,6 @@ jobs: Write-Output "[$now]: No delay required (value: '$delayMinutes')" } Write-Output '::endgroup::' - env: - TestDelayStartMinutes: ${{ needs.getTestConfigs.outputs.testDelayStartMinutes }} # ────────────────────────────────────────── # Execute test cases (matrix) @@ -435,19 +407,15 @@ jobs: shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${ JsonFilePath }' ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` -overallJsonVariableName 'bicepDeploymentResult' - env: - JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Parse Terraform Deployment Result - ${{ matrix.matrixSubDirName }}" id: parseTerraformDeploymentResult shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${ JsonFilePath }' ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` -overallJsonVariableName 'terraformDeploymentResult' - env: - JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Resource Test - ${{ matrix.matrixSubDirName }}" shell: pwsh env: @@ -455,10 +423,8 @@ jobs: terraformDeploymentResult: ${{ steps.parseTerraformDeploymentResult.outputs.terraformDeploymentResult }} outputFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testOutputFilePrefix }}-${{ matrix.matrixSubDirName }}.XML" outputFormat: ${{ needs.initiation.outputs.testOutputFormat }} - TestScriptName: ${{ needs.initiation.outputs.testScriptName }} run: | - ./${{ matrix.matrixSubDirRelativePath }}/${ TestScriptName } - + ./${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testScriptName }} - name: "Process Test Results - ${{ matrix.matrixSubDirName }}" if: always() uses: ./.github/actions/templates/parse-pester-results @@ -520,10 +486,8 @@ jobs: shell: pwsh run: | ./${{ env.testDeploymentParseResultScriptPath }} ` - -jsonFilePath '${ JsonFilePath }' ` + -jsonFilePath '${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}' ` -overallJsonVariableName 'bicepDeploymentResult' - env: - JsonFilePath: "${{ matrix.matrixSubDirFullPath }}/${{ needs.initiation.outputs.testBicepDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}/${{ needs.initiation.outputs.testDeploymentOutputFileName }}" - name: "Remove Bicep Test Resources - ${{ matrix.matrixSubDirName }}" if: >- steps.parseBicepDeploymentResult.outputs.bicepDeploymentId != '' || @@ -538,20 +502,13 @@ jobs: shell: pwsh run: | ./${{ env.testTFDeploymentDestroyScriptPath }} ` - -TestConfigFilePath '${ TestConfigFilePath }' ` - -terraformPath '${ TerraformPath }' ` - -tfBackendConfigFileName '${ TfBackendConfigFileName }' ` + -TestConfigFilePath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}' ` + -terraformPath '${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}' ` + -tfBackendConfigFileName 'backend-${{ github.run_id }}.tf' ` -tfAction 'destroy' ` - -tfBackendStateFileDirectory '${ TfBackendStateFileDirectory }' ` - -tfStateFileName '${ TfStateFileName }' ` - -tfEncryptedStateFileName '${ TfEncryptedStateFileName }' ` + -tfBackendStateFileDirectory '${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}' ` + -tfStateFileName '${{ needs.initiation.outputs.testTerraformStateFileName }}' ` + -tfEncryptedStateFileName '${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}' ` -uninitializeTerraform 'true' ` -aesEncryptionKey '${{ secrets.AES_ENCRYPTION_KEY }}' ` -aesIV '${{ secrets.AES_IV }}' - env: - TestConfigFilePath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testLocalConfigFileName }}" - TerraformPath: "${{ matrix.matrixSubDirRelativePath }}/${{ needs.initiation.outputs.testTerraformDirectoryName }}" - TfBackendConfigFileName: "backend-${{ github.run_id }}.tf" - TfBackendStateFileDirectory: "${{ runner.temp }}/${{ matrix.matrixSubDirName }}-tfstate-${{ github.run_id }}/${{ needs.initiation.outputs.testTerraformDeploymentOutputArtifactPrefix }}-${{ matrix.matrixSubDirName }}" - TfStateFileName: "${{ needs.initiation.outputs.testTerraformStateFileName }}" - TfEncryptedStateFileName: "${{ needs.initiation.outputs.testTerraformEncryptedStateFileName }}" From f688c7d1c52deb5280b56c3697d546f04d96b6c3 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 14:33:11 +1100 Subject: [PATCH 18/22] Refactor included directory handling in policy integration tests for improved clarity --- .github/workflows/policy-integration-tests.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/policy-integration-tests.yml b/.github/workflows/policy-integration-tests.yml index e04d4d6..682dac6 100644 --- a/.github/workflows/policy-integration-tests.yml +++ b/.github/workflows/policy-integration-tests.yml @@ -143,15 +143,13 @@ jobs: ./${{ env.getTestConfigsScript }} ` -directory '${{ env.testDirectory }}' ` -ignoreFileName '${{ env.testIgnoreFileName }}' ` - -includedDirectory "${IncludedDirectory}" ` + -includedDirectory "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" ` -policyComplianceStateDelay ${{ needs.initiation.outputs.waitTimeForPolicyComplianceStateAfterDeployment }} ` -appendModifyDelay ${{ needs.initiation.outputs.waitTimeForAppendModifyPoliciesAfterDeployment }} ` -DINEDelay ${{ needs.initiation.outputs.waitTimeForDeployIfNotExistsPoliciesAfterDeployment }} ` -testLocalConfigFileName '${{ needs.initiation.outputs.testLocalConfigFileName }}' ` -testScriptName '${{ needs.initiation.outputs.testScriptName }}' ` -skip ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }} - env: - IncludedDirectory: ${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }} # ────────────────────────────────────────── # Get test case sub directories @@ -183,11 +181,6 @@ jobs: ignore-file-name: "${{ env.testIgnoreFileName }}" included-directory: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.requiredTestCases || inputs.testToRun || ' ' }}" skip: "${{ github.event_name == 'pull_request' && needs.mapTestCases.outputs.shouldSkipTest || 'false' }}" - - name: Debug Outputs - shell: bash - run: | - echo "SubDirCount='${{ steps.getSubDirs.outputs.SubDirCount }}'" - echo 'SubDirectories=${{ steps.getSubDirs.outputs.SubDirectories }}' # ────────────────────────────────────────── # Deploy test resources (matrix) From afff89bc640647f246b882d490dbc940f1c5cdcb Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 14:39:49 +1100 Subject: [PATCH 19/22] Refactor PowerShell script calls in action YAML files for improved readability --- .../pol-int-test-detect-test-cases/action.yml | 10 +++------- .../templates/pol-int-test-get-sub-dir/action.yml | 13 ++++--------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/actions/templates/pol-int-test-detect-test-cases/action.yml b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml index 0f0b4b1..9643405 100644 --- a/.github/actions/templates/pol-int-test-detect-test-cases/action.yml +++ b/.github/actions/templates/pol-int-test-detect-test-cases/action.yml @@ -38,10 +38,6 @@ runs: shell: pwsh run: | ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-map-policy-integration-test-cases.ps1 ` - -testConfigFilePath "${TestConfigFilePath}" ` - -targetGitBranch "${TargetGitBranch}" ` - -testCaseDir "${TestCaseDir}" - env: - TestConfigFilePath: ${{ inputs.test-config-file-path }} - TargetGitBranch: ${{ inputs.target-git-branch }} - TestCaseDir: ${{ inputs.test-case-dir }} + -testConfigFilePath "${{ inputs.test-config-file-path }}" ` + -targetGitBranch "${{ inputs.target-git-branch }}" ` + -testCaseDir "${{ inputs.test-case-dir }}" diff --git a/.github/actions/templates/pol-int-test-get-sub-dir/action.yml b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml index 7e72b45..9eae478 100644 --- a/.github/actions/templates/pol-int-test-get-sub-dir/action.yml +++ b/.github/actions/templates/pol-int-test-get-sub-dir/action.yml @@ -39,12 +39,7 @@ runs: shell: pwsh run: | ${{ github.workspace }}/scripts/pipelines/policy-integration-tests/pipeline-get-sub-directories.ps1 ` - -directory "${Directory }" ` - -ignoreFileName "${IgnoreFileName}" ` - -includedDirectory "${IncludedDirectory}" ` - -skip "${Skip}" - env: - Directory: ${{ inputs.directory }} - IgnoreFileName: ${{ inputs.ignore-file-name }} - IncludedDirectory: ${{ inputs.included-directory }} - Skip: ${{ inputs.skip }} + -directory "${{ inputs.directory }}" ` + -ignoreFileName "${{ inputs.ignore-file-name }}" ` + -includedDirectory "${{ inputs.included-directory }}" ` + -skip "${{ inputs.skip }}" From 74eff7db0101573340e56a672d60276ac6d2936e Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 15:04:07 +1100 Subject: [PATCH 20/22] Add restriction for 'Microsoft.DBforPostgreSQL/servers' resource type in policy assignment --- policyAssignments/prod/pa-p-res-restriction.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/policyAssignments/prod/pa-p-res-restriction.json b/policyAssignments/prod/pa-p-res-restriction.json index 2e66b40..768effb 100644 --- a/policyAssignments/prod/pa-p-res-restriction.json +++ b/policyAssignments/prod/pa-p-res-restriction.json @@ -24,6 +24,9 @@ }, "RR-005_Effect": { "value": "Deny" + }, + "RR-006_Effect": { + "value": "Deny" } }, "notScopes": [ @@ -49,6 +52,10 @@ { "policyDefinitionReferenceId": "RR-005", "message": "The resource type 'Microsoft.ContainerRegistry/registries/scopeMaps' is not allowed." + }, + { + "policyDefinitionReferenceId": "RR-006", + "message": "The resource type 'Microsoft.DBforPostgreSQL/servers' is not allowed. Use PostgreSQL Flexible Server instead." } ] }, From 9933137cb30f0ddded4e2ce22e5c556893842686 Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 15:07:16 +1100 Subject: [PATCH 21/22] Remove duplicate email domain from MON-001_allowedEmailDomains parameter in policy assignment --- policyAssignments/prod/pa-p-monitor.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/policyAssignments/prod/pa-p-monitor.json b/policyAssignments/prod/pa-p-monitor.json index a2fdc7f..54a056c 100644 --- a/policyAssignments/prod/pa-p-monitor.json +++ b/policyAssignments/prod/pa-p-monitor.json @@ -14,8 +14,7 @@ }, "MON-001_allowedEmailDomains": { "value": [ - "contoso.com", - "lumagatena.com" + "contoso.com" ] }, "MON-002_Effect": { From f7bc0baaade6087ee92b45cd0ed42da7694b1d2d Mon Sep 17 00:00:00 2001 From: Tao Yang Date: Sat, 4 Apr 2026 15:46:58 +1100 Subject: [PATCH 22/22] Add maxRetry parameter to ARM API functions for improved reliability --- scripts/pipelines/helper/helper-functions.ps1 | 44 ++++++++++++------- ...line-deploy-policy-test-bicep-template.ps1 | 10 ++--- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/scripts/pipelines/helper/helper-functions.ps1 b/scripts/pipelines/helper/helper-functions.ps1 index 1b5042b..c41fedd 100644 --- a/scripts/pipelines/helper/helper-functions.ps1 +++ b/scripts/pipelines/helper/helper-functions.ps1 @@ -218,7 +218,8 @@ function getResourceViaARMAPI { Param( [Parameter(Mandatory = $true)][string]$resourceId, [Parameter(Mandatory = $true)][string]$apiVersion, - [Parameter(Mandatory = $false)][string]$token + [Parameter(Mandatory = $false)][string]$token, + [parameter(Mandatory = $false)][int]$maxRetry = 3 ) $uri = "https://management.azure.com{0}?api-version={1}" -f $resourceId, $apiVersion Write-Verbose "[$(getCurrentUTCString)]: Trying getting resource via the Resource provider API endpoint '$uri'" -Verbose @@ -228,14 +229,18 @@ function getResourceViaARMAPI { $headers = @{ 'Authorization' = "Bearer $token" } - try { - $request = Invoke-WebRequest -Uri $uri -Method "GET" -Headers $headers - if ($request.StatusCode -ge 200 -and $request.StatusCode -lt 300) { - $resourceExists = $true + $retryCount = 0 + do { + try { + $request = Invoke-WebRequest -Uri $uri -Method "GET" -Headers $headers + if ($request.StatusCode -ge 200 -and $request.StatusCode -lt 300) { + $resourceExists = $true + } + } catch { + $resourceExists = $false } - } catch { - $resourceExists = $false - } + $retryCount++ + } while (-not $resourceExists -and $retryCount -lt $maxRetry) if ($resourceExists) { $resource = ($request.Content | ConvertFrom-Json -Depth 99) } @@ -250,7 +255,8 @@ function newResourceGroupViaARMAPI { [Parameter(Mandatory = $true)][string]$location, [Parameter(Mandatory = $false)][hashtable]$tags, [Parameter(Mandatory = $false)][string]$token, - [Parameter(Mandatory = $false)][string]$apiVersion = '2021-04-01' + [Parameter(Mandatory = $false)][string]$apiVersion = '2021-04-01', + [parameter(Mandatory = $false)][int]$maxRetry = 3 ) $uri = "https://management.azure.com/subscriptions/{0}/resourceGroups/{1}?api-version={2}" -f $subscriptionId, $resourceGroupName, $apiVersion Write-Verbose "[$(getCurrentUTCString)]: Trying creating resource group via the Resource provider API endpoint '$uri'" -Verbose @@ -268,15 +274,19 @@ function newResourceGroupViaARMAPI { $body.tags = $tags } $body = $body | ConvertTo-Json -Depth 10 - try { - $request = Invoke-WebRequest -Uri $uri -Method "PUT" -Headers $headers -Body $body - if ($request.StatusCode -ge 200 -and $request.StatusCode -lt 300) { - $resourceGroupCreated = $true + $retryCount = 0 + do { + try { + $request = Invoke-WebRequest -Uri $uri -Method "PUT" -Headers $headers -Body $body + if ($request.StatusCode -ge 200 -and $request.StatusCode -lt 300) { + $resourceGroupCreated = $true + } + } catch { + Write-Error $_.Exception.Message + $resourceGroupCreated = $false } - } catch { - Write-Error $_.Exception.Message - $resourceGroupCreated = $false - } + $retryCount++ + } while (-not $resourceGroupCreated -and $retryCount -lt $maxRetry) if ($resourceGroupCreated) { Write-Verbose "Resource group '$resourceGroupName' created successfully." -Verbose $resourceGroupId = ($request.Content | ConvertFrom-Json -Depth 99).id diff --git a/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 index 3567883..3fa6911 100644 --- a/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 +++ b/scripts/pipelines/policy-integration-tests/pipeline-deploy-policy-test-bicep-template.ps1 @@ -88,14 +88,14 @@ If ($deploymentResultFilePath -ne '') { #create the test resource group if it's specified in the local config file and doesn't exist if ($testResourceGroupName) { $testResourceGroupResourceId = '/subscriptions/{0}/resourceGroups/{1}' -f $testSubId, $testResourceGroupName - $existingTestResourceGroup = getResourceViaARMAPI -ResourceId $testResourceGroupResourceId -apiVersion $resourceGroupApiVersion + $existingTestResourceGroup = getResourceViaARMAPI -ResourceId $testResourceGroupResourceId -apiVersion $resourceGroupApiVersion -maxRetry $maxRetry if (!($existingTestResourceGroup)) { if ($tagsForResourceGroup) { Write-Output "[$(getCurrentUTCString)]: Resource group '$testResourceGroupName' doesn't exist. Creating the resource group '$testResourceGroupName' with predefined tags..." - $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion-Tag $tags + $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion-Tag $tags -maxRetry $maxRetry } else { Write-Output "[$(getCurrentUTCString)]: Resource group '$testResourceGroupName' doesn't exist. Creating the resource group '$testResourceGroupName' without any tags..." - $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion + $testResourceGroup = newResourceGroupViaARMAPI -subscriptionId $testSubId -resourceGroupName $testResourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion -maxRetry $maxRetry } } else { @@ -145,14 +145,14 @@ if (Test-path $BicepFilePath -PathType Leaf) { $subscriptionId = $globalTestConfig.subscriptions.$subscriptionName.id $rgResourceId = '/subscriptions/{0}/resourceGroups/{1}' -f $subscriptionId, $resourceGroupName Write-Verbose "Checking if the resource group '$rgResourceId' exists..." -verbose - $existingRg = getResourceViaARMAPI -ResourceId $rgResourceId -apiVersion $resourceGroupApiVersion + $existingRg = getResourceViaARMAPI -ResourceId $rgResourceId -apiVersion $resourceGroupApiVersion -maxRetry $maxRetry if ($existingRg) { Write-Verbose "[$(getCurrentUTCString)]: Resource group '$rgResourceId' already exists. Skipping creation." -verbose $additionalResourceGroups += $existingRg.id } else { Write-Output "[$(getCurrentUTCString)]: Resource group '$rgResourceId' doesn't exist. Creating in location '$testLocation'..." #create the resource group using ARM REST API directly so we don't have to change the subscription in the Az context - $additionalResourceGroups += newResourceGroupViaARMAPI -subscriptionId $subscriptionId -resourceGroupName $resourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion + $additionalResourceGroups += newResourceGroupViaARMAPI -subscriptionId $subscriptionId -resourceGroupName $resourceGroupName -location $testLocation -apiVersion $resourceGroupApiVersion -maxRetry $maxRetry } } Write-Verbose "[$(getCurrentUTCString)]: Additional resource groups:" -verbose