added documetation

This commit is contained in:
Jurjen Ladenius
2025-11-03 08:12:01 +01:00
parent 8840b0e300
commit a226ca97ac
37 changed files with 8315 additions and 1481 deletions

View File

@@ -1,101 +1,329 @@
<#
.SYNOPSIS
Comprehensive Entra ID (Azure AD) group membership analysis with recursive member enumeration and parent group discovery.
.DESCRIPTION
This script provides detailed analysis of Entra ID group memberships by recursively enumerating all members
of a specified group and discovering all parent groups the target group belongs to. It handles nested group
structures, prevents infinite loops through circular reference detection, and exports comprehensive reports.
Key Features:
• Recursive member enumeration with circular reference protection
• Parent group discovery and membership chain analysis
• Support for both group names and Group IDs as input
• Detailed member information including user principals and group types
• Dual CSV export: group members and parent group memberships
• Maximum recursion depth protection (50 levels)
• Comprehensive error handling and logging
.PARAMETER GroupId
The Group ID (GUID) or display name of the Entra ID group to analyze.
Supports both formats:
- Group ID: "12345678-1234-1234-1234-123456789012"
- Group Name: "Developer Team" or "# Developer ADM"
.EXAMPLE
.\GroupMemberships.ps1 -GroupId "12345678-1234-1234-1234-123456789012"
Analyzes the group with the specified GUID, recursively enumerating all members and parent groups.
.EXAMPLE
.\GroupMemberships.ps1 -GroupId "# Developer ADM"
Analyzes the group with display name "# Developer ADM", automatically resolving the name to Group ID.
.EXAMPLE
.\GroupMemberships.ps1 -GroupId "Domain Admins"
Analyzes the "Domain Admins" group, useful for security auditing of privileged groups.
.OUTPUTS
Two CSV files are generated:
1. "[timestamp] ([GroupName]) group members.csv" - Complete recursive member listing
2. "[timestamp] ([GroupName]) group memberships - parent groups.csv" - Parent group hierarchy
CSV Columns for Members:
- ParentGroupId: ID of the group containing this member
- ParentGroupName: Display name of the parent group
- ParentGroupType: Group type classification
- MemberId: Unique identifier of the member
- MemberType: Type of member (user, group, etc.)
- MemberName: Display name of the member
- MemberUPN: User Principal Name (for users)
- MemberEmail: Email address
- Level: Nesting level in the group hierarchy
- Path: Complete membership path showing nested relationships
CSV Columns for Parent Groups:
- ChildGroupId: ID of the child group
- ParentGroupId: ID of the parent group
- ParentGroupName: Display name of the parent group
- ParentGroupType: Group type classification
- ParentGroupEmail: Email address of the parent group
- MembershipLevel: Level in the parent hierarchy
.NOTES
File Name : GroupMemberships.ps1
Author : Cloud Engineering Team
Prerequisite : Microsoft Graph PowerShell SDK
Created : 2024
Updated : 2025-10-30
Version : 2.0
Required Permissions:
• Group.Read.All - Read group properties and memberships
• GroupMember.Read.All - Read group member details
• User.Read.All - Read user properties (for member details)
Security Considerations:
• Script requires privileged Graph API permissions
• Handles sensitive group membership data
• Implements circular reference protection
• Maximum recursion depth prevents infinite loops
• Comprehensive audit trail in CSV exports
Performance Notes:
• Large groups may take considerable time to process
• Recursive enumeration can be resource-intensive
• Implements caching to prevent duplicate API calls
• Progress indicators help track long-running operations
.LINK
https://docs.microsoft.com/en-us/graph/api/group-list-members
https://docs.microsoft.com/en-us/graph/api/group-list-memberof
.FUNCTIONALITY
• Entra ID group membership analysis
• Recursive member enumeration
• Parent group discovery
• Circular reference detection
• Comprehensive reporting
• Security auditing support
#>
param(
[Parameter(Mandatory=$true)]
[Parameter(Mandatory=$true, HelpMessage="Enter the Group ID (GUID) or display name of the Entra ID group to analyze")]
[ValidateNotNullOrEmpty()]
[string]$GroupId
)
# GroupMemberships.ps1 -GroupId "# Developer ADM"
# GroupMemberships.ps1 -GroupId "Domain Admins"
# GroupMemberships.ps1 -GroupId "# Developer"
# GroupMemberships.ps1 -GroupId "# Interne Automatisering Team-Assistent"
# GroupMemberships.ps1 -GroupId "# Interne Automatisering"
# Example usage patterns:
# .\GroupMemberships.ps1 -GroupId "# Developer ADM"
# .\GroupMemberships.ps1 -GroupId "Domain Admins"
# .\GroupMemberships.ps1 -GroupId "# Developer"
# .\GroupMemberships.ps1 -GroupId "# Interne Automatisering Team-Assistent"
# .\GroupMemberships.ps1 -GroupId "# Interne Automatisering"
#Requires -Modules Microsoft.Graph.Groups, Microsoft.Graph.Users
#Requires -Version 5.1
# Initialize script execution
Write-Host "🔍 Entra ID Group Membership Analyzer" -ForegroundColor Cyan
Write-Host "======================================" -ForegroundColor Cyan
Write-Host "📅 Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green
Write-Host "🎯 Target Group: $GroupId" -ForegroundColor Green
Write-Host ""
# Generate timestamped output file paths
[string] $date = Get-Date -Format "yyyy-MM-dd HHmm"
[string] $OutputPath = ".\$date ($GroupId) group members.csv"
[string] $OutputPath = ".\$date ($GroupId) group members.csv"
[string] $membershipOutputPath = ".\$date ($GroupId) group memberships - parent groups .csv"
# Connect to Microsoft Graph if not already connected
Write-Host "Connecting to Microsoft Graph..."
Connect-MgGraph -Scopes "Group.Read.All", "GroupMember.Read.All" -NoWelcome
# Initialize error tracking
$Global:ErrorCount = 0
$Global:WarningCount = 0
# If GroupId is actually a group name, resolve it to the actual GroupId
try {
# Connect to Microsoft Graph with required permissions
Write-Host "🔐 Establishing Microsoft Graph connection..." -ForegroundColor Yellow
$requiredScopes = @("Group.Read.All", "GroupMember.Read.All", "User.Read.All")
# Check if already connected with required scopes
$currentContext = Get-MgContext
if ($currentContext -and $currentContext.Scopes) {
$missingScopes = $requiredScopes | Where-Object { $_ -notin $currentContext.Scopes }
if ($missingScopes.Count -gt 0) {
Write-Warning "Missing required scopes: $($missingScopes -join ', '). Reconnecting..."
$Global:WarningCount++
Disconnect-MgGraph -ErrorAction SilentlyContinue
$currentContext = $null
}
}
if (-not $currentContext) {
Connect-MgGraph -Scopes $requiredScopes -NoWelcome -ErrorAction Stop
Write-Host "✅ Successfully connected to Microsoft Graph" -ForegroundColor Green
} else {
Write-Host "✅ Using existing Microsoft Graph connection" -ForegroundColor Green
}
# Display connection context
$context = Get-MgContext
Write-Host "🏢 Tenant: $($context.TenantId)" -ForegroundColor Gray
Write-Host "👤 Account: $($context.Account)" -ForegroundColor Gray
Write-Host "🔑 Scopes: $($context.Scopes -join ', ')" -ForegroundColor Gray
Write-Host ""
}
catch {
Write-Error "❌ Failed to connect to Microsoft Graph: $($_.Exception.Message)"
$Global:ErrorCount++
exit 1
}
# Resolve group identifier (support both Group ID and display name)
if ($GroupId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') {
Write-Host "Resolving group name '$GroupId' to GroupId..."
Write-Host "🔍 Resolving group name '$GroupId' to Group ID..." -ForegroundColor Yellow
try {
# Use filter to find groups by display name
$group = Get-MgGroup -Filter "displayName eq '$GroupId'" -ErrorAction Stop
if ($group) {
if ($group.Count -gt 1) {
Write-Warning "Multiple groups found with name '$GroupId'. Using the first one."
Write-Warning "⚠️ Multiple groups found with name '$GroupId'. Using the first match."
$Global:WarningCount++
Write-Host " Found groups:" -ForegroundColor Yellow
$group | ForEach-Object { Write-Host " - $($_.DisplayName) ($($_.Id))" -ForegroundColor Yellow }
$GroupId = $group[0].Id
} else {
$GroupId = $group.Id
}
Write-Host "Resolved to GroupId: $GroupId"
Write-Host "Resolved to Group ID: $GroupId" -ForegroundColor Green
} else {
Write-Error "Group with name '$GroupId' not found."
Write-Error "Group with name '$GroupId' not found in tenant."
$Global:ErrorCount++
exit 1
}
} catch {
Write-Error "Error resolving group name: $($_.Exception.Message)"
Write-Error "Error resolving group name: $($_.Exception.Message)"
$Global:ErrorCount++
exit 1
}
} else {
Write-Host "🆔 Using provided Group ID: $GroupId" -ForegroundColor Green
}
# Function to get groups that this group is a member of (reverse membership)
# Validate the resolved group exists and get basic information
try {
Write-Host "🔍 Validating target group..." -ForegroundColor Yellow
$targetGroup = Get-MgGroup -GroupId $GroupId -Select "Id,DisplayName,Description,GroupTypes,Mail,SecurityEnabled,MailEnabled" -ErrorAction Stop
Write-Host "✅ Target group validated:" -ForegroundColor Green
Write-Host " 📝 Name: $($targetGroup.DisplayName)" -ForegroundColor White
Write-Host " 🆔 ID: $($targetGroup.Id)" -ForegroundColor White
Write-Host " 📧 Email: $($targetGroup.Mail ?? 'N/A')" -ForegroundColor White
Write-Host " 🏷️ Type: $($targetGroup.GroupTypes -join ', ' ?? 'Security Group')" -ForegroundColor White
Write-Host " 🔒 Security Enabled: $($targetGroup.SecurityEnabled)" -ForegroundColor White
Write-Host " 📨 Mail Enabled: $($targetGroup.MailEnabled)" -ForegroundColor White
if ($targetGroup.Description) {
Write-Host " 📄 Description: $($targetGroup.Description)" -ForegroundColor White
}
Write-Host ""
}
catch {
Write-Error "❌ Failed to validate group '$GroupId': $($_.Exception.Message)"
$Global:ErrorCount++
exit 1
}
# Function to get groups that this group is a member of (reverse membership discovery)
function Get-GroupMembershipRecursive {
<#
.SYNOPSIS
Recursively discovers all parent groups that the specified group belongs to.
.DESCRIPTION
This function performs reverse membership analysis by finding all groups that contain
the specified group as a member. It handles nested group structures and prevents
infinite loops through circular reference detection.
.PARAMETER GroupId
The Group ID to analyze for parent group memberships.
.PARAMETER Level
Current recursion level (used internally for depth tracking).
.PARAMETER ProcessedMemberships
Hashtable tracking processed groups to prevent circular references.
.RETURNS
Array of custom objects representing parent group relationships.
#>
param(
[Parameter(Mandatory=$true)]
[string]$GroupId,
[Parameter(Mandatory=$false)]
[int]$Level = 0,
[Parameter(Mandatory=$false)]
[hashtable]$ProcessedMemberships = @{}
)
# Check for circular reference in membership chain
# Circular reference protection
if ($ProcessedMemberships.ContainsKey($GroupId)) {
Write-Warning "Circular membership reference detected for group: $GroupId"
Write-Warning "⚠️ Circular membership reference detected for group: $GroupId (Level: $Level)"
$Global:WarningCount++
return @()
}
# Check for maximum depth
# Maximum recursion depth protection
if ($Level -gt 50) {
Write-Warning "Maximum membership recursion depth reached for group: $GroupId"
Write-Warning "⚠️ Maximum membership recursion depth (50) reached for group: $GroupId"
$Global:WarningCount++
return @()
}
# Mark group as being processed for membership
# Mark group as being processed for membership discovery
$ProcessedMemberships[$GroupId] = $true
$membershipResults = @()
try {
Write-Verbose "Discovering parent groups for GroupId: $GroupId (Level: $Level)"
# Get groups that this group is a member of
$memberOfGroups = Get-MgGroupMemberOf -GroupId $GroupId -All
$memberOfGroups = Get-MgGroupMemberOf -GroupId $GroupId -All -ErrorAction Stop
foreach ($parentGroup in $memberOfGroups) {
# Only process actual groups (not other object types)
if ($parentGroup.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
$parentGroupDetails = Get-MgGroup -GroupId $parentGroup.Id -Select "DisplayName,Mail,GroupTypes"
$membershipObject = [PSCustomObject]@{
ChildGroupId = $GroupId
ParentGroupId = $parentGroup.Id
ParentGroupName = $parentGroupDetails.DisplayName
ParentGroupType = $parentGroupDetails.GroupTypes -join ","
ParentGroupEmail = $parentGroupDetails.Mail
MembershipLevel = $Level
try {
# Get detailed information about the parent group
$parentGroupDetails = Get-MgGroup -GroupId $parentGroup.Id -Select "DisplayName,Mail,GroupTypes,SecurityEnabled,MailEnabled" -ErrorAction Stop
# Create membership relationship object
$membershipObject = [PSCustomObject]@{
ChildGroupId = $GroupId
ParentGroupId = $parentGroup.Id
ParentGroupName = $parentGroupDetails.DisplayName
ParentGroupType = if ($parentGroupDetails.GroupTypes) { $parentGroupDetails.GroupTypes -join "," } else { "Security" }
ParentGroupEmail = $parentGroupDetails.Mail ?? ""
MembershipLevel = $Level
SecurityEnabled = $parentGroupDetails.SecurityEnabled
MailEnabled = $parentGroupDetails.MailEnabled
DiscoveredAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
}
$membershipResults += $membershipObject
Write-Verbose "Found parent group: $($parentGroupDetails.DisplayName) at level $Level"
# Recursively discover parent groups of this parent group
if ($parentGroup.Id -ne $GroupId) {
$nestedMemberships = Get-GroupMembershipRecursive -GroupId $parentGroup.Id -Level ($Level + 1) -ProcessedMemberships $ProcessedMemberships
$membershipResults += $nestedMemberships
}
}
$membershipResults += $membershipObject
# Recursively get parent groups of this parent group
if ($parentGroup.Id -ne $GroupId) {
$nestedMemberships = Get-GroupMembershipRecursive -GroupId $parentGroup.Id -Level ($Level + 1) -ProcessedMemberships $ProcessedMemberships
$membershipResults += $nestedMemberships
catch {
Write-Warning "⚠️ Failed to get details for parent group $($parentGroup.Id): $($_.Exception.Message)"
$Global:WarningCount++
}
}
}
}
catch {
Write-Error "Error getting group memberships for $GroupId`: $($_.Exception.Message)"
Write-Error "Error getting group memberships for $GroupId`: $($_.Exception.Message)"
$Global:ErrorCount++
}
finally {
# Remove from processed memberships when done
# Clean up: remove from processed memberships when done
$ProcessedMemberships.Remove($GroupId)
}
@@ -103,123 +331,322 @@ function Get-GroupMembershipRecursive {
}
# Initialize collections
$groupMembers = @()
$processedGroups = @{}
$groupStack = @()
# Initialize script-level collections for member enumeration
$script:groupMembers = @()
$script:processedGroups = @{}
$script:groupStack = @()
$script:memberCount = 0
$script:groupCount = 0
# Function to get group members recursively
# This function will handle circular references and maximum recursion depth
# Function to recursively enumerate all group members
function Get-GroupMembersRecursive {
<#
.SYNOPSIS
Recursively enumerates all members of a group, including nested group members.
.DESCRIPTION
This function performs deep enumeration of group membership by recursively processing
nested groups. It includes comprehensive protection against circular references and
infinite recursion, while providing detailed member information.
.PARAMETER GroupId
The Group ID to enumerate members for.
.PARAMETER Level
Current recursion level (used internally for depth tracking).
.PARAMETER ParentGroupName
Name of the parent group (used for audit trail).
.NOTES
Uses script-level variables to maintain state across recursive calls:
- $script:groupMembers: Collection of all discovered members
- $script:processedGroups: Tracks processed groups for circular reference protection
- $script:groupStack: Current processing stack for immediate loop detection
#>
param(
[Parameter(Mandatory=$true)]
[string]$GroupId,
[Parameter(Mandatory=$false)]
[int]$Level = 0,
[Parameter(Mandatory=$false)]
[string]$ParentGroupName = ""
)
# Check for circular reference
if ($processedGroups.ContainsKey($GroupId)) {
Write-Warning "Circular reference detected for group: $GroupId"
# Circular reference protection
if ($script:processedGroups.ContainsKey($GroupId)) {
Write-Warning "⚠️ Circular reference detected for group: $GroupId (Level: $Level)"
$Global:WarningCount++
return
}
# Check for stack overflow (max depth)
# Maximum recursion depth protection
if ($Level -gt 50) {
Write-Warning "Maximum recursion depth reached for group: $GroupId"
Write-Warning "⚠️ Maximum recursion depth (50) reached for group: $GroupId"
$Global:WarningCount++
return
}
# Mark group as being processed
$processedGroups[$GroupId] = $true
$groupStack += $GroupId
# Mark group as being processed and add to processing stack
$script:processedGroups[$GroupId] = $true
$script:groupStack += $GroupId
$script:groupCount++
try {
# Get the group information
$group = Get-MgGroup -GroupId $GroupId -ErrorAction Stop
# Get the group information with required properties
$group = Get-MgGroup -GroupId $GroupId -Select "Id,DisplayName,GroupTypes,Mail,SecurityEnabled,MailEnabled" -ErrorAction Stop
Write-Verbose "Processing group: $($group.DisplayName) (Level: $Level)"
# Get group members
$members = Get-MgGroupMember -GroupId $GroupId -All
# Get all group members
$members = Get-MgGroupMember -GroupId $GroupId -All -ErrorAction Stop
Write-Host " 📋 Processing $($members.Count) members in group: $($group.DisplayName) (Level: $Level)" -ForegroundColor Gray
foreach ($member in $members) {
# Create custom object for the result
$script:memberCount++
# Create comprehensive member object
$memberObject = [PSCustomObject]@{
ParentGroupId = $GroupId
ParentGroupName = $group.DisplayName
ParentGroupType = $group.GroupTypes -join ","
ParentGroupType = if ($group.GroupTypes) { $group.GroupTypes -join "," } else { "Security" }
ParentGroupEmail = $group.Mail ?? ""
MemberId = $member.Id
MemberType = $member.AdditionalProperties['@odata.type'] -replace '#microsoft.graph.', ''
Level = $Level
Path = ($groupStack -join " -> ") + " -> " + $member.Id
Path = ($script:groupStack -join " -> ") + " -> " + $member.Id
ProcessedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
SecurityEnabled = $group.SecurityEnabled
MailEnabled = $group.MailEnabled
}
# Get member details based on type
switch ($member.AdditionalProperties['@odata.type']) {
'#microsoft.graph.user' {
$user = Get-MgUser -UserId $member.Id -Select "DisplayName,UserPrincipalName,Mail"
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $user.DisplayName
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue $user.UserPrincipalName
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue $user.Mail
}
'#microsoft.graph.group' {
$memberGroup = Get-MgGroup -GroupId $member.Id -Select "DisplayName,Mail,GroupTypes"
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $memberGroup.DisplayName
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue $memberGroup.Mail
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ($memberGroup.GroupTypes -join ",")
}
default {
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Unknown"
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
# Get detailed member information based on object type
try {
switch ($member.AdditionalProperties['@odata.type']) {
'#microsoft.graph.user' {
$user = Get-MgUser -UserId $member.Id -Select "DisplayName,UserPrincipalName,Mail,AccountEnabled,UserType" -ErrorAction Stop
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $user.DisplayName
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue $user.UserPrincipalName
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ($user.Mail ?? "")
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $user.AccountEnabled
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue ($user.UserType ?? "Member")
}
'#microsoft.graph.group' {
$memberGroup = Get-MgGroup -GroupId $member.Id -Select "DisplayName,Mail,GroupTypes,SecurityEnabled,MailEnabled" -ErrorAction Stop
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $memberGroup.DisplayName
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ($memberGroup.Mail ?? "")
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue (if ($memberGroup.GroupTypes) { $memberGroup.GroupTypes -join "," } else { "Security" })
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $memberGroup.SecurityEnabled
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Group"
}
'#microsoft.graph.servicePrincipal' {
$servicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $member.Id -Select "DisplayName,AppId,ServicePrincipalType" -ErrorAction Stop
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue $servicePrincipal.DisplayName
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $true
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "ServicePrincipal"
}
default {
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Unknown Object Type"
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $null
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Unknown"
}
}
}
catch {
Write-Warning "⚠️ Failed to get details for member $($member.Id): $($_.Exception.Message)"
$Global:WarningCount++
$memberObject | Add-Member -NotePropertyName "MemberName" -NotePropertyValue "Error retrieving details"
$memberObject | Add-Member -NotePropertyName "MemberUPN" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberEmail" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "MemberGroupTypes" -NotePropertyValue ""
$memberObject | Add-Member -NotePropertyName "AccountEnabled" -NotePropertyValue $null
$memberObject | Add-Member -NotePropertyName "UserType" -NotePropertyValue "Error"
}
# Add member to results collection
$script:groupMembers += $memberObject
# If member is a group, recurse into it
# If member is a group, recursively process its members
if ($member.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') {
# Check if this group is already in the current path to prevent immediate loops
if ($member.Id -notin $groupStack) {
# Check if this group is already in the current processing path to prevent immediate loops
if ($member.Id -notin $script:groupStack) {
Write-Verbose "Recursing into nested group: $($memberObject.MemberName)"
Get-GroupMembersRecursive -GroupId $member.Id -Level ($Level + 1) -ParentGroupName $group.DisplayName
} else {
Write-Warning "Immediate circular reference detected. Skipping group: $($memberGroup.DisplayName)"
Write-Warning "⚠️ Immediate circular reference detected. Skipping group: $($memberObject.MemberName)"
$Global:WarningCount++
}
}
}
}
catch {
Write-Error "Error processing group $GroupId`: $($_.Exception.Message)"
Write-Error "Error processing group $GroupId`: $($_.Exception.Message)"
$Global:ErrorCount++
}
finally {
# Remove from stack and processed groups when done
$groupStack = $groupStack[0..($groupStack.Length-2)]
$processedGroups.Remove($GroupId)
# Clean up: remove from processing stack and processed groups when done
if ($script:groupStack.Length -gt 0) {
$script:groupStack = $script:groupStack[0..($script:groupStack.Length-2)]
}
$script:processedGroups.Remove($GroupId)
}
}
# Start the recursive process
Write-Host "Starting recursive group membership scan for group: $GroupId"
Get-GroupMembersRecursive -GroupId $GroupId
# Execute recursive member enumeration
Write-Host "🔍 Starting recursive group membership analysis..." -ForegroundColor Cyan
Write-Host "📊 Progress will be shown for each processed group..." -ForegroundColor Gray
Write-Host ""
# Export results to CSV
if ($groupMembers.Count -gt 0) {
$groupMembers | Export-Csv -Path $OutputPath -NoTypeInformation
Write-Host "Results exported to: $OutputPath"
Write-Host "Total members found: $($groupMembers.Count)"
} else {
Write-Host "No members found in the specified group."
$startTime = Get-Date
try {
Get-GroupMembersRecursive -GroupId $GroupId
$processingTime = (Get-Date) - $startTime
Write-Host ""
Write-Host "✅ Member enumeration completed!" -ForegroundColor Green
Write-Host "⏱️ Processing time: $($processingTime.TotalSeconds.ToString('F2')) seconds" -ForegroundColor Gray
Write-Host "📈 Performance metrics:" -ForegroundColor Gray
Write-Host " • Groups processed: $script:groupCount" -ForegroundColor Gray
Write-Host " • Members discovered: $script:memberCount" -ForegroundColor Gray
Write-Host " • Processing rate: $([math]::Round($script:memberCount / $processingTime.TotalSeconds, 2)) members/second" -ForegroundColor Gray
}
catch {
Write-Error "❌ Fatal error during member enumeration: $($_.Exception.Message)"
$Global:ErrorCount++
}
# Get group memberships (groups this group belongs to)
Write-Host "Getting group memberships for group: $GroupId"
$groupMemberships = Get-GroupMembershipRecursive -GroupId $GroupId
# Export member results to CSV with error handling
Write-Host ""
Write-Host "📄 Exporting member analysis results..." -ForegroundColor Yellow
if ($script:groupMembers.Count -gt 0) {
try {
$script:groupMembers | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
Write-Host "✅ Member results exported successfully!" -ForegroundColor Green
Write-Host " 📁 File: $OutputPath" -ForegroundColor White
Write-Host " 📊 Total members: $($script:groupMembers.Count)" -ForegroundColor White
# Provide member type breakdown
$memberTypes = $script:groupMembers | Group-Object MemberType
Write-Host " 📋 Member types:" -ForegroundColor White
foreach ($type in $memberTypes) {
Write-Host "$($type.Name): $($type.Count)" -ForegroundColor Gray
}
# Show nesting level statistics
$levels = $script:groupMembers | Group-Object Level
Write-Host " 🏗️ Nesting levels:" -ForegroundColor White
foreach ($level in ($levels | Sort-Object Name)) {
Write-Host " • Level $($level.Name): $($level.Count) members" -ForegroundColor Gray
}
}
catch {
Write-Error "❌ Failed to export member results: $($_.Exception.Message)"
$Global:ErrorCount++
}
} else {
Write-Host " No members found in the specified group or its nested groups." -ForegroundColor Yellow
}
# Execute parent group membership discovery
Write-Host ""
Write-Host "🔍 Discovering parent group memberships..." -ForegroundColor Cyan
$membershipStartTime = Get-Date
try {
$groupMemberships = Get-GroupMembershipRecursive -GroupId $GroupId
$membershipProcessingTime = (Get-Date) - $membershipStartTime
Write-Host "✅ Parent group discovery completed!" -ForegroundColor Green
Write-Host "⏱️ Processing time: $($membershipProcessingTime.TotalSeconds.ToString('F2')) seconds" -ForegroundColor Gray
}
catch {
Write-Error "❌ Error during parent group discovery: $($_.Exception.Message)"
$Global:ErrorCount++
$groupMemberships = @()
}
# Export parent group membership results with error handling
Write-Host ""
Write-Host "📄 Exporting parent group membership results..." -ForegroundColor Yellow
# Export group memberships to separate CSV if any found
if ($groupMemberships.Count -gt 0) {
$groupMemberships | Export-Csv -Path $membershipOutputPath -NoTypeInformation
Write-Host "Group memberships exported to: $membershipOutputPath"
Write-Host "Total parent groups found: $($groupMemberships.Count)"
try {
$groupMemberships | Export-Csv -Path $membershipOutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
Write-Host "✅ Parent group memberships exported successfully!" -ForegroundColor Green
Write-Host " 📁 File: $membershipOutputPath" -ForegroundColor White
Write-Host " 📊 Total parent groups: $($groupMemberships.Count)" -ForegroundColor White
# Show membership level breakdown
$membershipLevels = $groupMemberships | Group-Object MembershipLevel
Write-Host " 🏗️ Membership levels:" -ForegroundColor White
foreach ($level in ($membershipLevels | Sort-Object Name)) {
Write-Host " • Level $($level.Name): $($level.Count) parent groups" -ForegroundColor Gray
}
# Show parent group types
$parentGroupTypes = $groupMemberships | Group-Object ParentGroupType
Write-Host " 📋 Parent group types:" -ForegroundColor White
foreach ($type in $parentGroupTypes) {
Write-Host "$($type.Name): $($type.Count) groups" -ForegroundColor Gray
}
}
catch {
Write-Error "❌ Failed to export parent group memberships: $($_.Exception.Message)"
$Global:ErrorCount++
}
} else {
Write-Host "No group memberships found for the specified group."
Write-Host " Target group is not a member of any other groups." -ForegroundColor Yellow
}
# Generate comprehensive execution summary
Write-Host ""
Write-Host "📊 EXECUTION SUMMARY" -ForegroundColor Cyan
Write-Host "=====================" -ForegroundColor Cyan
$totalTime = (Get-Date) - $startTime
Write-Host "🎯 Target Group: $($targetGroup.DisplayName)" -ForegroundColor White
Write-Host "⏱️ Total Execution Time: $($totalTime.TotalMinutes.ToString('F2')) minutes" -ForegroundColor White
Write-Host "📈 Performance Metrics:" -ForegroundColor White
Write-Host " • Groups Processed: $script:groupCount" -ForegroundColor Gray
Write-Host " • Total Members Found: $script:memberCount" -ForegroundColor Gray
Write-Host " • Parent Groups Found: $($groupMemberships.Count)" -ForegroundColor Gray
Write-Host " • Processing Rate: $([math]::Round($script:memberCount / $totalTime.TotalSeconds, 2)) members/second" -ForegroundColor Gray
# Display error and warning summary
if ($Global:ErrorCount -gt 0 -or $Global:WarningCount -gt 0) {
Write-Host ""
Write-Host "⚠️ ISSUES ENCOUNTERED:" -ForegroundColor Yellow
if ($Global:ErrorCount -gt 0) {
Write-Host " ❌ Errors: $Global:ErrorCount" -ForegroundColor Red
}
if ($Global:WarningCount -gt 0) {
Write-Host " ⚠️ Warnings: $Global:WarningCount" -ForegroundColor Yellow
}
Write-Host " 💡 Review output above for details" -ForegroundColor Cyan
} else {
Write-Host "✅ No errors or warnings encountered!" -ForegroundColor Green
}
# Display output file locations
Write-Host ""
Write-Host "📁 OUTPUT FILES:" -ForegroundColor Cyan
if ($script:groupMembers.Count -gt 0) {
Write-Host " 📄 Member Analysis: $OutputPath" -ForegroundColor White
}
if ($groupMemberships.Count -gt 0) {
Write-Host " 📄 Parent Groups: $membershipOutputPath" -ForegroundColor White
}
Write-Host ""
Write-Host "✅ Group membership analysis completed successfully!" -ForegroundColor Green
Write-Host "📅 Finished: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Green