How to lint Azure Policy definitions in Azure DevOps with EPAC and Azure Policy Linter

Treating your Azure Policy like code

How to lint Azure Policy definitions in Azure DevOps with EPAC and Azure Policy Linter

Azure accepts a policy definition long before it's a good one. You can make a definition with a hard-coded Deny, a like operator that was meant to be equals, or a field alias that doesn't exist in older API versions deploys without a notice. Then, you find out later, when a developer's deployment is blocked for the wrong reason, or when compliance shows green while enforcement quietly does nothing.

That knowledge is spread across resource provider documentation, or simply in people's minds. Or is it?

Azure Policy Linter collects it all together into a command-line tool. Version 0.8.0 ships 42 rules that inspect a single policy definition and report findings at three severities, each linked to a documentation page that explains the risk and the fix. It runs offline, reads the same JSON you commit to source control, and writes JSON for CI systems.

In this article, you apply the same DevOps principles to Azure Policy that you already apply to your application code. You first start by linting every change, delivering it through a DevOps pipeline, and promoting it from development to production.

Prerequisites

Before you begin this tutorial, there are a couple of prerequisites:

  • An Azure subscription and permission to create management groups
  • An Azure DevOps project with permission to create pipelines, service connections, and environments
  • .NET SDK 10.0 or later to run the linter locally
  • Working knowledge of EPAC

The EPAC getting started guide covers the fundamental knowledge, and this article doesn't repeat it. This tutorial will also provision some resources and assumes you clone the sample repository:

git clone https://github.com/Gijsreyn/random-tools-gijs
cd random-tools-gijs/azure-devops/epac-policy-linter-pipeline

The code blocks in this article are condensed to the lines that matter. The repository holds the complete pipeline, templates, scripts, and sample definitions, and every path in this article is relative to its root.

How the linter reports findings

Every finding has one of three severities. The linter's own design guide defines them like this:

Severity Meaning
Error The policy fails to deploy, fails at evaluation time, or behaves significantly differently from what the author intended.
Warning The policy behaves unexpectedly in some cases: logic gaps, missed edge cases, resource type gotchas, misused operators. Also used for high-value best practices such as parameterizing the effect.
Informational Worth knowing, not worth a warning. Ignoring it has little or no effect on the policy's behavior.

Two details matter for a pipeline. First, the linter processes up to 1,000 files per invocation and writes one JSON document with --output, keyed by input path. Second, its exit code reports whether the tool could run, not whether your policies are healthy. A policy with ten Error findings still exits with 0. That's deliberate, and it's why a later section wraps the linter in a PowerShell script that decides pass or fail.

[!NOTE]
The linter inspects policy definitions only. Policy set definitions, assignments, and exemptions are out of scope, and so are data-plane policies and effect details such as deployIfNotExists templates. EPAC's Build-DeploymentPlans validates the rest of your Definitions folder.

Run the linter locally

The Azure Policy Linter CLI comes as a global .NET tool, so you can install it with the .NET CLI. Then you can point it to a flawed policy in the sample repository:

dotnet tool install --global Microsoft.Azure.Policy.PolicyLinter.Cli
policylinter ./samples/lint-findings/allowed-locations.before.json
Figure 1: Linting report findings

The policy is a custom allowed-locations rule, as someone might write it on a first attempt. Here are the relevant parts:

"parameters": {
    "allowedLocations": {
        "type": "Array",
        "defaultValue": [ "westeurope", "northeurope" ]
    }
},
"policyRule": {
    "if": {
        "allOf": [
            { "field": "location", "notIn": "[parameters('allowedLocation')]" },
            { "field": "location", "notEquals": "global" },
            { "field": "type", "notLike": "Microsoft.Resources/subscriptions/resourceGroups" }
        ]
    },
    "then": {
        "effect": "Deny"
    }
}

The linter reports five findings. The output is abbreviated here to the three that change how the policy behaves:

All Parameter References Must Resolve
Identifier: all-parameter-references-must-resolve
Line: 32, Position: 66, Path: properties.policyRule.if.allOf[0].notIn
The parameter 'allowedLocation' is referenced but is not declared in the policy's 'parameters' block, so the reference cannot resolve.

like/notLike Without Wildcards
Identifier: like-notlike-without-wildcards
Line: 40, Position: 85, Path: properties.policyRule.if.allOf[2].notLike
The condition uses the 'notLike' operator with value 'Microsoft.Resources/subscriptions/resourceGroups' which contains no wildcard (*). Without a wildcard it behaves identically to 'notEquals'; use 'notEquals' to express exact-match intent.

Hard-Coded Policy Enforcement Effect
Identifier: hard-coded-policy-enforcement-effect
Line: 45, Position: 32, Path: properties.policyRule.then.effect
The policy effect 'Deny' is hard-coded, so assignments cannot select a non-enforcement effect. Add a string 'effect' parameter with defaultValue 'audit' and allowedValues containing 'audit', 'Deny', 'disabled', then set the policy effect to "[parameters('effect')]".

The first finding is an Error: allowedLocation is a typo for allowedLocations, and the definition fails when you deploy it. The other two are Warnings. The notLike condition behaves exactly like notEquals, so the operator only misleads the next person who reads the rule. The hard-coded Deny means every assignment enforces it from day one, with no way to audit first. The remaining two findings are Informational: the description is missing, and the type condition isn't first in the allOf.

Each finding carries a line and column. That's what lets the pipeline annotations in a later section land on the right line of the file.

The corrected definition at Definitions/policyDefinitions/General/allowed-locations.json fixes all five. Its rule looks like this:

"policyRule": {
    "if": {
        "allOf": [
            { "field": "type", "notEquals": "Microsoft.Resources/subscriptions/resourceGroups" },
            { "field": "location", "notIn": "[parameters('allowedLocations')]" },
            { "field": "location", "notEquals": "global" }
        ]
    },
    "then": {
        "effect": "[parameters('effect')]"
    }
}

The effect parameter defaults to Audit with Audit, Deny, and Disabled as allowed values. That's the pattern the linter's rule documentation recommends, and the pattern EPAC's own guidance asks for.

Turn findings into a pass or fail

Now that you ran the linter locally, you can see that the linter leaves something to you. That's where those annotations come in, and the wrapper at scripts/Invoke-PolicyLinter.ps1 does it all into one:

  1. Finds every .json and .jsonc file under Definitions/policyDefinitions. The linter parses comments and trailing commas, so EPAC's `.jsonc` files need no preprocessing.
  2. Installs the tool if policylinter isn't on the PATH, with an optional pinned version.
  3. Runs the linter in batches of 200 files with --output, then merges the JSON.
  4. Prints the findings. When the TF_BUILD environment variable shows it's on an Azure Pipelines agent, it also writes ##vso[task.logissue] logging commands, so Errors and Warnings appear in the run summary and link to the source line.
  5. Writes a Markdown summary and attaches it to the run with ##vso[task.uploadsummary].
  6. Exits with 1 when any finding is at or above the -FailOn severity. The default is Warning.

The core of the script, condensed:

& policylinter @files --output $resultPath
if ($LASTEXITCODE -ne 0) { throw 'policylinter could not complete.' }   # a tool failure, not a finding

$results = Get-Content $resultPath -Raw | ConvertFrom-Json -AsHashtable
$findings = foreach ($file in $results.Keys) {
    foreach ($entry in @($results[$file])) {
        [pscustomobject]@{
            Severity       = [string] $entry.severity
            RuleIdentifier = $entry.ruleIdentifier
            File           = [IO.Path]::GetRelativePath($sourcesRoot, $file)
            Line           = $entry.lineNumber
            Column         = $entry.linePosition
            Description    = $entry.description
        }
    }
}

foreach ($f in $findings | Where-Object { $_.Severity -in 'Error', 'Warning' }) {
    $type = if ($f.Severity -eq 'Error') { 'error' } else { 'warning' }
    Write-Host "##vso[task.logissue type=$type;sourcepath=$($f.File);linenumber=$($f.Line);columnnumber=$($f.Column);code=$($f.RuleIdentifier)]$($f.Description)"
}

