- 7 hours ago
- 12 min read

Azure Policy is easy to demonstrate with one portal assignment. It becomes much more interesting when you treat definitions, initiatives, assignments, managed identities, RBAC, remediations, exemptions, and rollout stages as one versioned system.
In this hands-on Azure Workshopz lab, you will build that system with PowerShell 7, Terraform 1.15.8, Azure CLI, and the Azure/azapi provider 2.11.0. You will start with an intentionally non-compliant storage account, observe the findings without blocking anything, remediate two existing conditions, promote a required tag from Audit to Deny, add a temporary resource-level waiver, detect out-of-band drift, and finish with a clean Terraform plan.
The assignment is created at subscription scope, but a resourceLocation selector restricts evaluation to a North Europe canary. No virtual machines or inbound networking are required.
Workshop time: approximately 25–30 minutes, plus Azure Policy propagation time. Compliance evaluation and remediation are asynchronous and can take several minutes.
What you will build
The lab creates:
• One disposable North Europe resource group.
• One Standard LRS storage account and its automatically created Blob service.
• One Log Analytics workspace with 30-day retention and a small daily cap.
• Two custom, versioned Azure Policy definitions.
• One versioned initiative containing the custom policies and a pinned Microsoft built-in.
• One subscription-scoped assignment in a North Europe canary.
• One system-assigned managed identity with three roles scoped only to the lab resource group.
• Two Terraform-managed remediation tasks.
• One resource-level, seven-day Waiver exemption.
The initiative contains these members:
| Reference ID | Type | Normal effect | Purpose |
|---|---|---|---|
| requireCostCenter | Custom | Deny | Require a CostCenter tag on storage accounts |
| addManagedBy | Custom | Modify | Add or replace ManagedBy=AzurePolicy |
| deployBlobDiagnostics | Built-in | DeployIfNotExists | Send Blob service logs and metrics to Log Analytics |
The built-in definition uses immutable ID b4fe1a3b-0715-4c6c-a5ea-ffc33cf823cb and version constraint 4.0.*. The wildcard is intentional: the active service rejected the exact string 4.0.0, while 4.0.* resolves within the tested major/minor line and currently selects 4.0.0. Keep the provider lockfile and validate the resolved version in your own tenant before promotion.


Before you begin
You need:
• PowerShell 7.
• Azure CLI authenticated to a disposable subscription.
• Terraform 1.15.8 on your PATH.
• Rights to create subscription policy resources and lab-resource-group role assignments.
• The downloaded workshop source.
Confirm your tools and active subscription:
terraform version
az version
az login
az account show --query "{subscription:name, user:user.name}" -o table
The source uses Azure CLI authentication. It does not store a client secret, access token, tenant ID, or subscription ID in committed files.
Create a local input file that is deliberately ignored by Git:
$subscriptionId = az account show --query id -o tsv
@{
subscription_id = $subscriptionId
} | ConvertTo-Json | Set-Content -Path .\local.auto.tfvars.json
Do not add this file to the source bundle. Terraform state and saved plan files also contain resource IDs and must stay local.
1. Run the North Europe safety gate
The assignment will eventually use Deny. Before deploying anything, prove that North Europe does not contain resources that could be unintentionally evaluated by this workshop assignment:
$subscriptionId = az account show --query id -o tsv
$existingResources = az resource list `
--subscription $subscriptionId `
--query "[?location=='northeurope'] | length(@)" `
-o tsv
$existingGroups = az group list `
--subscription $subscriptionId `
--query "[?location=='northeurope'] | length(@)" `
-o tsv
"North Europe resources: $existingResources"
"North Europe resource groups: $existingGroups"
Continue only when both counts are zero, or when you have independently reviewed every result and intentionally accept it. If this gate fails, keep enforcement_mode = "DoNotEnforce" and do not enable remediation.
The lab was verified with zero pre-existing North Europe resources immediately before bootstrap.
2. Understand the staged Terraform interface
One Terraform root owns the full lifecycle. Behavior is selected through four committed .tfvars files:
stages/
├── 01-bootstrap.tfvars
├── 02-observe.tfvars
├── 03-remediate.tfvars
└── 04-enforce.tfvars
The important variables are:
variable "subscription_id" { type = string }
variable "location" { type = string, default = "northeurope" }
variable "cost_center" { type = string, default = "CC-1001" }
variable "enable_governance" { type = bool }
variable "enforcement_mode" { type = string }
variable "audit_required_tag" { type = bool }
variable "enable_remediation" { type = bool }
variable "enable_exemption" { type = bool }
variable "include_managed_by_tag" { type = bool }
The stage files deliberately change only the rollout switches:
# 01-bootstrap.tfvars
enable_governance = false
enforcement_mode = "DoNotEnforce"
audit_required_tag = true
enable_remediation = false
enable_exemption = false
include_managed_by_tag = false
# 02-observe.tfvars
enable_governance = true
enforcement_mode = "DoNotEnforce"
audit_required_tag = true
enable_remediation = false
enable_exemption = false
include_managed_by_tag = false
# 03-remediate.tfvars
enable_governance = true
enforcement_mode = "DoNotEnforce"
audit_required_tag = true
enable_remediation = true
enable_exemption = false
include_managed_by_tag = true
# 04-enforce.tfvars
enable_governance = true
enforcement_mode = "Default"
audit_required_tag = false
enable_remediation = true
enable_exemption = true
include_managed_by_tag = true
This is the central Policy-as-Code idea: promotion is a reviewed input change, not a collection of portal clicks.
3. Initialize Terraform and enable AzAPI preflight
The provider configuration pins the tested versions and enables ARM preflight validation:
terraform {
required_version = "= 1.15.8"
required_providers {
azapi = {
source = "Azure/azapi"
version = "= 2.11.0"
}
}
}
provider "azapi" {
enable_preflight = true
}
AzAPI reads the active Azure CLI subscription. A Terraform check block compares that subscription with the local subscription_id input and stops the run when they differ.
Run formatting, initialization, and validation:
terraform fmt -recursive
terraform init -upgrade
terraform fmt -check
terraform validate
Commit .terraform.lock.hcl. It records the provider selection and checksums, which makes local and CI runs more reproducible.

