[AP 7.1] Build Sheet: A Custom Definition From Scratch

Confirm the alias before designing anything, handle the absent-property trap on purpose, and prove one custom definition against a resource built to fail and one built to pass.


By the end of this you have written a policy definition from an empty file, confirmed its alias before designing anything, handled the absent-property trap on purpose, and proved it against one resource built to fail and one built to pass.

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 management group where the definition will live, Contributor on one subscription beneath it for the test resource, and the Azure CLI signed in with an account that holds both. No licence is involved. Azure Policy carries no charge for Azure resources, so nothing here appears on a bill except the test storage account, which costs a fraction of a cent and gets deleted in the last step.

You should also have applied the test from the previous article and concluded that nothing in the catalogue covers this and that the rule is specific to your organisation. The worked example here is a control on anonymous blob access, chosen because it is small enough to read in one screen and because it exercises the absent-property trap that ruins most first attempts. In a real estate this particular rule exists as a built-in and you would assign that instead, which is the point: the mechanics below are what you use when it genuinely does not.

The worked estate is Northfork Supply Co., the one the Landing Zones series built. The rule deliberately avoids required tags and allowed locations, because the Landing Zones build sheet already builds both on this same estate.


Step 0. Confirm the alias exists, before anything else

A policy rule can only inspect a property that Azure Policy exposes as an alias, and alias coverage is uneven. This check costs a minute and it is the difference between an afternoon’s work and a fortnight spent designing a control that was never addressable. Run it before you write a line of JSON.

az provider show --namespace Microsoft.Storage \
  --expand "resourceTypes/aliases" \
  --query "resourceTypes[?resourceType=='storageAccounts'].aliases[].name" -o tsv \
  | grep -i "publicaccess"

Expected output includes Microsoft.Storage/storageAccounts/allowBlobPublicAccess. If the property you want returns nothing, stop here. There is no workaround, no clever operator, and no alternative field: the documented path when an alias does not exist is a support request. Find another way to express the control, or accept that this one cannot be a policy.

Object names in this build follow the convention set out in the first build sheet of this series, NFK-Type-Domain-Control, and the 24 character limit on assignment names at management group scope still applies.


Step 1. Set the variables the rest of the build uses

Set these once. Every later command reads them, and nothing in this build sheet prints a resource ID you have to retype.

MG_NAME="northfork"
MG_ID="/providers/Microsoft.Management/managementGroups/$MG_NAME"
SUB_ID=$(az account show --query id -o tsv)
RG="rg-northfork-policylab"
LOC="eastus"

az group create --name "$RG" --location "$LOC" --subscription "$SUB_ID"

Substitute your own management group name. If your intermediate root is called something else, that is the only edit this build sheet needs.

Step 2. Write the rule

The rule targets storage accounts that permit anonymous blob access. Write it to rules.json. Note that the effect is a parameter reference rather than a literal, which is the whole point of the object model article and the reason this definition will still be useful when you want it to deny rather than audit.

cat > rules.json <<'EOF'
{
  "if": {
    "allOf": [
      {
        "field": "type",
        "equals": "Microsoft.Storage/storageAccounts"
      },
      {
        "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess",
        "notEquals": "false"
      }
    ]
  },
  "then": {
    "effect": "[parameters('effect')]"
  }
}
EOF

The notEquals rather than equals true is deliberate and it is the single most common authoring mistake in this product. A storage account created without the property set at all does not return true and does not return false. It returns nothing. A rule written as equals true silently passes every resource where the property was never specified, which is most of them, and reports a compliance figure that looks excellent and means nothing.

Step 3. Write the parameter definition

cat > params.json <<'EOF'
{
  "effect": {
    "type": "String",
    "metadata": {
      "displayName": "Effect",
      "description": "Audit while proving the rule, Deny once the estate is clean."
    },
    "allowedValues": [ "Audit", "Deny", "Disabled" ],
    "defaultValue": "Audit"
  }
}
EOF

The default is Audit on purpose. A definition whose default effect is Deny will eventually be assigned by someone in a hurry who did not read the parameters, and the estate will find out at deployment time.

Step 4. Create the definition at the management group

az policy definition create \
  --name "NFK-Def-Storage-BlockAnonymousBlob" \
  --display-name "Storage accounts must not permit anonymous blob access" \
  --description "Anonymous blob access is disallowed on Northfork storage accounts." \
  --rules rules.json \
  --params params.json \
  --mode Indexed \
  --management-group "$MG_NAME"
SettingValueWhy
ModeIndexedThe rule inspects a resource type that supports tags and location. Indexed excludes resource groups and subscriptions from evaluation, which keeps the compliance count honest. Use All only when the rule genuinely has to see untagged, unlocated objects.
Scope of the definitionIntermediate root management groupA definition can be assigned anywhere at or below where it lives. Create it low and you will copy it later.
Display nameStates the control in a sentenceThis string appears in the compliance blade next to a resource somebody is about to be asked to fix. Write it for that person.

In the portal the same object is created under Azure Policy, in Definitions, using the Policy definition page, with the definition location set to the management group, the rule pasted into the policy rule editor, and the category set on the same page. The portal writes exactly the JSON above.

Step 5. Wrap it in an initiative

One definition, one initiative. This looks like ceremony and it is the step that saves you a year from now, when the second storage rule arrives and joins an initiative that is already assigned in four places rather than needing four new assignments.

DEF_ID=$(az policy definition show \
  --name "NFK-Def-Storage-BlockAnonymousBlob" \
  --management-group "$MG_NAME" \
  --query id -o tsv)

