Azure Policy & effects
Deny, deployIfNotExists, initiatives at scale.
A building inspector at the permit desk has one job. Before a single beam goes up, they hold the plans against the code and either stamp them, attach a condition, or refuse the permit. They are not the auditor who walks the finished building a year later and writes up what is already wrong. Azure Policy is the inspector, not the auditor. Before a resource is created or changed, Azure Resource Manager (ARM, the one control plane every deployment flows through) checks the proposed configuration against your rules and decides what happens next.
Role-based access control (RBAC, the system that decides which identities may perform which actions) answers one question: who is allowed to build. Azure Policy answers a different one: what is allowed to exist once they do. Hold those two apart and the rest of this lesson clicks. A team can own every permission needed to deploy a storage account and still be unable to make its blobs public, because that configuration is turned away at the door, whoever is standing there.
That door stops a lot of incidents before they begin. A public storage container is one wrong flag away from serving your data to the open internet, and it is a genuinely common breach path. RBAC will not help you here, because the engineer legitimately holds the right to create storage. Policy will, because it judges the shape of the request, not the identity behind it.
The effects, from watching to blocking
A policy definition is an if/then rule written in JSON (JavaScript Object Notation, the plain-text format Azure uses for these rules). The if matches resources by type and property. The then names an effect. Every effect reads the same conditions. They differ only in how much authority they carry, and picking the right one is most of the skill.
audit and auditIfNotExists only watch: they mark a resource non-compliant and touch nothing, which is how you measure a problem before you act on it. deny is the bouncer, rejecting a non-compliant create or update at request time, before the resource ever exists. deployIfNotExists (DINE, said like 'dine') is corrective: when a required companion is missing, a diagnostic setting, a backup, a Defender for Cloud plan (formerly Azure Security Center), it deploys that companion for you. modify and append rewrite the request as it goes by, stamping on a required tag or forcing supportsHttpsTrafficOnly to true. denyAction blocks one specific operation, most usefully delete, so a protected resource cannot be torn down. manual parks a control for a human to attest to, and disabled switches a rule off without deleting the assignment, which is how you silence a noisy policy mid-incident.
Where an effect runs matters as much as what it does. deny, denyAction, modify, and append are decided synchronously by ARM, right in the request path, so they apply the same way whether the call came from the portal, the az command-line interface (CLI), a Bicep file, or Terraform. There is no side door. audit and the two ifNotExists effects also run in a background compliance scan, roughly every 24 hours, which is why a brand-new assignment can honestly report zero non-compliant resources for a while. Read the number one minute after assigning and you have measured nothing yet.
Author once, assign in dry run
You write the rule one time, then assign it at a scope. Point it at a single resource group and it guards that one box. Point it at a management group (a container that sits above subscriptions and holds a batch of them) and every subscription underneath inherits the guardrail, the ones that exist today and the ones created next year. That inheritance is what turns a written standard into an enforced one, with nobody re-applying it per subscription.
The first time you assign anything with teeth, use --enforcement-mode DoNotEnforce. The policy still evaluates and still reports who would fail, but it blocks nothing. That is your dry run, and its whole reason to exist is letting you size the blast radius (how many existing resources would fail if you switched it on) before the teeth come on. The other dial is --mode. Indexed evaluates only resource types that carry a location and support tags, which is right for a storage account. All evaluates everything, including resource groups and subscriptions. Reach for Indexed unless you are policing resource groups themselves.
Keep the rule in its own file so it lives in version control and code review, then create the definition at the management-group root and assign it in dry run.
{"if": {"allOf": [{"field": "type","equals": "Microsoft.Storage/storageAccounts"},{"field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess","equals": "true"}]},"then": {"effect": "deny"}}
# Author the deny rule at the management-group root so every subscription inherits it.az policy definition create \--name deny-public-blob \--display-name "Deny public blob access on storage accounts" \--management-group acme-root \--mode Indexed \--rules @deny-public-blob.rules.json
{"displayName": "Deny public blob access on storage accounts","id": "/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyDefinitions/deny-public-blob","mode": "Indexed","name": "deny-public-blob","policyRule": {"if": {"allOf": [{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },{ "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess", "equals": "true" }]},"then": { "effect": "deny" }},"policyType": "Custom"}
mg=/providers/Microsoft.Management/managementGroups/acme-root# Assign in DRY RUN first: it evaluates and reports, but blocks nothing yet.az policy assignment create \--name deny-public-blob \--policy "$mg/providers/Microsoft.Authorization/policyDefinitions/deny-public-blob" \--scope "$mg" \--enforcement-mode DoNotEnforce
{"displayName": "deny-public-blob","enforcementMode": "DoNotEnforce","id": "/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyAssignments/deny-public-blob","identity": null,"location": null,"name": "deny-public-blob","policyDefinitionId": "/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyDefinitions/deny-public-blob","scope": "/providers/Microsoft.Management/managementGroups/acme-root"}
Self-healing with deployIfNotExists
audit tells you a resource is missing its diagnostic settings. deployIfNotExists goes and creates them. Because the policy now takes an action instead of only reading state, it needs an identity to act as and a place to run from. So any DINE or modify assignment has to carry a managed identity (an automatic service account in Microsoft Entra ID, the current name for what used to be Azure Active Directory, or Azure AD) and a --location for that identity. Drop --mi-system-assigned and --location and the assignment still gets created, but it can never remediate a thing.
Here is where the CLI and the portal split, and it catches people. Creating the identity grants it no permissions. In the portal, assigning a DINE policy reads the roles the policy declares in its roleDefinitionIds and grants them to the identity for you. Over the CLI, ARM, or any SDK (software development kit), nobody does that step. You end up with an identity that holds no roles, and every deployment it attempts dies with an authorization error. You grant the roles yourself.
# <diag-storage-dine> = the built-in definition id (find it with: az policy definition list).# A DINE or modify assignment MUST carry an identity (--mi-system-assigned) and a --location,# or it can never remediate.az policy assignment create \--name deploy-diag-settings \--policy "/providers/Microsoft.Authorization/policyDefinitions/<diag-storage-dine>" \--scope /subscriptions/1111-2222-3333-4444 \--mi-system-assigned --location eastus \--params '{"logAnalytics":{"value":"/subscriptions/1111-2222-3333-4444/resourceGroups/soc/providers/Microsoft.OperationalInsights/workspaces/soc-law"}}'
{"displayName": "deploy-diag-settings","enforcementMode": "Default","identity": {"principalId": "9c2f7b1a-5d84-4a2e-b6c1-3e0f9a7d21bb","tenantId": "aaaabbbb-cccc-dddd-eeee-ffff00001111","type": "SystemAssigned"},"location": "eastus","name": "deploy-diag-settings","policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/<diag-storage-dine>","scope": "/subscriptions/1111-2222-3333-4444"}
Pull the identity's principal ID (the object ID that names it inside Entra ID) off the assignment, then grant it exactly the roles the policy needs at the scope it will run over. A diagnostic-settings policy usually wants Log Analytics Contributor, and often Monitoring Contributor too, so read the definition's roleDefinitionIds rather than guessing. Passing --assignee-object-id with --assignee-principal-type ServicePrincipal skips a directory lookup that tends to fail in the first minute, before the new identity has replicated across Entra ID.
# The CLI does NOT auto-grant roles the way the portal does. Grab the identity's# principalId, then grant it the policy's roleDefinitionIds at the run scope.pid=$(az policy assignment show \--name deploy-diag-settings \--scope /subscriptions/1111-2222-3333-4444 \--query identity.principalId -o tsv)az role assignment create \--assignee-object-id "$pid" \--assignee-principal-type ServicePrincipal \--role "Log Analytics Contributor" \--scope /subscriptions/1111-2222-3333-4444
{"id": "/subscriptions/1111-2222-3333-4444/providers/Microsoft.Authorization/roleAssignments/7b1d3e6a-2c9f-4e8b-9a1d-5f2c8b0e4a77","name": "7b1d3e6a-2c9f-4e8b-9a1d-5f2c8b0e4a77","principalId": "9c2f7b1a-5d84-4a2e-b6c1-3e0f9a7d21bb","principalType": "ServicePrincipal","roleDefinitionId": "/subscriptions/1111-2222-3333-4444/providers/Microsoft.Authorization/roleDefinitions/92aaf0da-9dab-42b6-94a3-d43ce8d16293","scope": "/subscriptions/1111-2222-3333-4444","type": "Microsoft.Authorization/roleAssignments"}
You can fold that grant into the assignment with --identity-scope and --role, but you still name the role by hand; nothing outside the portal reads roleDefinitionIds for you. Now the sharp edge. DINE evaluates on create and on update, so from here on every new storage account gets its diagnostics. Everything that already existed is only flagged. The back-catalogue does not heal on its own. To fix it you start a remediation task, which walks the non-compliant resources and runs the policy's embedded deployment against each one.
roleDefinitionIds and grants them. So an assignment made with --mi-system-assigned --location can end up with an identity that holds none of those roles. The assignment still exists, scans still mark resources non-compliant, and then every remediation task reports 0 successful deployments while nothing is actually fixed. Grant the roles at the assignment scope with az role assignment create and confirm they took before you trust self-healing.# New resources self-heal from now on; EXISTING ones are only flagged. Remediate them.az policy remediation create \--name fix-existing-diag \--policy-assignment /subscriptions/1111-2222-3333-4444/providers/Microsoft.Authorization/policyAssignments/deploy-diag-settings \--resource-group prod-rg
{"deploymentStatus": {"failedDeployments": 0,"successfulDeployments": 0,"totalDeployments": 12},"name": "fix-existing-diag","policyAssignmentId": "/subscriptions/1111-2222-3333-4444/providers/Microsoft.Authorization/policyAssignments/deploy-diag-settings","provisioningState": "Evaluating"}
# Remediation is async. Poll until every deployment lands.az policy remediation show \--name fix-existing-diag \--resource-group prod-rg \--query "{state:provisioningState, ok:deploymentStatus.successfulDeployments, total:deploymentStatus.totalDeployments}"
{"ok": 12,"state": "Succeeded","total": 12}
A remediation task is asynchronous (it kicks off and finishes later, not inline with the command). create returns while it is still evaluating, and you watch it finish with az policy remediation show; provisioningState lands on Succeeded once every deployment completes. If the assignment is an initiative (a bundle of definitions assigned as one unit) rather than a single policy, you also pass --definition-reference-id to say which member policy to remediate.
Prove the guardrail bites
A guardrail you have never watched stop something is a guess. Two things are worth proving: that the compliance data is real, and that deny actually rejects the thing you outlawed. When you are testing, do not wait on the 24-hour scan. Force an evaluation now, summarize the one assignment, and list the offenders by resource ID.
# Force evaluation now instead of waiting for the ~24h background scan.az policy state trigger-scan --resource-group prod-rg# Summarize compliance for this one assignment.az policy state summarize \--management-group acme-root \--filter "PolicyAssignmentName eq 'deny-public-blob'"# List the offenders by resource id.az policy state list \--filter "ComplianceState eq 'NonCompliant'" \--query "[].resourceId" -o tsv
# trigger-scan returns no output; it blocks until the scan finishes.# summarize:{"value": [{"results": {"nonCompliantPolicies": 1,"nonCompliantResources": 3}}]}# list (one resource id per line):/subscriptions/1111-2222-3333-4444/resourceGroups/legacy/providers/Microsoft.Storage/storageAccounts/legacypublicsa/subscriptions/1111-2222-3333-4444/resourceGroups/web/providers/Microsoft.Storage/storageAccounts/oldassetssa/subscriptions/5555-6666-7777-8888/resourceGroups/data/providers/Microsoft.Storage/storageAccounts/rawdumpsa
Three accounts would break. If that blast radius is acceptable and you have a plan for those three, take the assignment out of dry run. Then try to create exactly what you forbade and watch ARM turn it away.
# Blast radius acceptable? Flip from dry-run to enforcing.az policy assignment update \--name deny-public-blob \--scope /providers/Microsoft.Management/managementGroups/acme-root \--enforcement-mode Default# Now try to create exactly what the policy forbids.az storage account create \--name badpublicsa \--resource-group prod-rg \--allow-blob-public-access true
# assignment update:{"enforcementMode": "Default","name": "deny-public-blob","scope": "/providers/Microsoft.Management/managementGroups/acme-root"}# storage account create:ERROR: (RequestDisallowedByPolicy) Resource 'badpublicsa' was disallowed by policy. Policy identifiers: '[{"policyAssignment":{"name":"deny-public-blob","id":"/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyAssignments/deny-public-blob"},"policyDefinition":{"name":"deny-public-blob","id":"/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyDefinitions/deny-public-blob"}}]'Code: RequestDisallowedByPolicyMessage: Resource 'badpublicsa' was disallowed by policy.
RequestDisallowedByPolicy is the response you want to see. It names the assignment and the definition that stopped the request, which is what makes a denial debuggable at three in the morning instead of a mystery. That full loop, measure then enforce then verify the block, is the difference between a policy that exists and a control that works.
Exceptions, limits, and where this lands
Real environments carry real exceptions. A legacy account might need public access for a couple of months while you migrate off it. Weakening the policy for everyone is the wrong fix. A policy exemption is the right one: a scoped, time-boxed waiver written down where auditors can see it. Carve it at the narrowest scope that covers the exception, record why, and give it an expiry so it removes itself. Waiver says you are accepting the risk on the record; Mitigated says the goal is met another way.
# A legitimate, temporary exception: scoped narrow, on the record, self-expiring.az policy exemption create \--name legacy-sa-waiver \--policy-assignment /providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyAssignments/deny-public-blob \--exemption-category Waiver \--expires-on 2026-09-30 \--scope /subscriptions/1111-2222-3333-4444/resourceGroups/legacy
{"exemptionCategory": "Waiver","expiresOn": "2026-09-30T00:00:00+00:00","id": "/subscriptions/1111-2222-3333-4444/resourceGroups/legacy/providers/Microsoft.Authorization/policyExemptions/legacy-sa-waiver","name": "legacy-sa-waiver","policyAssignmentId": "/providers/Microsoft.Management/managementGroups/acme-root/providers/Microsoft.Authorization/policyAssignments/deny-public-blob","scope": "/subscriptions/1111-2222-3333-4444/resourceGroups/legacy"}
Evaluation is free, but the platform has limits you will meet at scale: about 200 policy assignments per scope, about 500 policy definitions per management group, and up to 1,000 policies inside a single initiative. Remediation is not free the same way. A task fanning out over thousands of resources spends real ARM deployment quota, so stage the big ones instead of firing them all at once. And because an assignment at the management-group root flows down to every subscription beneath it, a new subscription is governed the instant it exists. That is how guardrails get baked into a landing zone (a pre-governed subscription blueprint that new workloads drop into) from minute one, which is exactly where the next lesson begins.
allowBlobPublicAccess = true. They run a create with that flag set to true. What happens?--mi-system-assigned --location eastus. The identity exists, but remediation tasks report 0 successful deployments. Most likely cause?Try this
Run az policy state trigger-scan --resource-group prod-rg on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a DINE identity with no role fails silently. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.