A live API-version lesson
The original design targeted 2026-06-01 for policy definitions, initiatives, and assignments. The active Visual Studio Enterprise subscription returned InvalidResourceType for that version on deployment day. Its registered provider metadata reported 2025-11-01 as the latest stable supported API, so the tested source uses:
type = "Microsoft.Authorization/policyDefinitions@2025-11-01"
type = "Microsoft.Authorization/policySetDefinitions@2025-11-01"
type = "Microsoft.Authorization/policyAssignments@2025-11-01"
That is not a reason to abandon pinning. It is a reason to add preflight and a provider-capability check to the pipeline. API rollout can differ across clouds and subscriptions.
The other resource APIs used by this lab are:
• Resource group: 2025-04-01
• Storage account: 2025-08-01
• Log Analytics workspace: 2025-07-01
• Role assignment: 2022-04-01
• Policy remediation: 2024-10-01
• Policy exemption: 2022-07-01-preview
4. Bootstrap the intentionally non-compliant resources
Create a saved plan and apply exactly that file:
terraform plan `
-var-file="stages/01-bootstrap.tfvars" `
-out="01-bootstrap.tfplan"
terraform apply "01-bootstrap.tfplan"
The storage account begins with only workshop metadata:
legacy_storage_tags = {
Workshop = "AzurePolicyAsCode"
Environment = "Lab"
}
It intentionally has neither CostCenter nor ManagedBy. The Blob service is created by the Storage resource provider, so the Terraform root does not try to create a duplicate blobServices/default child.
The storage account disables public Blob access, cross-tenant replication, and Shared Key authorization, and requires HTTPS with TLS 1.2. Public network access remains enabled only to keep this governance lab compact; production guidance appears later.
5. Author definitions and a versioned initiative
A policy definition contains the rule. An assignment decides where and how that rule runs. An initiative is a named, versioned bundle of policy definitions that can be assigned and operated as one governance control.
The required-tag definition uses Indexed mode and a normal effect of Deny:
{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
{
"field": "[concat('tags[', parameters('tagName'), ']')]",
"exists": "false"
}
]
},
"then": {
"effect": "Deny"
}
}
The managed tag definition uses Modify:
{
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
{
"field": "[concat('tags[', parameters('tagName'), ']')]",
"notEquals": "[parameters('tagValue')]"
}
]
},
"then": {
"effect": "Modify",
"details": {
"roleDefinitionIds": [
"/providers/Microsoft.Authorization/roleDefinitions/4a9ae827-6dc8-4573-8ac7-8239d42aa03f"
],
"operations": [
{
"operation": "addOrReplace",
"field": "[concat('tags[', parameters('tagName'), ']')]",
"value": "[parameters('tagValue')]"
}
]
}
}
}
Modify changes matching create/update requests and can remediate existing resources. DeployIfNotExists evaluates for a related resource and can deploy it when absent. Neither should receive broad Contributor access by default.
Deploy the governance objects in observe mode:
terraform plan `
-var-file="stages/02-observe.tfvars" `
-out="02-observe.tfplan"
terraform apply "02-observe.tfplan"
6. Assign at subscription scope, evaluate only the canary
The assignment lives at subscription scope, but this selector narrows evaluation:
resourceSelectors = [
{
name = "NorthEuropeCanary"
selectors = [
{
kind = "resourceLocation"
in = [var.location]
}
]
}
]
resourceSelectors are first-class assignment data. They are safer and more reviewable than recreating a new assignment for every rollout ring.
The initial assignment also contains:
enforcementMode = "DoNotEnforce"
overrides = [
{
kind = "policyEffect"
value = "Audit"
selectors = [
{
kind = "policyDefinitionReferenceId"
in = ["requireCostCenter"]
}
]
}
]
DoNotEnforce still evaluates resources and reports compliance, but it suppresses enforcement during create/update requests. It is different from disabling a definition, which prevents evaluation. Remediation tasks can still be started for Modify and DeployIfNotExists members while the assignment is in DoNotEnforce.
The override targets only requireCostCenter; Modify and DeployIfNotExists retain their initiative effects.

7. Give the remediation identity only the access it needs
Policy evaluation itself does not use the assignment identity. Modify and DeployIfNotExists remediation do.
The assignment creates one system-assigned identity in North Europe:

Terraform grants three built-in roles by immutable ID at the lab resource group—not the subscription:
| Role | Immutable role ID | Why |
|---|---|---|
| Tag Contributor | 4a9ae827-6dc8-4573-8ac7-8239d42aa03f | Add or replace the managed tag |
| Monitoring Contributor | 749f88d5-cbae-40b8-bcfc-e573ddc772fa | Deploy the diagnostic setting |
| Log Analytics Contributor | 92aaf0da-9dab-42b6-94a3-d43ce8d16293 | Target the lab workspace |
The assignment is subscription-scoped for evaluation, but its mutation rights stop at the lab resource group. This separation is a useful governance pattern: broad visibility, narrow write authority.

8. Trigger and inspect the first compliance scan
Trigger a scan without blocking the terminal:
$subscriptionId = az account show --query id -o tsv
az policy state trigger-scan `
--subscription $subscriptionId `
--no-wait
Poll instead of assuming immediate convergence:
$assignmentId = terraform output -raw assignment_id
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
az policy state list `
--subscription $subscriptionId `
--filter "PolicyAssignmentId eq '$assignmentId'" `
--query "[].{
reference:policyDefinitionReferenceId,
state:complianceState,
type:resourceType,
group:resourceGroup
}" `
-o table
The verified first scan returned three non-compliant results:
• requireCostCenter for the storage account.
• addManagedBy for the storage account.
• deployBlobDiagnostics for the Blob service.
The required tag reports with effective type Audit, proving the override is active.

