SCRIPT LIBRARY · POWERSHELL
Report on Everything an Entra ID Group Touches with Microsoft Graph
Before you rename, rescope or delete a group, find out what it's wired to. Members, owners, licenses, apps and Intune policies, in one report.
- What it does
- Looks up a group by name or ID and lists its owners, members, group-based licenses, enterprise app assignments, and the Intune profiles and policies that include or exclude it. Read-only.
- Requires
- PowerShell 7.2+ (Windows PowerShell 5.1 works too)
- Microsoft.Graph.Groups, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement and Microsoft.Graph.Authentication modules
- Permissions
- Graph scopes: Directory.Read.All and DeviceManagementConfiguration.Read.All. An Intune read-only role covers the policy side.
- Runs on
- Windows, macOS, Linux
- Tested
- Parse-checked and dry-run with mocked Graph cmdlets in PowerShell 7.4
Part 4 of the thread The Microsoft Graph toolbox
Every tenant has that one group. It's called something like "SG-Test-2" and nobody remembers making it. You'd love to delete it, but you've got a nagging feeling it's holding up something important. A license, maybe. A BitLocker profile. A line-of-business app.
This script answers "what is this group actually connected to?" in one run. It lists who's in it and who owns it, which licenses it hands out, which enterprise apps it's assigned to, and which Intune configuration profiles, compliance policies and Settings catalog policies include or exclude it.
The older version of this post had a couple of real bugs. It checked a property called AssignedTo on Intune profiles that doesn't exist, so it never found a single assignment. It also printed the group's own name as the "app". This rewrite reads assignments properly and returns objects instead of Write-Host text.
<#
.SYNOPSIS
Reports what a Microsoft Entra ID group is and what it's wired to: members, owners,
group-based licenses, app assignments and Intune policy assignments.
.DESCRIPTION
Read-only. Looks the group up by name or object ID, then returns one row per finding
with Section, Name and Detail columns, so the output sorts, filters and exports to CSV
cleanly. Intune assignments cover device configuration profiles, compliance policies
and (unless -SkipSettingsCatalog) Settings catalog policies.
.PARAMETER Identity
The group's display name or object ID.
.PARAMETER SkipIntune
Don't look at Intune assignments. Handy if you don't have Intune read rights.
.PARAMETER SkipSettingsCatalog
Skip Settings catalog policies, which are only exposed on the Graph beta endpoint.
.EXAMPLE
.\Get-EntraGroupReport.ps1 -Identity 'Sales Team'
.EXAMPLE
.\Get-EntraGroupReport.ps1 -Identity 'Sales Team' | Export-Csv .\sales-team.csv -NoTypeInformation
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string]$Identity,
[switch]$SkipIntune,
[switch]$SkipSettingsCatalog
)
$ErrorActionPreference = 'Stop'
$scopes = 'Directory.Read.All', 'DeviceManagementConfiguration.Read.All'
if (-not (Get-MgContext)) { Connect-MgGraph -Scopes $scopes -NoWelcome }
function New-Row([string]$Section, [string]$Name, [string]$Detail = '') {
[pscustomobject]@{ Section = $Section; Name = $Name; Detail = $Detail }
}
# --- Find the group (by ID if it looks like a GUID, otherwise by exact display name)
$props = 'Id,DisplayName,Description,GroupTypes,MailEnabled,SecurityEnabled,Mail,MembershipRule,OnPremisesSyncEnabled,AssignedLicenses,CreatedDateTime'
if ($Identity -as [guid]) {
$group = Get-MgGroup -GroupId $Identity -Property $props
}
else {
$found = @(Get-MgGroup -Filter "displayName eq '$($Identity -replace "'", "''")'" -Property $props -All)
if ($found.Count -eq 0) { throw "No group named '$Identity'." }
if ($found.Count -gt 1) { throw "$($found.Count) groups are named '$Identity'. Use the object ID instead: $($found.Id -join ', ')" }
$group = $found[0]
}
$kind = if ($group.GroupTypes -contains 'Unified') { 'Microsoft 365' } elseif ($group.MailEnabled) { 'Mail-enabled security / distribution' } else { 'Security' }
New-Row 'Group' $group.DisplayName "$kind group, ID $($group.Id), created $($group.CreatedDateTime)"
if ($group.MembershipRule) { New-Row 'Group' 'Dynamic rule' $group.MembershipRule }
if ($group.OnPremisesSyncEnabled) { New-Row 'Group' 'Source' 'Synced from on-premises AD' }
# --- Owners and members
foreach ($o in Get-MgGroupOwner -GroupId $group.Id -All) {
New-Row 'Owner' $o.AdditionalProperties['displayName'] $o.AdditionalProperties['userPrincipalName']
}
foreach ($m in Get-MgGroupMember -GroupId $group.Id -All) {
$type = ($m.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.', '')
$upn = $m.AdditionalProperties['userPrincipalName']
$detail = if ($upn) { "$type, $upn" } else { $type }
New-Row 'Member' $m.AdditionalProperties['displayName'] $detail
}
# --- Licenses assigned through this group
if ($group.AssignedLicenses) {
$skuNames = @{}
Get-MgSubscribedSku -All | ForEach-Object { $skuNames[[string]$_.SkuId] = $_.SkuPartNumber }
foreach ($lic in $group.AssignedLicenses) {
$disabled = if ($lic.DisabledPlans) { "$($lic.DisabledPlans.Count) service plan(s) disabled" } else { 'All service plans' }
$name = if ($skuNames[[string]$lic.SkuId]) { $skuNames[[string]$lic.SkuId] } else { [string]$lic.SkuId }
New-Row 'License' $name $disabled
}
}
# --- Enterprise apps the group is assigned to
foreach ($a in Get-MgGroupAppRoleAssignment -GroupId $group.Id -All) {
$role = if ($a.AppRoleId -eq [guid]::Empty) { 'Default access' } else { "App role $($a.AppRoleId)" }
New-Row 'App' $a.ResourceDisplayName $role
}
# --- Intune policies that target the group
if (-not $SkipIntune) {
$sources = [ordered]@{
'Configuration profile' = 'v1.0/deviceManagement/deviceConfigurations?$expand=assignments&$select=id,displayName'
'Compliance policy' = 'v1.0/deviceManagement/deviceCompliancePolicies?$expand=assignments&$select=id,displayName'
}
if (-not $SkipSettingsCatalog) { $sources['Settings catalog'] = 'beta/deviceManagement/configurationPolicies?$expand=assignments&$select=id,name' }
foreach ($label in $sources.Keys) {
$uri = $sources[$label]
try {
while ($uri) {
$page = Invoke-MgGraphRequest -Method GET -Uri $uri
foreach ($policy in $page.value) {
foreach ($assignment in $policy.assignments) {
if ($assignment.target.groupId -ne $group.Id) { continue }
$mode = if ($assignment.target.'@odata.type' -like '*exclusion*') { 'Excluded' } else { 'Included' }
$policyName = if ($policy.displayName) { $policy.displayName } else { $policy.name }
New-Row $label $policyName $mode
}
}
$uri = $page.'@odata.nextLink'
}
}
catch { Write-Warning "Couldn't read $label assignments: $($_.Exception.Message)" }
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Identity | string | — | The group's display name or object ID. Required. If two groups share a name, the script stops and lists their IDs. |
-SkipIntune | switch | — | Leave out Intune assignments, for when you don't have Intune read rights or just need the directory side. |
-SkipSettingsCatalog | switch | — | Skip Settings catalog policies, which only live on the Graph beta endpoint. |
Run it
The quick look.
.\Get-EntraGroupReport.ps1 -Identity 'SG-Test-2'Just the Intune side, to see what a device group actually drives.
.\Get-EntraGroupReport.ps1 -Identity 'Windows - Pilot Ring' | Where-Object Section -notin 'Member', 'Owner'Document it before you delete it.
.\Get-EntraGroupReport.ps1 -Identity 6f1c1e1a-3b2d-4c5e-8f90-1a2b3c4d5e6f | Export-Csv .\group-before-delete.csv -NoTypeInformationWhat you'll see
Section Name Detail
------- ---- ------
Group SG-Test-2 Security group, ID 6f1c1e1a-3b2d-4c5e-8f90-1a2b3c4d5e6f, created 02/14/2023 16:02:11
Owner Dana Park [email protected]
Member Jane Doe user, [email protected]
Member PC-0142 device
License ENTERPRISEPACK All service plans
App Contoso Timesheets Default access
Configuration profile Win - Wi-Fi Corporate Included
Settings catalog Win - BitLocker Excluded
Compliance policy Win - Baseline Included
How it works
- Find exactly one group. If you pass a GUID it goes straight to that object. A name gets an exact-match filter, and if more than one group has that name, the script stops rather than report on the wrong one.
- Directory basics. Owners and members come from
Get-MgGroupOwnerandGet-MgGroupMemberwith-All, so big groups aren't cut off at 100. Each member's type (user, device, group, service principal) is shown alongside the name. - Licenses. Group-based licensing lives on the group itself in
AssignedLicenses. The script maps each SKU ID to its readable part number withGet-MgSubscribedSkuand notes if any service plans are switched off. - Apps.
Get-MgGroupAppRoleAssignmentlists the enterprise apps the group is assigned to, using the app's name (ResourceDisplayName), not the group's. - Intune. Profiles and policies are pulled with their assignments expanded in one request per type, following paging links, and each assignment is checked for this group's ID. Exclusions are labeled, because "excluded from BitLocker" is exactly the sort of thing you want to know.
Everything comes back as Section, Name, Detail rows, so it filters, sorts and exports without any extra work.
Take it further
- Add Conditional Access.
Get-MgIdentityConditionalAccessPolicy(scopePolicy.Read.All) exposes included and excluded groups underConditions.Users. Same pattern, one more section. - Hunt for empty groups. Loop over every group, and anything with no members and no Intune or app assignments is a strong cleanup candidate.
- Snapshot before changes. Run it before and after a migration and compare the CSVs. It's a cheap way to prove nothing fell off.
Things that'll trip you up
- "All users" and "All devices" don't count. Intune policies assigned to the built-in All users or All devices targets don't reference any group, so they won't show up here, even though they obviously apply to your members too.
- Settings catalog lives on beta. Settings catalog policies are only exposed through the Graph beta endpoint. The script calls it directly and warns instead of failing if that call is refused. Use -SkipSettingsCatalog if you'd rather stay on v1.0.
- Nested groups aren't expanded. If another group is a member, you'll see it listed as a group, not its people. Anything assigned to the parent group applies to the nested members too, which is worth remembering before a delete.
- It's not the whole picture. Conditional Access policies, SharePoint and Teams permissions, app protection policies and PIM assignments can all point at a group too. This covers the common ones, not every corner of Microsoft 365.