The first post explained what Enterprise Policy as Code (EPAC) is. Running it by hand from a laptop gives you version control but not much else. The useful part is the pipeline around it.

This post builds a set of Azure DevOps pipelines that cover:

  • Pull request validation, so mistakes are caught before merge
  • Promotion from DEV to Non-Prod to Production
  • Manual approval gates for Non-Prod and Production
  • Scheduled remediation of non-compliant resources

Pipeline architecture

The setup uses several pipelines:

┌─────────────────────────────────────────────────────────────────┐
│ Git Repository │
│ (Policy Definitions, Assignments, Exemptions) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PR Validation Pipeline │
│ • Syntax validation │
│ • Build deployment plan │
│ • Post plan as PR comment │
└─────────────────────────────────────────────────────────────────┘
│
▼ (merge)
┌─────────────────────────────────────────────────────────────────┐
│ DEV Deployment Pipeline │
│ • Deploy to DEV environment │
│ • Run compliance tests │
│ • Automatic on merge to main │
└─────────────────────────────────────────────────────────────────┘
│
▼ (manual trigger)
┌─────────────────────────────────────────────────────────────────┐
│ Non-Prod Deployment Pipeline │
│ • Deploy to staging/test management groups │
│ • Extended validation │
│ • Manual approval gate │
└─────────────────────────────────────────────────────────────────┘
│
▼ (manual approval)
┌─────────────────────────────────────────────────────────────────┐
│ PROD Deployment Pipeline │
│ • Deploy to production management groups │
│ • Create remediation tasks │
│ • Generate compliance documentation │
└─────────────────────────────────────────────────────────────────┘

Service principal configuration

Required permissions

Permission Scope Purpose
Reader Tenant Root Group Discover existing policies
Resource Policy Contributor Target Management Groups Create/modify policies
Role Based Access Control Administrator Target Management Groups Create role assignments for remediation

Creating the service principals

Use one service principal per environment, so a mistake in DEV credentials can’t touch production:

Terminal window
# Create App Registration for EPAC Production
$prodApp = New-AzADApplication -DisplayName "epac-prod-deployer"
$prodSp = New-AzADServicePrincipal -ApplicationId $prodApp.AppId
# Create App Registration for EPAC Non-Production
$nonprodApp = New-AzADApplication -DisplayName "epac-nonprod-deployer"
$nonprodSp = New-AzADServicePrincipal -ApplicationId $nonprodApp.AppId
# Create App Registration for EPAC Development
$devApp = New-AzADApplication -DisplayName "epac-dev-deployer"
$devSp = New-AzADServicePrincipal -ApplicationId $devApp.AppId

Assigning permissions

