Everybody in a governance meeting says "we'll put a policy on it". Very few people in that meeting could tell you what the JSON looks like, why the policy did not block anything, or why the compliance dashboard says 94% when nothing has ever been enforced.

Azure Policy is not complicated, but it is made of five distinct objects that people constantly confuse: a definition, an initiative, an assignment, an exclusion, and an exemption. Mixing them up is how you end up with a governance estate nobody trusts.

This is the first of a seven-part series that takes those objects apart one at a time, then shows how to manage the whole thing as code.

The one-sentence definition

An Azure Policy definition is a rule written in JSON that Azure Resource Manager evaluates against your resources, and an action to take when the rule matches.

That is it. A condition and an effect. Everything else - initiatives, assignments, exemptions, compliance dashboards - is packaging and plumbing around that single idea.

The important consequence: a definition on its own does nothing. It is inert. It has to be assigned to a scope before a single resource is evaluated. I have lost count of the number of times someone has created a beautiful custom definition, gone to the compliance blade, and found nothing there. Nothing was wrong. Nothing was assigned. That is Part 3.

Policy is not RBAC

The single most useful mental model is this one:

Azure RBACAzure Policy
Question it answersWho can do it?What is allowed to exist?
Evaluated againstThe caller's identityThe resource payload
Typical outcome403 because you lack a permission403 because the resource shape is not permitted
Applies to existing resourcesNoYes - continuously re-evaluated
Can fix thingsNoYes, with modify and deployIfNotExists

An Owner on a subscription has every permission in Azure and still cannot create a VM in a banned region if a deny policy says so. Conversely, policy will never stop someone reading a storage key. The two systems are orthogonal, and you need both.

The second consequence of "evaluated against the resource" is the one people miss: policy keeps evaluating resources that already exist. RBAC is a gate at the door. Policy is a gate at the door and a continuous audit of everyone already in the building.

Anatomy of a definition

Here is a complete, realistic custom definition. Everything below is a variation on this shape.

json
{
  "properties": {
    "displayName": "Storage accounts must disable public blob access",
    "policyType": "Custom",
    "mode": "Indexed",
    "description": "Denies creation or update of a storage account that allows public blob access.",
    "metadata": {
      "version": "1.0.0",
      "category": "Storage"
    },
    "parameters": {
      "effect": {
        "type": "String",
        "metadata": {
          "displayName": "Effect",
          "description": "Enable or disable the execution of the policy"
        },
        "allowedValues": [ "Audit", "Deny", "Disabled" ],
        "defaultValue": "Audit"
      }
    },
    "policyRule": {
      "if": {
        "allOf": [
          {
            "field": "type",
            "equals": "Microsoft.Storage/storageAccounts"
          },
          {
            "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess",
            "notEquals": "false"
          }
        ]
      },
      "then": {
        "effect": "[parameters('effect')]"
      }
    }
  }
}

Six things to notice.

Note

Always parameterise effect, and always name the parameter effect. Every policy-as-code tool in Part 7 assumes that convention, and it is what makes staged rollouts (audit first, deny later) a config change rather than a rewrite.

The condition side

The if block is built from logical operators and conditions.

The logical operators are allOf (AND), anyOf (OR), and not. They nest freely, which is how you end up with policy rules that need a code review.

A condition compares something on the resource with a value. The something is either a field, a value expression, or a count expression.

Fields

The universally available fields are:

FieldWhat it holds
nameThe resource name
fullNameParent-prefixed name, for example myServer/myDatabase
typeThe full resource type, for example Microsoft.Storage/storageAccounts
kindThe resource kind where one exists
locationNormalised location; use global for location-agnostic resources
idThe full resource ID
identity.typeNone, SystemAssigned, UserAssigned, or SystemAssigned, UserAssigned
tagsWritten as tags['costCentre'] or tags.costCentre

Anything else - a storage account's TLS version, a VM's SKU, an NSG rule - is reached through an alias.

Aliases

An alias is Azure Policy's stable pointer into a resource provider's property bag. Microsoft.Storage/storageAccounts/allowBlobPublicAccess in the example above is one.

Aliases matter for two reasons. First, you cannot write a meaningful custom policy without them. Second, not every property has one, and discovering that a property is unreachable after you have designed a control is a bad afternoon.

Array aliases end in [*] and let you reason about collections - every NSG rule, every subnet, every IP configuration.

I keep a searchable copy of every supported alias here: Azure Policy Aliases. If you prefer the terminal, I wrote up the PowerShell approach in Search Azure Policy Aliases and Send Output to an Interactive Table.

Condition operators

The full current set:

CategoryOperators
Equalityequals, notEquals
Wildcardlike, notLike - one * maximum
Patternmatch, notMatch, matchInsensitively, notMatchInsensitively
Substringcontains, notContains - no wildcards allowed in the value
Setin, notIn, containsKey, notContainsKey
Numeric or dateless, lessOrEquals, greater, greaterOrEquals
Presenceexists

Two traps worth memorising. match and notMatch are case-sensitive - every other string condition is not. And in a match pattern, # means a digit, ? means a letter, and . means any character. That is not a regular expression, and treating it like one produces rules that silently never fire.

Counting things

count is how you express "at least one subnet without an NSG" or "more than three inbound rules allowing any source".

json
{
  "count": {
    "field": "Microsoft.Network/networkSecurityGroups/securityRules[*]",
    "where": {
      "allOf": [
        { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].direction", "equals": "Inbound" },
        { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].access", "equals": "Allow" },
        { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].sourceAddressPrefix", "in": [ "*", "Internet", "0.0.0.0/0" ] }
      ]
    }
  },
  "greater": 0
}

There is also a value count form that iterates over an array parameter rather than a resource array, which is how you write "the resource must carry every tag in this list".

Warning

If a template function inside a value expression throws, policy evaluation fails, and a failed evaluation is an implicit deny. A malformed substring() on a resource name shorter than you expected will block deployments across the whole scope. Guard functions with if(), and roll new rules out with enforcement disabled first - see Part 3.

The effect side

The effect is what happens when the condition matches. The current supported set:

EffectWhat it does
auditRecords non-compliance. Changes nothing.
denyBlocks the create or update request.
denyActionBlocks a specific action - today only delete.
appendAdds fields to the request payload before it is processed.
modifyAdds, updates, or removes properties and tags, and can remediate existing resources.
deployIfNotExistsDeploys an ARM template when a related resource is missing.
auditIfNotExistsReports non-compliance when a related resource is missing.
disabledTurns the rule off without deleting anything.
manualCompliance is set by a human attestation, for non-technical controls.
mutateMutates Kubernetes objects - Microsoft.Kubernetes.Data mode only.
addToNetworkGroupPopulates an Azure Virtual Network Manager network group - Microsoft.Network.Data mode only.

modify and deployIfNotExists are the two that make Azure Policy an automation engine rather than a reporting tool. Both require the assignment to carry a managed identity, and the definition to declare the roles that identity needs in a roleDefinitionIds array. That is Part 3 territory, and it is where most of the operational pain lives.

Of the whole list, deny is the one your users will meet. This is what it looks like from the other side - the deployment fails and the resource comes back Forbidden:

A deployment blocked by a deny policy, with the resource marked Forbidden
A failed deployment where the resource status reads Forbidden because a deny policy blocked it

Source: Tutorial: Build policies to enforce compliance - © Microsoft, Microsoft Learn.

The order effects run in

This matters more than people expect, because effects layer cumulatively and the most restrictive one wins.

  1. disabled - checked first, decides whether the rule is evaluated at all
  2. append and modify - they change the request, which can prevent a later audit or deny from ever triggering
  3. deny - before audit, so a blocked request is not double-logged
  4. audit
  5. manual
  6. auditIfNotExists
  7. denyAction - evaluated last

auditIfNotExists and deployIfNotExists then evaluate after the resource provider returns success, because both of them ask a question about a related resource that may only exist once the main one does.

Info

This ordering is why append/modify and deny on the same property is a design smell. The modify fixes the payload, the deny never fires, and your compliance dashboard shows a control that has never actually blocked anything. Pick one.

denyAction, briefly

