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 = @()
    $groupCollectionError = $null

    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 {
        $groupCollectionError = [string]$_.Exception.Message
        if (-not $ReturnObjects) {
            Write-Host "[WARNING] Could not collect groups: $($_.Exception.Message)`n" -ForegroundColor Yellow
        }
    }

    return [PSCustomObject]@{
        Groups = $groups
        CollectionError = $groupCollectionError
    }
}