CloudGuru

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.

See the framework →

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.

Example repository layout
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.yml

Effects

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.

EffectWhat it does
Audit / AuditIfNotExistsReports non-compliance and changes nothing. The safe first step for any new rule.
DenyBlocks a create or update request that breaks the rule.
DenyActionBlocks specific actions, currently delete, on matching resources.
Modify / AppendAdds or changes properties such as tags during the request. Modify needs a managed identity for remediation.
DeployIfNotExistsDeploys a related resource, such as diagnostic settings, when it is missing. Needs a managed identity and role assignment.
DisabledTurns the rule off. Useful as a parameter value so one definition can be switched per assignment.

Rollout

A pipeline that cannot surprise production

  1. 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.

  2. 02

    Deploy to a test scope

    Deploy definitions and assign them to a non-production management group with enforcementMode set to DoNotEnforce.

  3. 03

    Check compliance

    Trigger a compliance scan and review the results. Fix the rule or add exemptions before anything is enforced.

  4. 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.

Azure CLI · compliance check
# 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 tsv

Worked 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.

definitions/tagging/require-rg-tag.json
{
  "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')]" }
    }
  }
}
assignments/nonprod/baseline-tagging.bicep
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.