Azure Function Intune Automation An interesting Cloud-Only Architecture
| | |

Azure Function Intune Automation: An interesting Cloud-Only Architecture

Reading Time: 7 minutes

This is Part 2 in the series on automating Intune device group membership beyond what dynamic rules can express using an Azure Function and Graph API. In Part 1, we covered the gap itself; this post walks through the actual Azure Function Intune automation architecture behind it: a cloud-only design that runs entirely inside the Entra ID tenant, with nothing installed on the endpoint.

In the last post we landed on the pattern this series is building: a scheduled job that reads Intune’s own device inventory through Microsoft Graph and populates (or removes) devices from a static group based on a condition, without anything running on the device itself. That’s a clean sentence to write. Actually running it means answering a handful of very concrete questions: where does this job run, how does it prove its identity to Graph, and what stops the credential it uses from becoming its own security problem. This post is those answers.

Why cloud-only, and what that actually buys you

It’s worth separating two things that get confused in “automate based on installed software”: collecting the signal, and acting on it.

Acting on it (deciding a device qualifies, adding or removing it from a group) is always going to happen somewhere central, because group membership is a tenant-wide object, not a device-local one.

Collecting the signal is the part people assume needs a device-side script, and for this specific case, it doesn’t. Intune already inventories installed applications on every managed device as part of its normal check-in cycle, and that inventory is fully queryable through Graph’s detectedApps endpoint. So an Azure Function on a timer can just ask Graph “which devices currently have this installed,” the same question a remediation script running locally would have to answer itself, without deploying anything new to a single machine. We could also expand this one using Defender for Endpoint inventory, but that’s a more complex process for a later post.

That’s the whole argument for cloud-only in one line: if the fact you need already exists in Graph, adding a device-side script to also go collect it is redundant work with extra attack surface for no benefit.

This pattern isn’t just for installed software

Everything so far has used “has this app installed” as the running example, because it’s concrete and it’s the exact gap Part 1 opened with. But nothing about the architecture itself is specific to detectedApps. The Function doesn’t actually care what question it’s asking Graph, only that the answer comes back as a set of device IDs to diff against a group. Swap the Graph query, and the same timer, Managed Identity, Key Vault, and diffing logic works for any device fact Graph already knows about.

Condition to group onWhere it comes from in GraphExample use case
Installed applicationdeviceManagement/detectedAppsGate a migration wave on an app being removed
Compliance statemanagedDevices property complianceStatePull non-compliant devices into a group for a follow-up campaign
OS version or buildmanagedDevices property osVersionTrack real adoption of a specific Windows or macOS release
Autopilot profile assignment statusdeviceManagement/windowsAutopilotDeviceIdentitiesFind devices still waiting on profile assignment
A value written by an entirely different systemextensionAttribute1-15 on the device objectThe bridge pattern from Part 1, now automatable from the cloud too

The only two things that actually change when you retarget this at a different property: the Graph query in the “find matching devices” step, and, occasionally, the app registration’s permission scope. DeviceManagementManagedDevices.Read.All already covers most fields on the managed device object, so compliance state or OS version need nothing extra.

The four pieces, and why each one exists

Four components make up the actual architecture, and each one has a single, specific job:

A timer-triggered Azure Function. Consumption plan, PowerShell 7.4, no endpoint dependency. This is the thing that wakes up on a schedule and does the work, currently every 2 days, tunable depending on how quickly you need group membership to reflect reality.

A system-assigned Managed Identity, scoped to Key Vault only. The Function’s own identity in Entra. It has exactly one job: read one secret from Key Vault. It cannot call Microsoft Graph, and it doesn’t need to.

An app registration with Application permissions, admin-consented once. This is the identity that actually has Graph permissions: for the installed-software example running through this series, that’s DeviceManagementManagedDevices.Read.All, Device.Read.All, GroupMember.ReadWrite.All, though exactly which permissions you need shifts with whatever property you’re actually targeting, per the table above. Its client secret lives in Key Vault, never in code, never on disk.

Microsoft Graph itself, the only thing that can see both Intune’s device inventory and Entra’s group membership at once, which is exactly why every design here routes through it rather than trying to bridge the two systems any other way.

Why keep an app registration and Key Vault in a single tenant

Here’s a fair question, since this is a single-tenant setup: a system-assigned Managed Identity can, in principle, be granted Graph API permissions directly, with no app registration, no client secret, and no Key Vault at all. Fewer moving parts, nothing to rotate. Why not just do that?

Two practical reasons kept the app registration and Key Vault in the design:

Managed Identity directApp Registration + Key Vault
Credentials to manageNoneOne secret, centrally rotated
Grantable via Entra admin center UINo, requires a PowerShell script against Graph (New-MgServicePrincipalAppRoleAssignment)Yes, native App registrations UI, same flow as any Graph integration
Identity tied toThe specific Function App resourceA standalone object, independent of which compute runs it
Blast radius if compute is redeployed/renamedPermissions must be re-granted to the new identitySecret and permissions are untouched; just point new compute at the same Key Vault
AuditabilityBuried in the Function App’s own identity bladeA distinct, named object in Enterprise Applications with its own permission history