$severityRank = @{ Informational = 1; Warning = 2; Error = 3 }
$blocking = $findings | Where-Object { $severityRank[$_.Severity] -ge $severityRank[$FailOn] }
if ($blocking) { exit 1 }

Two parameters deserve a mention. -FailOn sets the threshold: Error lets warnings through as annotations, Informational fails on everything, and None only reports. -ExcludeRule takes rule identifiers whose findings are still printed but never fail the run, for findings you have reviewed and accepted.

[!TIP]
You need -ExcludeRule sooner than you might expect. The storage policy at samples/alias-warnings/storage-https-only.json is well formed, yet it gets a Warning from field-alias-unavailable-in-old-api-versions: the supportsHttpsTrafficOnly property doesn't exist in storage API versions from 2015 and 2016, so a request on one of those versions resolves the alias to an empty value.

Set up the (repository) folder structure

The sample repository folder structure follows the EPAC convention of a Definitions root. The pipeline and scripts live beside it, never inside it. This way, EPAC doesn't mistake them for policy files.

Figure 2: Folder structure

It's good to know that in this example, the global-settings.jsonc declares two EPAC environments. Each one points to the management group the pipeline creates:

"pacEnvironments": [
    {
        "pacSelector": "epac-dev",
        "cloud": "AzureCloud",
        "tenantId": "00000000-0000-0000-0000-000000000000",
        "deploymentRootScope": "/providers/Microsoft.Management/managementGroups/mg-epac-dev",
        "desiredState": { "strategy": "full", "keepDfcSecurityAssignments": false, "doNotDisableDeprecatedPolicies": false },
        "managedIdentityLocation": "westeurope"
    },
    {
        "pacSelector": "epac-prod",
        // same shape, rooted at mg-epac-prod
    }
]

Replace both tenantId values with your tenant ID and generate a new pacOwnerId with (New-Guid).Guid. EPAC uses the owner ID to tell its own deployments apart from everything else at the scope, so never copy one between repositories.

The assignment file assigns both definitions at the root of each environment. The scope map is keyed by pacSelector, so one file drives dev and prod:

"scope": {
    "epac-dev": [ "/providers/Microsoft.Management/managementGroups/mg-epac-dev" ],
    "epac-prod": [ "/providers/Microsoft.Management/managementGroups/mg-epac-prod" ]
}

[!NOTE]
EPAC's documentation names its production environment tenant rather than prod, to keep EPAC environments distinct from your workload environments. This sample uses epac-prod because the two management groups are the point of the exercise. In a real tenant, the production EPAC environment is usually your existing intermediate root management group, and only the development one is new.

Create the management groups with PowerShell

Time to create the management groups. scripts/New-PolicyManagementGroups.ps1 creates mg-epac-dev and mg-epac-prod under a parent that you choose. With no parent specified, it uses the tenant root group, whose ID is the tenant ID. The script is idempotent: existing groups are reported and left alone, so the pipeline can run it on every build.

The core logic per management group:

$existing = Get-ManagementGroupOrNull -Name $target.Name   # wraps Get-AzManagementGroup, returns $null on NotFound
if ($existing) {
    [pscustomobject]@{ Name = $existing.Name; Id = $existing.Id; Status = 'Exists' }
    continue
}

if ($PSCmdlet.ShouldProcess("$($target.Name) under $parentResourceId", 'Create management group')) {
    New-AzManagementGroup -GroupName $target.Name -DisplayName $target.DisplayName -ParentId $parentResourceId
    Wait-ManagementGroup -Name $target.Name -Timeout $TimeoutSeconds   # poll until it reads back
}

The wait loop matters. A freshly created management group can take a short while before Azure Policy accepts it as a scope, and the next stage of the pipeline deploys definitions to it. Because the script supports -WhatIf, you can preview it from your own machine:

