Microsoft 365, Entra and Intune
Intune configuration as code: export it, commit it, know what changed
Somebody changed a setting. Compliance went from 96 percent to 71 percent overnight. The portal will happily show you the current state of every policy, and it will not tell you which of two hundred settings moved, when, or who moved it.
Intune configuration as code fixes that in about an afternoon, and the fix is unglamorous: export the policies to JSON, commit them to a repository, and diff the live state against what is committed.
What intune configuration as code actually gets you
Three things, and the third is the one people did not expect to want.
A named property rather than a changed policy. A diff that says
passwordMinimumLength: 12 -> 8 is actionable. "This policy differs" is not.
A timestamp and an author. Not from Intune, from git. The commit history is the record the portal does not keep.
Configuration management evidence. If anyone is asking you for proof that device configuration is controlled, a repository of timestamped policy states with a diff history is materially better evidence than a screenshot taken the week before an audit. That applies whether or not you are chasing a certificate, and the book covers what auditors actually accept here.
Exporting
Graph returns policies as JSON already, so the export is mostly plumbing. The part worth getting right is which endpoints to hit, because Intune policy lives in several places.
Connect-MgGraph -Scopes 'DeviceManagementConfiguration.Read.All'
$sets = @{
deviceConfiguration = 'deviceManagement/deviceConfigurations'
settingsCatalog = 'deviceManagement/configurationPolicies'
compliance = 'deviceManagement/deviceCompliancePolicies'
endpointSecurity = 'deviceManagement/intents'
}
foreach ($name in $sets.Keys) {
$uri = "https://graph.microsoft.com/beta/$($sets[$name])"
$all = @()
do {
$page = Invoke-MgGraphRequest -Method GET -Uri $uri
$all += $page.value
# Paging is not optional here. configurationPolicies returns 25 at a
# time and ignores $top, so a naive call silently reports a fraction
# of your policies as your entire estate.
$uri = $page.'@odata.nextLink'
} while ($uri)
$all | ForEach-Object {
$safe = ($_.name ?? $_.displayName) -replace '[^\w\- ]', '_'
$_ | ConvertTo-Json -Depth 20 |
Set-Content "baselines/$name/$safe.json" -Encoding utf8
}
}
Two traps worth naming, both of which have cost me time.
configurationPolicies pages at 25 and ignores $top. If you have 44 settings
catalog policies and no paging loop, you export 25 and your diff reports the
other 19 as deleted. The absence of an error makes this worse than a failure.
And under Set-StrictMode, referencing @odata.nextLink on the final page
throws rather than returning null, so the loop needs the property access to be
safe. I have written a version that looped forever on the last page.
Export assignments too. A policy that is correct and assigned to nobody is a different problem from one that is wrong, and the assignment is not in the policy object.
Diffing, and why whole-blob comparison is not enough
Comparing two JSON files with a text diff works for legacy device configuration profiles, where the structure is flat and a changed value appears on its own line.
Settings catalog policies are the reason this needs more care. Their settings
are nested several levels deep inside settingsDelta structures, values are
wrapped in typed objects, and array ordering is not guaranteed between exports.
A text diff on those produces either an enormous unreadable change or nothing at
all, depending on how the export serialised that day.
So the comparison has to walk the object and normalise as it goes: sort arrays
by a stable key, ignore read-only metadata such as lastModifiedDateTime and
version, and report leaf properties by path. The output you want looks like
this:
POLICY Win10 Baseline (deviceConfiguration)
~ firewallRules[2].localPortRanges "3389" -> "3389,5985"
~ passwordMinimumLength 12 -> 8
+ smartScreenBlockOverrideForFiles (added) true
POLICY MacOS Platform SSO (settingsCatalog)
~ authenticationMethod "Password" -> "SmartCard"
2 policies drifted, 4 properties changed
That is a diff somebody can act on without opening the portal.
The change that looks harmless
One warning that generalises beyond Intune. Editing a policy is not always a narrow operation.
Some compliance and configuration settings trigger behaviour on every assigned device when the policy is updated, regardless of which field you edited. A change to an unrelated property on a policy that enforces a password requirement can cause a password change prompt across the whole assigned fleet, because the platform re-evaluates and re-applies rather than diffing.
The practical consequences: read what a policy enforces before editing any field on it, prefer creating a new policy and moving assignments over editing a live one that touches credentials, and test on a filtered pilot group rather than on All Devices. A committed baseline helps here too, because it tells you exactly what the policy enforced before you touched it.
Microsoft's device configuration documentation covers the policy types; the re-application behaviour is the sort of thing you learn from a fleet-wide prompt rather than from documentation.
What to commit, and what to leave out
An export that includes everything Graph returns produces a repository where every run looks like a change, which trains you to ignore the diff.
Strip the fields the platform owns. lastModifiedDateTime moves whenever
anything touches the policy, version increments, and the various @odata
annotations vary between API versions. None of them tell you anything about
configuration, and all of them create noise on every single run.
Keep the identifiers. Policy id values are stable and are how you match a
policy across exports when somebody renames it, which they will. Matching on
display name alone means a rename reads as a deletion plus an addition, and you
lose the history of the thing you actually care about.
And commit the assignment separately from the policy body. Assignments change for different reasons and on a different schedule, usually as groups are restructured rather than as settings are tuned, so mixing them means every group change looks like a configuration change.
The repository layout that has worked for me is one directory per policy type, one file per policy named by display name for readability, with the id inside. That way a diff is browsable by a human and survives a rename.
Running it on a schedule
Export, commit, compare, and report only when something moved. Weekly is enough for most estates.
It has to fail loudly. A drift check that cannot reach Graph and reports no drift has produced a clean result from an empty comparison, which is the same silent-success problem as everywhere else in these notes. Exit non-zero when the export fails, so a scheduled run that could not look does not look like a run that found nothing.
Questions
Does this let me push configuration back into Intune?
Deliberately not, in the version I run. A tool that can overwrite device policy across a fleet is a different risk category from one that reads, and the value here is knowing what changed rather than automating the change.
What permissions does the export need?
DeviceManagementConfiguration.Read.All for policies, plus directory read to
resolve assignment group names. Certificate authentication on an app
registration is preferable to a client secret, for the reasons in
the credential expiry note.
Will a text diff not do?
For legacy device configuration profiles, usually. For settings catalog policies, no: nesting depth and non-deterministic array ordering mean a text diff reports either everything or nothing. Property-path comparison is the part that makes this useful.
How do I handle policies that legitimately change often?
Commit the change with a message saying why. The point is not that nothing changes, it is that every change has a record. A repository where every commit has a reason is the evidence; a repository with no commits is just a snapshot.
Does the beta Graph endpoint matter?
Settings catalog policies are more complete on beta, which is why the export above uses it. Beta can change without notice, so pin your tooling and re-test after Intune releases. That is a real cost of using it and worth accepting deliberately.