Export-EntraGroupMemberShip.ps1


<#PSScriptInfo

.VERSION 1.0.0

.GUID 3980b2a1-4d15-4cc6-98b6-c3e619407421

.AUTHOR Chendrayan Venkatesan

.COMPANYNAME

.COPYRIGHT

.TAGS EntraID AzureAD GroupMembership MicrosoftGraph Export

.LICENSEURI

.PROJECTURI

.ICONURI

.EXTERNALMODULEDEPENDENCIES

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES No module dependency, uses device code sign-in and Invoke-RestMethod against Microsoft Graph directly


.PRIVATEDATA

#>


<#
.SYNOPSIS
    Prompts for interactive Microsoft Entra ID (Azure AD) authentication and exports a detailed
    group membership report to CSV, including group name, group source, group type, and members.

.DESCRIPTION
    Authenticates interactively against Microsoft Graph using the OAuth 2.0 device code flow
    (Invoke-RestMethod only - no Microsoft.Graph, MSAL.PS, or AzureAD module dependency) and
    enumerates Microsoft Entra ID groups. The report contains one row per group with:
      - GroupName : the group's display name.
      - GroupSource : Cloud (native Entra ID) or Windows Server AD (synced via Entra Connect).
      - GroupType : Microsoft 365, Security, Mail-Enabled Security, Distribution, Dynamic, etc.
      - Members : comma-separated list of direct members (users and groups). Members that
                              are themselves groups are suffixed with " (Group)".
      - NestedGroupMembers : for every direct member that is a group, that nested group's own members,
                              formatted as "NestedGroupName: member1, member2; NextGroup: member3".

.PARAMETER GroupNames
    Optional list of exact group display names to limit the report to. If omitted, all groups in
    the tenant are reported on. Cannot be combined with -GroupNameStartsWith.

.PARAMETER GroupNameStartsWith
    Optional prefix used to limit the report to groups whose display name starts with this value.
    Cannot be combined with -GroupNames.

.PARAMETER OutputPath
    Path to the CSV file to create. Defaults to a timestamped file in the current user's temp folder.

.PARAMETER TenantId
    Azure AD tenant to authenticate against. Defaults to 'common' (the signed-in user's home tenant).

.PARAMETER ClientId
    Azure AD application (client) ID used for the device code sign-in. Defaults to the Microsoft
    first-party "Microsoft Graph Command Line Tools" public client (14d82eec-204b-4c2f-b7e8-296a70dab67e),
    which is pre-registered in every tenant. Override with your own app registration's client ID if
    your tenant restricts sign-in to specific applications.

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1

    Prompts for device code sign-in and exports every group's detailed membership to a timestamped
    CSV in the temp folder.

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1 -GroupNames "Finance Team","IT Admins" -OutputPath C:\Reports\GroupReport.csv

    Exports membership only for the named groups.

.EXAMPLE
    .\Export-EntraGroupMemberShip.ps1 -GroupNameStartsWith "Sales-"

    Exports membership only for groups whose display name starts with "Sales-".

.NOTES
    No external module dependency - only built-in Invoke-RestMethod is used to call Microsoft Graph.
    Required Graph delegated permissions (must be consented in the tenant): Group.Read.All, User.Read.All.
#>

[CmdletBinding()]
param(
    [Parameter()]
    [string[]]$GroupNames,

    [Parameter()]
    [string]$GroupNameStartsWith,

    [Parameter()]
    [string]$OutputPath = (Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "GroupMembershipReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"),

    [Parameter()]
    [string]$TenantId = 'common',

    [Parameter()]
    [string]$ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
)

if ($GroupNames -and $GroupNameStartsWith) {
    throw '-GroupNames and -GroupNameStartsWith cannot be used together.'
}

$ErrorActionPreference = 'Stop'
$graphBaseUri = 'https://graph.microsoft.com/v1.0'

function Get-DeviceCodeAccessToken {
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string[]]$Scopes
    )

    $deviceCodeUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode"
    $tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $scopeString = ($Scopes -join ' ')

    try {
        $deviceCodeResponse = Invoke-RestMethod -Method Post -Uri $deviceCodeUri -ContentType 'application/x-www-form-urlencoded' -Body @{
            client_id = $ClientId
            scope     = $scopeString
        }
    }
    catch {
        throw "Failed to start device code sign-in: $($_.Exception.Message)"
    }

    Write-Host $deviceCodeResponse.message -ForegroundColor Yellow

    $interval = [int]$deviceCodeResponse.interval
    if ($interval -le 0) { $interval = 5 }
    $expiresAt = (Get-Date).AddSeconds([int]$deviceCodeResponse.expires_in)

    while ((Get-Date) -lt $expiresAt) {
        Start-Sleep -Seconds $interval

        try {
            $tokenResponse = Invoke-RestMethod -Method Post -Uri $tokenUri -ContentType 'application/x-www-form-urlencoded' -Body @{
                grant_type  = 'urn:ietf:params:oauth:grant-type:device_code'
                client_id   = $ClientId
                device_code = $deviceCodeResponse.device_code
            } -ErrorAction Stop

            return $tokenResponse
        }
        catch {
            $errorBody = $null
            if ($_.ErrorDetails.Message) {
                $errorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
            }

            switch ($errorBody.error) {
                'authorization_pending' { continue }
                'slow_down'              { $interval += 5; continue }
                'authorization_declined' { throw 'Sign-in was declined.' }
                'expired_token'          { throw 'The device code expired before sign-in completed.' }
                default                  { throw "Sign-in failed: $($errorBody.error_description)" }
            }
        }
    }

    throw 'Timed out waiting for device code sign-in to complete.'
}