Terminal window
# Example: Assign permissions for production deployer
$prodMgId = "/providers/Microsoft.Management/managementGroups/production"
# Reader at tenant root for policy discovery
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "Reader" `
-Scope "/providers/Microsoft.Management/managementGroups/tenant-root"
# Resource Policy Contributor for policy management
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "Resource Policy Contributor" `
-Scope $prodMgId
# Role Based Access Control Administrator for remediation role assignments
# Preferred over User Access Administrator - supports ABAC conditions to restrict assignable roles
New-AzRoleAssignment `
-ObjectId $prodSp.Id `
-RoleDefinitionName "Role Based Access Control Administrator" `
-Scope $prodMgId

Azure DevOps pipeline: PR validation

This pipeline runs on every pull request, validates the definitions and builds a plan of what would change. The plan summary is posted to the PR through the Azure DevOps REST API (I used the same API for the Recycle Bin cleanup script).

azure-pipelines-pr.yml

trigger: none
pr:
branches:
include:
- main
paths:
include:
- Definitions/**
- Scripts/**
pool:
vmImage: 'ubuntu-latest'
variables:
- group: epac-dev-credentials
stages:
- stage: Validate
displayName: 'Validate Policy Changes'
jobs:
- job: BuildPlan
displayName: 'Build Deployment Plan'
steps:
- task: PowerShell@2
displayName: 'Install EPAC Module'
inputs:
targetType: 'inline'
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name EnterprisePolicyAsCode -Force -AllowClobber
pwsh: true
- task: AzurePowerShell@5
displayName: 'Build Plan for DEV'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: PowerShell@2
displayName: 'Generate Plan Summary'
inputs:
targetType: 'inline'
script: |
$planPath = "$(Build.ArtifactStagingDirectory)/plans"
$summary = Get-Content "$planPath/policy-plan.json" | ConvertFrom-Json
$markdown = @"
## EPAC Deployment Plan Summary
| Action | Count |
|--------|-------|
| New Definitions | $($summary.policyDefinitions.new.Count) |
| Updated Definitions | $($summary.policyDefinitions.update.Count) |
| Deleted Definitions | $($summary.policyDefinitions.delete.Count) |
| New Assignments | $($summary.policyAssignments.new.Count) |
| Updated Assignments | $($summary.policyAssignments.update.Count) |
| Deleted Assignments | $($summary.policyAssignments.delete.Count) |
Review the full plan in the pipeline artifacts.
"@
$markdown | Out-File "$(Build.ArtifactStagingDirectory)/plan-summary.md"
pwsh: true
- task: PublishBuildArtifacts@1
displayName: 'Publish Plan Artifacts'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/plans'
ArtifactName: 'deployment-plans'
# NOTE: GitHubComment@0 is a GitHub Actions task and does not exist
# in Azure DevOps. The $(cat ...) shell expansion also does not work
# in AzDO YAML fields. Instead, read the file into a variable first,
# then post a comment via the Azure DevOps REST API.
- task: PowerShell@2
displayName: 'Set Plan Summary Variable'
condition: eq(variables['Build.Reason'], 'PullRequest')
inputs:
targetType: 'inline'
script: |
$summary = Get-Content "$(Build.ArtifactStagingDirectory)/plan-summary.md" -Raw
Write-Host "##vso[task.setvariable variable=PlanSummary]$summary"
pwsh: true
- task: PowerShell@2
displayName: 'Post Plan Comment to PR'
condition: eq(variables['Build.Reason'], 'PullRequest')
inputs:
targetType: 'inline'
script: |
$commentBody = @{ comments = @(@{ parentCommentId = 0; content = "$(PlanSummary)"; commentType = 1 }); status = 1 } | ConvertTo-Json -Depth 5
$prId = "$(System.PullRequest.PullRequestId)"
$org = "$(System.CollectionUri)"
$project = "$(System.TeamProject)"
$repo = "$(Build.Repository.Name)"
$url = "${org}${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/threads?api-version=7.1"
Invoke-RestMethod -Uri $url -Method Post -Body $commentBody -ContentType "application/json" -Headers @{ Authorization = "Bearer $(System.AccessToken)" }
pwsh: true
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)

Azure DevOps pipeline: environment deployment

DEV deploys automatically on merge to main. Non-Prod and Production wait for approval.

azure-pipelines-deploy.yml

trigger:
branches:
include:
- main
paths:
include:
- Definitions/**
pr: none
pool:
vmImage: 'ubuntu-latest'
stages:
# ============================================
# DEV DEPLOYMENT - Automatic
# ============================================
- stage: DeployDev
displayName: 'Deploy to DEV'
variables:
- group: epac-dev-credentials
jobs:
- job: DeployPolicies
displayName: 'Deploy Policies to DEV'
steps:
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build DEV Plan'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy DEV Policies'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "dev" `
-InputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy DEV Role Assignments'
inputs:
azureSubscription: 'epac-dev-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-RolesPlan `
-PacEnvironmentSelector "dev" `
-InputFolder "$(Build.ArtifactStagingDirectory)/dev-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
# ============================================
# NON-PROD DEPLOYMENT
# The manual gate comes from the 'environment' setting below.
# You must configure approval checks on the 'epac-nonprod' environment
# in Azure DevOps (Pipelines > Environments) for the gate to work.
# ============================================
- stage: DeployNonProd
displayName: 'Deploy to Non-Prod'
dependsOn: DeployDev
condition: succeeded()
variables:
- group: epac-nonprod-credentials
jobs:
- deployment: DeployPolicies
displayName: 'Deploy Policies to Non-Prod'
environment: 'epac-nonprod' # Requires approval
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build Non-Prod Plan'
inputs:
azureSubscription: 'epac-nonprod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "nonprod" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/nonprod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Non-Prod Policies'
inputs:
azureSubscription: 'epac-nonprod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "nonprod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/nonprod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
# ============================================
# PRODUCTION DEPLOYMENT - Requires Approval
# ============================================
- stage: DeployProd
displayName: 'Deploy to Production'
dependsOn: DeployNonProd
condition: succeeded()
variables:
- group: epac-prod-credentials
jobs:
- deployment: DeployPolicies
displayName: 'Deploy Policies to Production'
environment: 'epac-production' # Requires approval
strategy:
runOnce:
deploy:
steps:
- checkout: self
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Build Production Plan'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-DeploymentPlans `
-PacEnvironmentSelector "prod" `
-OutputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Production Policies'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-PolicyPlan `
-PacEnvironmentSelector "prod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Deploy Production Role Assignments'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Deploy-RolesPlan `
-PacEnvironmentSelector "prod" `
-InputFolder "$(Build.ArtifactStagingDirectory)/prod-plans"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: AzurePowerShell@5
displayName: 'Generate Compliance Documentation'
inputs:
azureSubscription: 'epac-prod-connection'
ScriptType: 'InlineScript'
Inline: |
Build-PolicyDocumentation `
-OutputFolder "$(Build.ArtifactStagingDirectory)/docs"
azurePowerShellVersion: 'LatestVersion'
pwsh: true
- task: PublishBuildArtifacts@1
displayName: 'Publish Documentation'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/docs'
ArtifactName: 'compliance-docs'

