This is Part 2 of a four-part series for Azure Spring Clean “Policy-as-Code Made Simple: Modern Azure Governance with Bicep”.
In Part 1 I covered the baseline shift from JSON to Bicep for Azure Policy definitions. The structure gets cleaner, but the real gains come from leaning into Bicep’s language features. Multi-line interpolated strings, loops, conditional expressions, and user-defined types all have direct applications in policy-as-code that make the authoring experience far more practical.
The policyRule block in a policy definition is a JSON object that Azure Policy evaluates at runtime. In raw JSON, embedding this is straightforward but verbose. In Bicep, it gets interesting because the policy rule contains ARM template expressions (like parameters('tagName')) that should not be interpolated by Bicep at compile time.
Bicep 0.40.2 graduated multi-line interpolated strings to GA, and this feature is a perfect fit for policy rules. Consider a policy that audits storage accounts without HTTPS enforcement:
targetScope = 'subscription'
param policyEffect string = 'audit'
resource storageTlsPolicy 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
name: 'audit-storage-https'
properties: {
displayName: 'Audit Storage Accounts without HTTPS'
policyType: 'Custom'
mode: 'Indexed'
metadata: {
version: '1.0.0'
category: 'Storage'
}
parameters: {}
policyRule: json(loadTextContent('rules/audit-storage-https.json'))
}
}
The loadTextContent function lets you keep the policy rule as a separate JSON file while importing it at compile time. This is useful when the rule is complex and benefits from dedicated JSON syntax highlighting, but it means managing an extra file.
Alternatively, with multi-line interpolated strings, you can inline the rule and still parameterise it:
var policyEffect = 'audit'
var storagePolicyRule = json($'''
{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
{
"field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"notEquals": "true"
}
]
},
"then": {
"effect": "${policyEffect}"
}
}
''')
The $''' syntax starts a multi-line interpolated string. The ${policyEffect} expression is replaced by Bicep at compile time with the variable value. The outer json() function converts the string result into an object that the ARM template engine accepts.
The important distinction here is what gets interpolated when. The ${policyEffect} is resolved by Bicep during compilation. Any ARM template expressions like [parameters('tagName')] inside the string are left as literal strings and evaluated later by the policy engine during compliance scans. This separation is exactly what you need for policy authoring.
When your policy rule needs ARM template functions that should not be touched by Bicep, you need to be careful with escaping. If the rule contains [parameters('tagName')], Bicep will try to interpret the square brackets. The solution is to use the escape sequence [[ to produce a literal [ in the output:
policyRule: {
if: {
field: '[[concat(\'tags[\', parameters(\'tagName\'), \']\')]'
exists: 'false'
}
then: {
effect: '[[parameters(\'effect\')]'
}
}
This is one of the gotchas I have encountered. The double bracket [[ tells Bicep to output a literal [ in the compiled ARM template, which is then interpreted by the ARM engine at deployment time. Without it, Bicep throws a compilation error because it does not recognise ARM functions as valid expressions.
One of the patterns I find most useful is deploying the same policy across multiple scopes or with different parameters using Bicep loops. A common scenario is assigning a set of tag policies for different required tags:
targetScope = 'subscription'
param requiredTags array = [
'Environment'
'CostCentre'
'Owner'
'Application'
]
param policyDefinitionId string
resource tagAssignments 'Microsoft.Authorization/policyAssignments@2023-04-01' = [
for tag in requiredTags: {
name: 'require-tag-${toLower(tag)}'
properties: {
displayName: 'Require ${tag} tag on all resources'
policyDefinitionId: policyDefinitionId
enforcementMode: 'Default'
parameters: {
tagName: {
value: tag
}
}
nonComplianceMessages: [
{
message: 'Resource must include the ${tag} tag. Add it and redeploy.'
}
]
}
}
]
Four policy assignments, one loop, one template. Adding a new required tag means updating the array parameter, not creating a new file. The toLower function ensures the assignment names are consistent, and the string interpolation in displayName and nonComplianceMessages keeps everything readable.
Different environments need different enforcement strategies. Development subscriptions might audit while production subscriptions deny. Bicep’s ternary operator handles this cleanly:
targetScope = 'subscription'
@allowed([
'dev'
'test'
'prod'
])
param environmentName string
param policyDefinitionId string
var enforcementMode = environmentName == 'prod' ? 'Default' : 'DoNotEnforce'
var policyEffect = environmentName == 'prod' ? 'deny' : 'audit'
resource tagPolicy 'Microsoft.Authorization/policyAssignments@2023-04-01' = {
name: 'require-environment-tag-${environmentName}'
properties: {
displayName: 'Require Environment Tag (${environmentName})'
policyDefinitionId: policyDefinitionId
enforcementMode: enforcementMode
parameters: {
effect: {
value: policyEffect
}
}
nonComplianceMessages: [
{
message: environmentName == 'prod'
? 'BLOCKED: All production resources require an Environment tag.'
: 'WARNING: This resource should have an Environment tag.'
}
]
}
}
The @allowed decorator on the parameter constrains the input values at deployment time. The ternary expressions derive enforcement mode, policy effect, and even the non-compliance message text from that single parameter. One template, three environments, no file duplication.
Bicep’s native .bicepparam files are a cleaner alternative to JSON parameter files. For policy deployments across environments, this pattern works well:
// prod.bicepparam
using './policy-assignment.bicep'
param environmentName = 'prod'
param policyDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/require-tag-on-resources'
// dev.bicepparam
using './policy-assignment.bicep'
param environmentName = 'dev'
param policyDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/require-tag-on-resources'
The using statement links the parameter file directly to the template, which means the Bicep tooling validates the parameter names and types against the template at authoring time, not at deployment time. This catches mismatches before they reach a pipeline.
The Bicep features that matter most for policy-as-code:
loadTextContent keeps complex rules in separate JSON files while importing at compile time.bicepparam files provide type-safe, validated parameter managementIn Part 3 I take a deeper look at multi-line string interpolation specifically — the syntax details, how Bicep-time and ARM-runtime expressions interact inside the block, and the $$''' variant for embedding scripts. In Part 4, I will cover the module patterns that make policy-as-code reusable across teams and projects, including initiative definitions, and how to wire everything into a CI/CD pipeline with validation gates.