Home / Azure

AzureHow-toAdvanced

Custom Azure Policy definitions that actually match: aliases, [*] arrays, count expressions and child resources

Write custom Azure Policy rules that catch what you mean. Find the right alias, get [*] array logic and count right, cover child resource types, and test before you enforce.

Deepak Bhardwaj7 min read

Applies toAzure Policy (Resource Manager mode), Azure CLI 2.60+, Az PowerShell 12+

Goal

You need a custom policy, for example "no NSG rule may allow SSH from the internet" or "storage firewall rules may not contain 0.0.0.0/0". The first version usually has one of four bugs:

  1. It uses a property path that isn't an alias, so the condition never evaluates.
  2. It uses [*] and gets "all" when it meant "any", or the reverse.
  3. It checks the parent resource and misses the same setting created as a child resource.
  4. It's assigned with deny before anyone has seen what it matches.

This guide covers each one with commands you can run.

Step 1: find the alias, not the ARM property path

Policy conditions use aliases, not raw JSON paths. Before you write a condition, list the aliases for the resource type:

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

PowerShell:

(Get-AzPolicyAlias -NamespaceMatch 'Microsoft.Network' -ResourceTypeMatch 'networkSecurityGroups').Aliases |
  Where-Object Name -like '*securityRules*' | Select-Object Name

Points that catch people:

  • An alias ending in [*] points at each element of an array. The same alias without [*] points at the array as a whole.
  • If a property has no alias, policy can't see it. A condition on a made-up path doesn't fail. It just never matches.
  • For modify, the alias must be modifiable. Check the alias metadata:
az provider show --namespace Microsoft.Storage --expand "resourceTypes/aliases" \
  --query "resourceTypes[?resourceType=='storageAccounts'].aliases[?defaultMetadata.attributes=='Modifiable'].name | []" -o tsv

To check what a property looks like on a real resource, read the resource JSON. Policy evaluates the request payload, which has the same shape:

az network nsg show -g rg-app -n nsg-web --query "securityRules[0]" -o jsonc

Step 2: get [*] semantics right

A plain condition on a [*] alias is true only if it holds for every element:

Condition Meaning
"field": "...securityRules[*].access", "equals": "Allow" Every rule is Allow
"field": "...securityRules[*].access", "notEquals": "Allow" No rule is Allow

So you can write "none of" directly. But you can't use plain conditions to say "any single rule is Allow and inbound and port 22 and from Internet". Three separate [*] conditions can each be met by a different rule, which gives false positives. A rule can also fail to match when you expected it to.

Use a count expression with where. All conditions inside where are evaluated against the same array element:

{
  "if": {
    "allOf": [
      { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups" },
      {
        "count": {
          "field": "Microsoft.Network/networkSecurityGroups/securityRules[*]",
          "where": {
            "allOf": [
              { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].direction", "equals": "Inbound" },
              { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].access", "equals": "Allow" },
              { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
              { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].destinationPortRange", "in": ["22", "*"] }
            ]
          }
        },
        "greater": 0
      }
    ]
  },
  "then": { "effect": "[parameters('effect')]" }
}

Tip

This rule deliberately leaves out sourceAddressPrefixes (plural), destinationPortRanges and ranges such as 20-30. A real policy has to cover those as well, or you'll get false negatives. Range matching isn't possible with basic operators. Decide whether to reject ranges outright or to allow-list specific ports.

count also supports a value count over a parameter array. That's useful when you want "every element of the resource array must be in this allowed list":

{
  "count": {
    "field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*]",
    "where": {
      "field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*].value",
      "in": ["0.0.0.0/0", "0.0.0.0/1", "128.0.0.0/1"]
    }
  },
  "greater": 0
}

Field values are case-insensitive for most operators. equals on strings ignores case, but match and like work on patterns, so test them against real values.

Step 3: cover the child resource type

NSG rules can be created two ways:

  • Inline in the NSG body: the resource type is Microsoft.Network/networkSecurityGroups.
  • As a separate resource: Microsoft.Network/networkSecurityGroups/securityRules, which is what az network nsg rule create and many Terraform/Bicep modules do.

A policy that checks only the parent never sees the second path. Add an anyOf branch for the child type, which uses its own non-array aliases:

{
  "allOf": [
    { "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
    { "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
    { "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
    { "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
    { "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "in": ["22", "*"] }
  ]
}

The same split appears elsewhere: subnets inside a VNet versus virtualNetworks/subnets, SQL firewall rules, and storage blob services versus the account. When you write a policy, check both the inline and the child alias lists.

Step 4: pick the mode

  • Indexed: only resource types that support tags and location. Use it for tag and location policies.
  • All: every type, including child resources such as securityRules. The NSG policy above needs All. In Indexed mode the child branch is silently never evaluated.

Step 5: create, assign in audit, read the results

Wrap the rule with a parameter for the effect so the same definition can go from audit to deny:

cat > params.json <<'EOF'
{
  "effect": {
    "type": "String",
    "allowedValues": ["Audit", "Deny", "Disabled"],
    "defaultValue": "Audit"
  }
}
EOF

az policy definition create \
  --name deny-nsg-ssh-from-internet \
  --display-name "NSG rules must not allow SSH from the internet" \
  --mode All \
  --rules rules.json \
  --params params.json \
  --management-group mg-platform

Assign it at a test scope in audit, and use DoNotEnforce as an extra safety:

az policy assignment create \
  --name nsg-ssh-audit \
  --policy "/providers/Microsoft.Management/managementGroups/mg-platform/providers/Microsoft.Authorization/policyDefinitions/deny-nsg-ssh-from-internet" \
  --scope "/subscriptions/<sub-id>/resourceGroups/rg-policy-test" \
  --params '{"effect":{"value":"Audit"}}'

az policy state trigger-scan --resource-group rg-policy-test

A compliance scan can take a while on large scopes. Then read what it matched:

az policy state list --resource-group rg-policy-test \
  --filter "policyAssignmentName eq 'nsg-ssh-audit'" \
  --query "[].{resource:resourceId, state:complianceState}" -o table

Build a test fixture with known-good and known-bad resources: an inline rule, a child rule, a * port, a range, a sourceAddressPrefixes list. Confirm each one comes out as expected before you widen the scope.

Step 6: test deny without enforcing

Once the audit results look right, change the effect to Deny but keep --enforcement-mode DoNotEnforce on a wider assignment. Deny-effect results still appear in compliance without blocking anything. When you switch enforcement to Default, check that a bad request actually fails:

az network nsg rule create -g rg-policy-test --nsg-name nsg-test -n bad-ssh \
  --priority 100 --direction Inbound --access Allow --protocol Tcp \
  --source-address-prefixes Internet --destination-port-ranges 22
# Expect: RequestDisallowedByPolicy

Warning

deny only acts on create and update requests. Resources that already break the rule keep working, but the next update to them is blocked, even an unrelated change such as a tag or a new rule. Clean up existing violations before you enforce, or teams will hit the block in the middle of an incident.

Prevent it next time

  • Store definitions as JSON in source control and deploy them with your pipeline, not the portal.
  • For every custom policy, keep a fixture of resources that must match and must not match, and run the audit check in CI against a sandbox subscription.
  • Use count with where whenever two or more conditions describe the same array element.
  • When you write the rule, check both the inline alias and the child resource type.
  • Go from audit to deny with DoNotEnforce, then enforce. Track compliance per assignment before each step.