2059 words
10 minutes
Part 7: Maintaining Policies with Azure DevOps (or PowerShell)

Mastering App Control for Business#

Part 7: Maintaining Policies with Azure DevOps (or PowerShell)#


Table of Contents#

  1. Overview
  2. Azure DevOps Setup
  3. Workload Identity Federation
  4. Create Service Connection for Workload Identity Federation
  5. Grant API Permissions to the Service Principal
  6. Azure DevOps Repository Structure
  7. Azure DevOps Pipeline
  8. Pipeline YAML Reference
  9. PowerShell Script: Publish-ACFBPolicy.ps1
  10. Workflow: Commit to Pipeline to Intune
  11. Microsoft Graph API Details

1. Overview#

This document describes how to maintain App Control for Business (ACfB) policies as code, using an Azure DevOps Pipeline for automated deployment and PowerShell 7 for interactive use — with Git version control as the source of truth.

The approach provides:

  • Full audit trail for all policy changes via Git history
  • Automated signing and Intune upload on every commit to main
  • No stored secrets — authentication uses short-lived OIDC tokens
  • Support for both CI/CD (pipeline) and manual (interactive) workflows

Prerequisite: An Azure DevOps organization must be connected to your Microsoft Entra ID tenant before proceeding.

flowchart LR
    subgraph REPO["Azure DevOps Repository"]
        direction TB
        XML[Policies/unsigned_original/\nBase_*.xml\nSupplemental_*.xml]
        CERT[Certs/\norg-code-sign.cer]
        PS[Publish-ACFBPolicy.ps1]
        YML[ACFB-Build-Pipeline.yml]
    end
    subgraph PIPELINE["Azure DevOps Pipeline"]
        direction TB
        T1[Task 1: Get Graph Token\nOIDC via service connection]
        T2[Task 2: Sign + Upload\nPublish-ACFBPolicy.ps1]
        T3[Task 3: Commit signed output\nback to repo]
        T1 --> T2 --> T3
    end
    subgraph INTUNE["Microsoft Intune"]
        direction TB
        POL[ACfB Configuration Policy\nCreated / Updated]
        DEV[Enrolled Devices\nPolicy applied]
        POL --> DEV
    end
    XML & CERT --> PIPELINE
    PIPELINE -->|Graph API PUT/POST| INTUNE
    T3 --> REPO
    style REPO fill:#1e3a5f,color:#93c5fd,stroke:#2563eb
    style PIPELINE fill:#162032,color:#58a6ff,stroke:#2563eb
    style INTUNE fill:#14532d,color:#86efac,stroke:#16a34a

2. Azure DevOps Setup#

Organization & Project Requirements#

RequirementDetails
Azure DevOps organizationProvision at https://aex.dev.azure.com/
Project with Git repositoryAt least one project containing a Git repo is required

3. Workload Identity Federation#

To prevent storing secrets in Azure DevOps, the pipeline authenticates against Microsoft Graph using Workload Identity Federation (OIDC-based) — a secretless authentication pattern.

How It Works#

StepAction
1Pipeline executes — Azure DevOps requests authentication from Microsoft Entra ID via service principal
2Instead of stored credentials, Entra ID verifies the request and issues a short-lived token
3Token is valid only for the duration of the pipeline run
4Token grants access only to resources specified in its permissions

This eliminates long-lived secrets from the pipeline entirely, reducing credential exposure risk.

sequenceDiagram
    participant ADO as Azure DevOps Pipeline
    participant ENTRA as Microsoft Entra ID
    participant SP as Service Principal
    participant GRAPH as Microsoft Graph API
    participant INTUNE as Intune

    ADO->>ENTRA: Request OIDC token\n(no stored credentials)
    ENTRA->>ENTRA: Verify pipeline identity\nagainst federated credential
    ENTRA-->>ADO: Short-lived bearer token\n(valid for pipeline run only)
    ADO->>GRAPH: GET configurationPolicies\nAuthorization: Bearer {token}
    GRAPH->>ENTRA: Validate token
    ENTRA-->>GRAPH: Token valid
    GRAPH-->>ADO: Existing policy list
    ADO->>ADO: Compare VersionEx\nlocal vs remote
    alt Local version > Remote
        ADO->>GRAPH: PUT/POST policy\nwith signed XML payload
        GRAPH->>INTUNE: Update policy
        INTUNE-->>ADO: 200 OK
    else Versions match
        ADO->>ADO: Skip — no update needed
    end

4. Create Service Connection for Workload Identity Federation#

Project settings → Service connections → Create service connection
→ Azure Resource Manager → Workload Identity federation (automatic)

Scope Configuration#

SettingValue
Scope levelSubscription (Resource Group)
Default role assignedContributor (scoped to selected Resource Group)

Note: The Contributor role grants more permissions than strictly required. You may assign a less privileged role such as Reader — however, the workload identity must be assigned at least one role on the resource group, or all authentication attempts will fail. These permissions are fully scoped to the assigned resource group and do not extend beyond it.


5. Grant API Permissions to the Service Principal#

After creating the federated identity:

  1. Navigate to the automatically created App Registration in Microsoft Entra ID
  2. Rename the app registration to comply with your organizational naming convention
  3. Assign the required Microsoft Graph API permissions to enable the MS Graph API calls used by the publishing script

The required scope is: DeviceManagementConfiguration.ReadWrite.All


6. Azure DevOps Repository Structure#

The following structure is used for the policy-as-code repository:

repo-root/
├─ Publish-ACFBPolicy.ps1
├─ ACFB-Build-Pipeline.yml
├─ README.md
├─ Certs/
│ └─ org-code-sign.cer
├─ Policies/
│ ├─ unsigned_original/
│ │ ├─ Base_MyBigBusinessFromWizard.xml
│ │ └─ Supplemental_Allow_Vendors.xml
│ └─ signed/ ← target directory (auto-populated by pipeline)
Folder / FilePurpose
Publish-ACFBPolicy.ps1Core script — signs and uploads policies to Intune via Graph
ACFB-Build-Pipeline.ymlPipeline definition
Certs/Signing certificate(s) (.cer / .crt)
Policies/unsigned_original/Source XML files — edit these when updating policies
Policies/signed/Output directory — auto-populated by the pipeline after signing

Source files are available at: PatrickSeltmann/AppControlForBusiness_DevOps on GitHub.

flowchart TD
    ROOT[repo-root/] --> SCRIPT[Publish-ACFBPolicy.ps1]
    ROOT --> YML[ACFB-Build-Pipeline.yml]
    ROOT --> README[README.md]
    ROOT --> CERTS[Certs/]
    ROOT --> POLICIES[Policies/]
    CERTS --> CERT1[org-code-sign.cer]
    POLICIES --> UNSIGNED[unsigned_original/]
    POLICIES --> SIGNED[signed/\nauto-populated by pipeline]
    UNSIGNED --> BASE[Base_MyBigBusiness.xml\nBase_*.xml]
    UNSIGNED --> SUPP[Supplemental_Allow_Vendors.xml\nSupplemental_*.xml]
    style ROOT fill:#162032,color:#58a6ff
    style SIGNED fill:#14532d,color:#86efac
    style CERT1 fill:#1a0a2e,color:#c4b5fd

7. Azure DevOps Pipeline#

Build Service Permissions#

Before the pipeline can commit signed output back to the repository, the build service account must have Contribute rights granted on the repository.

Navigation:

Project settings → Repositories → [Your Repo] → Security
→ [Project] Build Service → Contribute → Allow

Create the Pipeline#

StepAction
1Upload Publish-ACFBPolicy.ps1 and signer certificate to the repository
2Navigate to Pipelines → New pipeline
3Select “Existing Azure Pipelines YAML file”
4Choose ACFB-Build-Pipeline.yml from the repository

Note: The first pipeline run will prompt for permission to use the service connection. Approve when prompted.


8. Pipeline YAML Reference#

The pipeline uses a one-script approach: a single PowerShell script handles signing and Intune upload. Three pipeline tasks orchestrate the full workflow.

