Home› Blog› Week 13 — Diagnostic Settings at Scale, and the Tw…
Azure Weekly Lab Azure Terraform

Week 13 — Diagnostic Settings at Scale, and the Two Resources That Were Never Going to Comply

One built-in initiative, four resources, three different outcomes — and the two that look like failures are the ones worth understanding.

Verified against current vendor documentation on 14 November 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
Azure Policy deployIfNotExists Log Analytics Diagnostic Settings Terraform
Azure Platform Engineering Lab · Week 13 of 52

📋 In This Post

Lab Overview

The problem. "All our resources send logs" is a claim every platform team makes and almost none can demonstrate. Diagnostic settings are per-resource, created one at a time, and silently absent on anything nobody remembered. Week 12 built detections that read a log table; this is the week that fills it.

The objective. Turn resource logging on automatically across a subscription using Azure Policy, then prove it — not by reading a compliance percentage, but by asking the workspace whether a log was actually written.

The outcome. Nine resources, seven validation checks, and 14 Key Vault audit rows in the workspace. Two of the four target resources were deliberately left untouched, and that is the result rather than a gap.

Cloud Services and Concepts

ServicePurpose hereWhat it is
Azure Policy initiative Turns logging on across resource types without writing a rule per type A bundle of policy definitions assigned as one unit. Azure ships one for this job containing 69 policies for the audit category group.
deployIfNotExists Creates the missing diagnostic setting rather than just reporting it A policy effect that deploys a resource when the condition isn't met. Needs an identity to deploy as, which is why the assignment carries one.
Diagnostic setting The object that routes a resource's logs to the workspace A per-resource routing rule: these categories, to that destination. It collects only what happens after it exists.
Remediation task Applies the policy to resources that already existed A one-off job per policy. Without it, the assignment only affects resources created afterwards.
Log Analytics workspace Where the logs land, and the only thing that can confirm they did The table store behind Azure Monitor, billed per GB ingested.

Category groups. audit and allLogs are two preset bundles of log categories. The choice is usually described as a volume decision; it is also a coverage decision, because a resource type can support one and not the other. That distinction is this week's result.

Architecture and Workflow

Architecture diagram. At subscription scope, a built-in Azure Policy initiative is assigned with a system-assigned managed identity holding Log Analytics Contributor; the identity is what deployIfNotExists deploys as. Inside the resource group, four target resources produce three outcomes. A key vault and a public IP are remediated and send resource logs to the Log Analytics workspace. A network security group is untouched, because it supports the allLogs category group and not audit. A storage account is untouched because no built-in policy covers the type at any category group. The workspace itself is also covered and logs itself. Remediation tasks are created per policy, not per initiative.
One initiative, three outcomes. The two untouched resources are not gaps — one needs a different category group, the other has no built-in policy to comply with.
  1. A built-in initiative is assigned at subscription scope with a system-assigned managed identity.
  2. The identity is granted Log Analytics Contributor — the role named in the policies' roleDefinitionIds.
  3. Policy evaluation finds which resources are missing a diagnostic setting.
  4. A remediation task per policy applies the setting to resources that already existed.
  5. Each remediated resource writes its logs to the workspace.
  6. A deliberate Key Vault operation generates an audit event.
  7. The workspace is queried for the row — the only step that proves data moved.

Three design decisions.

Assign the initiative, do not write the policies. Azure ships 69 policy definitions for audit and 139 for allLogs. Hand-writing per-type rules would reinvent them and then rot as Azure adds types.

Look it up by display name, not GUID. A bare GUID in a .tf file is indistinguishable from a leaked identifier to the repository's secret sweep — and the name says what it is.

Cap the workspace in code. The week's whole purpose is turning ingestion on, and ingestion is the largest surprise bill in Azure.

Prerequisites

  • An Azure subscription where you hold Owner — the assignment creates a role assignment at subscription scope.
  • Providers registered: Microsoft.OperationalInsights, Microsoft.Insights, Microsoft.PolicyInsights, Microsoft.KeyVault, Microsoft.Storage, Microsoft.Network.
  • Terraform ≥ 1.10, hashicorp/azurerm ~> 5.2.
  • No cross-week dependency. The week builds its own workspace so the ingestion it generates is measured in isolation.

Implementation

Step 1 — Price it, and notice what the gate does not cover

Why. Week 11 of this lab shipped a cost note that missed a flat $6/month meter, so every week since runs a gate first. This week the gate passes and still does not protect you.

Terminal output of price_check.py for week 13 listing 30 flat recurring meters, all acknowledged, with a note that the meter which actually applies is Analytics Logs Data Ingestion at 2.76 dollars per GB and is not covered by the gate
30 flat meters, all acknowledged — and none of them is the risk. The exposure is $2.76/GB ingestion, a usage meter the gate does not watch.

So the bound went into Terraform instead, with a prediction written down first so the measurement could contradict it:

daily_quota_gb = 0.5     # ingestion STOPS at the cap, it does not bill past it
# predicted: under 0.05 GB/day, recorded in meters.json before deploying

Step 2 — Assign Azure's initiative, not your own

data "azurerm_policy_set_definition" "diag_to_law" {
  display_name = "Enable ${var.category_group} category group resource logging for supported resources to Log Analytics"
}

resource "azurerm_subscription_policy_assignment" "diag" {
  policy_definition_id = data.azurerm_policy_set_definition.diag_to_law.id
  identity { type = "SystemAssigned" }
  parameters = jsonencode({ logAnalytics = { value = azurerm_log_analytics_workspace.diag.id } })
}
The Azure Policy Assignments blade showing the week 13 assignment named resource logs to Log Analytics at audit, scoped to sub-lab-dev, with type Initiative
Assigned at subscription scope, type Initiative. Full source: policy.tf.

