Azure governance · Policy as Code
Treat guardrails like any other code.
Policies clicked together in the portal drift, nobody remembers why an exemption exists, and a single Deny can stop a release. Keeping policy in Git gives every rule a review, a history and a tested path to production.
Open-source framework
AZPaC: Azure Policy as Code with Terraform
My framework puts these ideas into practice: one audit/enforce switch per initiative, exemptions that must expire, and pipelines for plan, drift and remediation.
The building blocks
Four objects to keep in source control
Definition
One rule: an "if" condition on resource properties and a "then" effect. Store it as JSON with its parameters.
Initiative
A policy set definition that groups definitions, such as a baseline for storage or tagging, so they are assigned and reported together.
Assignment
Applies a definition or initiative to a scope (management group, subscription or resource group) with parameter values and an enforcement mode.
Exemption
Excludes a scope from an assignment, as a Waiver or Mitigated, ideally with an expiry date and a ticket reference.
policy/
definitions/
tagging/require-rg-tag.json
storage/deny-public-network-access.json
initiatives/
baseline-tagging.json
assignments/
nonprod/baseline-tagging.bicep
prod/baseline-tagging.bicep
exemptions/
prod/legacy-app-tagging.json
pipelines/
policy.ymlEffects
Start in Audit, earn your way to Deny
Make the effect a parameter. The same definition can then audit in one scope and deny in another, and nobody has to edit the rule to change its strength.
| Effect | What it does |
|---|---|
| Audit / AuditIfNotExists | Reports non-compliance and changes nothing. The safe first step for any new rule. |
| Deny | Blocks a create or update request that breaks the rule. |
| DenyAction | Blocks specific actions, currently delete, on matching resources. |
| Modify / Append | Adds or changes properties such as tags during the request. Modify needs a managed identity for remediation. |
| DeployIfNotExists | Deploys a related resource, such as diagnostic settings, when it is missing. Needs a managed identity and role assignment. |
| Disabled | Turns the rule off. Useful as a parameter value so one definition can be switched per assignment. |
Rollout
A pipeline that cannot surprise production
- 01
Pull request
Lint the JSON, check parameter names and run a what-if or plan. A reviewer sees exactly which definitions and assignments change.
- 02
Deploy to a test scope
Deploy definitions and assign them to a non-production management group with enforcementMode set to DoNotEnforce.
- 03
Check compliance
Trigger a compliance scan and review the results. Fix the rule or add exemptions before anything is enforced.
- 04
Promote and enforce
Promote the same files to production scopes, then switch enforcementMode to Default once the audit results are understood.
The pipeline identity needs Resource Policy Contributor at the target management group to write definitions and assignments. DeployIfNotExists and Modify assignments also need a managed identity, and someone has to grant that identity its own role.
# Re-evaluate compliance now instead of waiting for the next cycle
az policy state trigger-scan --resource-group rg-policy-test
# Summarise non-compliant resources for one assignment
az policy state list --filter "policyAssignmentName eq 'require-rg-owner-tag' and complianceState eq 'NonCompliant'" \
--query "[].resourceId" -o tsvWorked example
Require an owner tag on resource groups
The definition takes the tag name and effect as parameters. The assignment sets them for one management group and starts in DoNotEnforce, so the compliance report shows what would have been blocked before anything is blocked.
{
"properties": {
"displayName": "Require a tag on resource groups",
"mode": "All",
"parameters": {
"tagName": {
"type": "String",
"metadata": { "displayName": "Tag name" }
},
"effect": {
"type": "String",
"allowedValues": ["Audit", "Deny", "Disabled"],
"defaultValue": "Audit"
}
},
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Resources/subscriptions/resourceGroups" },
{ "field": "[concat('tags[', parameters('tagName'), ']')]", "exists": "false" }
]
},
"then": { "effect": "[parameters('effect')]" }
}
}
}targetScope = 'managementGroup'
param policyDefinitionId string
resource requireOwnerTag 'Microsoft.Authorization/policyAssignments@2023-04-01' = {
name: 'require-rg-owner-tag'
properties: {
displayName: 'Require owner tag on resource groups'
policyDefinitionId: policyDefinitionId
enforcementMode: 'DoNotEnforce' // switch to 'Default' after review
parameters: {
tagName: { value: 'owner' }
effect: { value: 'Deny' }
}
}
}Practical notes
What keeps it maintainable
- Define high, assign deliberately. Store definitions at the top management group so every scope can use them. Assign at the lowest scope that covers the intent.
- Prefer built-ins. Wrap built-in definitions in your own initiatives. Write a custom rule only when no built-in fits.
- Exemptions are code too. Each exemption gets a category, a reason, an owner and an expiry. Review them like any other change.
- Make Git the only writer. Remove portal write access for policy, or have the pipeline flag drift, so the repository stays the source of truth.
- Pick one tool. Bicep, Terraform or EPAC all work. Mixing them for the same scope leads to fights over ownership.
