[AP 9.1] Build Sheet: The Staged Rollout of a Deny

One deny rule walked from audit in a single region to enforced across the estate, with the compliance gate stated before each widening and the rollback written beside the step that needs it.


By the end of this you have taken a deny rule from nothing to enforced across the Northfork estate in six stages, and at no point could a mistake have reached more than one tier. The assignment is created once, at full scope, on the first step.

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 Resource Policy Contributor or Owner at the scope you are assigning to, and an estate with resources in more than one region, since regions are the tiers. The rule used here requires storage accounts to disable public network access, which is a real built-in and a control most organisations want and are afraid to enforce, which makes it the right shape for this exercise.

MG_NAME="northfork"
MG_ID="/providers/Microsoft.Management/managementGroups/$MG_NAME"
SUB_ID=$(az account show --query id -o tsv)
ASSIGN="NFK-Asgn-Stg-PubNet"
DEF="b2982f36-99f2-4db5-8eff-283140c09693"   # Storage accounts should disable public network access

TIER_5="westus3"
TIER_3="northeurope"
TIER_0="eastus"

Set the tier variables to your own regions, ordered from the one you would least mind breaking to the one that would ruin the week. That ordering is the only judgement in this build sheet and it is worth ten minutes with somebody who runs the workloads.


Step 1. Assign at full scope, evaluating one region, running as audit

The assignment is created once, at the scope it will permanently occupy. Everything that follows edits two properties on it. Creating a series of narrow assignments and deleting them as you go is the intuitive approach and it is worse, because each one is a separate object with its own name, parameters and exemptions, and the final state is not the thing you tested.

az policy assignment create \
  --name "$ASSIGN" \
  --display-name "Northfork storage public network access" \
  --scope "$MG_ID" \
  --policy "$DEF" \
  --resource-selectors "[
    {
      \"name\": \"SDPRegions\",
      \"selectors\": [
        { \"kind\": \"resourceLocation\", \"in\": [ \"$TIER_5\" ] }
      ]
    }
  ]" \
  --overrides '[
    { "kind": "policyEffect", "value": "Audit" }
  ]' \
  --non-compliance-messages '[{"message":"Storage accounts in Northfork must disable public network access. Use a private endpoint. Contact the platform team for exceptions."}]'
SettingValueWhy
ScopeThe full management group, from the startThe assignment never moves. Only its selectors change, so the object you tested is the object you end with.
Resource selectorOne region, the least criticalLimits what the assignment evaluates without limiting where it lives.
Override kindpolicyEffect, value AuditRuns a deny definition as audit. Not the same as DoNotEnforce, which suppresses the effect and tells you nothing about what would have been refused.
Non-compliance messageNames the control and the routeWritten now, while you are thinking about it, rather than during the incident where somebody first reads it.

Step 2. The gate, and it goes before every widening

Wait roughly five minutes for the assignment to apply, then force a scan rather than waiting for the daily cycle. Read the results and compare them against a number you predicted before running the query. A gate you evaluate after seeing the answer is not a gate.

az policy state trigger-scan --no-wait

az policy state summarize \
  --filter "policyAssignmentName eq '$ASSIGN'" \
  --query "value[0].results" --output json

Expected output is a summary carrying a non-compliant resource count for the tier you selected and nothing from any other region. Check the second half of that sentence explicitly, because a selector that failed to apply produces a perfectly plausible result set drawn from the entire estate.

az policy state list \
  --filter "policyAssignmentName eq '$ASSIGN'" \
  --query "[].resourceLocation" --output tsv | sort -u

This must return exactly one region. Any second value means the selector is not in force, and the correct action is to stop and fix it rather than proceed on the assumption that it will apply later.

Step 3. Widen the audit pass through the tiers

az policy assignment update --name "$ASSIGN" --scope "$MG_ID" \
  --resource-selectors "[
    {
      \"name\": \"SDPRegions\",
      \"selectors\": [
        { \"kind\": \"resourceLocation\", \"in\": [ \"$TIER_5\", \"$TIER_3\" ] }
      ]
    }
  ]"

Repeat step 2’s gate after each widening, adding one tier at a time until the selector lists every region you intend to govern. Resist combining tiers to save time. The reason to stage is that you do not yet know what the rule matches, and every tier you add without checking is a tier you did not test.

The limits are ten selectors on an assignment and fifty values in each list, which is generous for regions and can be reached with resource types on a large estate.

Step 4. Deal with what the audit found, before enforcing anything

At this point the whole estate is being evaluated and nothing is being blocked, which is the most useful state this assignment will ever be in. Every non-compliant resource is a deployment that would have failed. Fix them, or exempt them deliberately with a date, and do it now rather than after the effect flips.

