Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEGet-EntraGroupReport.ps1
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

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.

Get-EntraGroupReport.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Identitystring—The group's display name or object ID. Required. If two groups share a name, the script stops and lists their IDs.
-SkipIntuneswitch—Leave out Intune assignments, for when you don't have Intune read rights or just need the directory side.
-SkipSettingsCatalogswitch—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 -NoTypeInformation

What you'll see

Example outputvalues are illustrative
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

  1. 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.
  2. Directory basics. Owners and members come from Get-MgGroupOwner and Get-MgGroupMember with -All, so big groups aren't cut off at 100. Each member's type (user, device, group, service principal) is shown alongside the name.
  3. Licenses. Group-based licensing lives on the group itself in AssignedLicenses. The script maps each SKU ID to its readable part number with Get-MgSubscribedSku and notes if any service plans are switched off.
  4. Apps. Get-MgGroupAppRoleAssignment lists the enterprise apps the group is assigned to, using the app's name (ResourceDisplayName), not the group's.
  5. 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 (scope Policy.Read.All) exposes included and excluded groups under Conditions.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.