Connect-AzAccount
./scripts/New-PolicyManagementGroups.ps1 -WhatIf

[!NOTE]
The identity that runs the script needs the Management Group Contributor role on the parent management group.

Build the pipeline

pipelines/epac-policy-pipeline.yml runs six stages in order:

Figure 3: EPAC with Azure Policy Linter pipeline

Pull requests run only Lint and Plan epac-dev. Pushes to main run everything. The plan and deploy templates follow the EPAC Starter Kit's Azure DevOps templates: Build-DeploymentPlans -DevOpsType ado publishes the plans as an artifact and sets the deployPolicyChanges and deployRoleChanges output variables, and the deploy stages skip themselves when both are no.

The Lint stage is the new part. Its template installs the .NET SDK and runs the wrapper:

steps:
  - checkout: self

  - task: UseDotNet@2
    displayName: Use .NET SDK 10.0
    inputs:
      packageType: sdk
      version: 10.0.x

  - pwsh: |
      ./scripts/Invoke-PolicyLinter.ps1 `
          -Path '$(Build.SourcesDirectory)/Definitions/policyDefinitions' `
          -FailOn '${{ parameters.failOn }}' `
          -OutputPath '$(Build.ArtifactStagingDirectory)/lint/policy-lint-results.json'
    displayName: Run Azure Policy Linter

  - publish: $(Build.ArtifactStagingDirectory)/lint
    artifact: policy-lint-results
    condition: succeededOrFailed()

The pipeline exposes the threshold as a runtime parameter, lintFailOn, allowing you to rerun a build with Error or None without editing YAML. A second parameter, lintExcludeRules, passes a comma-separated list of rule identifiers to -ExcludeRule.

Running the pipeline

The pipeline references four Azure Resource Manager service connections by name. Create them with the roles below, following EPAC's service principal guidance. For a lab, one service principal with all three roles at the parent management group can stand behind all four names.

Pipeline variable Role Scope
bootstrapServiceConnection Management Group Contributor Parent management group
planServiceConnection Reader Tenant root group
devServiceConnection Resource Policy Contributor, Role Based Access Control Administrator mg-epac-dev
prodServiceConnection Resource Policy Contributor, Role Based Access Control Administrator mg-epac-prod

Then run it end to end:

  1. In Azure DevOps, create a pipeline from pipelines/epac-policy-pipeline.yml and run it on main. The Lint stage passes, the Bootstrap stage creates both management groups, and EPAC deploys the two definitions and their assignments to mg-epac-dev, then to mg-epac-prod. The deploy stages use deployment jobs, so this first run also creates the Azure DevOps environments epac-dev and epac-prod.
  2. (Optional) Put a person in front of production. Select Pipelines > Environments > epac-prod > Approvals and checks, and add an approval. Without it, a push to main flows to production as soon as epac-dev succeeds, which is fine for a lab and not for a tenant you share.
  3. Create a branch, copy samples/lint-findings/allowed-locations.before.json over Definitions/policyDefinitions/General/allowed-locations.json, and open a pull request. The Lint stage fails with one error and two warnings. Each one appears in the run summary with a link to the file and line, and the Extensions tab shows the Markdown table of findings. The Plan stage never runs.
  4. Restore the corrected file, push, and watch the same pull request go green. Merge it. The run on main deploys to epac-dev, then to epac-prod, pausing there for approval if you added one.

Clean up resources

Delete the two management groups when you're done. Azure removes the policy assignments and definitions EPAC deployed to them. Move any subscriptions out first, because a management group with children can't be deleted.

Remove-AzManagementGroup -GroupName mg-epac-dev
Remove-AzManagementGroup -GroupName mg-epac-prod

Summary

You now run Azure Policy the way you run code. Every pull request is linted, and a finding at Warning or higher fails the build with the file and line attached. Changes that pass are delivered by EPAC to a development management group first and to production second, behind an approval if you added one. The linter judges the definition, EPAC deploys it, and the only custom code is one PowerShell script whose two decisions, the failing severity and the accepted rules, are pipeline parameters.