07 — Governance: policy that denies a real deployment¶
Three custom definitions, an initiative that bundles them, and an assignment scoped narrowly enough that a Deny effect cannot take the subscription down with it. Then a deployment that gets rejected, on purpose.
Time: 40 minutes · Cost: none — policy is free
Files: policy-definitions.bicep ·
policy-assignment.bicep ·
policy-rules/
7.1 Why the rules live in JSON¶
A policy rule is full of ARM expressions:
Write that string inside a Bicep file and the compiler escapes the leading bracket, because
a string starting with [ is how ARM marks an expression. You get [[parameters('effect')]
where you meant a literal, and the fix — escaping the escape — is unreadable.
loadJsonContent() sidesteps it entirely:
var denyPublicNetworkAccess = loadJsonContent('policy-rules/deny-public-network-access.json')
resource publicNetworkAccess 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
name: '${namePrefix}-deny-public-network-access'
properties: {
policyType: 'Custom'
mode: 'Indexed'
parameters: denyPublicNetworkAccess.parameters
policyRule: denyPublicNetworkAccess.policyRule
}
}
The rule stays readable to anyone who has only ever seen policy in the portal, and it can be pasted straight into the policy editor to test.
Worth knowing anyway: in the compiled template you will see
[[parameters('effect')], and that is correct. ARM converts[[back to[when it writes the policy, so the policy engine receives the expression rather than a pre-evaluated value.
7.2 The three rules¶
Storage must not allow anonymous blob access¶
{
"if": {
"allOf": [
{ "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess", "notEquals": "false" }
]
},
"then": { "effect": "[parameters('effect')]" }
}
notEquals: "false" rather than equals: "true" — that is intentional. The property may be
absent, and an absent property is not true, so equals: "true" would let an unset account
through.
Data services must not be reachable from the internet¶
"anyOf": [
{ "allOf": [ { "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
{ "field": "Microsoft.Storage/storageAccounts/publicNetworkAccess", "notEquals": "Disabled" } ] },
{ "allOf": [ { "field": "type", "equals": "Microsoft.DBforPostgreSQL/flexibleServers" },
{ "field": "Microsoft.DBforPostgreSQL/flexibleServers/network.publicNetworkAccess", "notEquals": "Disabled" } ] },
{ "allOf": [ { "field": "type", "equals": "Microsoft.KeyVault/vaults" },
{ "field": "Microsoft.KeyVault/vaults/networkAcls.defaultAction", "notEquals": "Deny" } ] }
]
Look at the third branch. Key Vault is checked on networkAcls.defaultAction, not on
publicNetworkAccess — because this platform deliberately leaves the latter Enabled so
the ARM provider can write secrets (chapter 03).
This is the reason the definitions are custom. A built-in policy demanding
publicNetworkAccess: Disabled on every vault would reject the platform it was assigned to
protect. Governance has to encode the decisions the architecture actually made, not the ones
a generic rule assumes.
App Service must be HTTPS-only on a current TLS version¶
"anyOf": [
{ "field": "Microsoft.Web/sites/httpsOnly", "notEquals": "true" },
{ "field": "Microsoft.Web/sites/siteConfig.minTlsVersion", "less": "[parameters('minimumTlsVersion')]" }
]
A second parameter — minimumTlsVersion — so the floor can be raised to 1.3 at assignment
time without editing the definition.
Finding aliases¶
Microsoft.DBforPostgreSQL/flexibleServers/network.publicNetworkAccess is an alias, and
aliases are not guessable. List them:
az provider show --namespace Microsoft.DBforPostgreSQL --expand "resourceTypes/aliases" --query "resourceTypes[?resourceType=='flexibleServers'].aliases[].name" -o tsv
A wrong alias does not error. The condition simply never matches, and you get a policy that appears to be enforcing and is not — the failure mode worth being paranoid about.
7.3 Initiative and assignment: two scopes, deliberately¶
Definitions and the initiative live at subscription scope, because that is the lowest scope a definition can exist at. The assignment lands on individual resource groups:
module platformPolicyAssignment 'modules/governance/policy-assignment.bicep' = {
scope: platformResourceGroup
params: {
name: '${workload}-baseline'
policySetDefinitionId: policyDefinitions.outputs.initiativeId
enforcementMode: policyEnforcementMode
}
dependsOn: [ keyVault, storage ]
}
Widening the scope later is one parameter. Narrowing it after a Deny effect has blocked something unrelated across the subscription is a conversation with other people.
dependsOn is doing real work here. A Deny assignment that lands mid-deployment blocks the
very resources it was written to describe, and the failure reads like a template bug.
Two modes worth knowing¶
DoNotEnforce is what-if for policy: evaluation runs and compliance is recorded, but nothing
is blocked. It is how a Deny policy is introduced to an environment that already has
resources in it.
nonComplianceMessages: [
{ message: 'This platform requires private networking. See docs/07-governance.md.' }
]
That message is returned in the deployment error. The difference between "denied by policy" and a support ticket is usually one sentence.
No DeployIfNotExists, on purpose¶
Every rule here uses Audit, Deny or Disabled. None uses deployIfNotExists or
modify, which would require the assignment to carry a managed identity and that identity to
hold a role — turning a Contributor-friendly lab into one that needs User Access
Administrator on every assignment. Remediation policy is powerful and is a different lab.
7.4 Watch it deny something¶
The platform complies, so nothing is blocked during a normal deployment. Break it deliberately:
az policy assignment create --name ztwp-baseline-test --resource-group rg-policy-test --policy-set-definition ztwp-zero-trust-baseline
Now try to create a storage account the policy hates:
az storage account create --name sttestpolicy$RANDOM --resource-group rg-policy-test --location westeurope --sku Standard_LRS --allow-blob-public-access true
Expected:
(RequestDisallowedByPolicy) Resource 'sttestpolicy…' was disallowed by policy.
Policy identifiers: '[{"policyAssignment":{"name":"ztwp-baseline-test"…
The deployment never happened. Not "was created and flagged" — refused.
Then the same command with --allow-blob-public-access false --public-network-access Disabled
succeeds. Clean up:
7.5 Verify¶
az policy assignment list --scope /subscriptions/<sub>/resourceGroups/rg-ztwp-platform-dev-weu --query "[].{name:name, mode:enforcementMode, definition:policyDefinitionId}" -o table
az policy state summarize --resource-group rg-ztwp-platform-dev-weu --query "value[0].results" -o json
Compliance evaluation runs on a schedule and lags a deployment by up to 30 minutes. Force it:
Exercises¶
-
Add a fourth rule requiring the four platform tags on every resource, and watch it fail against something you created by hand in an earlier exercise.
-
Deny by region. Write a rule that only permits
westeuropeandgermanywestcentral, assign it, and try to deploy toeastus. This is the single most effective cost control in most organisations. -
Audit first. Set
policyEnforcementMode = 'DoNotEnforce', redeploy, and confirm the non-compliant storage account is now recorded rather than blocked. That is the migration path for an existing environment.
Checkpoint¶
- Three definitions and one initiative exist at subscription scope
- Two assignments exist, each on one resource group
- A non-compliant storage account is refused with
RequestDisallowedByPolicy - You can explain why the Key Vault rule checks
networkAcls.defaultAction
Next: 08 — Deployment stacks