Wednesday, September 16, 2026

Excluding Admins from a Dynamic "All Users" Group in Microsoft Entra ID (The Hard Way, So You Don't Have To)

Standard



 If you manage identity in any reasonably mature Entra ID tenant, you've probably wanted a dynamic group that captures "everyone" for baseline Conditional Access policies, licensing, onboarding automation while cleanly leaving out your admin accounts. It sounds like a two-minute rule. It isn't. Here's what actually works, and why the obvious approaches don't.

The problem

A dynamic "all users" group is simple enough on its own:

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true)

The trouble starts the moment you try to add "and exclude anyone assigned an admin role." Directory role assignments and PIM eligibility are not attributes the dynamic membership rule engine can evaluate. There's no user.assignedRoles property, and role membership isn't exposed to the rule builder at all.

Dead end #1: memberOf

Entra does have a preview rule operator that lets a dynamic group pull in members of another group:

user.memberof -any (group.objectId -in ['<admin-group-id>'])

This works but only in isolation. memberOf cannot be combined with any other condition in the same rule. You cannot write:

(user.accountEnabled -eq true) and not(user.memberof -any (group.objectId -in ['<id>']))

Entra's validator rejects it outright, often with an unhelpful error like Invalid object type '...' specified on property 'memberof'. It's also worth knowing this operator is in preview and being sunset Microsoft has flagged it for retirement, so building anything long-term on top of it is a bad bet regardless of the combination limitation.

There's a second wrinkle if your admin structure is a "group of groups": memberOf only reads direct members of whatever group ID you reference. Users sitting inside a nested group underneath it won't be picked up.

Dead end #2: Custom security attributes

The next instinct is Entra ID Governance's custom security attributes feature cloud-native, flexible, seems perfect. It isn't usable here either: custom security attributes are explicitly not supported as a property in dynamic group membership rules. They exist for access reviews and RBAC conditions, not for this.

What actually works: directory extension attributes

The feature that is supported in dynamic rules, and is cloud-writable regardless of on-prem sync status, is a Microsoft Entra directory extension attribute (also called a custom extension property). It's registered against an app registration, and once created it behaves like any other attribute:

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true) and (user.extension_<appid>_isPrivilegedUser -ne "Admin")

This combines freely with other conditions because it's a normal property, not a relationship like memberOf.

Registering it

There's no portal UI for this step, it's Graph or PowerShell only:

Run on this powershell - 

Connect-MgGraph -Scopes "Application.ReadWrite.All"
$app = New-MgApplication -DisplayName "Directory Extensions Owner App"
New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
    Name = "isPrivilegedUser"
    DataType = "String"
    TargetObjects = @("User")
}

One gotcha that cost real debugging time: an app registration alone isn't enough. Writing the extension property onto a user fails with The following extension properties are not available until the app also has a service principal in the tenant:

Run on this powershell - 
New-MgServicePrincipal -AppId $app.AppId

Once that exists, Update-MgUser -AdditionalProperties @{ "extension_<appid>_isPrivilegedUser" = "Admin" } works as expected.

Bridging the gap: a tagging script

Since the dynamic rule can't read group or role membership directly, something has to translate "is this person an admin" into "does this attribute say Admin," and keep it current. That's a scheduled script's job:

  1. Recursively walk the admin group(s). If your admin structure nests groups inside groups, a flat membership read misses anyone underneath the nested layer so this needs to recurse until it hits actual users.
  2. Account for PIM. If your admin groups are PIM-for-Groups enabled, Get-MgGroupMember only returns active members. Anyone with an eligible-but-not-activated assignment won't show up in that call at all, which quietly defeats the purpose of the exclusion. Eligible members need a separate query Get-MgIdentityGovernancePrivilegedAccessGroupEligibilitySchedule unioned in with the active set.
  3. Tag, then untag. Every run tags newly-found admins and clears the tag from anyone no longer under any admin group, so people who lose admin status fall back into the "all users" group automatically on the next cycle rather than staying excluded indefinitely.

Running it unattended

This lands naturally in an Azure Automation runbook:

  • Create the Automation Account
In the Azure portal, search "Automation Accounts" > Create. Pick your subscription/resource group, a name (e.g. entra-admin-exclusion-automation), and a region. Leave other defaults as-is for a simple runbook like this one.
  • Enable a system-assigned managed identity
Inside the Automation Account, go to Account Settings > Identity. Turn System assigned to On and save. This gives the Automation Account its own identity in Entra ID that Connect-MgGraph -Identity will authenticate as  no stored credentials or secrets needed.

  • Import the required Graph modules into the Automation Account
Go to Modules > Browse gallery (or Modules > Add a module for newer UI). Add these, one at a time, and wait for each to finish installing before adding the next: Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Identity.Governance, Microsoft.Graph.Applications. These match what your script imports/uses locally.



  • Create the runbook and paste in the script
Go to Runbooks > Create a runbook. Choose PowerShell as the type and match the runtime version to what you tested locally (PowerShell 7.2 is the current recommended default). Paste in the full script content, with one change: swap the interactive Connect-MgGraph -Scopes "..." line for Connect-MgGraph -Identity, since Automation authenticates via the managed identity, not a browser prompt.

  • Test the runbook before scheduling it
Use the Test pane (Edit view > Test pane) to run it once manually and confirm the same output you saw locally: groups walked, PIM-eligible counts, users tagged, and no permission or extension-property errors. This catches any permission-scope gaps from step 3 before it's running unattended.

  • Create and link a schedule
Go to Schedules > Add a schedule > create new, set your recurrence (hourly or daily is typical for this kind of exclusion-tag refresh), then go back to the runbook's Schedules tab and link the schedule to it. This is what makes it run unattended going forward.

  • Check job history periodically
Under Jobs, review recent runs' output and any warnings/errors (the Write-Warning lines in the script surface here). Since PIM eligibility and group membership can shift, keep an eye on this for the first few runs to confirm the numbers look stable before trusting it fully.


Create Dynamic Group

Create a new Security group and select Dynamic query


Dynamic Query -  

(user.objectId -ne null) and (user.userType -eq "Member") and (user.accountEnabled -eq true) and (user.extension_<App ID>_isPrivilegedUser -ne "Admin")

Note - you need to include this on the App ID we created on the above registration process without any Space or hyphen. 

Now you can see that New custom extension appear for dynamic rules and able select it. 

0 comments:

Post a Comment