templates/install-epac.yml

steps:
- task: PowerShell@2
displayName: 'Install EPAC Module'
inputs:
targetType: 'inline'
script: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
# Pin the version to avoid breaking changes between EPAC major versions
Install-Module -Name EnterprisePolicyAsCode -Force -AllowClobber -RequiredVersion "10.4.2"
# Verify installation
$module = Get-Module -Name EnterprisePolicyAsCode -ListAvailable
Write-Host "Installed EPAC version: $($module.Version)"
pwsh: true

Environment configuration

EPAC defines its environments in a configuration file at the root of the repository. The management group scopes should match your cloud foundation hierarchy.

global-settings.jsonc

{
"$schema": "https://raw.githubusercontent.com/Azure/enterprise-azure-policy-as-code/main/Schemas/global-settings-schema.json",
"pacOwnerId": "your-unique-guid-here",
"pacEnvironments": [
{
"pacSelector": "dev",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/dev",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
},
{
"pacSelector": "nonprod",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/nonprod",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
},
{
"pacSelector": "prod",
"cloud": "AzureCloud",
"tenantId": "your-tenant-id",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/production",
"desiredState": {
"strategy": "full",
"keepDfcSecurityAssignments": true
}
}
]
}

Remediation pipeline

Policies with deployIfNotExists or modify effects only act on new or updated resources. Existing non-compliant resources need remediation tasks. I keep this in its own pipeline because remediation changes resources, while policy deployment only changes rules.

azure-pipelines-remediation.yml

