Time: ~2 hours · Difficulty: Intermediate · You need: a subscription you may experiment in, where you can create policy definitions and assign roles on one resource group (Owner covers both); the Azure CLI (command-line interface); and jq. Azure Policy has no charge on Azure resources, and the lab creates only network security groups, which run nothing.
How this page was checked: no policy was created or assigned in Azure to write this page, so none of the az commands in the walk-through has been run against Azure, and the results it describes are what Microsoft's documentation says should happen. What did run: every policy definition was parsed as JSON (JavaScript Object Notation) and its rule validated against Microsoft's published policy rule schema; every alias was checked against the alias lists Azure returns for Microsoft.Network and Microsoft.Insights (read-only calls, 3 November 2026); every az option was checked against the help of Azure CLI 2.91.0, and the CLI's handling of port lists against its source; and every shell block was run in bash and zsh with a stand-in for az to catch quoting errors, with the jq steps run for real. The role assignment conditions section was checked against Microsoft's documentation only.
A rule engine in front of every change
Every create, update and delete in Azure passes through Azure Resource Manager, whether it comes from the portal, the CLI, Terraform or a software development kit. Azure Policy is the rule engine inside that path. A policy definition is a JSON document that pairs a condition with an effect: if a resource looks like this, deny the request, or record the resource as non-compliant, or change it, or deploy something beside it. An assignment applies a definition to a scope, which is a management group, a subscription, a resource group or a single resource. From then on Azure checks every matching request against it and re-checks existing resources on a schedule.
Platform and cloud security teams write policy to set guardrails that hold whichever tool a team deploys with. Governance, risk and compliance (GRC) engineers assign Microsoft's regulatory compliance initiatives, such as those for the Center for Internet Security (CIS) benchmarks, the US National Institute of Standards and Technology's Special Publication 800-53 and the Payment Card Industry Data Security Standard (PCI DSS), which are collections of policy definitions assigned like any other. Either way, someone has to read and write the definition language, and that is what this page teaches, with one running example: a rule against Secure Shell (SSH) open to the internet on a network security group, written wrong first and then right, in a throwaway resource group.
A policy acts only on what it can see, and a comparison against a property it cannot see is false. Azure evaluates the JSON that Resource Manager holds for a request or a resource: only the resource types the definition's mode admits, only the properties present in that JSON, and only at the moments evaluation runs. When the JSON is not shaped the way the author pictured, a test such as equals or in is false, a false condition means the effect does not apply, and the resource is reported as compliant, with no warning anywhere. Each failure in the next section is a version of this. The exceptions are deliberate: exists: false, and not around a positive test, are true when a property is absent, which is how Microsoft's own built-in definitions catch a missing setting.
Five ways a policy silently does nothing
These five never produce an error. Each one leaves an assignment in place that looks like a control, with a compliance page to match. The walk-through below reproduces all five in one resource group, and fixes them.
- Indexed mode on a type that cannot be tagged. A definition in
Indexedmode evaluates only resource types that support tags and location. Resource groups, subscriptions and child resources such as the rules inside a network security group are never evaluated. A definition created without a mode getsIndexed: that is the API's default, and so the Azure CLI's when you pass only the rule, even if the file you took it from saysAll. - An audit effect read as enforcement.
auditwrites a warning to the activity log and marks the resource non-compliant, and the request goes through. Fifty audit assignments report problems and stop none of them. - A condition on a property that is absent. A
fieldcondition reads a property from the request or the stored resource. If the property is not in that JSON, a comparison such asequalsis false, the resource does not match, and it is reported compliant. Network security group rules carry ports as eitherdestinationPortRangeordestinationPortRanges, never both, so a policy written against one form passes every rule written in the other. - An assignment in
DoNotEnforcemode. The assignment is evaluated and reports non-compliance, but its effect never runs and nothing reaches the activity log. It is the right way to trial a deny and an easy setting to forget. - Resources that already existed.
deny,appendandmodifyact on create and update requests. Resources that already break the rule are marked non-compliant and left as they are until someone changes them, or until a remediation task runs, which onlymodifyanddeployIfNotExistssupport.
One failure runs the other way. If a template function inside a condition errors, for example substring() asked for three characters of a two-character name, the evaluation fails, and Azure treats a failed evaluation as a deny. A typo there blocks deployments instead of letting them through.
On this page
Anatomy of a definition
Here is the first attempt at the running example, as it would sit in a repository. It is meant to deny any network security group rule that lets SSH in from anywhere, and step 5 of the walk-through shows why it does not.
{
"properties": {
"displayName": "Deny SSH from the internet (first attempt)",
"mode": "All",
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "equals": "22" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] }
]
},
"then": { "effect": "deny" }
}
}
}The parts:
displayName, with optionaldescriptionandmetadata, describe the definition. Its name and ID are not in the file: they belong to the resource the definition becomes when you create it, under a management group or a subscription. That location also limits where it can be assigned.modedecides which resource types are evaluated at all.Allcovers resource groups, subscriptions and every resource type.Indexedcovers only types that support tags and location. Microsoft recommendsAllin most cases andIndexedfor policies that enforce tags or location, so that resources unable to carry them don't fill the report as non-compliant. A rule on resource groups or subscriptions needsAll. There are also resource provider modes, such asMicrosoft.Kubernetes.Datafor Kubernetes clusters andMicrosoft.KeyVault.Datafor the keys, secrets and certificates inside Key Vault, which the service itself evaluates.policyRuleholdsif, the condition, andthen, which names one effect.parameters, absent here, declare values that each assignment fills in, each with a type and optionallyallowedValuesand adefaultValue. A rule reads one as[parameters('name')]. The corrected definition in step 4 makes its effect a parameter, so the same definition can be assigned as Audit in one place and Deny in another.
Fields and aliases
A condition starts with what it reads. field reads from the resource. A few names work on every resource type: type, name, fullName, kind, location, id, identity.type, and tags or tags['owner']. Everything else is reached through an alias. Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange is an alias for the path properties.destinationPortRange inside a rule, and Azure keeps that mapping for each API (application programming interface) version, so one definition works whichever version a client sends.
Aliases come from each resource provider. List them for one resource type:
az provider show --namespace Microsoft.Network --expand "resourceTypes/aliases" --query "resourceTypes[?resourceType=='networkSecurityGroups/securityRules'].aliases[].name" -o tsv
The first lines of that list on 3 November 2026:
Microsoft.Network/networkSecurityGroups/securityRules/protocol Microsoft.Network/networkSecurityGroups/securityRules/sourcePortRange Microsoft.Network/networkSecurityGroups/securityRules/sourcePortRanges[*] Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]
A rule has no tags alias because a rule cannot be tagged, which is why Indexed mode skips it. Only some aliases can be written by the modify effect, and Azure marks those Modifiable. Filter for them:
az provider show --namespace Microsoft.Network --expand "resourceTypes/aliases" --query "resourceTypes[?resourceType=='networkSecurityGroups/securityRules'].aliases[] | [?defaultMetadata.attributes=='Modifiable'].name" -o tsv
Microsoft's Azure Policy extension for Visual Studio Code also shows the alias for a property when you hover over it in a resource's JSON. If a property has no alias, no condition can read it.
Conditions and logic
A condition is a field (or a value) and one operator: equals, notEquals, in, notIn, like and notLike (with one * wildcard), contains, containsKey, exists, less, greater and their OrEquals forms, and match, where # stands for a digit, ? for a letter and . for any character. String comparisons ignore case, except match and notMatch, which have matchInsensitively forms. Conditions combine with allOf (and), anyOf (or) and not, nested as deep as you need.
Use value in place of field to test the result of a template function or a parameter. Most Azure Resource Manager template functions are available, plus a few that exist only in policy: field(), current(), ipRangeContains(), addDays(), policy() and requestContext().apiVersion. This is where the fail-closed case lives: a function that errors fails the evaluation, which counts as a deny. Wrap risky calls in if(), as Microsoft's own example does:
{
"value": "[if(greaterOrEquals(length(field('name')), 3), substring(field('name'), 0, 3), 'not starting with abc')]",
"equals": "abc"
}Arrays and count
An alias ending in [*] selects every member of an array, and a plain field condition on it is true only when every member satisfies it. So destinationPortRanges[*] equals "22" is false for a rule allowing 22 and 443, and true for a rule with no port list at all, because no member breaks it. That is rarely the question you meant. count asks the question directly: it evaluates its where condition once for each member and compares the number that matched.
{
"count": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
"where": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
"in": ["22", "*"]
}
},
"greater": 0
}Inside where, the same alias refers to the one member being counted. A value count does the same over a literal array or an array parameter, naming the current member with name and reading it with current(). A rule may hold up to five field counts per array and ten value counts.
Effects
Each definition has exactly one effect. The effect decides what a match does, and when.
| Effect | What a match does | When it acts | Existing resources |
|---|---|---|---|
| audit | Writes a warning to the activity log and marks the resource non-compliant. The request succeeds. | Create and update, and every compliance scan | Marked non-compliant |
| deny | Fails the request with 403 Forbidden before it reaches the resource provider. | Create and update | Marked non-compliant, left as they are |
| modify | Adds, replaces or removes tags, and adds or replaces properties whose alias is Modifiable, in the request itself. | Create and update | Marked non-compliant until a remediation task fixes them |
| append | Adds fields to the request, and acts as a deny if that would overwrite a different value. | Create and update | Marked non-compliant, no remediation |
| auditIfNotExists | Looks for a related resource, such as a diagnostic setting on a key vault, and audits if none matches. | After the resource provider succeeds, 10 minutes later by default | Marked non-compliant |
| deployIfNotExists | The same check, then deploys a Resource Manager template when nothing matches. | After the resource provider succeeds | Marked non-compliant until a remediation task fixes them |
| denyAction | Blocks a delete of matching resources. delete is the only action it supports. | Delete requests | Reported as Protected |
| disabled | Nothing. Used through a parameter to switch one assignment off. | Never | Reported compliant |
Three more are specialised: manual lets a person attest compliance, as in regulatory initiatives; mutate changes Kubernetes objects in Azure Kubernetes Service; and addToNetworkGroup serves Azure Virtual Network Manager.
When several assignments apply to one request, Azure evaluates each independently, and the result is the most restrictive. Within a request, disabled is checked first, then append and modify, then deny, then audit, so a modify can make a request compliant before a deny sees it. auditIfNotExists and deployIfNotExists run after the resource provider reports success.
The two existence effects carry their target in details. This rule audits key vaults that have no diagnostic setting sending any logs, in the shape Microsoft's built-in Key Vault definitions use:
{
"if": { "field": "type", "equals": "Microsoft.KeyVault/vaults" },
"then": {
"effect": "auditIfNotExists",
"details": {
"type": "Microsoft.Insights/diagnosticSettings",
"existenceCondition": {
"count": {
"field": "Microsoft.Insights/diagnosticSettings/logs[*]",
"where": { "field": "Microsoft.Insights/diagnosticSettings/logs[*].enabled", "equals": "true" }
},
"greaterOrEquals": 1
}
}
}
}modify and deployIfNotExists change resources, so their details must list roleDefinitionIds, the roles their changes need. An assignment of either needs a managed identity holding those roles. Remediation tasks and deployIfNotExists deployments run as that identity, while evaluation always uses the identity of whoever sent the request. The portal grants the roles when it creates the assignment; the CLI and the software development kits do not, unless you ask, as step 5 does.
denyAction has edges worth knowing before relying on it: deleting a parent still deletes its children and extension resources, so protecting a diagnostic setting does not stop someone deleting the storage account it belongs to; and when a whole resource group is deleted, only a definition in Indexed mode can block it, through its cascadeBehaviors setting; one in All mode never does.
Assignments, initiatives and when evaluation runs
A definition does nothing until it is assigned. An assignment names the definition and a scope, and everything below that scope inherits it: assigned at a management group, it applies to every subscription, resource group and resource underneath. The assignment also carries:
- Parameters, the values for this scope, such as
{"effect": {"value": "Deny"}}. enforcementMode:Defaultapplies the effect.DoNotEnforceevaluates and reports but never applies the effect on create or update and writes no activity log entry; remediation tasks can still be run.Enrollenforces only for scopes that opt in through enrollment resources, for staged roll-outs.- Exclusions (
notScopes), scopes removed from evaluation entirely. - Non-compliance messages, shown to whoever is denied. A denial that says what to fix saves a ticket.
- An identity, for
modifyanddeployIfNotExists, plus alocationwhen it is system-assigned.
Microsoft recommends exclusions for broad, permanent bypasses, such as a test environment, and exemptions for specific or time-bound ones. For an exception to a security control, prefer the exemption. An excluded scope drops out of the compliance report and is visible only in the assignment's notScopes; an exempted resource stays in the report as Exempt, can carry an expiry date, and creating one needs a permission on the assignment itself, so the exception stays visible and reviewable.
An initiative (a policy set definition) groups definitions so they are assigned, parameterised and reported together. Microsoft's regulatory compliance initiatives are initiatives with hundreds of members. This one wraps the corrected SSH rule from the walk-through and passes its own parameter through:
{
"properties": {
"displayName": "Network guardrails",
"parameters": {
"sshEffect": { "type": "String", "allowedValues": ["Audit", "Deny", "Disabled"], "defaultValue": "Audit" }
},
"policyDefinitions": [
{
"policyDefinitionReferenceId": "noInternetSsh",
"policyDefinitionId": "/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/policyDefinitions/csoh-lab-ssh",
"parameters": { "effect": { "value": "[parameters('sshEffect')]" } }
}
]
}
}Evaluation happens at these moments, according to Microsoft's documentation:
- When a resource is created or updated, in the request itself. The compliance result for that one resource appears about 15 minutes later.
- When an assignment is created or updated. It takes about five minutes to apply, then the resources in scope are evaluated.
- Once every 24 hours, in a standard compliance scan of every assignment.
- On demand:
az policy state trigger-scan --resource-group <group>.
Resource types under Microsoft.Resources are never evaluated, except subscriptions and resource groups.
Walk-through
1. Know what you can test locally
Nothing on your own machine runs Azure Policy's engine. The Visual Studio Code extension can evaluate a definition against a resource, but it needs an Azure sign-in, and Microsoft documents that its evaluation feature does not work on macOS or Linux. PSRule for Azure has an experimental feature that converts policy assignments, exported from Azure, into its own rules for checking templates before deployment: useful in a pipeline, but a translation of your policy rather than Azure evaluating it. Locally you can check that a definition is well-formed JSON with the parts it needs. Whether it does what you meant is decided in Azure, so the test bed is a throwaway resource group with every assignment scoped to it.
Run the steps in one terminal: later steps reuse a variable set in step 2.
2. Choose the subscription and make the lab
az account show --query "{name:name, id:id}" -o tableCheck that it is the subscription you mean to use. The definitions in steps 3 and 4 are created in it but do nothing until assigned, and every assignment is scoped to the lab resource group. Keep the subscription ID for later steps:
SUB=$(az account show --query id -o tsv)
Create the resource group, tagged with an owner that step 7 copies onto resources, and one network security group before any policy exists:
az group create --name csoh-policy-lab --location eastus --tags owner=platform-team
az network nsg create --resource-group csoh-policy-lab --name lab-nsg-before
3. Create the first attempt
Save the definition from the anatomy section as a file:
cat > ssh-naive.json <<'EOF'
{
"properties": {
"displayName": "Deny SSH from the internet (first attempt)",
"mode": "All",
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "equals": "22" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] }
]
},
"then": { "effect": "deny" }
}
}
}
EOFThe CLI takes the parts of a definition as separate options, so pull each one out of the file with jq. This creates a definition in the subscription; it has no effect until step 5 assigns it.
az policy definition create --name csoh-lab-ssh-naive --display-name "$(jq -r .properties.displayName ssh-naive.json)" --mode "$(jq -r .properties.mode ssh-naive.json)" --rules "$(jq -c .properties.policyRule ssh-naive.json)"
4. Write the corrected rule, and create it twice
The corrected rule checks both forms of each property: the single destinationPortRange and the list destinationPortRanges, and the same for the source. It treats * (every port) like 22, and makes the effect a parameter. Like the first attempt, it judges rules created on their own, the way az network nsg rule create sends them; exercise 3 covers rules sent inside the network security group's own request.
cat > ssh.json <<'EOF'
{
"properties": {
"displayName": "SSH must not be open to the internet",
"description": "Inbound Allow rules from any source must not include port 22, in either the single or the list form of the rule.",
"mode": "All",
"metadata": { "version": "1.0.0", "category": "Network" },
"parameters": {
"effect": {
"type": "String",
"allowedValues": ["Audit", "Deny", "Disabled"],
"defaultValue": "Audit",
"metadata": { "displayName": "Effect", "description": "Audit, Deny or Disabled" }
}
},
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups/securityRules" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/direction", "equals": "Inbound" },
{
"anyOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange", "in": ["22", "*"] },
{
"count": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
"where": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
"in": ["22", "*"]
}
},
"greater": 0
}
]
},
{
"anyOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
{
"count": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]",
"where": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules/sourceAddressPrefixes[*]",
"in": ["*", "Internet", "0.0.0.0/0"]
}
},
"greater": 0
}
]
}
]
},
"then": { "effect": "[parameters('effect')]" }
}
}
}
EOFCreate it the careful way, with the mode from the file:
az policy definition create --name csoh-lab-ssh --display-name "$(jq -r .properties.displayName ssh.json)" --mode "$(jq -r .properties.mode ssh.json)" --params "$(jq -c .properties.parameters ssh.json)" --rules "$(jq -c .properties.policyRule ssh.json)"
Then create it again the way most people first do, passing the rule and parameters and nothing else:
az policy definition create --name csoh-lab-ssh-indexed --params "$(jq -c .properties.parameters ssh.json)" --rules "$(jq -c .properties.policyRule ssh.json)"
az policy definition show --name csoh-lab-ssh-indexed --query mode -o tsv
Expect Indexed: Azure CLI 2.91.0 documents Indexed as the default for --mode, and nothing in the rule says otherwise. Same rule, same file, and as step 5 shows, this one never evaluates a single network security group rule.
The last definition copies the resource group's owner tag onto any resource that lacks one. It is a modify policy, so it names the role its change needs: Tag Contributor.
cat > inherit-owner.json <<'EOF'
{
"properties": {
"displayName": "Inherit the owner tag from the resource group",
"mode": "Indexed",
"policyRule": {
"if": {
"allOf": [
{ "field": "tags['owner']", "exists": "false" },
{ "value": "[resourceGroup().tags['owner']]", "notEquals": "" }
]
},
"then": {
"effect": "modify",
"details": {
"roleDefinitionIds": [
"/providers/Microsoft.Authorization/roleDefinitions/4a9ae827-6dc8-4573-8ac7-8239d42aa03f"
],
"operations": [
{ "operation": "add", "field": "tags['owner']", "value": "[resourceGroup().tags['owner']]" }
]
}
}
}
}
}
EOFHere Indexed is correct: only resources that can carry tags should be judged on one.
az policy definition create --name csoh-lab-inherit-owner --display-name "$(jq -r .properties.displayName inherit-owner.json)" --mode "$(jq -r .properties.mode inherit-owner.json)" --rules "$(jq -c .properties.policyRule inherit-owner.json)"
5. Assign everything to the lab, and test
Every command here names the resource group. Without --resource-group or --scope, az policy assignment create assigns at the whole subscription.
az policy assignment create --name lab-ssh-naive --policy csoh-lab-ssh-naive --resource-group csoh-policy-lab
az policy assignment create --name lab-ssh-indexed --policy csoh-lab-ssh-indexed --resource-group csoh-policy-lab --params '{"effect": {"value": "Deny"}}'az policy assignment create --name lab-ssh-audit --policy csoh-lab-ssh --resource-group csoh-policy-lab --params '{"effect": {"value": "Audit"}}'az policy assignment create --name lab-ssh-trial --policy csoh-lab-ssh --resource-group csoh-policy-lab --params '{"effect": {"value": "Deny"}}' --enforcement-mode DoNotEnforceThe tag policy gets a system-assigned managed identity, granted Tag Contributor on the lab group only:
az policy assignment create --name lab-inherit-owner --policy csoh-lab-inherit-owner --resource-group csoh-policy-lab --mi-system-assigned --location eastus --role "Tag Contributor" --identity-scope "/subscriptions/$SUB/resourceGroups/csoh-policy-lab"
So four assignments carry an SSH rule: the first attempt as a deny, the corrected rule as a deny in Indexed mode, the corrected rule as an audit, and the corrected rule as a deny that is not enforced. Assignments take about five minutes to apply. Then try the plainest violation, SSH from anywhere, with one port:
az network nsg rule create --resource-group csoh-policy-lab --nsg-name lab-nsg-before --name allow-ssh --priority 100 --direction Inbound --access Allow --protocol Tcp --source-address-prefixes '*' --destination-port-ranges 22
This should fail with RequestDisallowedByPolicy, and the error lists the assignments that denied it. Expect lab-ssh-naive alone. lab-ssh-trial matches too but is not enforced, and lab-ssh-indexed never looked. If the rule is created instead, the assignments have not applied yet: delete it with az network nsg rule delete --resource-group csoh-policy-lab --nsg-name lab-nsg-before --name allow-ssh, wait a few minutes and try again.
Now the same rule as someone who also wants web traffic on port 443 would write it, with two ports:
az network nsg rule create --resource-group csoh-policy-lab --nsg-name lab-nsg-before --name allow-ssh-https --priority 110 --direction Inbound --access Allow --protocol Tcp --source-address-prefixes '*' --destination-port-ranges 22 443 --query "{name:name, destinationPortRange:destinationPortRange, destinationPortRanges:destinationPortRanges}"Expect it to be created: SSH open to the internet in a resource group with four assignments that carry a rule against it. The output shows why the first attempt missed it: given more than one port, the CLI sends the list form, destinationPortRanges, and leaves destinationPortRange out. The first attempt's condition on destinationPortRange has nothing to compare, so it is false. lab-ssh-indexed has the right condition but never evaluates rules. lab-ssh-audit matched and let it through, because that is what audit does, and lab-ssh-trial matched and is not enforced.
Create a second network security group, now that the tag policy is assigned. The modify effect should add the tag in the request itself:
az network nsg create --resource-group csoh-policy-lab --name lab-nsg-after --query "NewNSG.tags"
Expect {"owner": "platform-team"}. Then ask for a compliance scan of the lab, which can take several minutes, and read the results:
az policy state trigger-scan --resource-group csoh-policy-lab
az policy state list --resource-group csoh-policy-lab --query "[].{assignment:policyAssignmentName, state:complianceState, resource:resourceId}" -o tableLook at the rows for allow-ssh-https. lab-ssh-naive evaluated it and calls it Compliant: that is the absent-property failure, recorded as a pass. lab-ssh-audit and lab-ssh-trial call it NonCompliant, which is the corrected rule working, reported and not enforced. lab-ssh-indexed has no row for any rule: it was never evaluated. And lab-nsg-before is NonCompliant for lab-inherit-owner, because it existed before the policy did.
6. Enforce the corrected rule
The trial assignment reported what it would have blocked. Switch it to enforcing:
az policy assignment update --name lab-ssh-trial --resource-group csoh-policy-lab --enforcement-mode Default
After a few minutes, try a two-port rule again:
az network nsg rule create --resource-group csoh-policy-lab --nsg-name lab-nsg-before --name allow-ssh-rdp --priority 120 --direction Inbound --access Allow --protocol Tcp --source-address-prefixes '*' --destination-port-ranges 22 3389
Expect RequestDisallowedByPolicy from lab-ssh-trial. Note what has not changed: allow-ssh-https is still there and SSH is still open. A deny stops new requests and leaves existing resources as they are, so the cleanup of what is already wrong is a separate job: a remediation task where the effect supports one, and a person where it does not.
One request shape is still not covered. An Azure Resource Manager template, a Bicep file or Terraform's inline security_rule blocks can send rules inside the network security group's own request, and that request's type is Microsoft.Network/networkSecurityGroups, so this definition's first condition is false for it. Exercise 3 adds the branch that reads the group's own list of rules.
7. Remediate the existing resource
lab-nsg-before has no owner tag, and will not get one until something changes it. A remediation task applies the modify operation to existing non-compliant resources, using the assignment's identity:
az policy remediation create --name lab-fix-owner --policy-assignment lab-inherit-owner --resource-group csoh-policy-lab
az policy remediation show --name lab-fix-owner --resource-group csoh-policy-lab --query "{state:provisioningState, deployments:deploymentStatus}"az network nsg show --resource-group csoh-policy-lab --name lab-nsg-before --query tags
When the task reports Succeeded, the tag is {"owner": "platform-team"}. Had the identity lacked Tag Contributor on this group, the task would have failed, which is the usual result of creating a modify assignment from the CLI without --role.
8. Clean up
Remove the identity's role assignment first, while the identity still exists:
PRINCIPAL=$(az policy assignment show --name lab-inherit-owner --resource-group csoh-policy-lab --query identity.principalId -o tsv)
az role assignment delete --assignee-object-id "$PRINCIPAL" --role "Tag Contributor" --scope "/subscriptions/$SUB/resourceGroups/csoh-policy-lab"
for a in lab-ssh-naive lab-ssh-indexed lab-ssh-audit lab-ssh-trial lab-inherit-owner; do az policy assignment delete --name $a --resource-group csoh-policy-lab; done
for d in csoh-lab-ssh-naive csoh-lab-ssh-indexed csoh-lab-ssh csoh-lab-inherit-owner; do az policy definition delete --name $d; done
az group delete --name csoh-policy-lab --yes
Confirm nothing is left: the first command should print [] and the second false.
az policy definition list --query "[?starts_with(name, 'csoh-lab')].name"
az group exists --name csoh-policy-lab
Policy as code
Definitions and assignments are JSON, so they belong in a repository, reviewed in pull requests and deployed by a pipeline instead of edited in the portal. Microsoft's recommended workflow has four stages:
- Keep each definition and its assignments as files, and have the pipeline update Azure whenever they change.
- Assign a new or changed definition with
DoNotEnforceto a scope far from production, a resource group or subscription kept for testing, and read the compliance results for false positives and false negatives. Microsoft's guidance names the cases to cover: create and update requests, compliant and non-compliant resources, and a property missing from the resource. - Test remediation for
modifyanddeployIfNotExistsdefinitions: the task succeeds, compliance updates, and the resource really changed. - Switch the assignment to
Default, then widen its scope one environment at a time.
The pipeline is continuous integration (CI) like any other: give it its own identity, signing in through workload identity federation (OpenID Connect, OIDC) rather than with a stored secret, and only the rights it needs to write definitions and assignments. Deploy whole files, so that the mode in git is the mode in Azure. The jq extraction in step 3 does that, and so do Bicep and Terraform when the mode is written into the resource. A missing mode is filled in as Indexed by the API itself, whatever deploys the file, so a check in the pipeline should refuse a file without one. This one runs locally on the lab files, and prints nothing when all three are complete:
for f in ssh-naive.json ssh.json inherit-owner.json; do jq -e '.properties.mode and .properties.policyRule.if and .properties.policyRule.then.effect' "$f" >/dev/null || echo "incomplete: $f"; done
Before trusting its silence, make it fail once: delete the "mode" line from a copy of ssh.json, run it on the copy, and it should print the copy's name.
Role assignment conditions
Azure Policy governs what resources look like. Azure role-based access control (RBAC) governs who may act on them, and its role assignments can carry a condition, an attribute-based access control (ABAC) check that narrows what the role grants. A condition only narrows what the role grants; it cannot explicitly deny access. Conditions are available on role assignments that include Blob Storage or Queue Storage data actions, such as reading blobs only in one container or only with a given blob index tag, and on role assignments that let someone create or delete role assignments, which is where they matter most for security.
Giving someone Owner or User Access Administrator so that they can grant access lets them grant anything, including Owner. The safer delegation, from Microsoft's own example, is the Role Based Access Control Administrator role with a condition limiting which roles may be assigned and removed. This one allows only Backup Contributor and Backup Reader:
(
(
!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})
)
OR
(
@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {5e467623-bb1f-42f4-a55d-6e525e11384b, a795c7a0-d4a2-40c1-ae25-d81f01202912}
)
)
AND
(
(
!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})
)
OR
(
@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {5e467623-bb1f-42f4-a55d-6e525e11384b, a795c7a0-d4a2-40c1-ae25-d81f01202912}
)
)Read each half as an if-then. !(ActionMatches{...}) is true for every action except the one named, so any other action the role grants passes straight through; only the named action has to satisfy the expression after OR. That is the shape of every condition, and it is this language's version of the absent-property failure: an action the condition does not name is not constrained at all. Microsoft's example needs two halves because the attribute comes from different places. A new assignment's role is in the request (@Request); an existing one's is on the resource (@Resource). With only the first half, the delegate could remove anyone's role assignments.
A condition can be added when the role is assigned or to an existing assignment, in the portal's condition editor or with the CLI, where az role assignment create takes --condition and --condition-version 2.0 (both marked preview in Azure CLI 2.91.0). The two role IDs above are Backup Contributor and Backup Reader; look up any role's ID with az role definition list --name "Backup Reader" --query "[0].name" -o tsv.
Hands-on exercises
Exercises 1 to 3 build on the walk-through's definitions, and 1 and 2 can be checked in the lab before you clean up. Exercise 6 is about role assignment conditions.
A colleague "fixes" the first attempt by replacing its port condition with this one. Which of the lab's rules would the new policy deny: allow-ssh (port 22), allow-ssh-https (22 and 443), and a rule allowing only port 80 from anywhere?
{
"field": "Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRanges[*]",
"equals": "22"
}Show the answer
It denies allow-ssh and the port 80 rule, and allows allow-ssh-https: wrong in both directions.
A field condition on a [*] alias is true only when every member of the array satisfies it. allow-ssh-https has the list ["22", "443"], and 443 is not 22, so the condition is false and the rule is allowed. The single-port rules have no list at all, because the CLI sends one port as destinationPortRange. An empty or missing array satisfies an every-member condition, since no member breaks it, so the condition is true for both, whatever the port. The fix is a count with greater 0, as in ssh.json, together with a separate check of the single form.
With the corrected rule enforcing, someone creates a rule with --destination-port-ranges 20-25. Is it denied? If not, write a condition that catches a range containing 22 in the single form.
Show the answer
It is allowed. "20-25" is one string, not in ["22", "*"], so nothing matches. A range has to be split and compared as numbers, and the split guarded so that a value without a hyphen, or no value at all, never reaches int(), because an evaluation that errors becomes a deny. This is the guard Microsoft's own definition used:
{
"value": "[if(and(not(empty(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'))), contains(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-')), and(lessOrEquals(int(first(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))), 22), greaterOrEquals(int(last(split(field('Microsoft.Network/networkSecurityGroups/securityRules/destinationPortRange'), '-'))), 22)), 'false')]",
"equals": "true"
}Add it to the port anyOf, and do the same inside the count for the list form, reading the member with first(field('...destinationPortRanges[*]')), since inside where the field() function returns a one-member array. Microsoft's deprecated built-in definition "SSH access from the Internet should be blocked" used this approach. This is why policies on network rules grow long, and why a test resource for every form of the property, single, list and range, matters more than reading the rule twice.
Templates, Bicep and Terraform's inline security_rule blocks can create a network security group with its rules inside the same request. Would lab-ssh-trial, now enforcing, deny a group created that way with an inbound SSH rule from anywhere? Write the condition that would.
Show the answer
Expect not. That request's type is Microsoft.Network/networkSecurityGroups, so the corrected rule's first condition, a type check for securityRules, is false, and nothing else in it is tested. Microsoft's documentation describes no separate request-time evaluation of rules sent this way, so treat it as a gap until you have tested it by deploying such a group to the lab. The group exposes its rules as an array, and count can test each one:
{
"allOf": [
{ "field": "type", "equals": "Microsoft.Network/networkSecurityGroups" },
{
"count": {
"field": "Microsoft.Network/networkSecurityGroups/securityRules[*]",
"where": {
"allOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].access", "equals": "Allow" },
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].direction", "equals": "Inbound" },
{
"anyOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].destinationPortRange", "in": ["22", "*"] },
{ "not": { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].destinationPortRanges[*]", "notIn": ["22", "*"] } }
]
},
{
"anyOf": [
{ "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].sourceAddressPrefix", "in": ["*", "Internet", "0.0.0.0/0"] },
{ "not": { "field": "Microsoft.Network/networkSecurityGroups/securityRules[*].sourceAddressPrefixes[*]", "notIn": ["*", "Internet", "0.0.0.0/0"] } }
]
}
]
}
},
"greater": 0
}
]
}Inside where, securityRules[*] names the one rule being counted, so securityRules[*].destinationPortRanges[*] is that rule's list of ports. not around notIn is the other way to say "any member": every member outside the list, negated. It is false for an empty list, which is what you want here. To use it, wrap the corrected rule's allOf and this one in an anyOf, so the definition covers both request shapes.
Write a definition that denies creating or updating a resource group without an owner tag. What mode must it use, and what would happen with the other one?
Show the answer
{
"properties": {
"displayName": "Resource groups must have an owner tag",
"mode": "All",
"policyRule": {
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Resources/subscriptions/resourceGroups" },
{ "field": "tags['owner']", "exists": "false" }
]
},
"then": { "effect": "deny" }
}
}
}All. In Indexed mode resource groups are never evaluated, so the definition would be valid, assignable and silent, and its compliance page would show no applicable resources, which is easy to read as no violations. Microsoft's guidance is specific here: tag and location policies use Indexed, except those aimed at resource groups or subscriptions, which use All and name the type.
An assignment can sit only on a resource group that already exists, so catching the creation of one needs an assignment at the subscription or above. That is beyond a one-group lab: assign it there with DoNotEnforce first and read the compliance results.
A deny assignment for "no SSH from the internet" has been in place for a month, and a colleague cites the empty activity log, no denials at all, as evidence that nobody has tried. What do you check?
Show the answer
First the assignment's enforcementMode: under DoNotEnforce nothing is denied and nothing is written to the activity log, so an empty log is exactly what it produces. Then the compliance results, which are recorded either way: az policy state list --filter "complianceState eq 'NonCompliant'" for that assignment. Then the definition's mode, since in Indexed mode a rule policy has no applicable resources and nothing to deny. Then the condition itself, against a test resource in each property form. An empty log shows that nothing was denied, which is consistent with all four of these failures, and with nobody trying.
A delegate holds Role Based Access Control Administrator at a subscription with only the first half of the condition in the role assignment conditions section. Can they assign Owner? Can they remove the Owner assignment of the person who delegated to them?
Show the answer
They cannot assign Owner: roleAssignments/write is named, so a new assignment must use one of the two allowed roles. They can remove role assignments made at that scope, Owner included (subject to Azure's default refusal to remove a subscription's last Owner), because roleAssignments/delete is not named, so !(ActionMatches{...}) is true for it and the condition passes it through. A condition constrains the actions it names and nothing else, which is why Microsoft's example has a second half for delete.
Common mistakes
- Leaving the mode to a default. A definition without a mode is
Indexed, whether it came fromaz policy definition createwithout--modeor from a template that left it out, and child resources, resource groups and subscriptions are then never evaluated. (Azure PowerShell defaults toAllinstead.) Deploy the whole file, and check the mode withaz policy definition show. - Checking one form of a property. Many resources express a setting in more than one way: a single value or a list, a newer property or an older default. Microsoft's own built-in "Storage accounts should prevent shared key access" matches when
allowSharedKeyAccessdoes not exist or is empty, as well as when it is true, because an account with the property unset allows shared key access. - Using a
[*]field condition to mean "any member". It means every member, and it is true for an empty array. Usecount. - Treating audit as a control. It is a report. Count your assignments by effect before saying what is enforced.
- Leaving a trial in
DoNotEnforce. Give every trial an end date and a named owner, and list assignments in that mode as part of review. - Expecting a deny to clean up. Existing resources stay as they are. Plan the cleanup of what is already wrong, and use remediation tasks where the effect supports them.
- Creating a
modifyordeployIfNotExistsassignment from the CLI without a role. The portal grants the identity its roles; the CLI does so only with--roleand--identity-scope, and otherwise remediation fails. - Assigning without a scope.
az policy assignment createwith neither--resource-groupnor--scopeassigns at the whole subscription. - Excluding instead of exempting. An exclusion hides a scope from the report. An exemption keeps it visible, with a reason and an expiry.
- A template function that can error. A failed evaluation is a deny. Guard functions such as
substring()andsplit()withif().
Where next
- Azure security for where policy sits among the platform's other controls.
- Identity and access management (IAM) policy languages for the Amazon Web Services (AWS) side: identity policies, service control policies and Cedar, which make a different set of silent mistakes.
- Open Policy Agent (OPA) and Rego for checking Terraform plans before they reach Azure, which catches the same mistakes earlier and on any cloud.
- Kusto Query Language (KQL), because Azure Resource Graph answers compliance questions across subscriptions in it.
- The GRC engineer role for how compliance initiatives turn into evidence.
- Microsoft's definition structure reference, the effects reference, and the built-in definitions on GitHub, which are the best examples of handling every form of a property.
