Five posts in, the model is complete: definitions, initiatives, assignments, exclusions, exemptions. All five are JSON documents with dependencies on each other, and every one of them can break a production deployment.

Now scale it. Four management group layers, sixty subscriptions, three regulatory frameworks, two clouds, a security team that owns half the controls and a platform team that owns the other half, and an auditor who wants to know why control 4.2.1 was disabled between March and June.

The portal cannot do that. Not "it is inconvenient" - it structurally cannot, because it has no diff, no review, no history, no rollback, and no way to answer "what changed and who approved it".

What "policy as code" actually means

It is not "we wrote the JSON in VS Code". Everyone writes the JSON somewhere.

Policy as code means the repository is the source of truth, and Azure is a projection of it. Concretely:

That last point is the one that turns this from a nice-to-have into a control. Microsoft's own guidance says it directly: use a centralised deployment mechanism such as GitHub workflows or Azure Pipelines, and restrict write permissions on policy resources to the deployment identity.

The problems it solves

Drift

Someone adds a subscription to notScopes at 11 p.m. to unblock a release. It works. Nobody removes it. There is no record beyond an activity log entry that will age out, and the compliance dashboard now quietly excludes a subscription forever.

With a pipeline that reconciles desired state, that change either fails to persist or shows up as a diff on the next run. Drift becomes visible instead of cumulative.

Review

A policy change is a production change. Audit to Deny on a broad management group has the same blast radius as a firewall rule change and receives, in most organisations, roughly none of the scrutiny.

A pull request fixes this for free. The diff shows "effect": "Audit" becoming "effect": "Deny", a human approves it, and the approval is attached to the change permanently. My conventions for that are in GitHub Contribution Workflow and GitHub Branch Naming Convention.

Evidence

"Prove that encryption-at-rest was enforced on all production subscriptions for the whole of FY26."

From the portal: you cannot. Assignments show current state. From a repository: git log, and every change carries an author, a timestamp, a reviewer, and a linked ticket. Auditors like this considerably more than a screenshot.

Multi-environment consistency

The same baseline, Audit in the sandbox tenant and Deny in production, differing only by a parameter value. Building that twice by hand guarantees the two drift apart.

Recovery

A deleted management group takes its assignments with it. A deleted resource group takes its exemptions with it. If the repository is the source of truth, recovery is a pipeline run. If it is not, recovery is archaeology.

Microsoft's workflow: create, test, deploy

Microsoft's guidance page, Design Azure Policy as Code workflows, is short and worth reading. The recommended loop:

  1. Get everything into source control. Export what already exists before writing anything new.
  2. Create or update definitions as JSON in the repository.
  3. Create or update initiatives - after their member definitions exist.
  4. Test and validate by assigning in the environment furthest from production, with enforcementMode disabled, scoped to a dedicated validation resource group or subscription.
  5. Enable remediation tasks - grant the managed identity its roleDefinitionIds, trigger remediation, then verify three things: the task completed, compliance updated, and the resource properties actually changed.
  6. Promote to enforced assignments, ring by ring.

Microsoft's own diagram of that loop, which is worth pinning somewhere your team will see it:

Create, test and deploy workflow for Azure Policy as Code
Create definitions, create the initiative, assign with enforcement disabled, check compliance, grant the managed identity permissions and remediate, then enable enforcement

Source: Design Azure Policy as Code workflows - Β© Microsoft, Microsoft Learn.

Step 4 has a caveat Microsoft states explicitly and people ignore: enforcementMode is not a substitute for real testing. A definition should be tested with both PUT and PATCH requests, against compliant and non-compliant resources, and against edge cases like a property being absent entirely. A rule that assumes a property exists behaves very differently on a PATCH that does not include it.

The four hard problems

Any tool you choose - or build - has to solve these. This is the checklist to judge Part 7's options against.

1. Ordering

Policy objects have hard dependencies:

diagram
  policyDefinitions
         β”‚
         β–Ό
  policySetDefinitions   (reference definitions by resource ID)
         β”‚
         β–Ό
  policyAssignments      (reference a definition or a set)
         β”‚
         β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ί role assignments for the managed identity
         β”‚
         β–Ό
  policyExemptions       (reference an assignment)

Deploy them out of order and you get resolution failures. Delete them out of order and you get orphans: an initiative referencing a definition that no longer exists, or an exemption pointing at a deleted assignment.

Deletion order is the reverse of creation order, which naive tooling almost never gets right.

2. Scope spread

A single logical change touches multiple scopes: definitions at a management group, assignments at several child management groups, exemptions at resource groups, role assignments wherever the identity needs rights.

ARM deployments are scoped. A tool has to orchestrate deployments at management group, subscription, and resource group scope in one coherent operation - and know which scope each object belongs at.

3. The managed identity permission gap

Covered in Part 3, and it is the number one reason policy-as-code projects stall.

When you create a deployIfNotExists or modify assignment anywhere other than the portal, the role assignments its managed identity needs are not created for you. Your pipeline must create them, which means the pipeline identity needs rights to assign RBAC - a materially more privileged thing than deploying policy.

Good tooling separates these into two stages with two identities: one that can deploy policy, and one that can assign roles, with an approval gate between them. That is not paranoia; it is the difference between a compromised policy pipeline being annoying and being catastrophic.

4. Desired state and deletion

Creating and updating is easy. Deleting is where the design decisions are.

If a definition is removed from the repository, should the pipeline delete it from Azure? Say yes and you have true desired state - plus the ability to delete something a different team owns. Say no and Azure slowly accumulates objects nobody remembers creating.

Neither answer is universally right, which is why serious tools make it a configurable strategy and add an ownership marker so that "not in my repository" can be distinguished from "not in any repository". Part 7 shows how EPAC does this with pacOwnerId and a full versus ownedOnly strategy.

Getting what you already have out of Azure

Before the pipeline, the export. Most estates already have dozens of assignments, some created by Microsoft Defender for Cloud automatically.

Warning

The portal feature that exported policy definitions to GitHub was deprecated in April 2023. If you are following an older blog post that tells you to click Export definitions, stop - it is gone.

What works today:

powershell
# Custom definitions in a subscription
Get-AzPolicyDefinition -SubscriptionId $subId |
  Where-Object { $_.Properties.policyType -eq 'Custom' } |
  ForEach-Object { $_ | ConvertTo-Json -Depth 100 |
    Out-File "./policyDefinitions/$($_.Name).json" }

# Assignments at a management group and below
Get-AzPolicyAssignment -Scope "/providers/Microsoft.Management/managementGroups/mg-corp" |
  ConvertTo-Json -Depth 100 | Out-File "./assignments.json"
bash
az policy definition list --query "[?policyType=='Custom']" -o json
az policy set-definition list --query "[?policyType=='Custom']" -o json
az policy assignment list --scope "/providers/Microsoft.Management/managementGroups/mg-corp" -o json

For a whole-estate inventory, Azure Resource Graph is faster than iterating scopes:

kusto
policyresources
| where type in~ (
    "microsoft.authorization/policydefinitions",
    "microsoft.authorization/policysetdefinitions",
    "microsoft.authorization/policyassignments",
    "microsoft.authorization/policyexemptions")
| project type, name, id,
          displayName = tostring(properties.displayName),
          policyType  = tostring(properties.policyType)
| order by type asc, displayName asc

Raw exports are not repository-ready. They carry read-only fields - id, type, createdBy, createdOn, system-assigned principalId values - that must be stripped before the file can be redeployed. This tedium is exactly what EPAC's Export-AzPolicyResources automates, and it is a large part of why people adopt it rather than roll their own.

What goes in the repository

Microsoft suggests a versioned file convention:

FileContents
policy-v#.jsonThe full policy definition for that version
policyset-v#.jsonThe full initiative definition for that version
policy-v#.parameters.jsonJust properties.parameters
policy-v#.rules.jsonJust properties.policyRule
exemptionName.jsonAn exemption

That structure is fine and almost nobody uses it verbatim, because the tools have their own layouts. What matters is the principle underneath: one object per file, versioned, in a predictable place.