trigger: none # Manual or scheduled only
schedules:
- cron: "0 6 * * *" # Run daily at 6 AM UTC
displayName: 'Daily Remediation'
branches:
include:
- main
pool:
vmImage: 'ubuntu-latest'
parameters:
- name: environment
displayName: 'Environment to Remediate'
type: string
default: 'prod'
values:
- dev
- nonprod
- prod
variables:
- group: epac-${{ parameters.environment }}-credentials
stages:
- stage: Remediate
displayName: 'Create Remediation Tasks'
jobs:
- job: RemediateNonCompliant
displayName: 'Remediate Non-Compliant Resources'
steps:
- template: templates/install-epac.yml
- task: AzurePowerShell@5
displayName: 'Create Remediation Tasks'
inputs:
azureSubscription: 'epac-${{ parameters.environment }}-connection'
ScriptType: 'InlineScript'
Inline: |
$scope = $env:DEPLOYMENT_SCOPE
# Get non-compliant states, filter to effects that support remediation
$nonCompliantStates = Get-AzPolicyState `
-ManagementGroupName ($scope -split '/')[-1] `
-Filter "ComplianceState eq 'NonCompliant' and (PolicyDefinitionAction eq 'deployifnotexists' or PolicyDefinitionAction eq 'modify')" `
-Top 1000
# Group by assignment and create one remediation task per assignment
$grouped = $nonCompliantStates | Group-Object PolicyAssignmentId
foreach ($group in $grouped) {
Write-Host "Creating remediation for: $($group.Name) ($($group.Count) resources)"
Start-AzPolicyRemediation `
-Name "remediation-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$($group.Group[0].PolicyDefinitionName)" `
-ManagementGroupName ($scope -split '/')[-1] `
-PolicyAssignmentId $group.Name `
-ResourceDiscoveryMode ReEvaluateCompliance
Start-Sleep -Seconds 10 # Avoid throttling
}
azurePowerShellVersion: 'LatestVersion'
pwsh: true
env:
DEPLOYMENT_SCOPE: '/providers/Microsoft.Management/managementGroups/${{ parameters.environment }}'

GitHub Flow for policy changes

1. Create feature branch

Terminal window
git checkout -b feature/add-storage-encryption-policy

2. Make changes

Add or modify policy definitions, assignments, or exemptions in the Definitions/ folder.

3. Test locally (optional)

Terminal window
# Build plan without deploying
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "./local-test"
# Review the plan
Get-Content "./local-test/policy-plan.json" | ConvertFrom-Json | Format-List

4. Create pull request

Terminal window
git add .
git commit -m "Add storage encryption policy requirement"
git push origin feature/add-storage-encryption-policy

5. Review PR validation

The validation pipeline runs and posts the plan summary as a PR comment, so reviewers see what will change.

6. Merge and deploy

The merge triggers the DEV deployment. Non-Prod is triggered manually, and Production needs an approval.

Emergencies

Sometimes a policy change can’t wait for the normal promotion. For that I use a separate hotfix pipeline with its own environment, so the approvals and the justification are still logged.

azure-pipelines-hotfix.yml
trigger: none
parameters:
- name: environment
type: string
values: ['nonprod', 'prod']
- name: justification
type: string
stages:
- stage: EmergencyDeploy
displayName: 'Emergency Policy Deployment'
jobs:
- deployment: HotfixDeploy
environment: 'epac-${{ parameters.environment }}-emergency'
strategy:
runOnce:
deploy:
steps:
- script: |
echo "Emergency deployment requested"
echo "Environment: ${{ parameters.environment }}"
echo "Justification: ${{ parameters.justification }}"
displayName: 'Log Emergency Request'
# ... deployment steps

After an emergency deployment, open a PR with the same changes, document the incident, and let the change go through the normal promotion so the repository and all environments match again.

Next

Part 3 covers exemptions with expiry dates, assignment strategies, initiative design and troubleshooting. The governance framework post puts it into a wider Azure governance strategy.


Sources

  1. Microsoft, “Enterprise Policy as Code,” Cloud Adoption Framework, https://learn.microsoft.com/azure/cloud-adoption-framework/ready/policy-management/enterprise-policy-as-code

  2. Microsoft, “Azure DevOps Pipelines,” Azure DevOps Documentation, https://learn.microsoft.com/azure/devops/pipelines/

  3. Microsoft, “Resource Policy Contributor Role,” Azure RBAC Documentation, https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#resource-policy-contributor

  4. Microsoft, “Azure Policy Remediation,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources

  5. Azure, “EPAC Pipeline Examples,” GitHub, https://github.com/Azure/enterprise-azure-policy-as-code/tree/main/StarterKit/Pipelines