cat > initiative-definitions.json <<EOF
[
  {
    "policyDefinitionId": "$DEF_ID",
    "policyDefinitionReferenceId": "blockAnonymousBlob",
    "parameters": {
      "effect": { "value": "[parameters('storageEffect')]" }
    }
  }
]
EOF

cat > initiative-params.json <<'EOF'
{
  "storageEffect": {
    "type": "String",
    "metadata": { "displayName": "Storage control effect" },
    "allowedValues": [ "Audit", "Deny", "Disabled" ],
    "defaultValue": "Audit"
  }
}
EOF

az policy set-definition create \
  --name "NFK-Init-Storage" \
  --display-name "Northfork storage controls" \
  --definitions initiative-definitions.json \
  --params initiative-params.json \
  --management-group "$MG_NAME"

Read policyDefinitionReferenceId carefully, because it matters later and it is not obvious now. That string is how an exemption names one member of an initiative rather than exempting a resource from the whole thing. Choose it deliberately and never change it, since changing it breaks every exemption that referenced it and the breakage is silent.

Step 6. Assign it, in audit

az policy assignment create \
  --name "NFK-Asgn-Storage-Lab" \
  --display-name "Northfork storage controls (lab)" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/$RG" \
  --policy-set-definition "NFK-Init-Storage" \
  --params '{ "storageEffect": { "value": "Audit" } }'

There are two different ways to make an assignment harmless and they are not interchangeable. Setting the effect parameter to Audit means the rule runs, reports, and never blocks. Setting --enforcement-mode DoNotEnforce means the effect is suppressed entirely, which is what you use when the effect is deployIfNotExists or modify and you want evaluation without the deployment. For an audit or deny rule, the effect parameter is the lever. For a remediating rule, enforcement mode is the lever. Reaching for the wrong one produces an assignment that looks safe and is not.

One footnote worth recording. The CLI currently accepts a third enforcement mode value, Enroll, alongside Default and DoNotEnforce, and it does not appear in the safe deployment guidance. Checked August 2026. Do not use a mode whose behaviour is not documented on the page telling you how to roll changes out.


Step 7. Build something that must fail

A rule that has only ever seen compliant resources has not been tested. Create one storage account that violates it and one that satisfies it, so the result distinguishes a working rule from a rule that matches nothing.

BAD="stnfkbad$RANDOM"
GOOD="stnfkgood$RANDOM"

az storage account create --name "$BAD" --resource-group "$RG" \
  --location "$LOC" --sku Standard_LRS --allow-blob-public-access true

az storage account create --name "$GOOD" --resource-group "$RG" \
  --location "$LOC" --sku Standard_LRS --allow-blob-public-access false

echo "non-compliant candidate: $BAD"
echo "compliant candidate:     $GOOD"

The account names are generated rather than fixed, because storage account names are globally unique and a build sheet that prints a literal name is a build sheet that fails for the second reader. Read the real names from the two echo lines and use them in the next step.

Step 8. Force an evaluation and read the result

A new assignment takes roughly five minutes before it evaluates anything, and the first compliance scan on a newly assigned scope completes in around thirty. Waiting for the daily cycle is not required and not sensible during a build.

az policy state trigger-scan --resource-group "$RG"

az policy state list \
  --resource-group "$RG" \
  --filter "policyAssignmentName eq 'NFK-Asgn-Storage-Lab'" \
  --query "[].{resource:resourceId, state:complianceState}" \
  --output table

Expected output is two rows: the account created with public access allowed reading NonCompliant, and the one created with it disabled reading Compliant. Any other result means stop and diagnose rather than continue.

What you seeWhat it means
Both rows CompliantThe rule is not matching. Almost always the alias or the notEquals logic. Check the alias with az provider show or the Policy extension for VS Code before touching anything else.
No rows at allEvaluation has not finished, or the mode is wrong for the resource type. Wait, then re-run the scan once before assuming a defect.
Both rows NonCompliantThe condition is inverted. The compliant account is being caught, which in a deny assignment would have blocked legitimate deployments.

Step 9. Prove enforcement, then put it back

Audit proves the rule matches. It does not prove the rule blocks. Flip the effect on the assignment, attempt a violation, and confirm the request is refused before you trust this rule anywhere real.

az policy assignment update \
  --name "NFK-Asgn-Storage-Lab" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/$RG" \
  --params '{ "storageEffect": { "value": "Deny" } }'

# wait about five minutes for the assignment change to take effect, then:
az storage account create --name "stnfkdeny$RANDOM" --resource-group "$RG" \
  --location "$LOC" --sku Standard_LRS --allow-blob-public-access true

The expected result is a failure carrying RequestDisallowedByPolicy and naming the assignment. That error string is the thing to show a workload team, because it tells them which assignment refused them and therefore who to ask. Return the assignment to Audit afterwards if you are keeping the lab.

Step 10. Remove what you built

Delete in reverse dependency order. An initiative cannot be deleted while an assignment references it, and a definition cannot be deleted while an initiative contains it, and the errors when you try are not especially clear.

az policy assignment delete --name "NFK-Asgn-Storage-Lab" \
  --scope "/subscriptions/$SUB_ID/resourceGroups/$RG"
az policy set-definition delete --name "NFK-Init-Storage" --management-group "$MG_NAME"
az policy definition delete --name "NFK-Def-Storage-BlockAnonymousBlob" --management-group "$MG_NAME"
az group delete --name "$RG" --yes --no-wait

From here

That is the whole product covered: assign what Microsoft ships, customise it where it nearly fits, and author your own where nothing does. What remains is running the result. The next article is about the resources that will never comply, and the difference between quietly excluding one and recording that you decided to.


Azure Policy
‹ Previous: [AP 7] Writing Your Own, and When Not To
Next: [AP 8] Exemptions: The Ledger of Accepted Risk