mirror of
https://dev.azure.com/effectory/Survey%20Software/_git/Cloud%20Engineering
synced 2026-02-27 18:52:18 +01:00
added documetation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user