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.jsonCreating 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:
# Exemptions are included in the deployment planBuild-DeploymentPlans -PacEnvironmentSelector "prod"
# Plan shows:# - New exemptions to create# - Expired exemptions to remove# - Modified exemptions to updateSet 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.jsonEach 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.jsonOr 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.
# 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 differencesCompare-Object ($azurePolicy -split "`n") ($sourcePolicy -split "`n")Remediation tasks failing
Remediation tasks exist but resources don’t get fixed:
# 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.PolicyAssignmentIdif ($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:
# Trigger a compliance scanStart-AzPolicyComplianceScan -ResourceGroupName "your-rg"
# Check the specific policy stateGet-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.ComplianceReasonCodeBuild 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.
# Validate JSON files individuallyGet-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:
$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.jsonWhat 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
-
Microsoft, “Azure Policy Exemption Structure,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/exemption-structure
-
Microsoft, “Azure Policy Assignment Structure,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/assignment-structure
-
Microsoft, “Azure Policy Initiative Definition,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/concepts/initiative-definition-structure
-
Microsoft, “Remediate Non-Compliant Resources,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources
-
Azure, “EPAC Documentation,” GitHub, https://github.com/Azure/enterprise-azure-policy-as-code/tree/main/Docs
-
Microsoft, “Azure Policy Troubleshooting,” Azure Documentation, https://learn.microsoft.com/azure/governance/policy/troubleshoot/general
