A finance auditor asks a simple question: “Can you guarantee that nobody in the production subscription can create a storage account that allows public blob access?” You know the built-in policies cover some of this, but the exact control the auditor wants — block any storage account where allowBlobPublicAccess is true, across every resource group, with an exception for one legacy app — does not exist as a built-in. So you build it. That is the moment most engineers discover that Azure Policy is not just a catalogue of toggles you switch on; it is a small, declarative rules engine, and the custom policy definition is the unit you author when the built-ins fall short.
A custom definition is a JSON document with two halves. The policyRule is an if/then condition: an if block that evaluates resource properties using aliases (the addressable handle for a property like Microsoft.Storage/storageAccounts/allowBlobPublicAccess) joined by logical operators, and a then block that names an effect (deny, audit, modify, and others). The other half is parameters, which turn a hard-coded rule into a reusable one — the same definition can deny in production and merely audit in dev, driven by an assignment-time value. Get the alias right, get the condition shape right, pick the effect that matches the auditor’s intent, and you have a governance control that runs automatically on every resource create, update, and on a recurring compliance scan — no agent, no code, no cost beyond the resources themselves.
This is a step-by-step implementation guide. The centrepiece is a hands-on lab where you author that exact storage policy three ways — portal, az CLI, and Bicep — find its alias from first principles, parameterise its effect, assign it, watch a non-compliant create get denied in real time, and tear it down. Around the lab sit reference tables — the anatomy of policyRule, the operator and field-function matrices, the alias-discovery cheat sheet, the effect comparison — and a troubleshooting playbook for the three failures that bite every first-time author: the wrong alias, the array [*] trap, and the assignment that silently evaluates nothing. By the end you will stop searching the built-in list for a control that isn’t there and write the one that is.
What problem this solves
Built-in policies are excellent but finite. Microsoft ships hundreds, and initiatives like the Microsoft Cloud Security Benchmark bundle them into compliance packs, but they encode general controls. Your organisation has specific ones: “every resource must carry a costCenter tag from these twelve codes,” “no public IP on a VM in prod,” “storage accounts must enforce minimum TLS 1.2 and disable shared-key access.” When the exact property, allowed values, or combination isn’t a built-in, the answer is a custom definition. Without it you’re stuck — accepting a near-miss built-in that doesn’t match the audit requirement, or falling back to detective controls (a nightly script that emails violations) that never prevent the bad resource.
What breaks is subtle, because Azure Policy fails quietly. A misauthored definition doesn’t error loudly; it evaluates to “no resources match” and reports 100% compliant — the most dangerous outcome, because it looks like success. The classic causes: a guessed alias that doesn’t exist (the field never resolves), equals where the property is an array needing [*] + count, or an assignment at a scope containing nothing it applies to. In each case the control appears to work and protects nothing. Authoring deliberately — find the real alias, test against a known-bad resource, confirm a deny actually denies — is what separates an audit-grade control from security theatre.
Who hits this: platform and landing-zone teams building guardrails, security engineers translating a compliance framework into enforceable rules, and any architect asked “can we prevent that, not just report it?” It sits one layer below Azure Policy and Governance at Scale: Enforce the Rules Automatically — that article is the why and where; this one is how you write the actual JSON.
Learning objectives
By the end of this article you can:
- Read and write the
policyRulestructure — theifcondition (field/value/countexpressions joined byallOf/anyOf/not) and thetheneffect — from a blank file. - Find the correct alias for any resource property using
az provider operation show, the Azure Resource Graph alias listing, and the portal’s “View definition” cross-reference, and tell a normal alias from an array[*]alias. - Choose the right condition operator (
equals,notEquals,in,like,match,exists,greaterOrEquals, and thecountexpression) for the property type and the control you want. - Parameterise a definition so one document drives
denyin production andauditin dev, usingparameterswithallowedValuesand adefaultValue. - Author, validate, and assign a custom policy three ways — Azure portal,
az policyCLI, and Bicep — and confirm the assignment is actually evaluating resources. - Trigger and read a compliance evaluation (
az policy state trigger-scan, the Compliance blade) and prove a non-compliant resource is denied at create time. - Diagnose the three first-author failures — wrong/non-existent alias, array
[*]mishandling, and an empty-scope assignment — and fix each.
Prerequisites & where this fits
You should already understand the Azure resource hierarchy — policy is assigned at a scope (management group, subscription, resource group, or resource) and inherits downward — and the difference between a policy definition (the rule) and an assignment (the rule applied at a scope with parameter values). If those are fuzzy, read Azure Policy and Governance at Scale: Enforce the Rules Automatically and Management Groups 101: Designing a Hierarchy That Scopes Policy and RBAC first. You should run az in Cloud Shell, read JSON, and be comfortable with a small Bicep file — Deploy Your First Bicep File From Scratch: Author, Validate and Ship in 20 Minutes is enough.
Know the effects conceptually — what deny, audit, modify, and deployIfNotExists do — since this article focuses on authoring the condition, not choosing the effect. Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists goes deep on effects; here we use deny and audit and explain just enough to make the lab work.
This sits in the Governance track, upstream of grouping controls into packs — Policy Definitions vs Initiatives: When to Bundle Controls into a Set tells you when to gather definitions into an initiative. It pairs with Azure RBAC Fundamentals: Roles, Scopes and Assignments Without the Confusion: authoring and assigning policy needs Resource Policy Contributor, and modify/deployIfNotExists need a managed identity with its own role grant.
The map of which artefact owns what:
| Artefact | What it is | Where it lives | What you do with it |
|---|---|---|---|
| Policy definition | The reusable rule (JSON: policyRule + parameters) |
A scope (MG/sub) as a definition object | Author once; reuse across assignments |
| Initiative (policy set) | A named bundle of definitions | A scope | Group related definitions; assign as one |
| Assignment | A definition applied at a scope with parameter values | A scope | Turn the rule on; set effect, exclusions |
| Parameters | Typed inputs to a definition | Inside the definition + supplied at assignment | Make one definition serve many scopes |
| Alias | The addressable handle for a resource property | Defined by the resource provider | Reference a property inside field/value |
| Effect | What happens on a match (deny, audit, …) |
The then block of the rule |
Decide prevent vs report vs remediate |
| Compliance state | Per-resource result of evaluation | Resource Graph / Compliance blade | Read who’s compliant; prove the control works |
Core concepts
Five ideas make every later step obvious.
A policy definition is if then then. The rule is a JSON object whose policyRule has exactly two keys: if (a condition that returns true/false for a resource) and then (whose effect says what to do when if is true). True → the effect fires; false → the resource is untouched. Everything else (parameters, metadata, display name) is scaffolding. A policy that denies public storage is just: if storage account and allowBlobPublicAccess is true, then deny.
An alias is how you address a property. Inside the if you don’t write properties.allowBlobPublicAccess — you reference an alias, a provider handle like Microsoft.Storage/storageAccounts/allowBlobPublicAccess. Aliases exist because the property may be nested deep, may differ across API versions, and may live in an array; the alias is a stable, evaluable pointer. The single most common authoring failure is referencing an alias that doesn’t exist or maps elsewhere — the condition then silently never matches. Finding and verifying the alias is step one of any real definition: a discipline, not a guess.
The condition is a tree of field/value expressions. A leaf is one comparison — { "field": "<alias>", "equals": "<x>" } or { "value": "<expr>", "in": [...] }. You join leaves with logical operators: allOf (AND), anyOf (OR), not (invert), nesting them to express “storage account AND (public access true OR min TLS too low).” The field operand reads a resource property (often via alias); the value operand is a literal, a parameter, or a template function like resourceGroup().name. Knowing which leaf operator fits which property type is most of the craft.
Arrays need count, not equals. When a property is a collection — firewall IP rules, tags, NSG security rules — a scalar operator can’t compare it. You use the [*] array alias plus a count expression that asks “how many elements satisfy this inner condition?” and compares that number. “Deny if any NSG rule allows inbound from the internet on port 22” is a count over securityRules[*] with a where, compared greater 0. Treating an array like a scalar is the second-most-common failure and matches nothing.
Parameters make one definition reusable. Hard-coding deny forces a second near-identical definition to merely audit in dev. Instead declare an effect parameter (String, allowedValues: ["Audit","Deny","Disabled"]) and reference [parameters('effect')] in then; the assignment chooses per scope. The same pattern parameterises allowed SKUs, the required tag name, the minimum TLS version — author once, tune per environment. A well-authored definition is almost always parameterised.
The vocabulary in one table
Pin down every moving part before the deep sections; the glossary repeats these for lookup.
| Term | One-line definition | Where it appears | Why it matters |
|---|---|---|---|
policyRule |
The if/then core of the definition |
Top of the definition JSON | The actual rule; everything else is scaffolding |
if |
The condition that returns true/false | Inside policyRule |
When true, the effect fires |
then |
The action block naming the effect | Inside policyRule |
Decides prevent / report / remediate |
| Alias | Provider handle for a resource property | field / value operands |
Wrong alias → silent no-match |
field |
Operand that reads a resource property | A condition leaf | Most conditions compare a field |
value |
Operand that is a literal/parameter/function | A condition leaf | Compare against templates/parameters |
allOf / anyOf / not |
Logical AND / OR / NOT | Around condition leaves | Build compound conditions |
count |
Expression counting array elements that match | Array conditions | The only correct way to test collections |
[*] alias |
Array form of an alias | count field/where |
Required for collection properties |
| Effect | What a match does (deny/audit/…) |
then.effect |
The teeth of the policy |
| Parameter | Typed assignment-time input | parameters + then/if |
One definition, many behaviours |
| Assignment | Definition applied at a scope | A scope object | Where the rule actually runs |
| Compliance state | Per-resource evaluation result | Compliance blade / Resource Graph | Proof the control works |
Anatomy of a policy definition
Every custom definition is one JSON object: mode + policyRule (the rule) + parameters (the inputs), wrapped with displayName, description, and metadata. The annotated skeleton you fill in:
{
"properties": {
"displayName": "Deny storage accounts that allow public blob access",
"description": "Blocks any storage account where allowBlobPublicAccess is true.",
"policyType": "Custom",
"mode": "Indexed",
"metadata": { "category": "Storage", "version": "1.0.0" },
"parameters": {
"effect": {
"type": "String",
"metadata": { "displayName": "Effect" },
"allowedValues": [ "Audit", "Deny", "Disabled" ],
"defaultValue": "Audit"
}
},
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess", "equals": true }
]
},
"then": { "effect": "[parameters('effect')]" }
}
}
}
Read it top-down: metadata names and categorises it; parameters declares one input (effect); policyRule.if is an allOf of two leaves (storage account and public blob access on); policyRule.then fires whatever the parameter resolves to. A complete, production-grade custom policy. The table names every top-level field:
| Field | Required | Values | What it controls | Gotcha |
|---|---|---|---|---|
displayName |
Yes | string (≤128 chars) | Human name in portal/CLI | Make it state the control, not the tech |
description |
Recommended | string | Long explanation | Shown to assigners; keep it actionable |
policyType |
Set by Azure | Custom / BuiltIn / Static |
Marks who owns it | You can’t set BuiltIn on your own |
mode |
Yes | Indexed / All |
Which resource types are evaluated | Indexed skips RGs/subscriptions; use it for tag/property rules on resources |
metadata.category |
Recommended | string | Grouping in the portal list | Free-text; reuse existing categories |
metadata.version |
Recommended | semver string | Track changes | Bump on every meaningful edit |
parameters |
Optional | object of typed params | Assignment-time inputs | Empty {} if none; prefer parameterising the effect |
policyRule.if |
Yes | a condition object | The match logic | The heart; wrong here = wrong everywhere |
policyRule.then.effect |
Yes | effect name or [parameters(...)] |
What happens on match | Parameterise it for reuse |
mode — Indexed vs All
mode decides which resources the engine even looks at. Indexed evaluates only taggable resources (with tags and location), skipping resource groups and subscriptions; All evaluates everything. Rule of thumb: Indexed for resource property rules (TLS, SKU, public access) and tag rules on resources; All only when the policy must target resource groups or subscriptions themselves (e.g. “every RG must have an owner tag”). Indexed also keeps a property policy from flagging RGs and subscriptions that could never have the property.
mode |
Evaluates | Skips | Use it for | Common mistake |
|---|---|---|---|---|
Indexed |
Taggable resources (have location + tags) | Resource groups, subscriptions | Property/SKU/TLS rules; tags on resources | Using All and seeing RGs flagged non-compliant |
All |
Every resource type incl. RGs and subscriptions | Nothing | Tag on the RG/subscription; resource-group-level rules | Using it when Indexed suffices → noisy results |
Aliases: finding the handle for a property
You cannot author a condition until you know the alias for the property. An alias looks like Microsoft.Storage/storageAccounts/allowBlobPublicAccess — <provider>/<resourceType>/<path-to-property>. The discipline: never guess; look it up and confirm it resolves to the property you mean.
Three reliable ways. Fastest in practice is the portal cross-reference — open any built-in that touches the property, “View definition” → JSON, read its alias. Most authoritative is az/Resource Graph — enumerate a provider’s aliases and grep. Third is trial against a real resource — deploy it, read its JSON, map the property path to the alias shape.
Method 1 — list aliases with az provider
To enumerate every alias for storage accounts and find the public-access one:
# List aliases for a resource type, filter to the property you want
az provider show --namespace Microsoft.Storage \
--query "resourceTypes[?resourceType=='storageAccounts'].aliases[].name" -o tsv \
| grep -i publicaccess
# → Microsoft.Storage/storageAccounts/allowBlobPublicAccess
If that returns the alias, you have your handle. A nested property (a child of networkAcls) reflects the path: Microsoft.Storage/storageAccounts/networkAcls.defaultAction.
Method 2 — Azure Resource Graph alias listing
Resource Graph Explorer exposes the full alias catalogue with its default API mappings — the authoritative source when an alias behaves oddly across versions. For day-to-day work the az method plus the portal cross-reference is faster and sufficient; reach for the catalogue only when you suspect a version-mapping issue (the alias resolves to a different property on a newer API version).
Method 3 — read the resource’s own JSON
When in doubt, deploy a sample resource and read it — the property path maps directly to the tail of the alias:
# Create a throwaway storage account, then read the property you want to govern
az storage account show -n stkvtmp001 -g rg-policy-lab \
--query "{publicAccess:allowBlobPublicAccess, minTls:minimumTlsVersion, defaultAction:networkRuleSet.defaultAction}" -o json
If allowBlobPublicAccess shows up here, the alias is Microsoft.Storage/storageAccounts/allowBlobPublicAccess. This method also confirms the property’s type (boolean, string, array) — which decides your operator.
The alias-discovery cheat sheet
| You need… | Best method | Command / path | Confirms |
|---|---|---|---|
| The alias for a known property | az provider show |
--query "...aliases[].name" | grep |
The exact alias string |
| Whether a built-in already governs it | Portal “View definition” | Policy → built-in → Definition (JSON) | A working alias + condition shape |
| The property’s type (bool/str/array) | Read resource JSON | az resource show / az <svc> show |
Scalar vs array → which operator |
| Array vs scalar alias | Look for [*] |
Alias name ends in [*] |
Whether you need count |
| Version-mapping oddities | Resource Graph alias catalogue | Resource Graph Explorer | The API version the alias maps to |
Reading an alias: scalar vs array
The alias name’s shape tells you how to use it. A scalar alias (.../allowBlobPublicAccess) takes equals, notEquals, in, like. An array alias ends with [*] (.../networkAcls.ipRules[*]) and must be used inside a count — you cannot equals an array. The array-member field (.../networkAcls.ipRules[*].value) goes inside the count’s where to test each element. Recognising the [*] is the single signal you are in array territory.
| Alias shape | Example | Property kind | How you use it |
|---|---|---|---|
| Scalar | .../allowBlobPublicAccess |
boolean | { "field": alias, "equals": true } |
| Scalar string | .../minimumTlsVersion |
string | equals / in / less (with care) |
| Array | .../networkAcls.ipRules[*] |
collection | count over the array |
| Array member field | .../networkAcls.ipRules[*].value |
element property | inside the count where clause |
| Nested scalar | .../networkAcls.defaultAction |
string | equals Allow/Deny |
Building the condition: fields, values, and operators
With the alias in hand you build the if — a tree with logical operators (allOf/anyOf/not) at the branches and comparison leaves at the tips. Each leaf has an operand — field (reads a property/alias) or value (a literal, parameter, or function) — and one condition operator with its argument.
Logical operators (the branches)
| Operator | Meaning | Shape | Use when |
|---|---|---|---|
allOf |
Logical AND — all children true | "allOf": [ {...}, {...} ] |
Combine independent requirements |
anyOf |
Logical OR — at least one true | "anyOf": [ {...}, {...} ] |
Any of several violations should match |
not |
Invert the child | "not": { ... } |
Express “unless / does not” |
Nest them freely: allOf of [type check, anyOf of [public access true, min TLS too low]] means “a storage account that either allows public access or has weak TLS.” Most real policies are an allOf whose first leaf pins the resource type and whose rest test properties.
Comparison operators (the leaves)
The operator must match the property’s type. Strings take equals/like/match; numbers take greater/less/greaterOrEquals; presence takes exists. The full set you will use:
| Operator | Operand types | Example | Notes / gotcha |
|---|---|---|---|
equals |
string, bool, number | { "field": alias, "equals": true } |
Exact match; booleans are true/false (no quotes) |
notEquals |
string, bool, number | { "field": "location", "notEquals": "eastus" } |
Inverse of equals |
in |
array of values | { "field": "location", "in": ["eastus","westus"] } |
Membership; pair with a parameters array |
notIn |
array of values | { "field": alias, "notIn": "[parameters('allowed')]" } |
Not in the allow-list |
like |
string (* wildcard) |
{ "field": "name", "like": "prod-*" } |
Single-char ?, multi * |
notLike |
string | { "field": "name", "notLike": "*-temp" } |
Inverse |
match / matchInsensitively |
string (# digit, ? letter, . any) |
{ "field": "name", "match": "###" } |
Pattern, not full regex; matchInsensitively ignores case |
contains |
string | { "field": "name", "contains": "log" } |
Substring |
exists |
true / false |
{ "field": "tags['costCenter']", "exists": false } |
Property presence; great for “tag missing” |
greater / greaterOrEquals |
number, string (lexical) | { "value": "[length(field('name'))]", "greater": 24 } |
Use with care on version strings |
less / lessOrEquals |
number, string (lexical) | { "field": alias, "less": "1.2" } |
String compares are lexical, not numeric |
The version-string trap: minimumTlsVersion is a string ("TLS1_2"), so less compares lexically, not numerically. Enforce “TLS 1.2 or higher” with notEquals/notIn against the disallowed values ("TLS1_0", "TLS1_1"), not a numeric less. Knowing a property’s true type (Method 3) saves you this class of bug.
field vs value, and the count for arrays
A leaf uses field to read a property (the alias or a top-level field like type, name, location, tags['x'], kind) and value to read a computed expression — a parameter or a template function. For example, to flag a badly-named resource group: { "value": "[resourceGroup().name]", "notMatch": "rg-#####" }.
For arrays you need count: a block naming the array field (the [*] alias), an optional where each element must satisfy, then a comparison of the resulting number. “Deny if any storage firewall IP rule allows a public range” becomes:
{
"count": {
"field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*]",
"where": {
"field": "Microsoft.Storage/storageAccounts/networkAcls.ipRules[*].value",
"equals": "0.0.0.0/0"
}
},
"greater": 0
}
Read it: count the IP rules whose value is the open range; if that count exceeds zero, the condition is true. The count/where/compare triple is the only correct way to evaluate a collection. The operand/helper reference:
| Operand / helper | Reads | Example | Use for |
|---|---|---|---|
field: "type" |
Resource type | "equals": "Microsoft.Storage/storageAccounts" |
Pin the resource type (almost always leaf #1) |
field: "name" |
Resource name | "like": "prod-*" |
Naming-convention rules |
field: "location" |
Region | "in": "[parameters('allowed')]" |
Allowed-region rules |
field: "kind" |
Resource kind | "equals": "StorageV2" |
Differentiate variants |
field: "tags['x']" |
A specific tag | "exists": false |
Required-tag rules |
field: "<alias>" |
A provider property | "equals": true |
The bulk of property rules |
value: "[parameters('p')]" |
A parameter | "in": [...] |
Parameterised allow-lists |
value: "[resourceGroup().name]" |
RG context | "notMatch": "rg-#####" |
Context-aware naming |
value: "[concat(...)]" |
Composed string | comparisons | Build expected values |
count { field: "<alias>[*]" } |
An array | "greater": 0 |
Any/all-element collection rules |
Effects you will use here: deny and audit
The then.effect is the teeth. This article uses two — deny (the request is blocked at create/update; the resource never exists) and audit (allowed but marked non-compliant for reports). They share an identical condition; only the effect differs — which is exactly why you parameterise it. The safe rollout: assign with audit first, watch the Compliance blade for what would be blocked, then flip the parameter to deny once no legitimate resource is caught.
A brief comparison (the effects deep-dive covers the rest):
| Effect | When it acts | Blocks the resource? | Needs identity? | Use in this lab for |
|---|---|---|---|---|
deny |
At create/update (request time) | Yes — request rejected | No | The enforced control |
audit |
At create/update + on scan | No — only flags non-compliant | No | The safe dry-run before deny |
disabled |
Never | No | No | Turn the assignment off without deleting it |
modify |
At create/update | No — alters the request | Yes (managed identity + role) | (out of scope; alters properties/tags) |
deployIfNotExists |
After create + on scan | No — deploys a related resource | Yes (managed identity + role) | (out of scope; remediation) |
A subtlety worth internalising: deny only stops new or updated resources. It does not delete existing violators — those just show as non-compliant. Handle them by hand, with a modify/deployIfNotExists policy, or by letting deny block the next bad change. “I assigned a deny but the bad storage account is still there” is expected: deny is a gate on the front door, not a cleanup crew.
Parameters: one definition, many behaviours
A parameter is a typed, named input declared in parameters and supplied at assignment. The most valuable is the effect itself; next are the values your rule compares against (allowed SKUs, regions, the required tag name). allowedValues constrains what an assigner can choose; defaultValue makes the parameter optional.
"parameters": {
"effect": {
"type": "String",
"metadata": { "displayName": "Effect", "description": "Deny blocks; Audit reports." },
"allowedValues": [ "Audit", "Deny", "Disabled" ],
"defaultValue": "Audit"
},
"allowedSkus": {
"type": "Array",
"metadata": { "displayName": "Allowed storage SKUs" },
"defaultValue": [ "Standard_LRS", "Standard_ZRS" ]
}
}
Reference them in the rule: "effect": "[parameters('effect')]" in then, "in": "[parameters('allowedSkus')]" in a leaf. The parameter types:
| Type | Holds | Declare extras | Reference in rule | Example use |
|---|---|---|---|---|
String |
one value | allowedValues, defaultValue |
[parameters('effect')] |
The effect; a required tag name |
Array |
list of values | defaultValue (array) |
"in": "[parameters('skus')]" |
Allowed SKUs / regions |
Boolean |
true/false | defaultValue |
comparisons | Toggle a sub-rule on/off |
Integer |
a number | defaultValue |
"greater": "[parameters('max')]" |
Numeric thresholds |
Object |
structured map | defaultValue (object) |
tag maps in modify |
Tag name→value maps |
DateTime |
a timestamp | defaultValue |
comparisons | Time-bound rules |
Two rules that prevent grief: give every parameter a metadata.displayName (what the assigner sees in the portal) and a sensible defaultValue (so the assignment can be created with minimal input and a forgotten parameter doesn’t fail it). For the effect, default to Audit — a fat-fingered assignment then audits rather than blocks.
Architecture at a glance
Hold this mental model of how a custom policy flows from JSON to enforcement — four stages, each of which a troubleshooting step maps onto.
Stage one — authoring. You write the definition JSON and register it as a definition object at a scope, typically a management group (so child subscriptions reuse it) or a single subscription. Registering does nothing alone — it is a rule on a shelf. A definition without an assignment is inert; this is the most common “why isn’t my policy working” surprise.
Stage two — assignment. You create an assignment binding the definition to a target scope (MG, subscription, RG, or resource) and supplying parameter values (e.g. effect = Deny). This activates the rule, and the scope inherits downward — assign at a subscription and every RG beneath it is covered. You can carve out exclusions (a notScopes list) for a grandfathered resource group.
Stage three — evaluation at request time. When someone creates or updates a resource in scope, the Resource Manager request passes through the policy engine before the resource is committed. The engine runs every applicable if; if a deny condition is true, the request is rejected synchronously with an error naming the policy. This is the enforcement gate.
Stage four — evaluation on a compliance scan. Independently, Azure runs a periodic compliance evaluation (~24h, or on demand) across existing resources, recording each in the compliance state (queryable via Resource Graph, shown on the Compliance blade). This is how audit reports, how deny surfaces pre-existing violations it didn’t create, and how you prove the control is live — forceable with az policy state trigger-scan.
The throughline: definition → assignment (at a scope) → request-time deny + scheduled compliance scan. When something breaks, ask which stage: definition wrong (bad alias/condition), no assignment (or wrong scope), request not in scope, or the scan hasn’t run yet. Keeping the stages distinct turns “my policy is broken” into a one-minute triage.
Real-world scenario
Meridian Retail, a mid-size e-commerce company, runs about 40 storage accounts across a prod and a nonprod subscription. A penetration test flagged two accounts with allowBlobPublicAccess = true — one an honest mistake from a quickstart template that defaults it on, the other a forgotten proof-of-concept. The board told security lead Anika: “make it impossible to create a publicly-accessible storage account in prod, effective immediately, and show me which existing ones are non-compliant.” One wrinkle: a legacy marketing site served static assets from a public container in resource group rg-legacy-cdn and couldn’t change before the next quarter.
Anika checked the built-ins and found “Storage account public access should be disallowed” — but it was an audit policy, and the board wanted prevention. So she authored a custom definition: if storage account and allowBlobPublicAccess is true, then the parameterised effect. She found the alias in ninety seconds via the portal cross-reference (opening the built-in’s JSON and copying Microsoft.Storage/storageAccounts/allowBlobPublicAccess) rather than guessing, and registered the definition at the management group above both subscriptions for reuse.
She rolled out cautiously. First to nonprod with effect = Audit: a scan surfaced exactly the three dev sandboxes she expected — confirming the condition matched real resources and nothing spurious. Then to prod with effect = Deny, adding rg-legacy-cdn to the assignment’s notScopes so the grandfathered site kept working. Within minutes a developer’s pipeline tried to create a public-access account in prod and got a hard denial: “Resource ‘stmktg9’ was disallowed by policy. Deny storage accounts that allow public blob access.” The pipeline failed loudly — the point.
What went briefly wrong: the first prod assignment reported zero non-compliant existing resources, and Anika feared a bad alias. It wasn’t — the two flagged accounts had already been remediated by hand after the pen-test, so there was genuinely nothing left to flag, and deny correctly does not delete anything; it only blocks new violations. The runbook lesson she wrote: test the condition against a known-bad resource before trusting “0 non-compliant” — a clean result means either “working perfectly” or “matching nothing,” and only a deliberate negative test tells them apart. Three months later the legacy site migrated, rg-legacy-cdn left notScopes, and the deny applied everywhere.
Advantages and disadvantages
Custom policy is powerful but carries cost — mostly cognitive and operational. The trade-off at a glance, then the nuance:
| Advantages | Disadvantages |
|---|---|
| Prevents bad resources at request time — not just reports them | Authoring is fiddly; wrong alias/array handling fails silently |
| No agent, no code, no per-resource cost — runs on the platform | Compliance scan is ~24h; “it’s not flagged yet” confuses people |
| One parameterised definition serves every environment | deny doesn’t fix existing violations — only blocks new ones |
| Inherits down a scope hierarchy automatically | Debugging a non-match means re-deriving the alias/condition |
| Versioned, reviewable JSON — fits IaC and PR review | Over-broad deny can block legitimate work and anger teams |
| Composes into initiatives and compliance dashboards | Some properties have no alias yet — you can’t govern them |
The advantages dominate when the control is preventive and broad — a guardrail enforced across a whole landing zone, expressible as a property condition, tuned per environment. Policy shines there because it is declarative, platform-native, and free, and because one definition at a management group protects every current and future subscription beneath it.
The disadvantages bite the first-time author and complex conditions. The silent-failure problem is real: no compiler tells you an alias is wrong, so a typo produces a policy that compliantly protects nothing; array conditions are easy to get subtly wrong; and the gap between “I assigned it” and “the dashboard reflects it” (up to a day, absent a manual scan) trips everyone once. The mitigations are discipline, not luck — look the alias up, test against a known-bad resource, and trigger-scan rather than wait. Where a property has no alias, policy can’t reach it; you fall back to Resource Graph queries plus alerting.
Hands-on lab
The centre of the article. You will author the “deny public-access storage accounts” policy from scratch — find its alias, build and parameterise the definition, assign it, and prove it denies — three ways: portal, az CLI, and Bicep. Everything is free except the throwaway storage account, deleted at teardown.
Prerequisites. An Azure subscription where you hold Owner or Resource Policy Contributor + Contributor (you create a definition, an assignment, and a storage account), Cloud Shell (Bash) or a recent local az, and about 25 minutes.
Part 0 — Set up variables and a lab resource group
Set the working variables and create an isolated resource group so teardown is one command.
# 0.1 — variables
SUB_ID=$(az account show --query id -o tsv)
LOCATION="eastus"
RG="rg-policy-lab"
DEF_NAME="deny-storage-public-blob-access" # internal policy name
ASSIGN_NAME="deny-public-storage-rg" # assignment name
# 0.2 — create the lab resource group
az group create -n "$RG" -l "$LOCATION" -o table
Expected: a table showing rg-policy-lab with provisioningState: Succeeded. Confirm your role:
# 0.3 — verify you can author policy (look for Owner or Resource Policy Contributor)
az role assignment list --assignee "$(az account show --query user.name -o tsv)" \
--scope "/subscriptions/$SUB_ID" --query "[].roleDefinitionName" -o tsv
If you see Owner or Resource Policy Contributor, you’re good; otherwise ask your subscription admin for Resource Policy Contributor at the subscription scope.
Part 1 — Find the alias (do this first, always)
Before writing any JSON, derive the alias for the property you’ll govern:
# 1.1 — find the public-access alias for storage accounts
az provider show --namespace Microsoft.Storage \
--query "resourceTypes[?resourceType=='storageAccounts'].aliases[].name" -o tsv \
| grep -i "publicaccess"
Expected output:
Microsoft.Storage/storageAccounts/allowBlobPublicAccess
Cross-check against a real resource to confirm its type (boolean):
# 1.2 — create a throwaway account WITH public access on, to use as the known-bad test later
az storage account create -n "stlabbad$RANDOM" -g "$RG" -l "$LOCATION" \
--sku Standard_LRS --allow-blob-public-access true -o none && echo "created known-bad account"
# 1.3 — read the property to confirm the alias maps to a boolean
BAD_SA=$(az storage account list -g "$RG" --query "[0].name" -o tsv)
az storage account show -n "$BAD_SA" -g "$RG" --query "allowBlobPublicAccess" -o tsv
Expected output of 1.3: true. The alias resolves to a boolean property that is true on a known-bad resource — the foundation for a condition you can trust. Keep $BAD_SA for the negative test.
Part 2 — Author and create the definition (az CLI)
Write the rule and parameters to separate files (az policy definition create takes them separately), then create at subscription scope.
# 2.1 — the policy rule (if/then)
cat > /tmp/policy-rule.json <<'JSON'
{
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess", "equals": true }
]
},
"then": { "effect": "[parameters('effect')]" }
}
JSON
# 2.2 — the parameters (a single, defaulted-to-Audit effect)
cat > /tmp/policy-params.json <<'JSON'
{
"effect": {
"type": "String",
"metadata": { "displayName": "Effect", "description": "Deny blocks creation; Audit reports." },
"allowedValues": [ "Audit", "Deny", "Disabled" ],
"defaultValue": "Audit"
}
}
JSON
# 2.3 — create the definition at the subscription scope
az policy definition create \
--name "$DEF_NAME" \
--display-name "Deny storage accounts that allow public blob access" \
--description "Blocks any storage account where allowBlobPublicAccess is true." \
--rules /tmp/policy-rule.json \
--params /tmp/policy-params.json \
--mode Indexed \
--metadata category=Storage version=1.0.0 \
--subscription "$SUB_ID" \
-o table
Expected output: a row showing name: deny-storage-public-blob-access, policyType: Custom, mode: Indexed. The definition now exists but is inert until assigned. Verify it landed:
# 2.4 — confirm the definition is registered
az policy definition show --name "$DEF_NAME" --query "{name:name, type:policyType, mode:mode}" -o table
Part 3 — Assign with Audit first (the safe dry-run)
Assign to the lab resource group with effect = Audit — reports without blocking, so the known-bad account gets flagged.
# 3.1 — assign at the resource-group scope, effect = Audit
RG_ID=$(az group show -n "$RG" --query id -o tsv)
az policy assignment create \
--name "$ASSIGN_NAME" \
--display-name "Deny public storage (lab RG)" \
--policy "$DEF_NAME" \
--scope "$RG_ID" \
--params '{ "effect": { "value": "Audit" } }' \
-o table
Expected output: a table row for the assignment with scope ending in /resourceGroups/rg-policy-lab. Now force a compliance scan rather than wait ~24h:
# 3.2 — trigger an on-demand compliance scan for this RG (this can take 1–5 minutes)
az policy state trigger-scan --resource-group "$RG"
# 3.3 — read the compliance state for the known-bad account
az policy state list --resource-group "$RG" \
--filter "PolicyAssignmentName eq '$ASSIGN_NAME'" \
--query "[].{resource:resourceId, state:complianceState}" -o table
Expected output of 3.3: at least one row with complianceState: NonCompliant pointing at $BAD_SA. This is the proof your condition matches a real resource — the most important checkpoint in the lab. If the known-bad account shows NonCompliant, your alias and condition are correct.
Part 4 — Flip to Deny and prove it blocks
Update the assignment’s parameter to Deny (the definition is untouched) and attempt a non-compliant create — it should be rejected.
# 4.1 — update the assignment to Deny
az policy assignment update \
--name "$ASSIGN_NAME" --scope "$RG_ID" \
--params '{ "effect": { "value": "Deny" } }' \
-o table
# 4.2 — wait ~30s for the assignment change to propagate, then attempt a known-bad create
az storage account create -n "stlabdeny$RANDOM" -g "$RG" -l "$LOCATION" \
--sku Standard_LRS --allow-blob-public-access true -o none
Expected output of 4.2 — a denial, not a created resource:
(RequestDisallowedByPolicy) Resource 'stlabdenyXXXXX' was disallowed by policy.
Policy identifiers: '[{"policyAssignment":{"name":"Deny public storage (lab RG)"} ...}]'
Now prove the compliant path still works — create an account with public access off:
# 4.3 — the compliant create succeeds (public access disabled)
az storage account create -n "stlabok$RANDOM" -g "$RG" -l "$LOCATION" \
--sku Standard_LRS --allow-blob-public-access false -o table
Expected output of 4.3: a normal storage-account row with provisioningState: Succeeded. You have now shown both halves — the bad create is denied, the good create succeeds. Assignment changes can take up to ~30 minutes to propagate; if 4.2 unexpectedly succeeds, wait and retry (covered in troubleshooting).
Part 5 — The portal version (same policy, click-by-click)
The identical control in the UI — skip if the CLI path worked, since it produces the same definition.
- Search Policy and open the Policy service → Authoring → Definitions → + Policy definition.
- Definition location: click
..., pick your subscription (or a management group). This is where the definition is stored. - Name:
Deny storage accounts that allow public blob access; paste the Description; Category → Use existing → Storage. - In the POLICY RULE box, replace the sample with the full definition JSON (the skeleton above —
mode,parameters,policyRuleat the top level). Click Save; it appears under Definitions filtered to Custom. - Open the definition → Assign. Scope: click
..., pick your subscription thenrg-policy-lab, Select (optionally add an Exclusion). - Parameters tab: Effect = Audit for the first pass → Review + create → Create.
- Compliance → find your assignment → Trigger evaluation (or wait). It shows Non-compliant with the offending count — click in to see the known-bad account.
- To enforce: assignment → Edit → Parameters → Effect = Deny → Save. Re-test by creating a public-access account in the portal; the create blade returns a policy violation error naming your assignment.
The portal and CLI paths converge on the same definition object — the portal is friendlier for the first; CLI/Bicep are how you do it repeatably.
Part 6 — The Bicep version (infrastructure-as-code)
For repeatable, reviewable governance, author the definition and assignment as Bicep — how the control lives in a landing-zone repo. Save as policy.bicep:
targetScope = 'subscription' // definitions/assignments at subscription scope
@description('Effect for the storage public-access policy.')
@allowed([ 'Audit', 'Deny', 'Disabled' ])
param effect string = 'Audit'
@description('Resource group to scope the assignment to (must already exist).')
param targetResourceGroupId string
resource denyPublicStorage 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
name: 'deny-storage-public-blob-access'
properties: {
displayName: 'Deny storage accounts that allow public blob access'
description: 'Blocks any storage account where allowBlobPublicAccess is true.'
policyType: 'Custom'
mode: 'Indexed'
metadata: { category: 'Storage', version: '1.0.0' }
parameters: {
effect: {
type: 'String'
metadata: { displayName: 'Effect', description: 'Deny blocks; Audit reports.' }
allowedValues: [ 'Audit', 'Deny', 'Disabled' ]
defaultValue: 'Audit'
}
}
policyRule: {
if: {
allOf: [
{ field: 'type', equals: 'Microsoft.Storage/storageAccounts' }
{ field: 'Microsoft.Storage/storageAccounts/allowBlobPublicAccess', equals: true }
]
}
then: { effect: '[parameters(\'effect\')]' }
}
}
}
resource assignment 'Microsoft.Authorization/policyAssignments@2023-04-01' = {
name: 'deny-public-storage-rg'
properties: {
displayName: 'Deny public storage (lab RG)'
policyDefinitionId: denyPublicStorage.id
// Scope the assignment to a specific resource group within the subscription
parameters: {
effect: { value: effect }
}
}
}
Scoping note: a subscription-deployed assignment defaults to the whole subscription. To scope it to one resource group as in the CLI lab, deploy the assignment at resource-group scope (a module with targetScope = 'resourceGroup'), or assign at subscription scope with a notScopes array. Deploy and validate:
# 6.1 — preview the change (what-if), then deploy
az deployment sub what-if \
--location "$LOCATION" \
--template-file policy.bicep \
--parameters effect=Audit targetResourceGroupId="$RG_ID"
az deployment sub create \
--location "$LOCATION" \
--template-file policy.bicep \
--parameters effect=Audit targetResourceGroupId="$RG_ID" \
-o table
Expected output: what-if shows a + Create for both the definition and the assignment; the create returns provisioningState: Succeeded. Bicep is the form you check into a repo and gate with a PR — see Bicep What-If: Preflight Validation as a CI Gate for wiring what-if into CI.
Part 7 — Validate end to end
A final checklist that the control is live:
# 7.1 — the assignment exists and points at the right definition
az policy assignment show --name "$ASSIGN_NAME" --scope "$RG_ID" \
--query "{name:name, def:policyDefinitionId, effect:parameters.effect.value}" -o json
# 7.2 — compliance reflects the known-bad account as NonCompliant (after a scan)
az policy state summarize --resource-group "$RG" \
--query "value[0].results.{nonCompliant:nonCompliantResources, resources:resourceDetails}" -o json
If 7.1 shows effect: Deny and 7.2 reports a non-zero nonCompliant count (or you saw the deny error in Part 4), the control is proven.
Part 8 — Teardown
Remove in reverse order — assignment, definition, then the resource group with its test accounts.
# 8.1 — delete the assignment
az policy assignment delete --name "$ASSIGN_NAME" --scope "$RG_ID"
# 8.2 — delete the definition
az policy definition delete --name "$DEF_NAME" --subscription "$SUB_ID"
# 8.3 — delete the lab resource group (removes all test storage accounts)
az group delete -n "$RG" --yes --no-wait
Expected: the assignment and definition deletions return silently; the RG delete proceeds in the background. The lab cost nothing beyond a few minutes of a Standard_LRS account. The whole-lab step/parameter reference:
| Step | Command / action | Expected result | Validates |
|---|---|---|---|
| 1 Find alias | az provider show ... | grep |
.../allowBlobPublicAccess |
The handle exists |
| 2 Create definition | az policy definition create |
policyType: Custom |
Definition registered |
| 3 Assign (Audit) | az policy assignment create |
scope = lab RG | Rule activated, non-blocking |
| 3 Scan | az policy state trigger-scan |
known-bad = NonCompliant | Condition matches reality |
| 4 Flip to Deny | az policy assignment update |
effect = Deny | Enforcement on |
| 4 Bad create | az storage account create --allow-blob-public-access true |
RequestDisallowedByPolicy |
Deny actually blocks |
| 4 Good create | ... --allow-blob-public-access false |
Succeeded |
No false positives |
| 6 Bicep deploy | az deployment sub create |
Succeeded |
IaC parity |
| 8 Teardown | delete assignment → def → RG | silent success | Clean slate |
Common mistakes & troubleshooting
These are the failures that bite every first-time author. Each: the symptom, the root cause, the exact way to confirm it, and the fix.
1. The policy reports 100% compliant and blocks nothing — wrong or non-existent alias.
Root cause: A guessed alias (allowPublicBlobAccess instead of allowBlobPublicAccess, or a path that doesn’t exist), so the field never resolves. Policy doesn’t error — it silently matches nothing.
Confirm: Re-derive with az provider show --namespace <ns> --query "resourceTypes[?resourceType=='<type>'].aliases[].name" -o tsv | grep -i <prop> and diff against your rule. Then test a known-bad resource (lab Part 3): if it shows Compliant, the alias is wrong.
Fix: Use the verified alias; re-create the definition; re-scan. Always test against a known-bad resource before trusting a clean result.
2. An array property never matches — scalar operator on a collection.
Root cause: { "field": ".../ipRules", "equals": ... } against an array property; scalar operators can’t evaluate collections.
Confirm: Read the resource JSON (az resource show ... --query <path>); if it’s [ {...}, {...} ], it’s an array. Does the alias end in [*]?
Fix: Switch to a count over the [*] alias with a where, comparing the count (e.g. "greater": 0). See the IP-rules example above.
3. The assignment evaluates nothing — wrong or empty scope.
Root cause: Assigned at a scope with no matching resources (empty RG, wrong subscription), or you registered the definition but never created an assignment.
Confirm: az policy assignment list --scope <scope> -o table — is the assignment there? az resource list --resource-group <rg> -o table — does the scope contain matching resources?
Fix: Create the assignment if missing; re-assign at a scope that contains the resources (or higher, so it inherits down).
4. mode: All floods the report with non-compliant resource groups.
Root cause: A property policy with mode: All evaluates RGs and subscriptions too — which can never have the property — generating noise.
Confirm: Compliance shows resource groups / the subscription flagged.
Fix: Set mode: Indexed for property/tag-on-resource policies (delete and recreate — mode isn’t always updatable in place).
5. The deny error appears but the existing bad resource is still there.
Root cause: deny blocks only new creates/updates; it does not delete pre-existing violations. By design.
Confirm: The non-compliant resource predates the assignment; the deny error only fires on new creates.
Fix: Remediate existing resources manually, or use modify/deployIfNotExists. Don’t expect deny to clean up history.
6. “I assigned it but compliance still shows the old state.”
Root cause: The scan runs ~every 24h; you’re reading stale state. The assignment is fine.
Confirm: The assignment is correct, but the Compliance blade hasn’t updated.
Fix: Force it — az policy state trigger-scan --resource-group <rg> (or Trigger evaluation); re-read after 1–5 minutes.
7. A deny assignment didn’t block a create that should have failed.
Root cause: Assignment changes take time to propagate (minutes, occasionally up to ~30). You tested too soon after flipping to Deny.
Confirm: az policy assignment show --name <name> --scope <scope> --query "parameters.effect.value" returns Deny, yet the create succeeded.
Fix: Wait and retry. If it persists past 30 minutes, check the resource is actually in scope and not in a notScopes exclusion.
8. The definition won’t create — JSON or parameter-reference error.
Root cause: Malformed JSON, an [parameters('x')] referencing an undeclared parameter, or effect set as a literal while parameterised (or vice versa).
Confirm: az policy definition create returns a parse/validation error naming the field.
Fix: Validate the JSON; ensure every [parameters('x')] has a matching entry; ensure allowedValues includes the value the assignment supplies (an out-of-range value fails the assignment).
9. An audit policy shows known-violating resources as compliant.
Root cause: Usually a too-narrow type leaf (the violators are a different kind) or a case/format mismatch in an equals.
Confirm: Temporarily reduce the condition to just the type leaf and re-scan — if the resources now appear, the property leaf is wrong; if not, the type leaf is.
Fix: Correct the type/property comparison; remember booleans are unquoted true/false and string compares can be case-sensitive (use matchInsensitively with care).
10. The [*] count matches when it shouldn’t (or vice versa) — where logic.
Root cause: The where tests the wrong element field, or the comparison (greater: 0 vs equals: 0) is inverted relative to “any element violates” vs “all comply.”
Confirm: Restate it: count(... where <bad>) > 0 = “at least one bad element exists.” Is that what you wrote?
Fix: Align the where field with the array-member alias (...[*].value) and pick the comparison matching “any” (greater: 0) vs “none/all.”
The decision table for “my policy isn’t doing what I expect”:
| If you see… | It’s probably… | Do this |
|---|---|---|
| 100% compliant, known-bad resource not flagged | Wrong/missing alias | Re-derive alias; test known-bad |
| Array property never matches | Scalar op on a collection | Switch to count/[*]/where |
| Nothing evaluated at all | No assignment / empty scope | Create assignment; assign higher |
| RGs/subscriptions flagged spuriously | mode: All on a property rule |
Set mode: Indexed |
| Deny error fires but old resource remains | Working as designed | Remediate existing separately |
| Compliance shows stale state | Scan hasn’t run | trigger-scan |
| Deny didn’t block a fresh create | Propagation lag | Wait/retry; check scope & notScopes |
| Definition won’t create | JSON / parameter mismatch | Fix JSON; match [parameters()] to declared params |
Best practices
- Find the alias before writing a line of JSON. Use
az provider show … \| grepor copy from a built-in’s “View definition.” Never guess — a wrong alias fails silently. - Test every new definition against a known-bad resource. A clean result is ambiguous (working vs matching-nothing); only a deliberate negative test proves the condition fires.
- Parameterise the effect, default it to
Audit. One definition drivesDenyin prod andAuditin dev, and a careless assignment audits rather than blocks. - Roll out
audit→ scan →deny. Assign asaudit, force a scan, confirm only the expected resources flag, then flip todeny. Never assign a brand-newdenystraight to prod. - Use
mode: Indexedfor property and tag-on-resource rules. ReserveAllfor rules targeting resource groups or subscriptions; it keeps reports clean. - Pin the resource
typeas the first leaf. It scopes the rule and makes every later leaf cheaper and clearer. - Give every parameter a
displayNameanddefaultValue. The display name is what assigners see; the default avoids “missing parameter” failures. - Use
count/[*]/wherefor every collection. The moment a property is an array, scalar operators are wrong. State the semantics out loud (“any element bad → count > 0”) to avoid inverted logic. - Author and version definitions as Bicep in a repo. Review in PRs, gate with
what-if, storemetadata.version; governance is code, not click-ops. - Assign high, exclude narrowly. Assign at an MG or subscription so the control covers current and future children; carve out grandfathered scopes with a tight
notScopes. - Force the scan; don’t wait a day.
az policy state trigger-scanafter any assignment change so the blade reflects reality in minutes. - Write the control’s intent into
displayName/description. “Deny storage accounts that allow public blob access” tells the on-call engineer and the auditor exactly what and why.
Security notes
- Authoring and assignment are privileged. Definitions and assignments require Resource Policy Contributor (or Owner) at the scope; grant it least-privilege at the lowest scope that works, not Owner at the root. See Azure RBAC Fundamentals: Roles, Scopes and Assignments Without the Confusion.
modify/deployIfNotExistsneed a managed identity. Those effects act on your behalf, so the assignment’s identity must be granted exactly the role it needs — over-granting it is a privilege-escalation risk. (deny/auditneed no identity.)- Policy is a guardrail, not a secret store. Never put secrets in parameter values or descriptions — they’re readable by anyone who can read the assignment.
- An over-broad
denyis a self-inflicted outage. Assigned at a management group it can block legitimate deployments across every subscription beneath it. Always dry-run asauditand scope deliberately. - Enforce baselines, don’t just report. Where built-ins audit (public access, weak TLS, missing encryption), a custom
denyturns the audit into prevention — the strongest “make it impossible to create X” control Azure offers. - Exclusions (
notScopes) are audit-visible holes. Track, time-box, and remove every grandfathered exception (as Meridian did withrg-legacy-cdn) rather than letting it become permanent. - Roll up with Defender for Cloud. Custom policies surface in the Microsoft Cloud Security Benchmark and regulatory-compliance dashboards; see Microsoft Cloud Security Benchmark Controls Explained.
Cost & sizing
The good news dominates: Azure Policy itself is free — no per-evaluation, per-assignment, or per-definition charge; the engine runs as part of Resource Manager. You pay only for the resources you govern; the lab cost a few minutes of a Standard_LRS account (well under ₹1). What you “size” is operational, not financial:
- Assignment count and scope breadth drive how much compliance data accumulates — thousands of resources under one assignment produce thousands of records (free, but more to read).
deployIfNotExists/modifyremediation can create resources (a diagnostic setting, an agent), and those cost money — e.g. a diagnostic setting sending logs to Log Analytics adds ingestion cost. Budget the remediation’s output, not the policy.- Definition and assignment limits are the real ceilings — caps per MG/subscription and per scope (hundreds to low thousands). For large estates, bundle definitions into initiatives: one initiative assignment counts as one slot regardless of how many definitions it carries. See Policy Definitions vs Initiatives: When to Bundle Controls into a Set.
The cost/limit picture at a glance:
| Item | Cost | Limit / sizing consideration | Watch-out |
|---|---|---|---|
| Policy definitions | Free | Capped per MG/subscription (hundreds–thousands) | Bundle into initiatives at scale |
| Policy assignments | Free | Capped per scope | One initiative assignment = one slot |
| Compliance evaluation | Free | ~24h cycle; on-demand scan available | Force a scan rather than wait |
audit / deny effects |
Free | No identity, no side-effects | An over-broad deny is an outage |
deployIfNotExists remediation |
Free to run, but deploys billable resources | Each remediation creates real resources | Budget the deployed resource (e.g. Log Analytics) |
| Compliance data | Free | Volume grows with resource count | More to read, not more to pay |
The honest sizing rule: policy is free, but governance discipline is the cost — the time to author correctly, test against known-bad resources, and maintain definitions as code.
Interview & exam questions
1. What are the two halves of a custom policy definition? The policyRule (the if/then core: an if condition over resource properties and a then naming the effect) and the parameters (typed assignment-time inputs making one definition reusable). The condition matches, the effect acts, parameters tune behaviour per assignment.
2. What is an alias and why is finding the right one critical? The provider-published handle for a resource property (e.g. Microsoft.Storage/storageAccounts/allowBlobPublicAccess) referenced in a field/value operand. A wrong or non-existent alias makes the condition silently never match — the policy reports 100% compliant and protects nothing. Verify with az provider show … aliases, a built-in’s “View definition,” or by reading a real resource’s JSON.
3. How do you evaluate an array property like a storage firewall’s IP rules? Not with a scalar operator — with a count expression over the array’s [*] alias plus an optional where, then compare the resulting number. For “any rule allows the open range,” count(ipRules[*] where value == '0.0.0.0/0') greater 0. Treating an array with equals is a classic silent no-match.
4. Difference between deny and audit, and the safe rollout? deny blocks the create/update at request time (the resource never exists); audit allows it but marks it non-compliant. Safe rollout: parameterise the effect, assign as audit first, scan, confirm only the expected resources flag, then flip the parameter to deny — never assign a brand-new deny straight to prod.
5. Why might a freshly-assigned policy show known-violating resources as compliant? Either the scan hasn’t run (~24h; force it with az policy state trigger-scan) or the condition is wrong (bad alias, scalar op on an array, too-narrow type leaf). The disambiguator: test against a known-bad resource — if it’s still “compliant” after a fresh scan, the condition is at fault.
6. What does mode: Indexed vs mode: All control? Which resource types are evaluated. Indexed evaluates only taggable resources and skips resource groups and subscriptions — correct for property and tag-on-resource rules. All evaluates everything including RGs and subscriptions — needed only when the policy targets those container scopes. All on a property rule floods the report with spurious non-compliant RGs.
7. Does a deny policy delete or fix existing non-compliant resources? No. deny is a request-time gate blocking new or updated resources; pre-existing violations remain (shown as non-compliant). Fix them manually or with a modify/deployIfNotExists policy. “I assigned deny but the bad resource is still there” is expected.
8. How do you make one definition behave differently per environment? Parameterise — declare an effect parameter (String, allowedValues: [Audit, Deny, Disabled], a default) and reference [parameters('effect')] in then; the assignment supplies the value. The same pattern parameterises allowed SKUs, regions, or a required tag name.
9. Which RBAC role authors/assigns policy, and what extra does modify/deployIfNotExists need? Resource Policy Contributor (or Owner) at the scope creates definitions and assignments. Effects that act — modify, deployIfNotExists — additionally need a managed identity on the assignment, granted exactly the remediation role (e.g. Contributor on the target). deny/audit need no identity.
10. How do you scope a policy and grandfather an exception? Assign the definition at a scope (MG, subscription, RG, or resource); it inherits downward. To exempt a child, add it to the assignment’s notScopes (or create a policy exemption). Assign high (future children covered), exclude narrowly (a tracked, time-boxed exception) — as in the Meridian rg-legacy-cdn case.
11. What’s the logical-operator vocabulary? allOf (AND), anyOf (OR), not (invert), nested around comparison leaves. A typical rule is an allOf whose first leaf pins the resource type and whose remaining leaves test properties, sometimes with a nested anyOf for “violates this OR that.”
12. You enforce “minimum TLS 1.2” but less behaves oddly. Why, and the robust approach? minimumTlsVersion is a string ("TLS1_2"), so less/greater compare lexically, not numerically. The robust approach: disallow the bad values explicitly with notEquals/notIn against "TLS1_0" and "TLS1_1". Knowing a property’s true type (from the resource JSON) prevents this whole class of bug.
These map to AZ-104 — implement and manage governance: create and assign Azure Policy, configure parameters and effects — and AZ-500 — platform protection and governance controls. The IaC angle (Bicep definitions/assignments, what-if gating) touches AZ-400. A compact mapping:
| Question theme | Primary cert | Objective area |
|---|---|---|
| Definition structure, effects, parameters | AZ-104 | Implement and manage governance |
Aliases, conditions, count arrays |
AZ-104 | Author and assign Azure Policy |
| Deny as a security guardrail, exemptions | AZ-500 | Platform protection & governance |
| RBAC for authoring; identity for remediation | AZ-500 / AZ-104 | Manage access and governance |
Bicep definitions + what-if CI gate |
AZ-400 | Infrastructure as code & governance |
Quick check
- You write a policy with the alias
Microsoft.Storage/storageAccounts/allowPublicBlobAccessand it reports every storage account as compliant, including one you know is public. What is the single most likely cause and how do you confirm it? - A property is a list of firewall IP rules. Which expression do you use to test “any rule allows
0.0.0.0/0,” and what alias shape does it require? - You assign a brand-new
denypolicy to production. What’s the safer two-step rollout you should have used instead? - You assigned the policy but the Compliance blade still shows the old state ten minutes later. What’s happening and what’s the one command to fix it?
- You want the same definition to block in prod but only report in dev. What feature makes that possible, and what’s the safe default value?
Answers
- The alias is wrong — the correct one is
allowBlobPublicAccess, notallowPublicBlobAccess, so thefieldnever resolves and nothing matches (silent no-match). Confirm by re-deriving it withaz provider show --namespace Microsoft.Storage --query "...aliases[].name" -o tsv | grep -i publicaccessand testing against a known-bad resource: a definitely-public account still showing “compliant” proves the alias is the culprit. - A
countexpression over the[*]array alias:countthenetworkAcls.ipRules[*]whose[*].valueequals0.0.0.0/0, comparedgreater 0. It requires the array ([*]) alias for thefieldand the array-member field ([*].value) inside thewhere— scalar operators can’t evaluate a collection. - Assign as
auditfirst, trigger a compliance scan, and confirm only the resources you expect are flagged — then flip the assignment’seffectparameter todeny. Going straight to deny in prod risks blocking legitimate deployments you didn’t anticipate. - The compliance scan runs only ~every 24 hours, so you’re reading stale state — the assignment itself is fine. Force an on-demand scan:
az policy state trigger-scan --resource-group <rg>(or Trigger evaluation in the portal), then re-read after 1–5 minutes. - Parameterising the effect — declare an
effectparameter (type: String,allowedValues: [Audit, Deny, Disabled]) and reference[parameters('effect')]inthen; the prod assignment passesDeny, the dev assignment passesAudit. The safedefaultValueisAudit, so a careless assignment reports rather than blocks.
Glossary
- Policy definition — the reusable rule, a JSON object with a
policyRule(if/then) and optionalparameters; inert until assigned. policyRule— the core of the definition: anifcondition and atheneffect.if/then— the condition that returns true/false, and the action (effect) that fires when it’s true.- Alias — the provider-published handle for a resource property (e.g.
Microsoft.Storage/storageAccounts/allowBlobPublicAccess) used infield/value; a wrong alias silently matches nothing. [*](array) alias — the array form of an alias, usable only inside acountexpression; signals you’re evaluating a collection.field— a condition operand that reads a resource property (an alias or a top-level field liketype,name,location,tags['x']).value— a condition operand that reads a literal, a parameter, or a template function (e.g.[resourceGroup().name]).allOf/anyOf/not— logical AND / OR / NOT used to combine condition leaves.count— an expression that counts array elements satisfying an innerwherecondition, then compares the number; the only correct way to test arrays.- Effect — what a match does:
deny(block),audit(flag non-compliant),disabled,modify,deployIfNotExists. deny— blocks a create/update at request time; the resource never exists. Does not delete pre-existing violations.audit— allows the resource but records it as non-compliant for reporting; the safe dry-run beforedeny.mode—Indexed(evaluate taggable resources, skip RGs/subscriptions) orAll(evaluate everything); useIndexedfor property/tag rules.- Parameter — a typed assignment-time input (
String/Array/Boolean/Integer/Object/DateTime) with optionalallowedValuesanddefaultValue. - Assignment — a definition bound to a scope with parameter values; what actually activates the rule. Inherits down the hierarchy.
notScopes— the assignment’s exclusion list; scopes within the assignment that are exempt (grandfathered exceptions).- Compliance state — the per-resource result (Compliant / NonCompliant) recorded by the periodic or on-demand evaluation.
az policy state trigger-scan— forces an on-demand compliance evaluation instead of waiting for the ~24h cycle.- Resource Policy Contributor — the RBAC role that allows creating and assigning policy definitions and assignments.
Next steps
You can now author a custom policy condition, find its alias, parameterise its effect, ship it three ways, and prove it denies. Build outward.
- Next: Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists — go deep on every effect, including the
modify/deployIfNotExistsremediation pair that this article left out. - Related: Policy Definitions vs Initiatives: When to Bundle Controls into a Set — gather your definitions into an initiative and assign them as one.
- Related: Azure Policy and Governance at Scale: Enforce the Rules Automatically — the landing-zone view: where to assign, how it inherits, and the operating model.
- Related: Management Groups 101: Designing a Hierarchy That Scopes Policy and RBAC — design the scope hierarchy your assignments inherit through.
- Related: Bicep What-If: Preflight Validation as a CI Gate — wire policy definitions and assignments into a reviewed, validated pipeline.