mirror of
https://dev.azure.com/effectory/Survey%20Software/_git/Cloud%20Engineering
synced 2026-02-27 10:45:02 +01:00
653 lines
30 KiB
PowerShell
653 lines
30 KiB
PowerShell
<#
|
||
.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
|
||
|