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

View File

@@ -1,15 +1,98 @@
Import-Module Microsoft.Graph
<#
.SYNOPSIS
Retrieves user login information from an Entra ID (Azure AD) group with recursive nested group support.
# .\UserLastLoginList.ps1 -GroupName "# Developer ADM"
.DESCRIPTION
This script connects to Microsoft Graph and retrieves all users from a specified Entra ID group,
including users in nested groups. For each user, it displays their display name, user principal name,
and last sign-in date/time information.
Features:
• Recursive group membership enumeration (handles nested groups)
• Circular reference protection to prevent infinite loops
• Last sign-in activity tracking for security and compliance
• Comprehensive error handling and diagnostic output
• Automatic Microsoft Graph authentication with required scopes
.PARAMETER GroupName
The display name of the Entra ID group to analyze. This parameter is mandatory.
The script will search for an exact match of the group display name.
.EXAMPLE
.\UserLastLoginList.ps1 -GroupName "# Developer ADM"
Retrieves all users from the "# Developer ADM" group, including any nested group memberships.
.EXAMPLE
.\UserLastLoginList.ps1 -GroupName "IT Security Team" -Debug
Retrieves users with detailed debug output showing group processing and user enumeration steps.
.INPUTS
None. You cannot pipe objects to this script.
.OUTPUTS
System.Management.Automation.PSCustomObject
Returns a formatted table with the following properties for each user:
- UserPrincipalName: The user's UPN (email-like identifier)
- DisplayName: The user's display name
- LastSignInDateTime: The user's last sign-in date/time, or "No sign-in data available" if unavailable
.NOTES
Requires PowerShell 5.1 or later
Requires Microsoft.Graph PowerShell module
Required Microsoft Graph Permissions:
- User.Read.All: To read user profiles and sign-in activity
- Group.Read.All: To read group memberships and nested groups
The script will automatically prompt for authentication if not already connected to Microsoft Graph.
Sign-in activity data may not be available for all users depending on license and retention policies.
.LINK
https://docs.microsoft.com/en-us/graph/api/user-get
https://docs.microsoft.com/en-us/graph/api/group-list-members
.COMPONENT
Microsoft Graph PowerShell SDK
.ROLE
Identity and Access Management, Security Reporting
.FUNCTIONALITY
Entra ID group analysis, user activity reporting, nested group enumeration
#>
param(
[Parameter(Mandatory = $true)]
[Parameter(
Mandatory = $true,
HelpMessage = "Enter the display name of the Entra ID group to analyze"
)]
[ValidateNotNullOrEmpty()]
[string]$GroupName
)
<#
.SYNOPSIS
Retrieves an Entra ID group by its display name.
.DESCRIPTION
This function searches for an Entra ID group using its display name and returns the group's unique identifier.
Uses Microsoft Graph API to perform an exact match search on the displayName property.
.PARAMETER EntraGroupName
The exact display name of the Entra ID group to search for.
.OUTPUTS
System.String
Returns the group's unique identifier (GUID) if found, or $null if not found or error occurs.
.EXAMPLE
$groupId = Get-EntraGroupByName -EntraGroupName "Developers"
Retrieves the group ID for the "Developers" group.
#>
function Get-EntraGroupByName {
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$EntraGroupName
)
@@ -33,18 +116,54 @@ function Get-EntraGroupByName {
}
}
# Initialize Microsoft Graph connection
Write-Host "🔐 Initializing Microsoft Graph connection..." -ForegroundColor Cyan
Write-Debug "Connecting to Microsoft Graph..."
# Connect to Microsoft Graph if not already connected
if (-not (Get-MgContext)) {
Write-Host "🔑 Authenticating to Microsoft Graph with required scopes..." -ForegroundColor Yellow
Connect-MgGraph -Scopes "User.Read.All", "Group.Read.All"
Write-Host "✅ Successfully connected to Microsoft Graph" -ForegroundColor Green
} else {
Write-Host "✅ Already connected to Microsoft Graph" -ForegroundColor Green
}
<#
.SYNOPSIS
Recursively retrieves all users from an Entra ID group, including nested groups.
.DESCRIPTION
This function performs a recursive search through an Entra ID group structure to find all users,
including those in nested groups. It includes circular reference protection to prevent infinite
loops when groups contain circular memberships.
.PARAMETER GroupId
The unique identifier (GUID) of the Entra ID group to process.
.PARAMETER ProcessedGroups
Internal hashtable used to track processed groups and prevent circular reference loops.
This parameter is used internally for recursion and should not be specified by callers.
.OUTPUTS
System.Management.Automation.PSCustomObject[]
Returns an array of custom objects with the following properties:
- UserPrincipalName: The user's UPN
- DisplayName: The user's display name
- LastSignInDateTime: The user's last sign-in date/time or status message
.EXAMPLE
$users = Get-EntraGroupUsersRecursive -GroupId "12345678-1234-1234-1234-123456789012"
Recursively retrieves all users from the specified group and its nested groups.
.NOTES
This function is designed to handle complex group hierarchies and prevents infinite recursion
through circular group memberships. It processes both user and group objects within the membership.
#>
function Get-EntraGroupUsersRecursive {
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$GroupId,
[hashtable]$ProcessedGroups = @{}
)
@@ -88,20 +207,44 @@ function Get-EntraGroupUsersRecursive {
}
}
$groupId = Get-EntraGroupByName -EntraGroupName $GroupName
if ($groupId) {
# Main execution logic
Write-Host ""
Write-Host "🔍 Searching for Entra ID group: '$GroupName'" -ForegroundColor Cyan
$groupId = Get-EntraGroupByName -EntraGroupName $GroupName
if ($groupId) {
Write-Host "✅ Group found! Group ID: $groupId" -ForegroundColor Green
Write-Host ""
# Get users recursively from the group
Write-Host "Getting users recursively from group..."
Write-Host "👥 Retrieving users recursively from group (including nested groups)..." -ForegroundColor Cyan
$recursiveUsers = Get-EntraGroupUsersRecursive -GroupId $groupId
if ($recursiveUsers) {
Write-Host "Found $($recursiveUsers.Count) users (including nested groups):"
Write-Host ""
Write-Host "📊 Analysis Results:" -ForegroundColor Green
Write-Host "===================" -ForegroundColor Green
Write-Host "Found $($recursiveUsers.Count) users (including nested groups)" -ForegroundColor White
Write-Host ""
# Display results sorted by display name
$recursiveUsers | Sort-Object DisplayName | Format-Table -AutoSize
# Additional statistics
$usersWithSignInData = ($recursiveUsers | Where-Object { $_.LastSignInDateTime -ne "No sign-in data available" }).Count
$usersWithoutSignInData = $recursiveUsers.Count - $usersWithSignInData
Write-Host ""
Write-Host "📈 Sign-in Data Summary:" -ForegroundColor Yellow
Write-Host "========================" -ForegroundColor Yellow
Write-Host "Users with sign-in data: $usersWithSignInData" -ForegroundColor White
Write-Host "Users without sign-in data: $usersWithoutSignInData" -ForegroundColor White
}
else {
Write-Warning "No users found in the group hierarchy or an error occurred."
Write-Warning "No users found in the group hierarchy or an error occurred."
}
}
else {
Write-Warning "Group not found."
Write-Warning "Group '$GroupName' not found. Please verify the group name and try again."
}