ASSIGN_ID=$(az policy assignment show --name "$ASSIGN" --scope "$MG_ID" --query id -o tsv)

az policy exemption create \
  --name "NFK-Exm-Stg-Legacy" \
  --display-name "Legacy reporting storage, migration to private endpoint in progress" \
  --policy-assignment "$ASSIGN_ID" \
  --exemption-category Waiver \
  --expires-on "2026-11-30T00:00:00Z" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/rg-northfork-reporting" \
  --description "Owner: platform team. Waiver pending the private endpoint migration tracked in NFK-1284."

A waiver with a date and an owner is a decision. The same resource left non-compliant while you enforce anyway is an outage with a paper trail explaining that somebody knew.

Step 5. Reset to one tier, and change the effect

This is the step people skip, and skipping it discards most of the value of everything before it. Narrow the selector back to the least critical tier and only then change the override to deny. You are now walking the tiers a second time, learning what the rule refuses rather than what it matches, and those are different questions.

az policy assignment update --name "$ASSIGN" --scope "$MG_ID" \
  --resource-selectors "[
    {
      \"name\": \"SDPRegions\",
      \"selectors\": [
        { \"kind\": \"resourceLocation\", \"in\": [ \"$TIER_5\" ] }
      ]
    }
  ]" \
  --overrides '[
    { "kind": "policyEffect", "value": "Deny" }
  ]'

Verify enforcement rather than assuming it, with a deliberate violation in the selected tier.

az group create --name "rg-nfk-denytest" --location "$TIER_5"
az storage account create --name "stnfkdeny$RANDOM" \
  --resource-group "rg-nfk-denytest" --location "$TIER_5" \
  --sku Standard_LRS --public-network-access Enabled

Expected result is a failure carrying RequestDisallowedByPolicy and the non-compliance message written in step 1. Then run the same command against a region not in the selector and confirm it succeeds, which proves the staging is real and not an artifact of the resource you happened to pick.

Step 6. Walk the tiers again, then remove the override

Widen the selector one tier at a time as in step 3, gating each time on both the compliance result and whatever signal tells you deployments are healthy. When every region is included and the estate is stable, remove the override so the assignment runs the definition’s own effect rather than a substituted one.

az policy assignment update --name "$ASSIGN" --scope "$MG_ID" --overrides "[]"

az policy assignment show --name "$ASSIGN" --scope "$MG_ID" \
  --query "{overrides:overrides, selectors:resourceSelectors, mode:enforcementMode}" \
  --output json

Leaving a permanent override in place works and is a mistake worth avoiding, because the assignment then reports one effect while the definition declares another, and the next person to read it has to know that overrides exist to understand what the estate is doing.

Rollback, at any stage

Every stage rolls back with one command and none of them require deleting the assignment.

SituationAction
Deployments failing in a tier you just addedUpdate --resource-selectors to remove that region. Effective in about five minutes.
Enforcement is wrong across the boardUpdate --overrides back to Audit. The rule keeps reporting while it stops blocking.
Emergency, the whole assignment must stop--enforcement-mode DoNotEnforce. Suppresses the effect entirely without losing the assignment, its parameters or its exemptions.
One resource must be let through nowAn exemption with a short expiry, not a permanent scope exclusion.
az group delete --name "rg-nfk-denytest" --yes --no-wait

The same technique, for somebody else’s change

Microsoft revises built-in definitions, and an unpinned assignment takes those revisions automatically. The identical staging machinery applies, using an override of kind definition version with its own selectors, so a new major version runs in one region while the rest of the estate stays where it was.

az policy assignment list --scope "$MG_ID" --filter "atScope()" \
  --expand "LatestDefinitionVersion, EffectiveDefinitionVersion" \
  --query "[].{name:name, effective:effectiveDefinitionVersion, latest:latestDefinitionVersion}" \
  --output table

az policy assignment update --name "$ASSIGN" --scope "$MG_ID" \
  --overrides "[
    {
      \"kind\": \"definitionVersion\",
      \"value\": \"2.0.*\",
      \"selectors\": [ { \"kind\": \"resourceLocation\", \"in\": [ \"$TIER_5\" ] } ]
    }
  ]"

Two notes. Wildcards come in two shapes only, a major with everything below floating or a major and minor with the patch floating, since exact patch pinning is not offered and patch changes are always taken. And the documentation names this override kind inconsistently, appearing as policy version on one page and definition version on another, so read the assignment back after writing it rather than trusting either page. Checked August 2026.


From here

Every gate in this build sheet reads a compliance result and trusts it. The next article is about how much that number deserves to be trusted, and what it leaves out.


Azure Policy
‹ Previous: [AP 9] Changing Policy Safely: Rollout Is the Job
Next: [AP 10] Compliance: What the Number Means and What It Hides