denyAction is the newest genuinely useful effect and the least known. It blocks a verb rather than a shape - today only delete - and returns 403 Forbidden. It is how you protect a production resource group from accidental deletion without resorting to locks that also block legitimate automation.

Its cascadeBehaviors setting (default deny) controls whether deleting a parent that would cascade into a protected child is also blocked. And a short list of types is deliberately exempt from denyAction so you cannot lock yourself out - policy assignments, deny assignments, locks, subscriptions, and deployment stacks among them.

Modes

mode tells Azure which resources the definition is even considered for.

ModeUse it for
allEverything: resource groups, subscriptions, and all resource types
indexedOnly resource types that support tags and location

If you write a tag or location policy and leave it in all, you will generate a flood of non-compliant results for resources that can never carry a tag. Use indexed for anything tag- or location-based, and all for everything else.

Note

A missing mode behaves differently depending on the tool: Azure PowerShell defaults it to all, Azure CLI leaves it null, and a null mode is treated as indexed for backward compatibility. Set it explicitly. Always.

Beyond those two there are resource provider modes, which push evaluation down into a specific provider rather than Resource Manager:

Resource provider modes generally support built-in definitions only, and exemptions do not work at the component level inside them. If your governance story depends on exempting one pod in one namespace, check that constraint before you promise it.

When does evaluation actually happen?

A definition does not run continuously. Evaluation is triggered by:

This is why the dashboard lags reality. You changed a definition, you checked compliance five minutes later, nothing moved. Nothing was wrong. Trigger a scan, or wait.

The dashboard in question is the Compliance blade, which rolls everything up into one percentage and a breakdown per assignment:

The Azure Policy compliance page in the Azure portal
Overall resource compliance, resources by compliance state, and the count of non-compliant initiatives and policies

Source: Get policy compliance data - © Microsoft, Microsoft Learn.

It is also why deny is not retroactive. Existing non-compliant resources are reported, never deleted. Turning a control on does not clean up the estate - remediation does, and that is Part 3.

Versioning

Built-in definitions carry a {Major}.{Minor}.{Patch} version, and assignments can pin to one:

Patch updates are always taken automatically and cannot be opted out of, because that path is reserved for text corrections and break-glass security fixes. If you have ever been surprised by a built-in changing behaviour under you, this is the mechanism you want to understand before Part 6.

Built-in or custom?

Azure ships well over a thousand built-in definitions. The right instinct is: use a built-in unless you have proven you cannot.

Built-ins are versioned, maintained, prepopulated with the correct roleDefinitionIds, and mapped to regulatory compliance frameworks. A custom definition is a permanent maintenance liability that you now own.

You can browse the whole catalogue, including initiatives, here: Azure Policies.

Write a custom definition when the built-in genuinely does not exist, or when it exists but cannot be parameterised the way your standard requires. Not when you could not find it.

The limits that actually bite

WhereWhatMaximum
ScopePolicy definitions500
ScopeInitiative definitions200
TenantInitiative definitions2,500
Policy definitionParameters20
Policy ruleNested conditionals512
Definition or assignment request bodyBytes1,048,576

The one that surprises people is 20 parameters per definition. A "one policy to rule them all" design hits that wall fast, and the fix is almost always to split the rule into several definitions and group them with an initiative - which is exactly what Part 2 is about.

There are evaluation-time limits too: a function returning a string longer than 131,072 characters, or an object nested deeper than 128 levels, causes evaluation to fail. And a failed evaluation, as noted above, behaves like a deny.

Putting it together

Read the example from the top of the article as English and it should now be unambiguous:

> For every resource that supports tags and location (mode: indexed), if the type is a storage account and allowBlobPublicAccess is not false, then apply whatever effect the assignment passes in - audit by default.

That definition, sitting in your subscription, still does nothing at all. It has no scope. Nobody has assigned it.

Where next

Part 1 gave you the atom. Real governance estates are not built from single definitions - a "secure storage" standard is eight or ten rules that have to be assigned, parameterised, and reported on together.

Part 2 covers initiatives: how to group definitions, how parameters flow from the initiative down to its members, why policyDefinitionReferenceId is the most important string in the whole file, and the one initiative property you can never change after assignment.

Enjoy!