Private/Cache/Get-OptimizedGroupMembership.ps1

function Get-OptimizedGroupMembership {
    <#
    .SYNOPSIS
        Retrieves group memberships for users in a highly optimized manner.

    .DESCRIPTION
        This function retrieves group memberships for users using batch processing and caching
        to minimize API calls and improve performance.

    .PARAMETER UserId
        The ID of the user to retrieve group memberships for.

    .PARAMETER ServicePrincipalId
        The ID of the service principal to retrieve group memberships for.

    .PARAMETER GroupIds
        Optional array of specific group IDs to check membership for. If not provided,
        all group memberships will be retrieved.

    .PARAMETER IncludeNestedGroups
        Whether to include transitive group memberships (nested groups).
        Always true when using the optimized function as it uses transitiveMemberOf.

    .PARAMETER ForceRefresh
        Forces a refresh of the cache for the specified user or service principal.

    .EXAMPLE
        Get-OptimizedGroupMembership -UserId "12345678-1234-1234-1234-123456789012"

    .EXAMPLE
        Get-OptimizedGroupMembership -ServicePrincipalId "87654321-4321-4321-4321-210987654321" -GroupIds @("group1", "group2")
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [string]$UserId,

        [Parameter(Mandatory = $false)]
        [string]$ServicePrincipalId,

        [Parameter(Mandatory = $false)]
        [string[]]$GroupIds,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeNestedGroups = $true,

        [Parameter(Mandatory = $false)]
        [switch]$ForceRefresh
    )

    # Initialize cache if it doesn't exist
    if (-not (Get-Variable -Name GroupMembershipCache -Scope Script -ErrorAction SilentlyContinue)) {
        $script:GroupMembershipCache = @{}
        $script:GroupMembershipCacheTime = @{}
    }

    # Define cache expiration (15 minutes)
    $cacheExpiration = [TimeSpan]::FromMinutes(15)

    # Determine the entity ID (either user or service principal)
    $entityId = if ($UserId) { $UserId } else { $ServicePrincipalId }
    $entityType = if ($UserId) { "user" } else { "servicePrincipal" }

    if (-not $entityId) {
        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Either UserId or ServicePrincipalId must be specified" -Level "Error"
        throw "Either UserId or ServicePrincipalId must be specified"
    }

    # Check if cache needs refreshing
    $cacheKey = "$entityType-$entityId"
    $cacheExpired = $false

    if ($script:GroupMembershipCacheTime.ContainsKey($cacheKey)) {
        $cacheExpired = ([DateTime]::Now - $script:GroupMembershipCacheTime[$cacheKey]) -gt $cacheExpiration
    }

    # Refresh cache if needed
    if ($ForceRefresh -or $cacheExpired -or -not $script:GroupMembershipCache.ContainsKey($cacheKey)) {
        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Refreshing group membership cache for $entityType $entityId" -Level "Info"

        try {
            $groups = @()

            if ($entityType -eq "user") {
                # Use transitive member of to get all groups (direct and nested)
                try {
                    $baseUri = "/v1.0/users/$entityId/transitiveMemberOf?`$select=id,displayName,description"
                    $nextLink = $baseUri

                    # Handle pagination to get ALL groups
                    $pageCount = 0
                    do {
                        Write-Verbose "Requesting group membership from Graph API: $nextLink"
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Requesting page $($pageCount+1) of group memberships for $entityType {$entityId}" -Level "Info"
                        $response = Invoke-MgGraphRequest -Method GET -Uri $nextLink -ErrorAction Stop
                        $pageCount++

                        # Enhanced debugging to see the actual API response
                        Write-Verbose "Graph API response received with $(($response.value).Count) groups"
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Page $pageCount returned $(($response.value).Count) groups" -Level "Info"

                        if ($response.value) {
                            $groups += $response.value
                        }

                        # Check if there are more pages
                        $nextLink = $response.'@odata.nextLink'
                        if ($nextLink) {
                            Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Found next page link, continuing pagination" -Level "Info"
                        }
                    } while ($nextLink)

                    Write-Verbose "Total groups retrieved: $($groups.Count)"
                    Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Total groups retrieved for $entityType {$entityId}: $($groups.Count) from $pageCount page(s)" -Level "Info"

                    if ($groups.Count -eq 0) {
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "No groups found for $entityType $entityId - verify this is expected" -Level "Warning"
                    }
                    else {
                        Write-Verbose "User is a member of these groups: $($groups.id -join ', ')"
                        $firstFiveGroups = $groups.id | Select-Object -First 5
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "First 5 group IDs: $($firstFiveGroups -join ', ')" -Level "Info"
                    }
                }
                catch {
                    # Enhanced error logging and reporting
                    $errorStatus = if ($_.Exception.Response) { $_.Exception.Response.StatusCode } else { "Unknown" }
                    $errorMessage = $_.Exception.Message

                    Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Error retrieving group memberships for user $entityId. Status: $errorStatus, Message: $errorMessage" -Level "Warning"

                    # Special handling for common error cases
                    if ($errorStatus -eq "NotFound") {
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "User with ID $entityId not found. Returning empty group list." -Level "Warning"
                    }
                    elseif ($errorMessage -like "*Authorization*" -or $errorMessage -like "*Permission*") {
                        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Permission issue when accessing user group memberships. Check that you have Directory.Read.All permission." -Level "Warning"
                    }

                    # Always return empty array instead of throwing error
                    $groups = @()
                }
            }
            else {
                # For service principals
                try {
                    $baseUri = "/v1.0/servicePrincipals/$entityId/transitiveMemberOf?`$select=id,displayName,description"
                    $nextLink = $baseUri

                    # Handle pagination to get ALL groups
                    do {
                        $response = Invoke-MgGraphRequest -Method GET -Uri $nextLink -ErrorAction Stop

                        if ($response.value) {
                            $groups += $response.value
                        }

                        # Check if there are more pages
                        $nextLink = $response.'@odata.nextLink'
                    } while ($nextLink)
                }
                catch {
                    $errorStatus = if ($_.Exception.Response) { $_.Exception.Response.StatusCode } else { "Unknown" }
                    $errorMessage = $_.Exception.Message

                    Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Error retrieving group memberships for service principal $entityId. Status: $errorStatus, Message: $errorMessage" -Level "Warning"

                    # Return empty array for all error cases
                    $groups = @()
                }
            }

            # Update cache
            $script:GroupMembershipCache[$cacheKey] = $groups
            $script:GroupMembershipCacheTime[$cacheKey] = [DateTime]::Now
            Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Updated cache with $($groups.Count) groups for $entityType $entityId" -Level "Info"
        }
        catch {
            $errorMessage = $_.Exception.Message
            Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Failed to retrieve group memberships: $errorMessage" -Level "Error"
            # Return empty array instead of throwing
            return @()
        }
    }

    # Return all groups or filter by specified group IDs
    $memberOf = $script:GroupMembershipCache[$cacheKey]
    Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Retrieved $($memberOf.Count) groups from cache for $entityType $entityId" -Level "Info"

    if ($GroupIds -and $GroupIds.Count -gt 0) {
        $filteredGroups = $memberOf | Where-Object { $_.id -in $GroupIds }
        Write-DiagnosticOutput -Source "Get-OptimizedGroupMembership" -Message "Filtered to $($filteredGroups.Count) out of $($GroupIds.Count) requested groups" -Level "Info"
        return $filteredGroups
    }
    else {
        return $memberOf
    }
}

Export-ModuleMember -Function Get-OptimizedGroupMembership