None of these make Managed Identity a bad choice in general; for plenty of Azure-to-Azure scenarios it’s the obviously correct one. But for an identity that’s specifically meant to hold sensitive Graph permissions against Intune and group membership, having that permission set live on a portable, independently auditable object, rather than tied to one specific Function App’s lifecycle, is worth the one extra secret to manage. It’s a deliberate trade of a little convenience for a cleaner separation between “what runs the job” and “what has the permissions.”

There’s a bigger payoff to this separation too, arguably the most important one: because the app registration’s identity and its permissions aren’t tied to a specific Function App, this same pattern extends cleanly to a cross-tenant setup later, exactly the shape an MSP would need to manage a client’s Intune tenant from centralized tooling, without redesigning anything here.

If you want the simpler route instead: Managed Identity direct

Everything above explains why this series keeps the app registration and Key Vault. If you don’t care about cross-tenant portability and just want the fewest possible moving parts in a single tenant, the Managed-Identity-direct variant is a completely legitimate choice, and it’s worth showing what it actually looks like rather than only describing it in a comparison table.

Managed Identity Approach - Azure function Intune automation

The flow collapses to three components instead of five: the timer, the Function with its Managed Identity, and Graph itself. No secret, nothing in Key Vault, nothing to rotate.

The catch is entirely in how you grant the permissions. A Managed Identity’s service principal doesn’t show up anywhere in the Entra admin center’s App registrations UI, so there’s no button to click for “add Graph permissions.” It has to be done with a script, once, using the Microsoft Graph PowerShell SDK:

Connect-MgGraph -Scopes "Application.Read.All", "AppRoleAssignment.ReadWrite.All"

# The Function App's Managed Identity, found by its service principal Object ID
# (Function App -> Identity -> System assigned -> Object (principal) ID)
$managedIdentityObjectId = "<managed-identity-object-id>"

# Microsoft Graph's own service principal is the same well-known app ID in every tenant
$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"

$permissionsToGrant = @(
    "DeviceManagementManagedDevices.Read.All",
    "Device.Read.All",
    "GroupMember.ReadWrite.All"
)

foreach ($permissionName in $permissionsToGrant) {
    $appRole = $graphServicePrincipal.AppRoles | Where-Object { $_.Value -eq $permissionName -and $_.AllowedMemberTypes -contains "Application" }

    New-MgServicePrincipalAppRoleAssignment `
        -ServicePrincipalId $managedIdentityObjectId `
        -PrincipalId $managedIdentityObjectId `
        -ResourceId $graphServicePrincipal.Id `
        -AppRoleId $appRole.Id
}

Run once, by someone with sufficient Entra permissions (Privileged Role Administrator or Global Administrator), and the Function’s own identity can call Graph immediately afterward, no token request, no client secret step. Inside the Function, this collapses to just Connect-MgGraph -Identity (or the REST equivalent using the Managed Identity’s token endpoint directly), then calling Graph straight away.

This is a genuinely good choice if you’re automating a single tenant you control indefinitely and the portability argument doesn’t apply to you. The series itself continues with the app registration and Key Vault pattern in Part 3, since it’s the shape that also works if this ever needs to extend beyond one tenant, but both are documented here so you can pick based on what you actually need rather than what this series happened to build.

The architecture, end to end

Main Approach - Azure function Intune automation

Reading the diagram left to right: the timer fires, the Function wakes up, its Managed Identity pulls the app registration’s secret from Key Vault, that secret is used in a standard OAuth2 client-credentials request to get a Graph token, and that token is what actually talks to Graph, querying detectedApps for the current installed-software state, then diffing that against the target group’s current membership and applying only the additions and removals actually needed.

The bottom of the diagram is deliberately the least interesting part, and that’s the point: managed endpoints just keep checking in with Intune exactly as they always have. Nothing about this automation changes what happens on the device side, which is the entire cloud-only argument made visual. detectedApps is simply which box this series happened to fill in first; any other row from the generalization table earlier slots into the same diagram unchanged.

Design decisions worth explaining

Why a 2-day schedule, not hourly. Intune’s own check-in cadence for a healthy device is around 8 hours by default, so running this Function more often than that doesn’t get you fresher data; it just burns Graph calls against data that hasn’t changed. A 2-day cadence was chosen here because the actual use case (gating a staged rollout) doesn’t need same-day reaction time; if your scenario does, tightening this is just a one-line change to the timer’s NCRONTAB schedule, not a redesign.

Why the diff happens before any write. The Function computes the full set of devices that should be in the group and the full set that currently are, and only acts on the difference. That’s what keeps repeated runs idempotent instead of blindly re-adding or flapping devices in and out.

What’s coming in Part 3

Part 3 is the implementation itself: setting up the app registration and its permissions, the Key Vault and Managed Identity wiring, the actual PowerShell running inside the Function, and, because this got built and tested for real rather than just designed on paper, the specific pitfalls that came up along the way.

Related reading: Entra ID Dynamic Groups Can’t Query Installed Software is Part 1 of this series, covering the gap this architecture solves

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *