Posted in

Azure Policy at Scale: Turning Failed Evaluations into Owner-Tagged Work Items

The problem with compliance percentages

Most Azure Policy content stops at “set a policy, check the compliance %.” That works fine when you have three subscriptions and a handful of initiatives. It falls apart when you’re running Azure Lighthouse across 40 client tenants and need to know who owns which failure.

A failed policy evaluation is just a signal. By itself, it doesn’t tell you whether the issue affects recovery, auditability, telemetry, or something else leadership cares about. A storage account without blob soft delete is a recovery risk. Missing Azure Activity logs creates an audit gap. A VM without Azure Monitor Agent is a telemetry blind spot. Azure Policy shows you the state. But you need one more join to understand why it matters.

Why PolicyAssignmentId is the operational key

Here’s the thing most people miss: the same PolicyDefinitionId can appear in multiple assignments across different scopes. A “Require blob soft delete” definition might be assigned at the management group level for compliance, then again at a subscription level for a specific workload with different metadata.

That makes PolicyAssignmentId your operational key, not the definition ID. When you’re routing work items to owners, you need to know which assignment failed, not just which policy definition.

The query pattern

This ARG/KQL query joins non-compliant PolicyStates with PolicyAssignments, enriching each failure with the assignment name, scope, and owner metadata:

policyResources | where type =~ 'microsoft.policyinsights/policystates' | where properties.complianceState == 'NonCompliant' | extend ResourceId = tostring(properties.resourceId), PolicyAssignmentId = tolower(trim(' ', tostring(properties.policyAssignmentId))), SubscriptionId = tostring(subscriptionId), LastEvaluated = todatetime(properties.timestamp) | extend ResourceName = tostring(extract('[^/]+$', 0, ResourceId)) | extend ResourceProvider = tostring(extract('providers/([^/]+)/', 1, ResourceId)) | extend ResourceCategory = case( ResourceId has 'Microsoft.Compute/virtualMachines', 'VM', ResourceId has 'Microsoft.Storage/storageAccounts', 'Storage', ResourceId has 'Microsoft.Network/virtualNetworks', 'Network', ResourceId has 'Microsoft.Sql/servers', 'SQL', ResourceId has 'Microsoft.KeyVault/vaults', 'Key Vault', ResourceProvider =~ 'Microsoft.Compute', 'Compute', ResourceProvider =~ 'Microsoft.Storage', 'Storage', ResourceProvider =~ 'Microsoft.Network', 'Network', ResourceProvider =~ 'Microsoft.KeyVault', 'Key Vault', 'Other' ) | project ResourceId, ResourceName, ResourceCategory, SubscriptionId, PolicyAssignmentId, LastEvaluated | join kind=inner ( policyResources | where type =~ 'microsoft.authorization/policyassignments' | extend AssignmentName = tostring(properties.displayName), AssignmentId = tolower(trim(' ', tostring(id))), Scope = tostring(properties.scope), PolicyOwner = tostring(properties.metadata.owner) | project AssignmentId, AssignmentName, Scope, PolicyOwner ) on $left.PolicyAssignmentId == $right.AssignmentId | project SubscriptionId, ResourceId, ResourceName, ResourceCategory, AssignmentName, Scope, PolicyOwner, ['LastEvaluated[UTC]'] = LastEvaluated | order by SubscriptionId, ResourceCategory

A few things to note:

  • The query filters out compliant resources upfront. If you want to include all states, drop the complianceState == 'NonCompliant' line.
  • The ResourceCategory case statement maps common resource types to friendly labels. Add your own mappings as needed.
  • The join pulls assignment metadata including the owner tag from properties.metadata.owner. If your assignments don’t have owner metadata, that field will be empty.
  • Remove the hardcoded assignment name filters from the original Microsoft example. This version is more general-purpose.

Three use cases that matter

Why go through this effort? Three scenarios come up constantly in MSP environments:

Audit readiness. When auditors ask “show me your compliance posture,” a percentage isn’t enough. They want to know which controls are failing, what resources are affected, and who’s responsible for remediation. This query gives you that in one result set.

Ownership routing. In multi-tenant setups, you might have the same policy assigned differently per customer. The assignment metadata (especially the owner tag) tells you which team or customer owns the failure. No more guessing whether the storage failure belongs to Contoso or Fabrikam.

Prioritization. Not all non-compliant resources are equal. A Key Vault without soft delete is a different risk profile than a test VM missing a diagnostic setting. The ResourceCategory field lets you triage by resource type and business impact.

Automating it with Logic Apps

Running this query manually in Azure Resource Graph Explorer is fine for ad-hoc checks. But if you want repeatable governance reporting, automate it with a Logic App:

  1. Recurrence trigger. Set the schedule (daily, weekly, whatever fits your audit cadence).
  2. HTTP action with managed identity. Call the Azure Resource Graph REST API. Use the system-assigned managed identity so you don’t need to store credentials. The Logic App needs Reader access to the management group or subscriptions you’re querying.
  3. Shape the output. Parse the JSON response, format it as an HTML table or CSV. HTML works for leadership emails; CSV is better if you’re feeding it into a ticketing system or spreadsheet.
  4. Send the report. Email it to your governance team, engineering leads, or audit contacts. You can also push it to a SharePoint list, Azure Storage, or Sentinel workbook.

The whole pattern is no-code-ish. No secrets to manage, no service principals to rotate. Just a scheduled query with managed identity auth.

The multi-tenant angle for MSPs

If you’re running Azure Lighthouse, this pattern scales across customer subscriptions without modification. The ARG query runs at the management group level and pulls data from all delegated subscriptions. You get one result set covering all tenants, with the SubscriptionId field telling you which customer each failure belongs to.

You can add customer-specific owner tags to each assignment during onboarding. Then the query automatically routes failures to the right team. One automation pattern, multiplied across your entire portfolio.

Where to start

If you’re already using Azure Policy, run the query in Resource Graph Explorer first. See what it returns. Check whether your assignments have owner metadata. If they don’t, add it. Then build the Logic App and schedule it.

The shift here is from “here’s your compliance dashboard” to “here’s your work item queue, prioritized by resource type and routed to the right owner.” That’s the difference between visibility and action.

Sources