<# .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, HelpMessage="Enter the Group ID (GUID) or display name of the Entra ID group to analyze")] [ValidateNotNullOrEmpty()] [string]$GroupId ) # 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] $membershipOutputPath = ".\$date ($GroupId) group memberships - parent groups .csv" # Initialize error tracking $Global:ErrorCount = 0 $Global:WarningCount = 0 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 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 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 Group ID: $GroupId" -ForegroundColor Green } else { Write-Error "❌ Group with name '$GroupId' not found in tenant." $Global:ErrorCount++ exit 1 } } catch { Write-Error "❌ Error resolving group name: $($_.Exception.Message)" $Global:ErrorCount++ exit 1 } } else { Write-Host "🆔 Using provided Group ID: $GroupId" -ForegroundColor Green } # 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 = @{} ) # Circular reference protection if ($ProcessedMemberships.ContainsKey($GroupId)) { Write-Warning "⚠️ Circular membership reference detected for group: $GroupId (Level: $Level)" $Global:WarningCount++ return @() } # Maximum recursion depth protection if ($Level -gt 50) { Write-Warning "⚠️ Maximum membership recursion depth (50) reached for group: $GroupId" $Global:WarningCount++ return @() } # 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 -ErrorAction Stop foreach ($parentGroup in $memberOfGroups) { # Only process actual groups (not other object types) if ($parentGroup.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') { 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 } } 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)" $Global:ErrorCount++ } finally { # Clean up: remove from processed memberships when done $ProcessedMemberships.Remove($GroupId) } return $membershipResults } # Initialize script-level collections for member enumeration $script:groupMembers = @() $script:processedGroups = @{} $script:groupStack = @() $script:memberCount = 0 $script:groupCount = 0 # 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 = "" ) # Circular reference protection if ($script:processedGroups.ContainsKey($GroupId)) { Write-Warning "⚠️ Circular reference detected for group: $GroupId (Level: $Level)" $Global:WarningCount++ return } # Maximum recursion depth protection if ($Level -gt 50) { Write-Warning "⚠️ Maximum recursion depth (50) reached for group: $GroupId" $Global:WarningCount++ return } # Mark group as being processed and add to processing stack $script:processedGroups[$GroupId] = $true $script:groupStack += $GroupId $script:groupCount++ try { # 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 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) { $script:memberCount++ # Create comprehensive member object $memberObject = [PSCustomObject]@{ ParentGroupId = $GroupId ParentGroupName = $group.DisplayName 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 = ($script:groupStack -join " -> ") + " -> " + $member.Id ProcessedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" SecurityEnabled = $group.SecurityEnabled MailEnabled = $group.MailEnabled } # 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, recursively process its members if ($member.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.group') { # 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: $($memberObject.MemberName)" $Global:WarningCount++ } } } } catch { Write-Error "❌ Error processing group $GroupId`: $($_.Exception.Message)" $Global:ErrorCount++ } finally { # 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) } } # 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 "" $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++ } # 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 if ($groupMemberships.Count -gt 0) { 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 "ℹ️ 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