The layout I would actually recommend for a hand-rolled repository:

diagram
  policy/
  β”œβ”€β”€ definitions/
  β”‚   β”œβ”€β”€ storage/
  β”‚   └── network/
  β”œβ”€β”€ initiatives/
  β”œβ”€β”€ assignments/
  β”‚   β”œβ”€β”€ mg-corp-prod/
  β”‚   └── mg-corp-nonprod/
  β”œβ”€β”€ exemptions/
  β”‚   └── <subscription-name>/
  └── environments/
      β”œβ”€β”€ dev.parameters.json
      └── prod.parameters.json

Assignments grouped by the scope they target, exemptions grouped by who owns them, and environment differences isolated in parameter files rather than duplicated assignment files.

Which deployment technology?

Four realistic options for the deployment layer itself.

ApproachStrengthsWeaknesses
Native JSON + PowerShell/CLIFiles are exactly what the API expects; no translationYou write all the orchestration, ordering, and deletion logic
Bicep / ARMFirst-class resource types, what-if, no state filePolicy JSON must be embedded or loaded; multi-scope work is awkward
TerraformMature azurerm policy resources, real plan output, stateThe state file becomes a governance dependency; policy JSON lives inside HCL
Purpose-built tool (EPAC)Solves all four hard problems out of the boxAnother framework to learn and keep current

Bicep and ARM use Microsoft.Authorization/policyDefinitions, policySetDefinitions, policyAssignments, and policyExemptions - the same shapes as the raw JSON, which keeps portal exports usable.

Terraform's azurerm provider covers the ground properly: azurerm_policy_definition, azurerm_policy_set_definition, per-scope assignment resources (azurerm_management_group_policy_assignment, azurerm_subscription_policy_assignment, azurerm_resource_group_policy_assignment, azurerm_resource_policy_assignment), matching exemption and remediation resources, and azurerm_management_group_policy_set_definition. Note there is no azurerm_policy_definition_version resource, and the newer preview objects such as policy enrollments need the AzAPI provider instead.

If you are weighing the two IaC languages generally, I compared them here: Terraform vs Bicep: The Match.

Warning

Do not build on the Azure/manage-azure-policy GitHub Action. It is archived. It appears in a lot of blog posts and it is no longer maintained.

Pipeline shape

Whatever the technology, the pipeline should look like this:

diagram
  PR opened
     β”‚
     β”œβ”€β–Ί lint / schema validation
     β”œβ”€β–Ί build a PLAN, post it as a PR comment
     └─► human review + approval
              β”‚
         merge to main
              β”‚
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ Stage 1: PLAN         β”‚  identity: Reader
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  (approval gate)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ Stage 2: DEPLOY POLICYβ”‚  identity: Resource Policy Contributor
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚  (approval gate)
     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
     β”‚ Stage 3: DEPLOY ROLES β”‚  identity: RBAC Administrator
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Three properties make this work:

The plan is an artefact, not a log line. The thing reviewed and the thing deployed must be the same object, or the approval means nothing.

The stages have different identities. Reading policy, writing policy, and assigning RBAC are three different privilege levels and should not share a credential.

No secrets. Use workload identity federation - OIDC - so the pipeline holds no long-lived credential at all. I wrote that setup up here: Connect GitHub and Azure for Deployment Using OIDC.

Add a scheduled run on top - nightly or weekly - so drift is detected even when nobody has opened a pull request.

Testing policy for real

Policy is code, and untested code that can return 403 to your entire production estate is a bad idea.

Where next

You now have the shape of a policy-as-code practice: repository as source of truth, ordering respected, scopes orchestrated, identities separated, desired state reconciled, drift detected on a schedule.

Building all of that yourself is a project measured in months, and it is a project several thousand organisations have already funded. Part 7 looks at the tooling: EPAC in detail - folder structure, pacEnvironments, desired state strategies, the three-stage pipeline, ALZ integration - and the honest cases for choosing Azure Landing Zones, Terraform, or something simpler instead.

Enjoy!