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 |