function Invoke-GraphGet {
    param(
        [string]$Uri,
        [string]$AccessToken,
        [switch]$Eventual
    )

    $headers = @{ Authorization = "Bearer $AccessToken" }
    if ($Eventual) { $headers['ConsistencyLevel'] = 'eventual' }

    $results = [System.Collections.Generic.List[object]]::new()
    $nextUri = $Uri

    while ($nextUri) {
        $response = Invoke-RestMethod -Method Get -Uri $nextUri -Headers $headers
        if ($null -ne $response.value) {
            $results.AddRange(@($response.value))
        }
        $nextUri = $response.'@odata.nextLink'
    }

    return $results
}

function Get-EntraGroupTypeLabel {
    param($Group)

    $groupTypes = $Group.groupTypes
    $labels = [System.Collections.Generic.List[string]]::new()

    if ($groupTypes -contains 'Unified') {
        $labels.Add('Microsoft 365')
    }
    elseif ($Group.securityEnabled -and $Group.mailEnabled) {
        $labels.Add('Mail-Enabled Security')
    }
    elseif ($Group.securityEnabled) {
        $labels.Add('Security')
    }
    elseif ($Group.mailEnabled) {
        $labels.Add('Distribution')
    }
    else {
        $labels.Add('Unknown')
    }

    if ($groupTypes -contains 'DynamicMembership') {
        $labels.Add('Dynamic')
    }

    if ($Group.isAssignableToRole) {
        $labels.Add('Role-Assignable')
    }

    return ($labels -join ' / ')
}

function Get-EntraGroupSourceLabel {
    param($Group)

    if ($Group.onPremisesSyncEnabled) {
        return 'Windows Server AD'
    }
    return 'Cloud'
}

function Get-EntraMemberTypeLabel {
    param($Member)

    switch ($Member.'@odata.type') {
        '#microsoft.graph.user'             { 'User'; break }
        '#microsoft.graph.group'            { 'Group'; break }
        '#microsoft.graph.servicePrincipal' { 'Service Principal'; break }
        '#microsoft.graph.device'           { 'Device'; break }
        '#microsoft.graph.orgContact'       { 'Contact'; break }
        default                             { ($_ -replace '^#microsoft\.graph\.', '') }
    }
}

function Get-EntraMemberDisplayName {
    param($Member)

    if ($Member.displayName) {
        return $Member.displayName
    }
    if ($Member.userPrincipalName) {
        return $Member.userPrincipalName
    }
    return $Member.id
}

# Cache of GroupId -> comma-separated member names, so a group nested under
# multiple parents is only resolved once.
$script:NestedGroupMemberCache = @{}