9. Remediate Modify and DeployIfNotExists separately
One remediation task can target only one initiative member. Create two resources so their lifecycle and failure signals remain independent. The location filter is appropriate for the top-level storage account targeted by Modify:
resource "azapi_resource" "modify_tag_remediation" {
type = "Microsoft.PolicyInsights/remediations@2024-10-01"
parent_id = local.subscription_scope
name = "remediate-managed-by-tag"
body = {
properties = {
policyAssignmentId = azapi_resource.governance_assignment[0].id
policyDefinitionReferenceId = "addManagedBy"
resourceDiscoveryMode = "ReEvaluateCompliance"
resourceCount = 50
parallelDeployments = 5
filters = {
locations = [var.location]
}
failureThreshold = {
percentage = 0.1
}
}
}
}
The percentage value is a fraction. 0.1 means ten percent. The service correctly rejected the integer 10, which is a useful pre-production validation test.
The Blob diagnostic member evaluates the child resource blobServices/default. In the live subscription, that related resource did not expose a location that matched the remediation task's optional filters.locations value, so a first task completed successfully with zero selected deployments. The corrected Blob remediation omits that redundant filter; the assignment's North Europe resource selector already constrains evaluation. This is exactly why successful control-plane creation is not enough—you must verify the intended data-plane or related-resource outcome.
Apply the remediation stage:
terraform plan `
-var-file="stages/03-remediate.tfvars" `
-out="03-remediate.tfplan"
terraform apply "03-remediate.tfplan"
Poll both tasks:
do {
$tasks = az policy remediation list `
--subscription $subscriptionId `
--query "[?starts_with(name, 'remediate-')].{
name:name,
state:provisioningState,
successful:deploymentStatus.successfulDeployments,
failed:deploymentStatus.failedDeployments
}" | ConvertFrom-Json
$tasks | Format-Table
Start-Sleep -Seconds 15
} while ($tasks.state -contains "Evaluating" -or $tasks.state -contains "Running")
Verify the outcomes without exposing IDs:
$storageName = terraform output -raw legacy_storage_id |
Split-Path -Leaf
az storage account show `
--name $storageName `
--resource-group rg-policy-as-code-ne `
--query "tags.{ManagedBy:ManagedBy,Workshop:Workshop,Environment:Environment}" `
-o table
$storageId = terraform output -raw legacy_storage_id
az monitor diagnostic-settings list `
--resource "$storageId/blobServices/default" `
--query "value[].{
name:name,

The remediation stage sets include_managed_by_tag = true, bringing Terraform's desired configuration into alignment with the policy-owned change. Decide explicitly which system owns each field; otherwise Terraform and Policy can fight over the same property.
10. Promote Audit to Deny
Promotion changes two controls:
• enforcement_mode becomes Default.
• The Audit override is removed, exposing the definition’s normal Deny effect.
It also creates the temporary exemption and adopts ManagedBy=AzurePolicy into the storage account’s declared Terraform tags.
terraform plan `
-var-file="stages/04-enforce.tfvars" `
-out="04-enforce.tfplan"
terraform apply "04-enforce.tfplan"
Trigger another compliance scan and allow the assignment change to propagate.
Now attempt to create a second storage account in North Europe without CostCenter:
$denyTestName = "stdeny$((Get-Random -Maximum 99999999).ToString('00000000'))"
az storage account create `
--name $denyTestName `
--resource-group rg-policy-as-code-ne `
--location northeurope `
--sku Standard_LRS `
--kind StorageV2 `
--allow-blob-public-access false `
--allow-shared-key-access false `
--https-only true `
--min-tls-version TLS1_2 `
--tags Workshop=AzurePolicyAsCode Environment=Lab
Expected result:
RequestDisallowedByPolicy
Storage accounts must include the CostCenter governance tag.
Confirm that the denied test resource does not exist:
az storage account check-name `
--name $denyTestName `
--query "{name:name, available:nameAvailable, reason:reason}" `
-o table
11. Exempt one legacy resource, not the whole scope
An exclusion removes a scope from assignment evaluation. An exemption keeps evaluation context and records why one resource is accepted or mitigated.
This lab creates a resource-level Waiver for only the required-tag initiative member:
resource "azapi_resource" "legacy_storage_exemption" {
type = "Microsoft.Authorization/policyExemptions@2022-07-01-preview"
parent_id = azapi_resource.legacy_storage.id
name = "legacy-cost-center-waiver"
body = {
properties = {
displayName = "Legacy storage CostCenter waiver"
exemptionCategory = "Waiver"
expiresOn = var.exemption_expires_on
policyAssignmentId = azapi_resource.governance_assignment[0].id
policyDefinitionReferenceIds = ["requireCostCenter"]
metadata = {
approvedBy = "Workshop operator"
changeTicket = "LAB-2026-0730"
source = "Terraform and AzAPI"
}
}
}
}
The exemption expires after seven days. It does not exempt the storage account from the Modify or Blob diagnostics members.
Use Waiver when non-compliance is temporarily accepted. Use Mitigated when the policy intent is satisfied through another documented control. Expiration, an approver, and a change ticket turn exemptions into auditable exceptions instead of permanent loopholes.
12. Detect and reconcile out-of-band drift
Make one harmless assignment metadata change outside Terraform:
$assignmentId = terraform output -raw assignment_id
$assignment = az rest `
--method get `
--url "https://management.azure.com$assignmentId" `
--url-parameters api-version=2025-11-01 |
ConvertFrom-Json
$assignment.properties.metadata.driftControl = "temporary"
$body = @{
location = $assignment.location
identity = $assignment.identity
properties = $assignment.properties
} | ConvertTo-Json -Depth 30
$body | Set-Content -Path ".drift-assignment.json" -Encoding utf8
az rest `
--method put `
--url "https://management.azure.com$assignmentId" `
--url-parameters api-version=2025-11-01 `
--headers "Content-Type=application/json" `
--body "@.drift-assignment.json"
Detect it:
terraform plan -var-file="stages/04-enforce.tfvars"
The assignment declares metadata.driftControl = "terraform" and sets ignore_missing_property = false. Changing the declared value makes the ownership boundary unambiguous: the plan should show driftControl returning from temporary to terraform. Reconcile through the declared configuration:
terraform plan `
-var-file="stages/04-enforce.tfvars" `
-out="04-reconcile.tfplan"
terraform apply "04-reconcile.tfplan"
Finish with a no-change plan:
terraform plan `
-var-file="stages/04-enforce.tfvars" `
-detailed-exitcode
$LASTEXITCODE
Terraform returns exit code 0 when the plan is empty, 2 when changes exist, and 1 on error.
Troubleshooting
Policy results are empty
Assignment creation and compliance data are asynchronous. Trigger a scan and poll for up to 30 minutes:
az policy state trigger-scan --subscription $subscriptionId --no-wait
Check that:
• The assignment selector contains northeurope.
• The test resource’s normalized location is northeurope.
• The assignment ID in the query is exact.
• The policy assignment and definitions are visible in the same subscription.
Remediation remains Evaluating
ReEvaluateCompliance starts a fresh scan before selecting targets. Confirm the non-compliant policy state exists, then inspect:
az policy remediation list `
--subscription $subscriptionId `
--query "[].{
name:name,
state:provisioningState,
lastUpdated:lastUpdatedOn,
successful:deploymentStatus.successfulDeployments,
failed:deploymentStatus.failedDeployments
}" `
-o table
If rapid execution is more important than a fresh scan, use ExistingNonCompliant after you have explicitly verified current compliance data.
Remediation fails authorization
Confirm that the assignment identity has the three expected roles at the lab resource group:
$principalId = terraform output -raw assignment_principal_id
$resourceGroupId = terraform output -raw resource_group_id
az role assignment list `
--assignee-object-id $principalId `
--scope $resourceGroupId `
--query "[].{role:roleDefinitionName, scope:scope}" `
-o table
New managed identities can take time to replicate in Microsoft Entra ID. Terraform’s role assignments specify principalType = "ServicePrincipal" to reduce ambiguous lookup behavior.
Deny test unexpectedly succeeds
Verify that:
• enforcementMode is Default.
• The policyEffect=Audit override is gone.
• The assignment update has propagated.
• The test storage account is in North Europe.
• The request does not include CostCenter.
Delete the test account if it was created and return the assignment to DoNotEnforce before continuing.
Terraform and Policy disagree about tags
If Modify adds a tag outside Terraform, a refresh can show drift. Choose ownership intentionally:
• Add the policy-managed tag to Terraform’s desired map.
• Or use a narrowly scoped lifecycle ignore for a field that Policy exclusively owns.
Do not broadly ignore all tags; that can hide unrelated governance drift.
Production guidance
This is a compact, disposable workshop. For production:
• Use workload identity federation/OIDC from CI/CD instead of long-lived service-principal secrets.
• Store Terraform state in a locked, encrypted remote backend with narrowly scoped RBAC.
• Run fmt, validate, security checks, policy unit tests, preflight, and a saved plan before approval.
• Separate authoring, plan review, promotion, and emergency rollback responsibilities.
• Treat .terraform.lock.hcl, policy versions, initiative versions, and stage inputs as reviewed release artifacts.
• Start at DoNotEnforce, evaluate the least critical canary, remediate deliberately, then expand selector rings.
• Add health and cost signals to the rollout gate; compliance alone does not prove application safety.
• Use immutable built-in IDs and constrain versions. Test new built-in versions before widening the selector.
• Grant remediation identities only the roles and scopes required by the selected effects.
• Prefer private connectivity, network access controls, and centralized diagnostic destinations for production storage and Log Analytics.
• Export compliance state and exemption inventory to your governance reporting system.
• Alert on exemptions approaching expiry and require named owners and ticket references.
• Document ownership boundaries where Terraform, Policy, platform automation, and application teams can mutate the same fields.
Microsoft’s safe-deployment guidance recommends high-level assignments with selectors and staged effect changes rather than immediate broad Deny. This lab implements that pattern with a single subscription assignment and a location canary.
Cleanup
Before destroy, return the assignment to safe mode:
terraform plan `
-var-file="stages/03-remediate.tfvars" `
-out="00-safe-mode.tfplan"
terraform apply "00-safe-mode.tfplan"
Review a saved destroy plan:
terraform plan `
-destroy `
-var-file="stages/03-remediate.tfvars" `
-out="destroy.tfplan"
terraform show "destroy.tfplan"
terraform apply "destroy.tfplan"
Confirm that all lab artifacts are gone:
az group exists --name rg-policy-as-code-ne
az policy assignment list `
--subscription $subscriptionId `
--query "[?name=='pa-pac-governance-canary'] | length(@)" `
-o tsv
az policy definition list `
--subscription $subscriptionId `
--query "[?starts_with(name, 'pac-')] | length(@)" `
-o tsv
az policy set-definition list `
--subscription $subscriptionId `
--query "[?name=='pac-governance-baseline'] | length(@)" `
-o tsv
az policy remediation list `
--subscription $subscriptionId `
--query "[?starts_with(name, 'remediate-')] | length(@)" `
-o tsv
Expected results are false
, followed by four zeroes.

Key takeaways
• A definition is the rule; an assignment is the rollout decision; an initiative is the governed release unit.
• Subscription scope does not require subscription-wide blast radius when you use resource selectors.
• DoNotEnforce preserves evaluation, while an effect override can safely change only one initiative member.
• Modify and DeployIfNotExists need an assignment identity and explicit least-privilege RBAC.
• Remediation is asynchronous and initiative members require separate tasks.
• Exemptions are auditable exceptions; exclusions simply remove scope.
• Policy and Terraform need an explicit ownership contract for shared fields.
• A final no-change plan is part of the evidence, not an optional flourish.
References
• [Safe deployment of Azure Policy assignments](https://learn.microsoft.com/en-us/azure/governance/policy/how-to/policy-safe-deployment-practices)
• [Azure Policy assignment structure](https://learn.microsoft.com/en-us/azure/governance/policy/concepts/assignment-structure)
• [Azure Policy remediation task structure](https://learn.microsoft.com/en-us/azure/governance/policy/concepts/remediation-structure)
• [Remediate non-compliant resources](https://learn.microsoft.com/en-us/azure/governance/policy/how-to/remediate-resources)
• [Azure Policy exemption structure](https://learn.microsoft.com/en-us/azure/governance/policy/concepts/exemption-structure)
• [AzAPI provider preflight validation](https://registry.terraform.io/providers/Azure/azapi/latest/docs/guides/feature_preflight)
Comments