Step 3 — Remediate, one task per policy

Why. This is the step that gets skipped. An assignment applies to resources created after it; everything already there needs a remediation task, and for an initiative that means one per policy.

Terminal output showing three policy definition reference IDs evaluated as compliant and three separate remediation tasks each succeeded with one deployment
Three policies matched, so three tasks. The audit initiative holds 69; allLogs holds 139. In an estate, that is the operational cost of the pattern.

Validation and Evidence

TestExpectedActualStatus
Workspace existspresentlaw-wk13-dev-scus-001PASS
Daily ingestion cap setcapped0.5 GB/dayPASS
Setting on key vault11PASS
Setting on public IP11PASS
No setting on NSG under audit0, by design0PASS
No setting on storage0, by design0PASS
Logs actually arrived> 0 rows14 Key Vault audit rowsPASS
validate.sh output showing seven checks passing including two rows where the expected result is that no diagnostic setting exists, and a final row confirming fourteen Key Vault audit rows reached the workspace
Two of those PASS rows are resources the policy did not touch. Expected absence is a result, and a suite that cannot express it will call correct behaviour a failure.

What each result proves. Rows 1–2 prove configuration. Rows 3–6 prove the initiative's coverage boundary — including where it deliberately stops. Only row 7 proves a log was written.

Terminal output querying the Log Analytics workspace showing fourteen AuditEvent rows from Microsoft.KeyVault, with individual rows listing SecretGet, SecretSet and an Authentication event with result Unauthorized
The only slot that proves data moved. A compliance percentage describes configuration; this asks the workspace. The Unauthorized row is a real failed authentication the audit log caught.

Security and Cost

Security. No secrets exist in this week. The remediation identity is system-assigned — an object that cannot hold a password — and holds exactly Log Analytics Contributor, the role the policies name, at the scope they are assigned to. The Key Vault uses RBAC rather than access policies. The notable privilege is the assignment scope itself: a subscription-scope deployIfNotExists can create resources anywhere in the subscription, so the blast radius was enumerated before applying — one virtual network, one route table, one network watcher, none of them covered under audit.

Cost. Measured figure pending the invoice; this post will carry it rather than an estimate. What is established is the complete list of meters that could apply, written before deployment, and the three bounds: daily_quota_gb = 0.5, the smaller category group, and an enumerated blast radius.

One meter was worth chasing. The price API lists $4.85/hour under the Key Vault service — roughly $3,500/month. It belongs to Azure Dedicated HSM, a different product; a standard vault bills per 10,000 operations. Checked rather than assumed, which is the only reason a perfectly good resource was not dropped from the design.

Troubleshooting and Lessons

Problem. The apply failed with UndefinedPolicyParameter: the parameter 'categoryGroup' is not defined in the policy definition.
Root cause. Microsoft's parameter table lists categoryGroup among the common parameters, but that applies to the individual policies. Queried live, these initiatives accept only effect, diagnosticSettingName, resourceLocationList, logAnalytics and resourceTypeList.
Resolution. The category group is chosen by which initiative you assign, so the variable selects the initiative by name.
Lesson. Ask the API what a thing accepts; the doc table is a summary, not a schema.

Problem. The workspace stayed empty and the pipeline looked broken.
Root cause. The first probe wrote its secret in the same minute the diagnostic setting was being created. A diagnostic setting only collects what happens after it is active, so the event was never captured — and with nothing else touching the vault, there was nothing to find.
Resolution. Issue the operation once the setting is live. Rows appeared in about four minutes.
Lesson. An empty result is not a failure until you have established that the thing you are looking for ever happened.

Problem. The public IP read NonCompliant while its diagnostic setting demonstrably existed.
Root cause. Compliance state refreshes on its own schedule.
Lesson. Ask each resource, not the dashboard. The validation queries settings per resource for the same reason it queries the workspace for rows.

Problem. az monitor diagnostic-settings list --resource <id> fails with a usage error on CLI 2.86.0 given a valid resource ID.
Resolution. The ARM API answers normally; the validation goes through az rest. The same class of CLI breakage as az role assignment in week 12.

Cleanup and Key Takeaways

cleanup.sh output showing remediation tasks removed, nine resources destroyed, and four checks confirming against Azure that the resource group, workspace, subscription policy assignment and remediation role assignment are all gone
Four assertions, not one. Two of the nine resources live outside the resource group.

The teardown trap. The policy assignment and its role assignment are at subscription scope. Deleting the resource group removes the workspace and leaves the assignment behind — still trying to remediate into a destination that no longer exists. Both are asserted separately, against Azure rather than against the destroy's exit code.

Takeaways

  1. Assign Azure's initiative; don't write 69 policies. And look it up by display name, so no GUID lands in your Terraform.
  2. Remediation is per policy, not per initiative. Assigning it changes nothing for what already exists.
  3. Expected absence is a result. Two resources were never going to comply — one unsupported by the category group, one by any built-in policy. A suite that cannot say "0, by design" will report correct behaviour as failure.
  4. Compliance is not evidence. It says a setting exists. Ask the workspace whether a log was written.
  5. When the gate does not cover the risk, bound it in code. The meter that could bill here is a usage meter, so the cap went into Terraform and the prediction went into the repo before deployment.

Repository: week-13-diagnostic-settings-at-scale — Terraform, validation, cleanup, and the committed meters.json recording every charge this week accepted.

References

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent