[AP 4.1] Build Sheet: The Guardrail Set

Allowed resource types, working delete protection with its cascade behaviour set deliberately, and resource selectors that aim a rule at part of the estate first. The delta beyond what the Landing Zones build sheet already gave you.


By the end of this you have an allowed-resource-types control, delete protection whose cascade behaviour you chose rather than inherited, and both of them aimed at one region before the rest of the estate, using resource selectors on the assignment rather than a second assignment.

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, and what this deliberately does not cover

You need Resource Policy Contributor or Owner at the Northfork intermediate root, and the naming convention from the previous build sheet. The Landing Zones build sheet on this site already builds required tags, allowed locations, tag inheritance through modify with its remediation task, and a first pass at delete protection, on this same estate. This sheet does not rebuild any of that. What follows is the part that build did not reach.

MG_NAME="northfork"
MG_ID="/providers/Microsoft.Management/managementGroups/$MG_NAME"
LZ_MG="/providers/Microsoft.Management/managementGroups/northfork-corp"
PRIMARY_REGION="eastus"

Step 1. Decide what allowed resource types is for

An allowed-resource-types control is an allowlist, and allowlists in Azure age badly, because Microsoft ships resource providers faster than any governance team reviews them. Before writing one, be clear which of two jobs it is doing, because they want opposite lists.

IntentShapeWhy
Keep a landing zone to a known service setAllowlist of perhaps thirty typesAppropriate for a regulated or a fixed-purpose landing zone. Expect a request to amend it monthly and build a route for that request before you assign it.
Block a small number of specific thingsDenylist using the not-allowed built-in insteadAppropriate for a general estate. Blocking classic resource types, or a service your organisation has decided against, without standing between every team and every new Azure feature.

I use the denylist by default and the allowlist only where somebody can name the compliance obligation that requires it. An allowlist assigned at an intermediate root is one of the few policy decisions I have seen genuinely slow an engineering organisation down.

Step 2. Assign the type control to one landing zone, in audit

ALLOWED_TYPES='{
  "listOfResourceTypesAllowed": {
    "value": [
      "Microsoft.Compute/virtualMachines",
      "Microsoft.Compute/disks",
      "Microsoft.Network/networkInterfaces",
      "Microsoft.Network/networkSecurityGroups",
      "Microsoft.Storage/storageAccounts",
      "Microsoft.KeyVault/vaults",
      "Microsoft.Insights/components"
    ]
  }
}'

az policy assignment create \
  --name "NFK-Asgn-Types-Corp" \
  --display-name "Northfork Corp allowed resource types" \
  --scope "$LZ_MG" \
  --policy "a08ec900-254a-4555-9bf5-e42af04b5c5c" \
  --params "$ALLOWED_TYPES" \
  --enforcement-mode DoNotEnforce

The seven types above are a worked illustration and not a recommendation. Build your real list from what the landing zone already contains rather than from what you imagine it contains, which is a query rather than a workshop.

Step 3. Read the estate before enforcing the list

This check goes before the enforcement step, not after it, because promoting an allowlist that omits a type already in use blocks the next deployment from a team that has done nothing wrong.

az graph query -q "
Resources
| summarize count() by type
| order by count_ desc" --output table

Expected output is every resource type present, with counts. Compare it against the allowlist by hand once. Anything in the estate and not in the list is a decision you are about to make on somebody’s behalf, and it is much cheaper to make it now.


Step 4. Build delete protection that actually protects

The built-in is Do not allow deletion of resource types, definition ID 78460a36-508a-49a4-b2b2-2f5ec564f4bb, offering DenyAction and Disabled. It takes the resource types to protect as a parameter.

az policy assignment create \
  --name "NFK-Asgn-Protect-Corp" \
  --display-name "Northfork Corp deletion protection" \
  --scope "$LZ_MG" \
  --policy "78460a36-508a-49a4-b2b2-2f5ec564f4bb" \
  --params '{
    "resourceTypes": {
      "value": [
        "Microsoft.KeyVault/vaults",
        "Microsoft.Sql/servers/databases"
      ]
    }
  }' \
  --non-compliance-messages '[{"message":"These resource types are delete-protected by Northfork platform policy. Raise a change request with the platform team."}]'
SettingValueWhy
EffectDenyActionThe only alternative on this definition is Disabled. There is no audit tier for delete protection, which is exactly why the check in step 5 exists.
cascadeBehaviorsdeny (the default)Decides whether deleting the containing resource group is also blocked. Left alone it denies, which is usually what you want and almost never what people verify.
Non-compliance messageNames the owner and the routeThe person who hits this is mid-task. Give them the next action rather than a policy GUID.
Enforcement modeDefaultDoNotEnforce would suppress the effect completely, making the assignment decorative. Delete protection has no useful dry run.

Step 5. Test the protection before you rely on it

This is the step people skip, and the reason it matters is that a denyAction assignment reports Protected whether or not it would stop the deletion you care about. The state describes coverage, not efficacy. Build a throwaway resource of a protected type and try to delete it, and then try to delete its resource group, because those are two different code paths and only one of them depends on cascade behaviour.

TESTRG="rg-northfork-deletetest"
az group create --name "$TESTRG" --location "$PRIMARY_REGION"
KV="kv-nfk-$RANDOM"
az keyvault create --name "$KV" --resource-group "$TESTRG" --location "$PRIMARY_REGION"

# direct delete: expect failure
az keyvault delete --name "$KV" --resource-group "$TESTRG"

# container delete: expect failure only if cascadeBehaviors is deny
az group delete --name "$TESTRG" --yes

Both commands should fail with a 403 naming the assignment. If the first fails and the second succeeds, cascade behaviour is set to allow somewhere in the definition you assigned, and your protection covers the careful deletion while missing the careless one. That is the wrong way round.

Four limits survive whatever you configure, and they belong on the same page as the assignment rather than in somebody’s memory. Deleting the subscription removes everything regardless. Resources that do not support tags and location are not covered by the cascade rule. Deleting a parent resource takes its children with it even when the child is the protected type. And the platform’s own control objects, policy assignments and deny assignments and deployment stacks and locks and subscriptions, are exempt from denyAction by design, so this mechanism cannot be used to protect your governance from being removed.


Step 6. Aim a rule at part of the estate with resource selectors

A resource selector narrows which resources an assignment evaluates without narrowing the scope it is assigned at. That difference is the useful part. The assignment sits where it belongs in the hierarchy, permanently, and the selector controls how much of the estate it currently looks at, which you widen over time by editing one property.

az policy assignment update \
  --name "NFK-Asgn-Types-Corp" \
  --scope "$LZ_MG" \
  --resource-selectors '[
    {
      "name": "SDPRegions",
      "selectors": [
        { "kind": "resourceLocation", "in": [ "eastus" ] }
      ]
    }
  ]'

Selector kinds are resourceLocation, resourceType and resourceWithoutLocation. An assignment takes up to ten selectors, each list holds up to fifty values, and a resource has to satisfy every selector inside one selector block to be evaluated by it while satisfying any one block is enough overall. resourceWithoutLocation cannot appear alongside resourceLocation in the same block.

Confirm the selector took effect by reading the assignment back rather than by watching the compliance page, which lags.

az policy assignment show \
  --name "NFK-Asgn-Types-Corp" \
  --scope "$LZ_MG" \
  --query "{mode:enforcementMode, selectors:resourceSelectors}" \
  --output json

Step 7. The end-to-end test

One test proves both controls and the selector at once. Deploy a resource of a type that is not on the allowlist, into the selected region, and then the same thing into a region the selector excludes. The first attempt should be evaluated and the second should not.

az policy state trigger-scan --resource-group "$TESTRG" 2>/dev/null

az policy state list \
  --filter "policyAssignmentName eq 'NFK-Asgn-Types-Corp'" \
  --query "[].{resource:resourceId, state:complianceState, location:resourceLocation}" \
  --output table

Every row returned should carry a location matching the selector. A row from any other region means the selector is not applied, and the assignment is evaluating the whole scope while you believe it is evaluating one region. That is the failure worth catching here, because the next article promotes this assignment to enforcing and the selector is the thing standing between a staged rollout and an estate-wide one.

Step 8. Roll back

az policy assignment delete --name "NFK-Asgn-Types-Corp" --scope "$LZ_MG"
az policy assignment delete --name "NFK-Asgn-Protect-Corp" --scope "$LZ_MG"
# the delete-test resource group cannot be removed until the protection is gone
az group delete --name "$TESTRG" --yes --no-wait

Order matters and the reason is instructive. While the protection assignment exists, the test resource group cannot be deleted, including by you, including as the person who created the protection. That is the mechanism working, and it is also the shape of the call you will get one day from somebody who cannot clean up a project.


From here

Everything so far reports or refuses. The next article covers the effects that change resources on your behalf, and the identity that does the changing.


Azure Policy
‹ Previous: [AP 4] Effects: What Each One Commits You To
Next: [AP 5] Remediation: The Identity That Acts on Your Behalf