function Get-EntraNestedGroupMemberSummary {
    param(
        [string]$GroupId,
        [string]$GroupDisplayName,
        [string]$AccessToken
    )

    if ($script:NestedGroupMemberCache.ContainsKey($GroupId)) {
        return $script:NestedGroupMemberCache[$GroupId]
    }

    try {
        $nestedMembers = Invoke-GraphGet -Uri "$graphBaseUri/groups/$GroupId/members?`$top=999" -AccessToken $AccessToken
    }
    catch {
        Write-Warning "Failed to retrieve members for nested group '$GroupDisplayName': $($_.Exception.Message)"
        $nestedMembers = $null
    }

    if (-not $nestedMembers) {
        $summary = "$($GroupDisplayName): (No members)"
    }
    else {
        $names = foreach ($nestedMember in $nestedMembers) {
            $name = Get-EntraMemberDisplayName -Member $nestedMember
            if ((Get-EntraMemberTypeLabel -Member $nestedMember) -eq 'Group') {
                "$name (Group)"
            }
            else {
                $name
            }
        }
        $summary = "$($GroupDisplayName): $($names -join ', ')"
    }

    $script:NestedGroupMemberCache[$GroupId] = $summary
    return $summary
}

$requiredScopes = 'Group.Read.All', 'User.Read.All'
Write-Host 'Starting Microsoft Graph device code sign-in...' -ForegroundColor Cyan
$tokenResponse = Get-DeviceCodeAccessToken -TenantId $TenantId -ClientId $ClientId -Scopes $requiredScopes
$accessToken = $tokenResponse.access_token
Write-Host 'Authenticated successfully.' -ForegroundColor Green

Write-Host 'Retrieving groups...' -ForegroundColor Cyan
$groupSelect = 'id,displayName,groupTypes,securityEnabled,mailEnabled,isAssignableToRole,onPremisesSyncEnabled'

if ($GroupNames) {
    $groups = foreach ($name in $GroupNames) {
        $escaped = $name.Replace("'", "''")
        Invoke-GraphGet -Uri "$graphBaseUri/groups?`$filter=displayName eq '$escaped'&`$select=$groupSelect" -AccessToken $accessToken
    }
}
elseif ($GroupNameStartsWith) {
    $escaped = $GroupNameStartsWith.Replace("'", "''")
    $groups = Invoke-GraphGet -Uri "$graphBaseUri/groups?`$filter=startswith(displayName, '$escaped')&`$select=$groupSelect&`$count=true" -AccessToken $accessToken -Eventual
}
else {
    $groups = Invoke-GraphGet -Uri "$graphBaseUri/groups?`$select=$groupSelect&`$top=999" -AccessToken $accessToken
}

if (-not $groups) {
    Write-Warning 'No matching groups were found.'
    return
}

$report = [System.Collections.Generic.List[object]]::new()
$total = @($groups).Count
$current = 0

foreach ($group in $groups) {
    $current++
    Write-Progress -Activity 'Exporting group membership' -Status $group.displayName -PercentComplete (($current / $total) * 100)

    $groupTypeLabel = Get-EntraGroupTypeLabel -Group $group
    $groupSourceLabel = Get-EntraGroupSourceLabel -Group $group

    try {
        $members = Invoke-GraphGet -Uri "$graphBaseUri/groups/$($group.id)/members?`$top=999" -AccessToken $accessToken
    }
    catch {
        Write-Warning "Failed to retrieve members for group '$($group.displayName)': $($_.Exception.Message)"
        continue
    }

    if (-not $members) {
        $report.Add([pscustomobject]@{
            GroupName         = $group.displayName
            GroupSource       = $groupSourceLabel
            GroupType         = $groupTypeLabel
            Members           = '(No members)'
            NestedGroupMembers = ''
        })
        continue
    }

    $memberNames = [System.Collections.Generic.List[string]]::new()
    $nestedGroupSummaries = [System.Collections.Generic.List[string]]::new()

    foreach ($member in $members) {
        $memberTypeLabel = Get-EntraMemberTypeLabel -Member $member
        $memberName = Get-EntraMemberDisplayName -Member $member

        if ($memberTypeLabel -eq 'Group') {
            $memberNames.Add("$memberName (Group)")
            $nestedGroupSummaries.Add((Get-EntraNestedGroupMemberSummary -GroupId $member.id -GroupDisplayName $memberName -AccessToken $accessToken))
        }
        else {
            $memberNames.Add($memberName)
        }
    }

    $report.Add([pscustomobject]@{
        GroupName         = $group.displayName
        GroupSource       = $groupSourceLabel
        GroupType         = $groupTypeLabel
        Members           = ($memberNames -join ', ')
        NestedGroupMembers = ($nestedGroupSummaries -join '; ')
    })
}

Write-Progress -Activity 'Exporting group membership' -Completed

$report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

Write-Host "Report exported to: $OutputPath" -ForegroundColor Green
Write-Host "Total groups: $($report.Count)" -ForegroundColor Green