top of page
  • 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.

Sanitized terminal verification showing the Azure Policy lab resource group removed, governance artifact counts at zero, and an empty Terraform state



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


bottom of page