This is part 3 of the series, after the introduction and EPAC pipelines. Once the pipelines were running, these were the requests I kept getting:

  • “We need an exemption for this legacy application, but only for 90 days.”
  • “Can we use the same policy with different parameters for different business units?”
  • “Why is this policy compliant in DEV but non-compliant in production when the config is identical?”

This post covers the patterns I used for them: exemptions, assignment structures, initiative design and troubleshooting.

Policy exemptions

Every policy setup needs exceptions. What matters is that they are documented, time-bound and auditable, and not something someone disabled in the portal three years ago.

EPAC exemption structure

Exemptions in EPAC live alongside your assignments:

Definitions/
├── policyAssignments/
│ └── security-baseline.json
└── policyExemptions/
└── legacy-app-exemptions.json

Creating time-bound exemptions

{
"exemptions": [
{
"name": "legacy-erp-storage-encryption",
"displayName": "Legacy ERP Storage Encryption Exemption",
"description": "Temporary exemption for legacy ERP system during migration. Expires 2026-04-30.",
"exemptionCategory": "Mitigated",
"scope": "/subscriptions/12345678-1234-1234-1234-123456789abc/resourceGroups/legacy-erp",
"policyAssignmentId": "/providers/Microsoft.Management/managementGroups/production/providers/Microsoft.Authorization/policyAssignments/require-storage-encryption",
"expiresOn": "2026-04-30T23:59:59Z",
"metadata": {
"ticketNumber": "SEC-2024-1234",
"approvedBy": "security-team@company.com",
"mitigatingControls": "Network isolation, enhanced monitoring, scheduled migration Q2 2026"
}
}
]
}

Exemption categories

Category Use when Compliance state
Waiver Risk accepted, no compensating controls Exempt (risk acknowledged)
Mitigated Compensating controls in place Exempt (alternative controls documented)

Both categories result in the Exempt compliance state, and the resources are counted as exempt in compliance percentages. The category only documents intent and risk posture. The compliance engine treats them the same.

Exemption lifecycle

EPAC includes exemptions in the deployment plan:

Terminal window
# Exemptions are included in the deployment plan
Build-DeploymentPlans -PacEnvironmentSelector "prod"
# Plan shows:
# - New exemptions to create
# - Expired exemptions to remove
# - Modified exemptions to update

Set a reminder 30 days before an exemption expires. When EPAC removes an expired exemption, the resource becomes non-compliant, which can trigger remediation you didn’t plan for.

Many exemptions

With a lot of exemptions, organise the files by category (for the cost side, see also the Azure Cost Management post):

policyExemptions/
├── security/
│ ├── legacy-systems.json
│ └── third-party-integrations.json
├── cost-management/
│ └── dev-environment-exemptions.json
└── compliance/
└── regional-requirements.json

Each file can contain multiple exemptions:

{
"exemptions": [
{ "name": "exemption-1", ... },
{ "name": "exemption-2", ... },
{ "name": "exemption-3", ... }
]
}

Assignment patterns

Pattern 1: same policy, different parameters per scope

For example, required tags that differ per business unit:

{
"nodeName": "/Root/",
"children": [
{
"nodeName": "FinanceUnit/",
"scope": {
"tenant1": [
"/providers/Microsoft.Management/managementGroups/finance"
]
},
"assignment": {
"name": "require-tags-finance",
"displayName": "Require Tags - Finance"
},
"definitionEntry": {
"policySetName": "require-resource-tags"
},
"parameters": {
"requiredTags": ["CostCenter", "Project", "FinanceApprover"]
}
},
{
"nodeName": "EngineeringUnit/",
"scope": {
"tenant1": [
"/providers/Microsoft.Management/managementGroups/engineering"
]
},
"assignment": {
"name": "require-tags-engineering",
"displayName": "Require Tags - Engineering"
},
"definitionEntry": {
"policySetName": "require-resource-tags"
},
"parameters": {
"requiredTags": ["CostCenter", "Team", "Repository"]
}
}
]
}

Pattern 2: inheritance with override

A security baseline for all landing zones, with stricter parameters and a deny override for regulated workloads:

{
"nodeName": "/Root/",
"scope": {
"tenant1": [
"/providers/Microsoft.Management/managementGroups/landing-zones"
]
},
"assignment": {
"name": "security-baseline",
"displayName": "Security Baseline"
},
"definitionEntry": {
"policySetName": "security-baseline-initiative"
},
"parameters": {
"allowedLocations": ["westeurope", "northeurope"],
"requiredEncryption": "platform-managed"
},
"children": [
{
"nodeName": "RegulatedWorkloads/",
"scope": {
"tenant1": [
"/providers/Microsoft.Management/managementGroups/regulated"
]
},
"assignment": {
"name": "security-baseline-regulated",
"displayName": "Security Baseline - Regulated"
},
"parameters": {
"allowedLocations": ["westeurope"],
"requiredEncryption": "customer-managed"
},
"overrides": [
{
"kind": "policyEffect",
"selectors": [
{
"kind": "policyDefinitionReferenceId",
"in": ["auditStorageEncryption"]
}
],
"value": "deny"
}
]
}
]
}

Pattern 3: non-compliance messaging

Easy to skip, but worth it. When a deployment is denied, a clear message tells the engineer what to do instead of making them open a ticket:

{
"assignment": {
"name": "require-approved-images",
"displayName": "Require Approved Container Images"
},
"nonComplianceMessages": [
{
"message": "Container images must come from the approved registry: acr.company.com. See https://wiki.company.com/approved-images for the list of approved images and how to request additions."
},
{
"policyDefinitionReferenceId": "containerImageSource",
"message": "This specific container image is not from an approved source. Contact platform-team@company.com for assistance."
}
]
}

Pattern 4: resource selectors

Resource selectors limit an assignment to specific resource types or locations:

{
"assignment": {
"name": "require-private-endpoints",
"displayName": "Require Private Endpoints for PaaS"
},
"resourceSelectors": [
{
"name": "StorageAccountsInProduction",
"selectors": [
{
"kind": "resourceType",
"in": [
"Microsoft.Storage/storageAccounts",
"Microsoft.KeyVault/vaults",
"Microsoft.Sql/servers"
]
},
{
"kind": "resourceLocation",
"in": ["westeurope", "northeurope"]
}
]
}
]
}

Initiative (policy set) design

Initiatives group related policies. With a bad grouping you end up searching for which initiative a policy lives in. What worked for me:

  • Group by compliance domain instead of resource type.
  • Keep initiatives under 20 policies if you can. A 50-policy initiative is painful to manage.
  • Use parameter references so parameters aren’t duplicated across assignments.
  • Version initiatives so changes can be rolled out gradually.

Initiative structure

{
"name": "storage-security-initiative",
"properties": {
"displayName": "Storage Security Standards",
"description": "Security controls for Azure Storage accounts",
"metadata": {
"version": "2.1.0",
"category": "Storage"
},
"parameters": {
"effect": {
"type": "String",
"defaultValue": "Audit",
"allowedValues": ["Audit", "Deny", "Disabled"],
"metadata": {
"displayName": "Effect",
"description": "The effect for all policies in this initiative"
}
},
"allowedSkus": {
"type": "Array",
"defaultValue": ["Standard_LRS", "Standard_GRS", "Standard_RAGRS"],
"metadata": {
"displayName": "Allowed Storage SKUs"
}
}
},
"policyDefinitions": [
{
"policyDefinitionReferenceId": "requireHttpsTraffic",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9",
"parameters": {
"effect": {
"value": "[parameters('effect')]"
}
}
},
{
"policyDefinitionReferenceId": "requireEncryption",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/b5ec538c-daa0-4006-8596-35468b9148e2",
"parameters": {
"effect": {
"value": "[parameters('effect')]"
}
}
},
{
"policyDefinitionReferenceId": "restrictSkus",
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1",
"parameters": {
"listOfAllowedSKUs": {
"value": "[parameters('allowedSkus')]"
}
}
}
]
}
}

Initiative versioning

policySetDefinitions/
├── storage-security/
│ ├── v1.0.0/
│ │ └── storage-security.json
│ ├── v2.0.0/
│ │ └── storage-security.json
│ └── v2.1.0/
│ └── storage-security.json

Or use metadata versioning:

{
"metadata": {
"version": "2.1.0",
"changeLog": {
"2.1.0": "Added private endpoint requirement",
"2.0.0": "Breaking: Changed default effect to Deny",
"1.0.0": "Initial release"
}
}
}

Troubleshooting

These are the problems I ran into most often.

Plan shows changes when nothing changed

If every plan shows updates, the cause is almost always formatting, ordering or metadata differences between Azure and your source files.

Terminal window
# Get the raw policy from Azure
$azurePolicy = Get-AzPolicyDefinition -Name "your-policy" | ConvertTo-Json -Depth 100
# Compare with your source file
$sourcePolicy = Get-Content "./Definitions/policyDefinitions/your-policy.json" | ConvertFrom-Json | ConvertTo-Json -Depth 100
# Use a diff tool to find differences
Compare-Object ($azurePolicy -split "`n") ($sourcePolicy -split "`n")

Remediation tasks failing

Remediation tasks exist but resources don’t get fixed:

Terminal window
# Check remediation task status
$remediation = Get-AzPolicyRemediation -Name "your-remediation" -Scope "/subscriptions/..."
# Look at the provisioning state
$remediation.ProvisioningState
# Check for role assignment issues (only assignments with DeployIfNotExists/Modify have identities)
$assignment = Get-AzPolicyAssignment -Id $remediation.PolicyAssignmentId
if ($null -ne $assignment.Identity -and $null -ne $assignment.Identity.PrincipalId) {
Get-AzRoleAssignment -ObjectId $assignment.Identity.PrincipalId
} else {
Write-Warning "Assignment has no managed identity - remediation requires DeployIfNotExists or Modify effect"
}

Usually the managed identity is missing permissions, a resource provider isn’t registered, or the template in the deployIfNotExists policy has an error.

Policy showing wrong compliance state

A resource should be compliant but shows non-compliant, or the other way round:

Terminal window
# Trigger a compliance scan
Start-AzPolicyComplianceScan -ResourceGroupName "your-rg"
# Check the specific policy state
Get-AzPolicyState -PolicyAssignmentName "your-assignment" `
-Filter "ResourceId eq '/subscriptions/.../your-resource'"
# Look at the detailed compliance reason
$state = Get-AzPolicyState -PolicyAssignmentName "your-assignment" -Top 1
$state.ComplianceReasonCode

Build fails with circular reference

If Build-DeploymentPlans fails with a circular reference error, policy definitions usually reference each other or a JSON file is malformed.

Terminal window
# Validate JSON files individually
Get-ChildItem -Path "./Definitions" -Filter "*.json" -Recurse | ForEach-Object {
try {
$null = Get-Content $_.FullName | ConvertFrom-Json
Write-Host "Valid: $($_.FullName)" -ForegroundColor Green
} catch {
Write-Host "Invalid: $($_.FullName)" -ForegroundColor Red
Write-Host $_.Exception.Message
}
}

Debug mode

If none of that helps, turn on verbose output:

Terminal window
$VerbosePreference = "Continue"
$DebugPreference = "Continue"
Build-DeploymentPlans `
-PacEnvironmentSelector "dev" `
-OutputFolder "./debug-output"

Multi-tenant patterns

With more than one Entra ID tenant, a consistent management group hierarchy (see the cloud foundation post) matters even more.

Separate configurations per tenant

{
"pacEnvironments": [
{
"pacSelector": "tenant1-prod",
"cloud": "AzureCloud",
"tenantId": "tenant-1-guid",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/tenant1-root"
},
{
"pacSelector": "tenant2-prod",
"cloud": "AzureCloud",
"tenantId": "tenant-2-guid",
"deploymentRootScope": "/providers/Microsoft.Management/managementGroups/tenant2-root"
}
]
}

Shared definitions, tenant-specific assignments

Definitions/
├── policyDefinitions/ # Shared across tenants
│ └── custom-policies/
├── policySetDefinitions/ # Shared across tenants
│ └── security-baseline/
└── policyAssignments/
├── tenant1/ # Tenant-specific
│ └── production.json
└── tenant2/ # Tenant-specific
└── production.json

What I’d do differently next time

Every exemption gets an expiry date. If one needs to be permanent, the policy itself probably needs adjusting.

The assignment structure should follow the management group tree. If the tree is wrong, fix the tree first instead of working around it in assignments.

Keep initiatives small. With one big initiative, changing a single parameter can affect 40 policies.

That ends the EPAC series. The governance framework post puts EPAC into a wider Azure governance strategy.


Sources

  1. Microsoft, “Azure Policy Exemption Structure,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/exemption-structure

  2. Microsoft, “Azure Policy Assignment Structure,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/assignment-structure

  3. Microsoft, “Azure Policy Initiative Definition,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/initiative-definition-structure

  4. Microsoft, “Remediate Non-Compliant Resources,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources

  5. Azure, “EPAC Documentation,” GitHub, https://github.com/Azure/enterprise-azure-policy-as-code/tree/main/Docs

  6. Microsoft, “Azure Policy Troubleshooting,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/troubleshoot/general