Azure Governance

Author a Custom Azure Policy: Aliases, Conditions, and Field Functions

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:

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

modeIndexed 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.

  1. Search Policy and open the Policy service → AuthoringDefinitions+ Policy definition.
  2. Definition location: click ..., pick your subscription (or a management group). This is where the definition is stored.
  3. Name: Deny storage accounts that allow public blob access; paste the Description; CategoryUse existingStorage.
  4. In the POLICY RULE box, replace the sample with the full definition JSON (the skeleton above — mode, parameters, policyRule at the top level). Click Save; it appears under Definitions filtered to Custom.
  5. Open the definition → Assign. Scope: click ..., pick your subscription then rg-policy-lab, Select (optionally add an Exclusion).
  6. Parameters tab: Effect = Audit for the first pass → Review + createCreate.
  7. Compliance → find your assignment → Trigger evaluation (or wait). It shows Non-compliant with the offending count — click in to see the known-bad account.
  8. To enforce: assignment → EditParametersEffect = DenySave. 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

Security notes

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:

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 actmodify, 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-104implement and manage governance: create and assign Azure Policy, configure parameters and effects — and AZ-500platform 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

  1. You write a policy with the alias Microsoft.Storage/storageAccounts/allowPublicBlobAccess and 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?
  2. 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?
  3. You assign a brand-new deny policy to production. What’s the safer two-step rollout you should have used instead?
  4. 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?
  5. 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

  1. The alias is wrong — the correct one is allowBlobPublicAccess, not allowPublicBlobAccess, so the field never resolves and nothing matches (silent no-match). Confirm by re-deriving it with az provider show --namespace Microsoft.Storage --query "...aliases[].name" -o tsv | grep -i publicaccess and testing against a known-bad resource: a definitely-public account still showing “compliant” proves the alias is the culprit.
  2. A count expression over the [*] array alias: count the networkAcls.ipRules[*] whose [*].value equals 0.0.0.0/0, compared greater 0. It requires the array ([*]) alias for the field and the array-member field ([*].value) inside the where — scalar operators can’t evaluate a collection.
  3. Assign as audit first, trigger a compliance scan, and confirm only the resources you expect are flagged — then flip the assignment’s effect parameter to deny. Going straight to deny in prod risks blocking legitimate deployments you didn’t anticipate.
  4. 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.
  5. Parameterising the effect — declare an effect parameter (type: String, allowedValues: [Audit, Deny, Disabled]) and reference [parameters('effect')] in then; the prod assignment passes Deny, the dev assignment passes Audit. The safe defaultValue is Audit, so a careless assignment reports rather than blocks.

Glossary

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.

AzureAzure PolicyGovernanceAliasesBicepaz CLIComplianceAZ-104
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading