Hello everyone, in this last post in this series, I will describe how you can maintain your App Control for Business Policies with Azure DevOps Pipeline (or interactively via PowerShell 7) as code. Over the past few month, since I started the blog post series about App Control for Business, I did some research and developed a couple of PowerShell scripts, which helps me to maintain all these policies files as configurations with the advantages of git.
Before we can maintain the configurations with git, we have to connect an Azure DevOps organization with our tenant.
Azure DevOps
First of all, we need an Azure DevOps organization (https://aex.dev.azure.com/) with at least one project and git-repository. Then, to prevent storage of secrets in Azure DevOps, we need to add a Workload Identity Federation and associated it to our Azure DevOps pipeline, for authentication against Microsoft Graph endpoints. Thanks to federated workload identities sensitive credentials no longer need to be stored in repositories, when OpenID Connect (OIDC) is used. When a pipeline executes, Azure DevOps requests authentication from Microsoft Entra ID via service principal. Instead of using stored credentials, Entra ID verifies the request and issues a short-lived token. This token is valid only for the duration of the pipeline run and grants access only to the resources specified in its permissions.
oAuth/OIDC authentication flow for federated identities

Create a service connection point for Workload Identity Federation
Follow these steps to create a service Connection point: Project settings
–> Service connections
–> Create service connection
–> Azure Resource Manager
–> Workload Identity federation (automatic)


Now, you have to specify to what resource you will grant this newly created managed identity permissions to. For this use case, I decide to use Subscription
(Resource Group
) scope, which means that this managed identity will be granted a Contributor
role to the selected Resource Group
.

Note: The Contributor role more permissions as we needed. You can assign a less privileged role for this use case, such as Reader. However, it is important that workload identity must be assigned some role, otherwise all authentication attempts will fail! These permissions are scoped to the resource group we have assigned.

Once the federated identity has been created, we need to configure the service principal (toggle of some defaults) and assign the necessary graph permission for the service principal that belongs to the federated identity. You shoud rename the app registration to be compliant with your naming convention.


Grant API permission to the service principal
Next, we need to assign the right app permission to this service principal to perform the required ms graph calls.





Azure DevOps Repository
We used Azure DevOps Repository for version control and all the other benefits of git. The repository consist of the following structure:
repo-root/
├─ Publish-ACFBPolicy.ps1
├─ ACFB-Build-Pipeline.yml
├─ README.md
├─ Certs/
│ ├─ org-code-sign.cer #example
├─ Policies/
│ ├─ unsigned_original/
│ │ ├─ Base_MyBigBusinessFromWizard.xml
│ │ └─ Supplemental_Allow_Vendors.xml
│ └─ signed/ # target directory
Create Azure DevOps Pipeline
Thereby the pipeline works properly, we have to assign the build service contribute rights to our repository

Now, all required steps are done, we can upload the powershell script and related signer certificate to our repository. You can download the files from my github repo: PatrickSeltmann/AppControlForBusiness_DevOps

Afterwards, we create a new pipeline:


Next, we select “Existing Azure Pipelines XAML file” an chose the yaml pipline configuration file.

The pipeline consist of the following lines:
# 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 this pipeline only for changes on the 'main' branch
paths:
include:
- Policies/unsigned_original/** # Run only if files under this folder (or subfolders) changed
pool:
vmImage: windows-2022 # Use Microsoft-hosted Windows agent
steps:
- checkout: self
persistCredentials: true
clean: true # Start with a clean workspace
fetchDepth: 0 # Full history (needed for rebase/push)
# 1) Get a Microsoft Graph access token using OIDC (from the service connection)
# Then save it into a secret pipeline variable named 'secret'
- task: AzureCLI@2
displayName: Get Microsoft Graph access token (OIDC)
inputs:
azureSubscription: AC4B # Name of your service connection
scriptType: pscore # Use PowerShell Core inside the AzureCLI task
scriptLocation: inlineScript
inlineScript: |
# Ask Azure CLI for an access token for Microsoft Graph
$json = az account get-access-token --resource-type ms-graph -o json
$accessToken = ($json | ConvertFrom-Json).accessToken
# Basic check: fail early if token is empty
if ([string]::IsNullOrWhiteSpace($accessToken)) { throw "Failed to obtain MS Graph token." }
# Store the token as a secret variable named 'secret' (masked in logs)
Write-Host "##vso[task.setvariable variable=secret;issecret=true]$accessToken"
# 2) Run your one script that signs and uploads the policies
# We pass absolute paths and inject the token via -AccessToken
- task: PowerShell@2
displayName: Build & publish ACfB policies
inputs:
targetType: filePath # Run a script file from the repo
filePath: Publish-ACFBPolicy.ps1 # Script path (relative to repo root)
arguments: > # Script parameters (multi-line for readability)
-PolicyRootDir "$(Build.SourcesDirectory)\Policies\unsigned_original"
-OutputPolicyDir "$(Build.SourcesDirectory)\Policies\signed"
-CertFolder "$(Build.SourcesDirectory)\Certs"
-AccessToken "$(secret)" # Use the token from step 1 (non-interactive Graph auth)
pwsh: true # Use PowerShell 7
workingDirectory: '$(Build.SourcesDirectory)' # Run from repo root
# 3) Commit signed output back to the repo — only if this is not a PR
# (So main gets the new signed files, and [skip ci] prevents infinite pipeline loops)
- task: PowerShell@2
displayName: Commit signed policies (only if changed)
condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) # Skip for PRs
inputs:
targetType: inline
pwsh: true
script: |
$ErrorActionPreference = 'Stop'
# Configure Git author identity for this agent session
git config --global user.email "pipeline@domain.tbd"
git config --global user.name "Pipeline ACfB Build"
# Mark the workspace as safe (avoids Git security warnings on hosted agents)
git config --global --add safe.directory "$(Build.SourcesDirectory)"
# Pick the current source branch (fallback to main if not set)
$branch = "$(Build.SourceBranchName)"
if (-not $branch) { $branch = "main" }
# Make sure we are on that branch and up to date
git checkout $branch
git pull --rebase origin $branch
# Stage signed files for commit
git add "Policies/signed"
# Create a commit; [skip ci] prevents triggering the pipeline again from this commit
When you run the pipeline for the first time, you have to grant it permissions to the service connection.

If the pipeline was triggered, the powershell script will be executed.
Note: The powershell script can also be used without interactively with PowerShell 7
#Requires -Version 7.0 # Make sure we run on PowerShell 7 or newer
<#
.SYNOPSIS
Publishes App Control for Business (ACfB) policies to Intune:
- Signs local policy XML files (Base_*.xml / Supplemental_*.xml) with a certificate,
- Restores the original VersionEx after signing and compares local and remote version on it (signer tool reset it),
- Creates or updates the matching Intune configuration policy via Microsoft Graph.
.DESCRIPTION
This script automates the full ACfB policy publishing flow. It scans a source folder for
policy XMLs with the naming pattern Base_*.xml or Supplemental_*.xml, copies them to an output folder,
signs them using a .cer/.crt certificate (stored in the 'certs' folder), and uploads the signed XML to Intune using the Microsoft Graph API.
Key behaviors:
- Dual auth mode:
• CI/CD (non-interactive): Use -AccessToken (e.g., from Azure DevOps OIDC service connection).
• Manual (interactive): Prompt using Microsoft.Graph.Authentication with the required scope.
- update logic:
The script reads the currently deployed XML from Intune and compares versions/content:
• Update if local VersionEx > remote VersionEx,
- Safe writes:
The script writes the signed XML to the output folder and uses that file for upload, so you
can audit, commit, or archive the exact payload that Intune receives.
Requirements:
- PowerShell 7+ (see #Requires -Version 7.0).
- Microsoft.Graph PowerShell (module Microsoft.Graph.Authentication; auto-install on demand).
- A signing certificate file (.cer or .crt) accessible on the machine/repo.
- Intune / Graph permissions:
• Scope DeviceManagementConfiguration.ReadWrite.All (for manual interactive login)
• Or an app/workload identity with equivalent permissions and consent (for CI/CD).
Folders:
- $PolicyRootDir: unsigned XML templates (Base_*.xml / Supplemental_*.xml)
- $OutputPolicyDir: signed XML copies (final upload payloads)
- $CertFolder: contains .cer/.crt to sign with (first match is used)
API:
- Uses Microsoft Graph beta endpoints for ACfB configuration policies.
- Template families:
Base: endpointSecurityApplicationControl
Supplemental: endpointSecurityApplicationControlSupplementalPolicy
- The script targets the Intune setting definition “..._xml” to embed the full policy XML string.
Error handling & logging:
- The script stops on errors (ErrorActionPreference = 'Stop').
- If Graph returns an error with a JSON body, it is printed for troubleshooting.
- Logs source/local/remote VersionEx values to help diagnose version logic.
.PARAMETER PolicyRootDir
Path to the folder containing unsigned policy XML files (Base_*.xml / Supplemental_*.xml).
Default: .\Policies\unsigned_original
.PARAMETER OutputPolicyDir
Path to the folder where signed policy XML copies will be written (and uploaded from).
Default: .\Policies\signed
.PARAMETER CertFolder
Path to the folder containing .cer or .crt files used for signing.
The first found match is used.
Default: .\Certs
.PARAMETER TenantId
Optional tenant ID hint for interactive (manual) Connect-MgGraph.
Ignored when -AccessToken is provided (CI/CD).
.PARAMETER AccessToken
Optional bearer token for non-interactive (CI/CD) authentication with Connect-MgGraph -AccessToken.
If provided, the script will not prompt for interactive login.
.PARAMETER DryRun
If set, the script only prints what it would do (CREATE/UPDATE policy) without uploading to Intune.
.EXAMPLE
# Manual (interactive) run with default folders and interactive Graph login.
pwsh .\Publish-ACFBPolicy.ps1 `
-PolicyRootDir .\Policies\unsigned_original `
-OutputPolicyDir .\Policies\signed `
-CertFolder .\Certs
.EXAMPLE
# Manual run for a specific tenant (interactive login).
pwsh .\Publish-ACFBPolicy.ps1 `
-TenantId "00000000-0000-0000-0000-000000000000"
.EXAMPLE
# CI/CD (Azure DevOps): pass the access token obtained in a prior AzureCLI@2 step.
pwsh .\Publish-ACFBPolicy.ps1 `
-PolicyRootDir "$(Build.SourcesDirectory)\Policies\unsigned_original" `
-OutputPolicyDir "$(Build.SourcesDirectory)\Policies\signed" `
-CertFolder "$(Build.SourcesDirectory)\Certs" `
-AccessToken "$(secret)"
.EXAMPLE
# Dry run (no upload) to see what would be created/updated.
pwsh .\Publish-ACFBPolicy.ps1 -DryRun
.NOTES
- PowerShell 7+ is required. On Windows, run with “pwsh” (not “powershell”).
- If your signing helper resets VersionEx, this script restores it from the unsigned source.
- Make sure Add-SignerRule is available on the PATH (or dot-source your custom implementation).
- If you want to commit the signed outputs back to your repo, do so after the script finishes.
.LINK
Microsoft Graph docs (Intune configuration policies):
https://learn.microsoft.com/mem/intune/configuration/device-profile-create
#>
param (
[string]$PolicyRootDir = ".\Policies\unsigned_original",
[string]$OutputPolicyDir = ".\Policies\signed",
[string]$CertFolder = ".\Certs",
[string]$TenantId = $null,
[string]$AccessToken,
[switch]$DryRun
)
begin {
$ErrorActionPreference = 'Stop'
function Ensure-Module {
param([string]$Name)
if (-not (Get-Module -ListAvailable -Name $Name)) {
Install-Module $Name -Scope CurrentUser -Force -ErrorAction Stop
}
Import-Module $Name -ErrorAction Stop
}
function Connect-MSGraphSmart {
param([string]$Token, [string]$Tenant)
Ensure-Module -Name Microsoft.Graph.Authentication
if ($Token) {
$secureToken = ConvertTo-SecureString -String $Token -AsPlainText -Force
Connect-MgGraph -AccessToken $secureToken -NoWelcome | Out-Null
return
}
$scopes = @('DeviceManagementConfiguration.ReadWrite.All')
if ($Tenant) {
Connect-MgGraph -Scopes $scopes -TenantId $Tenant -NoWelcome | Out-Null
} else {
Connect-MgGraph -Scopes $scopes -NoWelcome | Out-Null
}
}
function Load-PolicyXml {
param([string]$Path)
try {
$rawXml = Get-Content -Path $Path -Raw -Encoding UTF8
[xml]$xml = $rawXml
return @{ Xml = $xml; Raw = $rawXml }
} catch {
Write-Error "Failed to load XML from '$Path': $($_.Exception.Message)"
return $null
}
}
function Get-VersionText {
param([string]$RawXml)
$m = [regex]::Match($RawXml, 'VersionEx\s*=\s*"([^"]+)"', 'IgnoreCase')
if ($m.Success) { return $m.Groups[1].Value }
$m = [regex]::Match($RawXml, '<VersionEx>\s*([^<]+)\s*</VersionEx>', 'IgnoreCase')
if ($m.Success) { return $m.Groups[1].Value }
return $null
}
# Connect to Microsoft Graph (token preferred)
Connect-MSGraphSmart -Token $AccessToken -Tenant $TenantId
$MgContext = Get-MgContext
if (-not $MgContext) { throw "Failed to connect to Microsoft Graph." }
Write-Host "Connected to Graph. TenantId=$($MgContext.TenantId)"
# Paths
if (-not (Test-Path $PolicyRootDir)) { throw "Directory '$PolicyRootDir' does not exist." }
if (-not (Test-Path $OutputPolicyDir)) { New-Item -Path $OutputPolicyDir -ItemType Directory | Out-Null }
if (-not (Test-Path $CertFolder)) { throw "Cert folder '$CertFolder' does not exist." }
# Ensure signer helper exists
if (-not (Get-Command Add-SignerRule -ErrorAction SilentlyContinue)) {
throw "Add-SignerRule not found on this machine/agent."
}
$NamePattern = [regex]'(?i)^(Base|Supplemental)_(.+)\.xml$'
$PolicyFiles = Get-ChildItem -Path $PolicyRootDir -Recurse -Filter *.xml |
Where-Object { $NamePattern.IsMatch($_.Name) }
if (-not $PolicyFiles) {
Write-Warning "No policies found in $PolicyRootDir."
return
}
}
process {
foreach ($PolicyFile in $PolicyFiles) {
$match = $NamePattern.Match($PolicyFile.Name)
$PolicyType = $match.Groups[1].Value
$PolicyKey = $match.Groups[2].Value
$PolicyName = "($PolicyType) - $PolicyKey"
switch ($PolicyType) {
"Base" {
$TemplateId = "4321b946-b76b-4450-8afd-769c08b16ffc_1"
$TemplateFamily = "endpointSecurityApplicationControl"
$TemplateDisplayName = "App Control for Business"
$SignSupplemental = $true
}
"Supplemental" {
$TemplateId = "08441ae9-e0c0-4e57-8e8b-6e72405cd64f_1"
$TemplateFamily = "endpointSecurityApplicationControlSupplementalPolicy"
$TemplateDisplayName = "App Control for Business - Supplemental"
$SignSupplemental = $false
}
default {
Write-Warning "Unknown policy type: $PolicyType skipping."
continue
}
}
Write-Host ""
Write-Host "Processing policy: $PolicyName" -ForegroundColor Cyan
# Read source version (before signing)
$SourceXmlRaw = Get-Content -Path $PolicyFile.FullName -Raw -Encoding UTF8
$SourceVersionText = Get-VersionText $SourceXmlRaw
if (-not $SourceVersionText) { $SourceVersionText = '0.0.0.0' }
# Sign
$Cert = Get-ChildItem -Path $CertFolder -Recurse -Include *.cer, *.crt -File | Select-Object -First 1
if (-not $Cert) { throw "No .cer/.crt found in '$CertFolder'." }
$SignedPath = Join-Path $OutputPolicyDir $PolicyFile.Name
Copy-Item -Path $PolicyFile.FullName -Destination $SignedPath -Force
$beforeHash = (Get-FileHash -Algorithm SHA256 -Path $SignedPath).Hash
Add-SignerRule -FilePath $SignedPath -CertificatePath $Cert.FullName -Update:$true -Supplemental:$SignSupplemental
$afterHash = (Get-FileHash -Algorithm SHA256 -Path $SignedPath).Hash
if ($beforeHash -eq $afterHash) { throw "Signing did not change '$SignedPath'." }
# Restore VersionEx after signing
[xml]$SignedXml = Get-Content -Path $SignedPath -Raw -Encoding UTF8
if ($SignedXml.SiPolicy.VersionEx) {
$SignedXml.SiPolicy.VersionEx = $SourceVersionText
} else {
$newNode = $SignedXml.CreateElement("VersionEx", $SignedXml.SiPolicy.NamespaceURI)
$newNode.InnerText = $SourceVersionText
[void]$SignedXml.SiPolicy.InsertAfter($newNode, $SignedXml.SiPolicy.FirstChild)
}
# PS7: utf8 is UTF-8 without BOM
$SignedXml.OuterXml | Set-Content -Path $SignedPath -Encoding utf8
# Local version from signed XML
$LocalPolicy = Load-PolicyXml -Path $SignedPath
if (-not $LocalPolicy) { continue }
$LocalXmlRaw = $LocalPolicy.Raw
$LocalVersionText = Get-VersionText $LocalXmlRaw
if (-not $LocalVersionText) { $LocalVersionText = '0.0.0.0' }
try { [version]$LocalVersion = $LocalVersionText } catch { $LocalVersion = [version]'0.0.0.0' }
# Query existing policy
$UriGET = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?`$filter=templateReference/TemplateFamily eq '$TemplateFamily' and name eq '$PolicyName'"
$Existing = Invoke-MgGraphRequest -Method GET -Uri $UriGET
$ExistingPolicy = $Existing.value | Select-Object -First 1
$DoCreate = -not $ExistingPolicy
$DoUpdate = $false
$RemoteVersion = [version]"0.0.0.0"
$RemoteXmlRaw = $null
if ($ExistingPolicy) {
$PolicyId = $ExistingPolicy.id
$UriSettings = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$PolicyId/settings"
$Settings = Invoke-MgGraphRequest -Method GET -Uri $UriSettings
foreach ($v in $Settings.value) {
$choice = $v.settingInstance.choiceSettingValue
if ($choice -and $choice.children) {
$xmlChild = $choice.children | Where-Object {
$_.settingDefinitionId -eq "device_vendor_msft_policy_config_applicationcontrol_policies_{policyguid}_xml"
} | Select-Object -First 1
if ($xmlChild -and $xmlChild.simpleSettingValue.value) {
$RemoteXmlRaw = [string]$xmlChild.simpleSettingValue.value
break
}
}
if (-not $RemoteXmlRaw -and $v.settingInstance.simpleSettingValue.value) {
$candidate = [string]$v.settingInstance.simpleSettingValue.value
if ($candidate -match '<SiPolicy') { $RemoteXmlRaw = $candidate; break }
}
}
if ($RemoteXmlRaw) {
$RemoteVersionText = Get-VersionText $RemoteXmlRaw
if (-not $RemoteVersionText) { $RemoteVersionText = '0.0.0.0' }
try { [version]$RemoteVersion = $RemoteVersionText } catch { }
}
if ($LocalVersion -gt $RemoteVersion) { $DoUpdate = $true }
else {
Write-Host "Versions match or remote is newer - no update required." -ForegroundColor Yellow
continue
}
}
if ($DryRun) {
Write-Host -NoNewline "[DRY RUN] Would "
if ($DoCreate) { Write-Host "CREATE $PolicyName" -ForegroundColor Cyan }
elseif ($DoUpdate) { Write-Host "UPDATE $PolicyName" -ForegroundColor Cyan }
continue
}
# Payload
$SettingsPayload = @(
@{
"@odata.type" = "#microsoft.graph.deviceManagementConfigurationSetting"
settingInstance = @{
"@odata.type" = "#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance"
settingDefinitionId = "device_vendor_msft_policy_config_applicationcontrol_policies_{policyguid}_policiesoptions"
choiceSettingValue = @{
"@odata.type" = "#microsoft.graph.deviceManagementConfigurationChoiceSettingValue"
value = "device_vendor_msft_policy_config_applicationcontrol_configure_xml_selected"
children = @(
@{
"@odata.type" = "#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance"
settingDefinitionId = "device_vendor_msft_policy_config_applicationcontrol_policies_{policyguid}_xml"
simpleSettingValue = @{
"@odata.type" = "#microsoft.graph.deviceManagementConfigurationStringSettingValue"
value = [string]$LocalXmlRaw
settingValueTemplateReference = @{
settingValueTemplateId = "88f6f096-dedb-4cf1-ac2f-4b41e303adb5"
}
}
settingInstanceTemplateReference = @{
settingInstanceTemplateId = "4d709667-63d7-42f2-8e1b-b780f6c3c9c7"
}
}
)
settingValueTemplateReference = @{
settingValueTemplateId = "b28c7dc4-c7b2-4ce2-8f51-6ebfd3ea69d3"
}
}
settingInstanceTemplateReference = @{
settingInstanceTemplateId = "1de98212-6949-42dc-a89c-e0ff6e5da04b"
}
}
}
)
$Payload = @{
name = $PolicyName
description = "Uploaded at $(Get-Date -Format 'yyyy-MM-dd HH:mm') via Azure DevOps Pipeline"
platforms = "windows10"
technologies = "mdm"
roleScopeTagIds = @("0")
templateReference = @{
"@odata.type" = "microsoft.graph.deviceManagementConfigurationPolicyTemplateReference"
templateId = $TemplateId
templateFamily = $TemplateFamily
templateDisplayName = $TemplateDisplayName
templateDisplayVersion = "Version 1"
}
settings = $SettingsPayload
}
try {
if ($DoUpdate) {
$UriUpdate = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$PolicyId')"
Invoke-MgGraphRequest -Method PUT -Uri $UriUpdate -Body $Payload -ContentType "application/json"
Write-Host "Updated: $PolicyName" -ForegroundColor Green
} elseif ($DoCreate) {
$UriCreate = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies"
Invoke-MgGraphRequest -Method POST -Uri $UriCreate -Body $Payload -ContentType "application/json"
Write-Host "Created: $PolicyName" -ForegroundColor Green
}
} catch {
Write-Error "Failed to process '$PolicyName': $($_.Exception.Message)"
if ($_.Exception.Response -and $_.Exception.Response.Content) {
try {
$errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
Write-Host ("Graph API Error Content:`n" + $errorContent) -ForegroundColor Red
} catch { }
}
}
}
}
end {
Disconnect-MgGraph | Out-Null
Write-Host ""
Write-Host "Script execution completed." -ForegroundColor DarkGray
}
Script logic
The logic in the powershell script finds existing policy via filter query

and compares the VersionEx version of the local policy file with the VersionEx of the remote policy file (Intune). If no policy exists, it will be created.


If the local version is higher then the remote one, the script will update the remote policy (intune).

When you modified the local policy, you have to increase the VersionEx manually!
Commit changes in git & trigger pipeline
Lets have a look at how commitments trigger the pipeline. First, we made a change and perform a commit:

The commit and the following sync triggers the pipeline and start executing the corresponding tasks:


Once the pipeline has been successfully completed, the application control for business policy has been update.


run it interactively
The powershell script is developed to use it in both ways – either via Azure DevOps Pipeline or run it interactively via Powershell.
If you run it interactively, you can use the -DryRun parameter to simulate, what would be done.
pwsh .\Publish-ACFBPolicy.ps1 `
-PolicyRootDir .\Policies\unsigned_original `
-OutputPolicyDir .\Policies\signed `
-CertFolder .\Certs `
-DryRun