By the end of this your policy definitions live in a repository, a pull request is the only way they change, and the workflow that deploys them refuses to enable enforcement until a compliance scan has returned the result you predicted.
How this was verified: every command, setting and value below is taken from current Microsoft documentation, checked against the product’s own command reference in August 2026, and shaped by estates I have built. The sequence has not been executed end to end as a single unbroken lab run for this article. Where a step is more likely than others to differ at a console, it says so in place.
Before you start
You need a GitHub repository, Owner or User Access Administrator on the target management group so that the deployment identity can be created and granted, and the Azure CLI. No framework is installed. Everything here is the plain mechanism, so that when you later adopt EPAC or the landing zone accelerator you know what it is doing on your behalf.
MG_NAME="northfork"
MG_ID="/providers/Microsoft.Management/managementGroups/$MG_NAME"
TENANT_ID=$(az account show --query tenantId -o tsv)
SUB_ID=$(az account show --query id -o tsv)
GH_ORG="your-org"
GH_REPO="northfork-policy"
Step 1. Export what you already have
Do this before writing anything new. The output is the first accurate inventory most estates have had, and it frequently changes what people intended to build.
mkdir -p export/definitions export/initiatives export/assignments
for NAME in $(az policy definition list --management-group "$MG_NAME" \
--query "[?policyType=='Custom'].name" -o tsv); do
az policy definition show --name "$NAME" --management-group "$MG_NAME" \
> "export/definitions/$NAME.json"
done
for NAME in $(az policy set-definition list --management-group "$MG_NAME" \
--query "[?policyType=='Custom'].name" -o tsv); do
az policy set-definition show --name "$NAME" --management-group "$MG_NAME" \
> "export/initiatives/$NAME.json"
done
az policy assignment list --scope "$MG_ID" --filter "atScope()" \
> export/assignments/all-assignments.json
The policyType=='Custom' filter matters. Without it the first loop attempts to export every built-in in Azure, which is thousands of files and none of them yours.
Step 2. Structure the repository
One folder per object, with the rule and the parameters kept as separate files, because that is what the CLI consumes directly and it keeps a diff on a rule readable.
northfork-policy/
definitions/
NFK-Def-Storage-BlockAnonymousBlob/
rule.json
parameters.json
metadata.json
initiatives/
NFK-Init-Storage/
definitions.json
parameters.json
assignments/
NFK-Asgn-Storage-Corp.json
exemptions/
NFK-Exm-Storage-Corp-Legacy.json
.github/workflows/
deploy-policy.yml
Exemptions belong in the repository alongside everything else, and this is the part most teams leave out. An exemption is a decision about accepted risk, and putting it here gives it a reviewer, a justification in the commit message and an expiry date visible in a diff.
| Folder | Deploy order | Why |
|---|---|---|
definitions/ | First | An initiative cannot reference a definition that does not yet exist in Azure. |
initiatives/ | Second | Must resolve every member definition ID at creation time. |
assignments/ | Third | Needs the initiative present, and creates the managed identity. |
exemptions/ | Last | References an assignment ID, and scope validation fails if the assignment is not there yet. |
Step 3. Create the deployment identity, without a secret
Use workload identity federation rather than a client secret. There is no credential to rotate, nothing to leak from the repository, and the trust is scoped to one branch of one repository.
APP_ID=$(az ad app create --display-name "gh-northfork-policy" --query appId -o tsv)
az ad sp create --id "$APP_ID"
SP_OID=$(az ad sp show --id "$APP_ID" --query id -o tsv)
az ad app federated-credential create --id "$APP_ID" --parameters "{
\"name\": \"gh-main\",
\"issuer\": \"https://token.actions.githubusercontent.com\",
\"subject\": \"repo:${GH_ORG}/${GH_REPO}:ref:refs/heads/main\",
\"audiences\": [\"api://AzureADTokenExchange\"]
}"
az role assignment create --assignee-object-id "$SP_OID" \
--assignee-principal-type ServicePrincipal \
--role "Resource Policy Contributor" --scope "$MG_ID"
echo "AZURE_CLIENT_ID=$APP_ID"
Read the client ID from the final line and store it, with the tenant and subscription IDs, as repository variables. Nothing secret is stored anywhere.
Resource Policy Contributor is deliberate and it is also incomplete, which is worth understanding rather than fixing carelessly. That role can create definitions, initiatives and assignments. It cannot grant a managed identity its roles, which is what a remediating assignment needs. The moment this pipeline deploys its first deployIfNotExists policy you will hit that wall, and the correct response is a separate, narrowly scoped grant for that specific step rather than promoting the whole pipeline to User Access Administrator at the root.
Step 4. Close the portal door
This step is what makes the repository authoritative, and it is the one most programmes never take. While engineers keep the ability to create assignments by hand, the repository describes the estate rather than defining it, and the two diverge within a month.
az role assignment list --scope "$MG_ID" \
--query "[?roleDefinitionName=='Resource Policy Contributor' || roleDefinitionName=='Owner'].{principal:principalName, role:roleDefinitionName}" \
--output table
Expected output after the change is the pipeline’s service principal and your break-glass accounts, and nobody else holding policy write at this scope. Run this before removing anything so that you know who you are about to affect, and tell them first. This is a political change wearing technical clothes and it fails when it arrives as a surprise.
Step 5. The workflow
Three jobs: deploy the definition, gate on a compliance scan, then enable enforcement. The gate is the entire point of the file.
name: deploy-policy
on:
push:
branches: [ main ]
paths: [ 'definitions/**', 'initiatives/**', 'assignments/**' ]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy definitions
run: |
set -euo pipefail
for D in definitions/*/; do
NAME=$(basename "$D")
az policy definition create \
--name "$NAME" \
--rules "$D/rule.json" \
--params "$D/parameters.json" \
--mode Indexed \
--management-group "${{ vars.MG_NAME }}"
done
- name: Assign, evaluation only
run: |
az policy assignment create \
--name "NFK-Asgn-Storage-Test" \
--scope "${{ vars.TEST_SCOPE }}" \
--policy-set-definition "NFK-Init-Storage" \
--enforcement-mode DoNotEnforce
gate:
needs: deploy
runs-on: ubuntu-latest
steps:
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Compliance scan
uses: azure/policy-compliance-scan@v0
with:
scopes: ${{ vars.TEST_SCOPE }}
The compliance scan action fails the workflow when resources in scope are non-compliant, which is what turns it into a gate rather than a report. That behaviour is also why the test scope has to be a scope you control the contents of. Pointing it at a shared subscription produces a pipeline that fails for reasons unrelated to the change being deployed, and a gate that fails for unrelated reasons gets disabled within two weeks.
The set -euo pipefail line is not decoration. Without it a failed az command inside the loop leaves the job green and the pipeline proceeds to enable enforcement on a definition that was never updated.
Step 6. The end-to-end test
Prove the gate works by making it fail on purpose. A gate that has only ever passed has not been tested, and this is the one component of the system whose failure mode is silent.
# in the test scope, create a resource that violates the policy
az storage account create --name "stnfkgate$RANDOM" \
--resource-group "rg-northfork-policytest" --location eastus \
--sku Standard_LRS --allow-blob-public-access true
# then push any change under definitions/ and watch the workflow
git commit --allow-empty -m "test: gate should fail" && git push
Expected result is the deploy job succeeding and the gate job failing, with the non-compliant resource named in its output. Delete the resource, re-run, and confirm the workflow goes green. Only then is the gate load-bearing.
Step 7. Roll back
A revert commit is the intended rollback and it covers definitions and initiatives cleanly. Two things it does not cover, both worth knowing before you rely on it.
Reverting a definition does not undo remediation. Resources changed by a modify or deployIfNotExists policy stay changed, because the policy made a change to the world rather than to a declaration. And removing an assignment from the repository does not remove it from Azure unless the workflow explicitly deletes assignments that are absent from the repository, which the workflow above does not do. Desired-state reconciliation is the single largest piece of work in a hand-built policy as code system, and it is the main reason organisations eventually adopt a framework that already implements it.
az policy assignment delete --name "NFK-Asgn-Storage-Test" --scope "${TEST_SCOPE}"
az ad app delete --id "$APP_ID"
From here
You now have the whole product: the object model, the effects, remediation, exemptions, staged rollout, compliance and the operating model. The last article is about restraint, and about which problems belong to something other than Azure Policy.
Azure Policy
‹ Previous: [AP 12] Policy as Code: The Operating Model
Next: [AP 13] The Decision: What Policy Owns, and What It Should Not ›