# Pipeline: Build & Publish ACfB Policies
# One-script approach: sign the XML and upload it
# Service connection to Entra ID / Graph: 'AC4B' (OIDC / workload identity)
# Script file lives in the repo: Publish-ACFBPolicy.ps1
# Certificates must be stored under Certs\*.cer or *.crt
trigger:
branches:
include:
- main # Run only for changes on main branch
paths:
include:
- Policies/unsigned_original/** # Run only if files under this folder changed
pool:
vmImage: windows-2022 # Microsoft-hosted Windows agent
steps:
- checkout: self
persistCredentials: true
clean: true # Start with a clean workspace
fetchDepth: 0 # Full history (needed for rebase/push)
# Step 1: Get Microsoft Graph access token using OIDC
- task: AzureCLI@2
displayName: Get Microsoft Graph access token (OIDC)
inputs:
azureSubscription: AC4B # Name of your service connection
scriptType: pscore
scriptLocation: inlineScript
inlineScript: |
$json = az account get-access-token --resource-type ms-graph -o json
$accessToken = ($json | ConvertFrom-Json).accessToken
if ([string]::IsNullOrWhiteSpace($accessToken)) { throw "Failed to obtain MS Graph token." }
Write-Host "##vso[task.setvariable variable=secret;issecret=true]$accessToken"
# Step 2: Run the script that signs and uploads the policies
- task: PowerShell@2
displayName: Build & publish ACfB policies
inputs:
targetType: filePath
filePath: Publish-ACFBPolicy.ps1
arguments: >
-PolicyRootDir "$(Build.SourcesDirectory)\Policies\unsigned_original"
-OutputPolicyDir "$(Build.SourcesDirectory)\Policies\signed"
-CertFolder "$(Build.SourcesDirectory)\Certs"
-AccessToken "$(secret)"
pwsh: true
workingDirectory: '$(Build.SourcesDirectory)'
# Step 3: Commit signed output back to repo (skip for PRs)
- task: PowerShell@2
displayName: Commit signed policies (only if changed)
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
inputs:
targetType: inline
pwsh: true
script: |
$ErrorActionPreference = 'Stop'
git config --global user.email "pipeline@domain.tbd"
git config --global user.name "Pipeline ACfB Build"
git config --global --add safe.directory "$(Build.SourcesDirectory)"
$branch = "$(Build.SourceBranchName)"
if (-not $branch) { $branch = "main" }
git checkout $branch
git pull --rebase origin $branch
git add "Policies/signed"
# Commit with [skip ci] to prevent triggering pipeline again
flowchart TD
    TRIGGER[Trigger:\nPush to main branch\nPolicies/unsigned_original/**] --> S1
    subgraph S1["Stage 1: Get Token"]
        AZ[AzureCLI@2 Task\naz account get-access-token\n--resource-type ms-graph]
        TOKEN[Secret pipeline variable\n$secret — masked in logs]
        AZ --> TOKEN
    end
    S1 --> S2
    subgraph S2["Stage 2: Build & Publish"]
        PS[PowerShell@2 Task\nPublish-ACFBPolicy.ps1]
        SIGN[Add-SignerRule\nSign XML with cert]
        VER[Compare VersionEx\nLocal vs Intune]
        UPLOAD[Graph API\nPOST or PUT policy]
        PS --> SIGN --> VER --> UPLOAD
    end
    S2 --> S3
    subgraph S3["Stage 3: Commit Signed"]
        GIT[git add Policies/signed\ngit commit skip ci\ngit push]
    end
    style TRIGGER fill:#1e3a5f,color:#93c5fd
    style S1 fill:#162032,color:#58a6ff
    style S2 fill:#14532d,color:#86efac
    style S3 fill:#1e3a5f,color:#93c5fd

Pipeline Task Summary#

TaskPurpose
AzureCLI@2Obtains a short-lived MS Graph access token via OIDC service connection
PowerShell@2 (Build)Calls Publish-ACFBPolicy.ps1 to sign and upload policies to Intune
PowerShell@2 (Commit)Commits signed XMLs back to Policies/signed/ with [skip ci] to prevent a pipeline loop

The [skip ci] commit message flag prevents the pipeline from re-triggering on the signed output commit.


9. PowerShell Script: Publish-ACFBPolicy.ps1#

The script automates the full ACfB policy publishing flow. It can be executed via Azure DevOps Pipeline or interactively via PowerShell 7 on a local workstation.

Script Parameters#

ParameterDescriptionDefault
-PolicyRootDirFolder containing unsigned policy XMLs (Base_*.xml / Supplemental_*.xml).\Policies\unsigned_original
-OutputPolicyDirFolder where signed policy XML copies are written and uploaded from.\Policies\signed
-CertFolderFolder containing .cer or .crt files for signing. First found match is used..\Certs
-TenantIdOptional tenant ID hint for interactive Connect-MgGraph. Ignored when -AccessToken is provided.$null
-AccessTokenBearer token for non-interactive (CI/CD) auth. If provided, no interactive login is prompted.
-DryRunIf set, prints what would be done without uploading to Intune.

Script Logic#

The script performs the following steps in sequence:

StepAction
1Scans source folder for policy XMLs matching Base_*.xml or Supplemental_*.xml
2Copies each file to the output folder
3Signs each policy using the .cer/.crt certificate found in the Certs folder
4Restores the original VersionEx after signing (the signing tool resets it)
5Queries Intune via Microsoft Graph for an existing policy with matching name and template family
6Compares VersionEx — local vs. remote (Intune)
7Creates, updates, or skips based on version comparison result
8Uploads signed XML to Intune via Graph API

Version Comparison Logic#

ConditionAction
No existing policy found in IntuneCREATE new policy
Local VersionEx > remote VersionExUPDATE existing policy
Versions match, or remote is newerSKIP — no update required

Important: When you modify a local policy XML, you must manually increment the VersionEx value in the XML. This is the mechanism the script uses to determine what requires updating. Without a version bump, the script will skip the policy even if the content has changed.

flowchart TD
    SCAN[Script scans\nPolicies/unsigned_original/] --> FIND[Find Base_*.xml\nand Supplemental_*.xml]
    FIND --> LOOP[For each policy file]
    LOOP --> SIGN[Sign XML\nAdd-SignerRule]
    SIGN --> RESTORE[Restore VersionEx\nfrom source after signing]
    RESTORE --> QUERY[Graph API: GET\nconfigurationPolicies\nfilter by name + templateFamily]
    QUERY --> EXISTS{Policy exists\nin Intune?}
    EXISTS -->|No| CREATE[Graph API: POST\nCreate new policy]
    EXISTS -->|Yes| COMPARE[Compare VersionEx:\nLocal vs Remote]
    COMPARE --> NEWER{Local >\nRemote?}
    NEWER -->|Yes| UPDATE[Graph API: PUT\nUpdate policy]
    NEWER -->|No| SKIP[Skip — no change\nneeded]
    CREATE & UPDATE --> NEXT[Next policy file]
    SKIP --> NEXT
    style CREATE fill:#14532d,color:#86efac
    style UPDATE fill:#1e3a5f,color:#93c5fd
    style SKIP fill:#1c1400,color:#fbbf24

Template Families#

Policy TypeTemplate IDTemplate Family
Base4321b946-b76b-4450-8afd-769c08b16ffc_1endpointSecurityApplicationControl
Supplemental08441ae9-e0c0-4e57-8e8b-6e72405cd64f_1endpointSecurityApplicationControlSupplementalPolicy

Authentication Modes#

ModeMethodWhen to Use
CI/CD (non-interactive)Pass -AccessToken from Azure DevOps OIDC stepPipeline runs
Manual (interactive)Connect-MgGraph prompt via Microsoft.Graph.Authentication moduleLocal workstation use

Required Microsoft Graph scope: DeviceManagementConfiguration.ReadWrite.All

Module Requirements#

RequirementDetails
PowerShell version7+ (enforced via #Requires -Version 7.0)
Microsoft.Graph.AuthenticationAuto-installed on demand if not present
Add-SignerRuleMust be available on PATH (part of the WDAC toolset)

Example Usage#

Interactive (manual) run:

Terminal window
pwsh .\Publish-ACFBPolicy.ps1 `
-PolicyRootDir .\Policies\unsigned_original `
-OutputPolicyDir .\Policies\signed `
-CertFolder .\Certs

With specific tenant (interactive login):

Terminal window
pwsh .\Publish-ACFBPolicy.ps1 `
-TenantId "00000000-0000-0000-0000-000000000000"

CI/CD (Azure DevOps — token passed from AzureCLI@2 step):

Terminal window
pwsh .\Publish-ACFBPolicy.ps1 `
-PolicyRootDir "$(Build.SourcesDirectory)\Policies\unsigned_original" `
-OutputPolicyDir "$(Build.SourcesDirectory)\Policies\signed" `
-CertFolder "$(Build.SourcesDirectory)\Certs" `
-AccessToken "$(secret)"

Dry run (simulate without uploading):

Terminal window
pwsh .\Publish-ACFBPolicy.ps1 -DryRun

10. Workflow: Commit to Pipeline to Intune#

The end-to-end workflow for updating a policy:

StepActorAction
1EngineerModifies policy XML in Policies/unsigned_original/
2EngineerIncrements VersionEx manually in the XML
3EngineerCommits and pushes to main branch
4Azure DevOpsPipeline triggers (path filter: Policies/unsigned_original/**)
5PipelineObtains short-lived Graph token via OIDC service connection
6PipelinePublish-ACFBPolicy.ps1 signs XML, compares versions, creates or updates policy in Intune
7PipelineSigned XML committed back to Policies/signed/ with [skip ci] to prevent pipeline re-trigger
sequenceDiagram
    participant DEV as Security Engineer
    participant GIT as Git Repository
    participant ADO as Azure DevOps Pipeline
    participant INTUNE as Microsoft Intune
    participant DEVICE as Enrolled Devices

    DEV->>GIT: Edit Base_Policy.xml\nIncrement VersionEx 10.0.5.1 → 10.0.5.2
    DEV->>GIT: git commit + git push → main
    GIT->>ADO: Trigger pipeline\n(path filter: Policies/unsigned_original/**)
    ADO->>ADO: Task 1: Get OIDC token
    ADO->>ADO: Task 2: Sign + compare versions
    Note over ADO: Local 10.0.5.2 > Remote 10.0.5.1
    ADO->>INTUNE: PUT configurationPolicy\nwith signed XML payload
    INTUNE-->>ADO: 200 OK — Updated
    ADO->>GIT: Commit signed XML\nto Policies/signed/ [skip ci]
    INTUNE->>DEVICE: Policy sync on next\ncheckin (8 hours or manual)
    DEVICE->>DEVICE: Apply updated\nACfB policy
    Note over DEVICE: New rules enforced\nEvent 3099 logged

Key Design Decisions#

DecisionRationale
Path-based trigger (Policies/unsigned_original/**)Prevents pipeline from running on unrelated file changes
[skip ci] on signed output commitBreaks the feedback loop that would otherwise re-trigger the pipeline
VersionEx as update signalProvides explicit, auditable versioning without relying on content diffing
OIDC token (no stored secrets)Eliminates long-lived credential exposure in the pipeline

11. Microsoft Graph API Details#

DetailValue
API versionMicrosoft Graph beta
API endpointhttps://graph.microsoft.com/beta/deviceManagement/configurationPolicies
Setting definition for XML embeddingdevice_vendor_msft_policy_config_applicationcontrol_policies_{policyguid}_xml
Required scopeDeviceManagementConfiguration.ReadWrite.All

The beta endpoint is required because ACfB configuration policies are not yet available on the v1.0 Microsoft Graph endpoint.


flowchart TD
    MAINTAIN[Ongoing Policy Maintenance] --> MON[Monitor Event Logs\nEvent 3077 — actual blocks]
    MON --> REVIEW{Legitimate\napp blocked?}
    REVIEW -->|Yes| RULE[Add allow rule to\nSupplemental or Base policy]
    REVIEW -->|No — expected| LOG[Log + document\nthe block]
    RULE --> VER[Increment VersionEx]
    VER --> COMMIT[Commit to repo]
    COMMIT --> PIPELINE[Pipeline triggers\nautomatically]
    PIPELINE --> INTUNE[Policy updated in Intune]
    INTUNE --> DEVICES[Devices receive\nnew policy]
    DEVICES --> MON
    style MAINTAIN fill:#1e3a5f,color:#93c5fd
    style PIPELINE fill:#162032,color:#58a6ff
    style DEVICES fill:#14532d,color:#86efac

Series Navigation#

PartTopic
Part 1Introduction & Key Concepts
Part 2Policy Templates & Rule Options
Part 3Application ID Tagging Policies & Managed Installer
Part 4Starter Base Policy for Lightly Managed Devices
Part 5Create a Base Policy for Fully Managed Devices
Part 6Sign, Apply, and Remove Signed Policies
Part 7Maintaining Policies with Azure DevOps (or PowerShell) (this document — final part)

Document compiled by Anubhav Gain from source material published at ctrlshiftenter.cloud.
Original author: Patrick Seltmann. For organizational reference use.

Part 7: Maintaining Policies with Azure DevOps (or PowerShell)
https://mranv.pages.dev/posts/app-control-part-7-maintaining-policies-azuredevops-powershell/
Author
Anubhav Gain
Published at
2026-05-01
License
CC BY-NC-SA 4.0