Private/Groups.ps1
|
function Get-M365SnapshotGroups { param( [Parameter(Mandatory=$true)] [hashtable]$GraphHeaders, [Parameter(Mandatory=$true)] [int]$EffectiveMaxGroups, [Parameter(Mandatory=$true)] [switch]$LoadAllGroups, [Parameter(Mandatory=$true)] [int]$MaxGroups, [Parameter(Mandatory=$true)] [switch]$ReturnObjects ) $groups = @() function Invoke-GraphRequestWithRetry { param( [Parameter(Mandatory=$true)] [string]$Uri, [Parameter(Mandatory=$true)] [hashtable]$Headers, [Parameter(Mandatory=$true)] [ValidateSet('GET','POST')] [string]$Method, [Parameter(Mandatory=$false)] [string]$Body, [Parameter(Mandatory=$false)] [int]$MaxRetries = 3, [Parameter(Mandatory=$false)] [int]$InitialDelaySeconds = 2, [Parameter(Mandatory=$false)] [string]$OperationName = 'Graph request' ) $attempt = 0 while ($true) { try { if ($Method -eq 'POST') { return Invoke-RestMethod -Uri $Uri -Headers $Headers -Method Post -Body $Body -ErrorAction Stop } return Invoke-RestMethod -Uri $Uri -Headers $Headers -Method Get -ErrorAction Stop } catch { $attempt++ $statusCode = $null try { if ($null -ne $_.Exception.Response -and $null -ne $_.Exception.Response.StatusCode) { $statusCode = [int]$_.Exception.Response.StatusCode } } catch {} $message = [string]$_.Exception.Message $isTransient = ($statusCode -in @(429,500,502,503,504)) -or ($message -match '(?i)internal server error|temporar|timeout|gateway|service unavailable|too many requests') if ((-not $isTransient) -or ($attempt -gt $MaxRetries)) { throw } $delaySeconds = [int][Math]::Min(30, ($InitialDelaySeconds * [Math]::Pow(2, ($attempt - 1)))) if (-not $ReturnObjects) { Write-Host " [INFO] $OperationName failed (attempt $attempt/$($MaxRetries + 1)): $message" -ForegroundColor DarkGray Write-Host " [INFO] Retrying in $delaySeconds second(s)..." -ForegroundColor DarkGray } Start-Sleep -Seconds $delaySeconds } } } try { $groupQueryProfiles = @( @{ Name = 'safe-default'; Select = 'id,displayName,mail,mailEnabled,securityEnabled,groupTypes,visibility,classification' }, @{ Name = 'core'; Select = 'id,displayName,mail,mailEnabled,securityEnabled,groupTypes,visibility' }, @{ Name = 'minimal'; Select = 'id,displayName,mail,mailEnabled,securityEnabled,groupTypes' }, @{ Name = 'default'; Select = $null }, @{ Name = 'full'; Select = 'id,displayName,mail,mailEnabled,securityEnabled,groupTypes,visibility,classification,assignedLabels' } ) $allGroups = @() $response = $null $uri = $null $selectedProfileName = '' $lastProfileError = $null foreach ($profile in $groupQueryProfiles) { $probeUri = if ([string]::IsNullOrWhiteSpace([string]$profile.Select)) { "https://graph.microsoft.com/v1.0/groups?`$top=999" } else { "https://graph.microsoft.com/v1.0/groups?`$select=$($profile.Select)&`$top=999" } try { $response = Invoke-GraphRequestWithRetry -Uri $probeUri ` -Headers $GraphHeaders ` -Method 'GET' ` -MaxRetries 2 ` -InitialDelaySeconds 2 ` -OperationName "Entra groups probe ($($profile.Name))" $selectedProfileName = [string]$profile.Name break } catch { $lastProfileError = $_ if (-not $ReturnObjects) { Write-Host " [INFO] Group query profile '$($profile.Name)' failed: $($_.Exception.Message)" -ForegroundColor DarkGray } } } if ($null -eq $response) { if ($null -ne $lastProfileError) { throw $lastProfileError } throw "Could not retrieve Entra groups using any query profile." } if (-not $ReturnObjects -and -not [string]::IsNullOrWhiteSpace($selectedProfileName)) { Write-Host " [INFO] Entra groups query profile: $selectedProfileName" -ForegroundColor DarkGray if ($selectedProfileName -ne 'full') { Write-Host " [INFO] Group sensitivity labels (assignedLabels) are unavailable for this tenant/query. Continuing without assignedLabels; groups may appear as '(none)' unless classification is present." -ForegroundColor DarkGray } } while ($true) { if ($response.value) { $remaining = $EffectiveMaxGroups - $allGroups.Count if ($remaining -gt 0) { $groupsPage = @($response.value) if ($groupsPage.Count -gt $remaining) { $allGroups += ($groupsPage | Select-Object -First $remaining) } else { $allGroups += $groupsPage } } } if ($allGroups.Count -ge $EffectiveMaxGroups) { break } $uri = [string]$response.'@odata.nextLink' if ([string]::IsNullOrWhiteSpace($uri)) { break } $response = Invoke-GraphRequestWithRetry -Uri $uri ` -Headers $GraphHeaders ` -Method 'GET' ` -MaxRetries 3 ` -InitialDelaySeconds 2 ` -OperationName 'Entra groups page retrieval' } $groupOwnerCountById = @{} $groupOwnerIsGroupById = @{} $groupExternalMemberCountById = @{} $groupExternalMembersById = @{} $groupMemberCountById = @{} if ($allGroups.Count -gt 0) { if (-not $ReturnObjects) { Write-Host " Retrieving group owners count..." -ForegroundColor DarkGray } $groupIdsForOwners = @($allGroups | ForEach-Object { [string]$_.id } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $ownerBatchSize = 20 $ownerBatchNumber = 0 for ($offset = 0; $offset -lt $groupIdsForOwners.Count; $offset += $ownerBatchSize) { $ownerBatchNumber++ if (-not $ReturnObjects -and ($ownerBatchNumber % 10 -eq 0 -or $offset -eq 0)) { $processedGroups = [Math]::Min($offset + $ownerBatchSize, $groupIdsForOwners.Count) Write-Host " Owners progress: $processedGroups/$($groupIdsForOwners.Count) groups" -ForegroundColor DarkGray } $batchIds = @($groupIdsForOwners[$offset..([Math]::Min($offset + $ownerBatchSize - 1, $groupIdsForOwners.Count - 1))]) $batchRequests = @() foreach ($batchGroupId in $batchIds) { $batchRequests += @{ id = $batchGroupId method = "GET" url = "/groups/$batchGroupId/owners?`$top=999&`$select=id" } } try { $batchBody = @{ requests = $batchRequests } | ConvertTo-Json -Depth 8 $batchResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/v1.0/`$batch" ` -Headers $GraphHeaders ` -Method 'POST' ` -Body $batchBody ` -MaxRetries 2 ` -InitialDelaySeconds 2 ` -OperationName "Group owners batch $ownerBatchNumber" foreach ($batchItemResponse in @($batchResponse.responses)) { $responseGroupId = [string]$batchItemResponse.id if ([string]::IsNullOrWhiteSpace($responseGroupId)) { continue } $ownerCount = $null $ownerIsGroup = $false if ($batchItemResponse.status -eq 200 -and $null -ne $batchItemResponse.body) { if ($null -ne $batchItemResponse.body.value) { $owners = @($batchItemResponse.body.value) $ownerCount = [int]$owners.Count foreach ($owner in $owners) { $ownerType = [string]$owner.'@odata.type' if ($ownerType -match '(?i)group$') { $ownerIsGroup = $true break } } } } $groupOwnerCountById[$responseGroupId.ToLower()] = $ownerCount $groupOwnerIsGroupById[$responseGroupId.ToLower()] = $ownerIsGroup } } catch { if (-not $ReturnObjects) { Write-Host "[INFO] Could not retrieve owner counts for one or more groups in batch $($ownerBatchNumber): $($_.Exception.Message)" -ForegroundColor DarkGray } foreach ($batchGroupId in $batchIds) { $groupOwnerCountById[$batchGroupId.ToLower()] = $null $groupOwnerIsGroupById[$batchGroupId.ToLower()] = $false } } } } if ($allGroups.Count -gt 0) { if (-not $ReturnObjects) { Write-Host " Retrieving external group members (guest users)..." -ForegroundColor DarkGray } $groupIdsForMembers = @($allGroups | ForEach-Object { [string]$_.id } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $memberBatchSize = 20 $memberBatchNumber = 0 for ($offset = 0; $offset -lt $groupIdsForMembers.Count; $offset += $memberBatchSize) { $memberBatchNumber++ if (-not $ReturnObjects -and ($memberBatchNumber % 10 -eq 0 -or $offset -eq 0)) { $processedGroups = [Math]::Min($offset + $memberBatchSize, $groupIdsForMembers.Count) Write-Host " External-members progress: $processedGroups/$($groupIdsForMembers.Count) groups" -ForegroundColor DarkGray } $batchIds = @($groupIdsForMembers[$offset..([Math]::Min($offset + $memberBatchSize - 1, $groupIdsForMembers.Count - 1))]) $batchRequests = @() foreach ($batchGroupId in $batchIds) { $batchRequests += @{ id = $batchGroupId method = "GET" url = "/groups/$batchGroupId/members?`$top=999&`$select=id,displayName,userPrincipalName,mail,userType" } } try { $batchBody = @{ requests = $batchRequests } | ConvertTo-Json -Depth 8 $batchResponse = Invoke-GraphRequestWithRetry -Uri "https://graph.microsoft.com/v1.0/`$batch" ` -Headers $GraphHeaders ` -Method 'POST' ` -Body $batchBody ` -MaxRetries 2 ` -InitialDelaySeconds 2 ` -OperationName "Group members batch $memberBatchNumber" foreach ($batchItemResponse in @($batchResponse.responses)) { $responseGroupId = [string]$batchItemResponse.id if ([string]::IsNullOrWhiteSpace($responseGroupId)) { continue } $externalMembers = @() $memberCount = $null if ($batchItemResponse.status -eq 200 -and $null -ne $batchItemResponse.body -and $null -ne $batchItemResponse.body.value) { $members = @($batchItemResponse.body.value) $memberCount = [int]$members.Count foreach ($member in $members) { $isExternal = $false if ([string]$member.userType -eq 'Guest') { $isExternal = $true } elseif (([string]$member.userPrincipalName -match '#EXT#') -or ([string]$member.mail -match '#EXT#')) { $isExternal = $true } if ($isExternal) { if (-not [string]::IsNullOrWhiteSpace([string]$member.mail)) { $externalMembers += [string]$member.mail } elseif (-not [string]::IsNullOrWhiteSpace([string]$member.userPrincipalName)) { $externalMembers += [string]$member.userPrincipalName } elseif (-not [string]::IsNullOrWhiteSpace([string]$member.displayName)) { $externalMembers += [string]$member.displayName } } } } $externalMembers = @($externalMembers | Sort-Object -Unique) $groupMemberCountById[$responseGroupId.ToLower()] = $memberCount $groupExternalMemberCountById[$responseGroupId.ToLower()] = $externalMembers.Count $groupExternalMembersById[$responseGroupId.ToLower()] = if ($externalMembers.Count -gt 0) { ($externalMembers | Select-Object -First 5) -join '; ' } else { '(none)' } } } catch { if (-not $ReturnObjects -and $memberBatchNumber -eq 1) { Write-Host " [INFO] Could not retrieve external members for groups (permission may be missing): $($_.Exception.Message)" -ForegroundColor DarkGray } foreach ($batchGroupId in $batchIds) { $groupMemberCountById[$batchGroupId.ToLower()] = $null $groupExternalMemberCountById[$batchGroupId.ToLower()] = 0 $groupExternalMembersById[$batchGroupId.ToLower()] = '(none)' } } } } $groups = $allGroups | Select-Object @{N='DisplayName';E={$_.displayName}}, @{N='GroupId';E={$_.id}}, @{N='PrimarySmtpAddress';E={$_.mail}}, @{N='MailEnabled';E={ $rawValue = $_.mailEnabled if ($rawValue -is [bool]) { [bool]$rawValue } elseif ($null -eq $rawValue) { $false } else { [string]$rawValue -match '^(?i:true|1)$' } }}, @{N='SecurityEnabled';E={ $rawValue = $_.securityEnabled if ($rawValue -is [bool]) { [bool]$rawValue } elseif ($null -eq $rawValue) { $false } else { [string]$rawValue -match '^(?i:true|1)$' } }}, @{N='GroupTypes';E={($_.groupTypes -join ', ')}}, @{N='OwnerCount';E={ $groupKey = ([string]$_.id).ToLower() if ($groupOwnerCountById.ContainsKey($groupKey)) { $groupOwnerCountById[$groupKey] } else { $null } }}, @{N='MemberCount';E={ $groupKey = ([string]$_.id).ToLower() if ($groupMemberCountById.ContainsKey($groupKey)) { $groupMemberCountById[$groupKey] } else { $null } }}, @{N='OwnerIsGroup';E={ $groupKey = ([string]$_.id).ToLower() if ($groupOwnerIsGroupById.ContainsKey($groupKey)) { $groupOwnerIsGroupById[$groupKey] } else { $false } }}, @{N='ExternalMemberCount';E={ $groupKey = ([string]$_.id).ToLower() if ($groupExternalMemberCountById.ContainsKey($groupKey)) { [int]$groupExternalMemberCountById[$groupKey] } else { 0 } }}, @{N='ExternalMembers';E={ $groupKey = ([string]$_.id).ToLower() if ($groupExternalMembersById.ContainsKey($groupKey)) { [string]$groupExternalMembersById[$groupKey] } else { '(none)' } }}, @{N='SharingType';E={ if ([string]::IsNullOrWhiteSpace([string]$_.visibility)) { '(not set)' } else { switch ([string]$_.visibility) { 'Public' { 'Public' } 'Private' { 'Private' } 'HiddenMembership' { 'HiddenMembership' } default { [string]$_.visibility } } } }}, @{N='Label';E={ $labels = @() foreach ($assignedLabel in @($_.assignedLabels)) { if ($null -eq $assignedLabel) { continue } if (-not [string]::IsNullOrWhiteSpace([string]$assignedLabel.displayName)) { $labels += [string]$assignedLabel.displayName } elseif (-not [string]::IsNullOrWhiteSpace([string]$assignedLabel.labelId)) { $labels += [string]$assignedLabel.labelId } } if ($labels.Count -gt 0) { ($labels | Sort-Object -Unique) -join ', ' } elseif (-not [string]::IsNullOrWhiteSpace([string]$_.classification)) { [string]$_.classification } else { '(none)' } }} if (-not $ReturnObjects) { Write-Host "[OK] Found $($groups.Count) groups`n" -ForegroundColor Green if (-not $LoadAllGroups -and $groups.Count -ge $MaxGroups) { Write-Host "[INFO] Group collection limited to $MaxGroups items. Use -LoadAllGroups to remove this limit.`n" -ForegroundColor DarkGray } } } catch { if (-not $ReturnObjects) { Write-Host "[WARNING] Could not collect groups: $($_.Exception.Message)`n" -ForegroundColor Yellow } } return [PSCustomObject]@{ Groups = $groups } } |