Public/Get-IntuneGroupAssignment.ps1

function Get-IntuneGroupAssignment {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$GroupNames,

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

        [Parameter(Mandatory = $false)]
        [switch]$ExportToCSV,

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

        [Parameter(Mandatory = $false)]
        [string]$ScopeTagFilter
    )

    Write-Host "Group selection chosen" -ForegroundColor Green

    # Get Group names from parameter or prompt
    if ($GroupNames) {
        $groupInput = $GroupNames
    }
    else {
        # Prompt for Group names or IDs
        Write-Host "Please enter Group names or Object IDs, separated by commas (,): " -ForegroundColor Cyan
        Write-Host "Example: 'Marketing Team, 12345678-1234-1234-1234-123456789012'" -ForegroundColor Gray
        $groupInput = Read-Host
    }

    if ([string]::IsNullOrWhiteSpace($groupInput)) {
        Write-Host "No group information provided. Please try again." -ForegroundColor Red
        return
    }

    $groupInputs = $groupInput -split ',' | ForEach-Object { $_.Trim() }
    $exportData = [System.Collections.ArrayList]::new()

    # Determine if nested group checking should be enabled
    $checkNestedGroups = $false
    if ($IncludeNestedGroups) {
        $checkNestedGroups = $true
    }
    else {
        $nestedPrompt = Read-Host "Include assignments inherited from parent groups? (y/n)"
        if ($nestedPrompt -match '^[Yy]') {
            $checkNestedGroups = $true
        }
    }

    $categories = Get-IntuneCategoryDefinition -Audience 'GroupContext'
    # Shared across groups so each entity set is fetched from Graph once per run
    $entityCache = @{}

    foreach ($groupInput in $groupInputs) {
        Write-Host "`nProcessing input: $groupInput" -ForegroundColor Yellow

        # Initialize variables
        $groupId = $null
        $groupName = $null

        # Check if input is a GUID
        if ($groupInput -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') {
            $groupInfo = Get-GroupInfo -GroupId $groupInput
            if (-not $groupInfo.Success) {
                Write-Host "No group found with ID: $groupInput" -ForegroundColor Red
                continue
            }
            $groupId = $groupInfo.Id
            $groupName = $groupInfo.DisplayName
        }
        else {
            # Try to find group by display name (single quotes escaped for the OData filter)
            $escapedGroupName = $groupInput -replace "'", "''"
            $groupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedGroupName'"
            $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get

            if ($groupResponse.value.Count -eq 0) {
                Write-Host "No group found with name: $groupInput" -ForegroundColor Red
                continue
            }
            elseif ($groupResponse.value.Count -gt 1) {
                Write-Host "Multiple groups found with name: $groupInput. Please use the Object ID instead:" -ForegroundColor Red
                foreach ($group in $groupResponse.value) {
                    Write-Host " - $($group.displayName) (ID: $($group.id))" -ForegroundColor Yellow
                }
                continue
            }

            $groupId = $groupResponse.value[0].id
            $groupName = $groupResponse.value[0].displayName
        }

        Write-Host "Found group: $groupName (ID: $groupId)" -ForegroundColor Green

        # Build effective group IDs list (direct + parent groups if nested checking enabled)
        $allGroupIds = @($groupId)
        $parentGroupMap = @{}
        if ($checkNestedGroups) {
            Write-Host "Checking parent group memberships..." -ForegroundColor Yellow
            $parentGroups = Get-TransitiveGroupMembership -GroupId $groupId
            if ($parentGroups.Count -gt 0) {
                foreach ($pg in $parentGroups) {
                    $allGroupIds += $pg.id
                    $parentGroupMap[$pg.id] = $pg.displayName
                }
                Write-Host "Found $($parentGroups.Count) parent group(s): $($parentGroups.displayName -join ', ')" -ForegroundColor Green
            }
            else {
                Write-Host "No parent groups found." -ForegroundColor Gray
            }
        }

        Write-Host "Fetching Intune Profiles and Applications for the group..." -ForegroundColor Yellow

        $processEntity = {
            param($ctx)

            $entity = $ctx.Entity

            if ($ctx.Category.Kind -eq 'MobileApps') {
                # Apps resolve reason and intent together from the raw assignments.
                # Exclusion-only assignments are kept (issue #126): the reason strings say
                # "Group Exclusion"/"Inherited Exclusion (via X)" and, when no inclusion
                # supplied an intent, the excluding assignment's intent decides the bucket.
                $relevantAppAssignmentReasons = @()
                $intentForGroup = $null
                $exclusionIntentForGroup = $null

                foreach ($assignmentItem in $ctx.RawAssignments) {
                    $appTargetGid = $assignmentItem.target.groupId
                    $reasonText = $null
                    if ($assignmentItem.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $allGroupIds -contains $appTargetGid) {
                        if ($appTargetGid -eq $groupId) {
                            $reasonText = "Direct Assignment"
                        }
                        elseif ($parentGroupMap.ContainsKey($appTargetGid)) {
                            $reasonText = "Inherited (via $($parentGroupMap[$appTargetGid]))"
                        }
                        if (-not $intentForGroup) { $intentForGroup = $assignmentItem.intent }
                    }
                    elseif ($assignmentItem.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget' -and $allGroupIds -contains $appTargetGid) {
                        if ($appTargetGid -eq $groupId) {
                            $reasonText = "Group Exclusion"
                        }
                        elseif ($parentGroupMap.ContainsKey($appTargetGid)) {
                            $reasonText = "Inherited Exclusion (via $($parentGroupMap[$appTargetGid]))"
                        }
                        if ($reasonText -and -not $exclusionIntentForGroup) { $exclusionIntentForGroup = $assignmentItem.intent }
                    }
                    if ($reasonText) {
                        $suffix = Format-AssignmentFilter -FilterId $assignmentItem.target.deviceAndAppManagementAssignmentFilterId -FilterType $assignmentItem.target.deviceAndAppManagementAssignmentFilterType
                        $relevantAppAssignmentReasons += "$reasonText$suffix"
                    }
                }

                if ($relevantAppAssignmentReasons.Count -gt 0) {
                    $appWithReason = $entity.PSObject.Copy()
                    $appWithReason | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue ($relevantAppAssignmentReasons -join "; ") -Force
                    # Exclusion-only assignments carry the intent of the excluding assignment
                    if (-not $intentForGroup) { $intentForGroup = $exclusionIntentForGroup }
                    switch ($intentForGroup) {
                        "required" { $ctx.Buckets['AppsRequired'].Add($appWithReason) }
                        "available" { $ctx.Buckets['AppsAvailable'].Add($appWithReason) }
                        "uninstall" { $ctx.Buckets['AppsUninstall'].Add($appWithReason) }
                    }
                }
                return
            }

            # Every other category (entities, App Protection, both Endpoint Security phases)
            # maps the normalized assignments to Direct/Inherited reason strings via the parent map.
            $assignmentReasons = @(Get-GroupAssignmentReasons -Assignments $ctx.Assignments -DirectGroupId $groupId -ParentGroupMap $parentGroupMap)
            if ($assignmentReasons.Count -gt 0) {
                $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue ($assignmentReasons -join "; ") -Force
                $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
            }
        }

        $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -AssignmentGroupIds $allGroupIds -ShowProgress -EntityCache $entityCache
        $relevantPolicies = $scanResult.Buckets

        # Apply scope tag filter if specified
        if ($ScopeTagFilter) {
            foreach ($key in @($relevantPolicies.Keys)) {
                $relevantPolicies[$key] = @(Filter-ByScopeTag -Items $relevantPolicies[$key] -FilterTag $ScopeTagFilter -ScopeTagLookup $script:ScopeTagLookup)
            }
        }

        # Display sections in the legacy order with the legacy per-category name resolution.
        # Sections without GetName use the Show-CategoryResultTable default
        # (displayName, then name, then "Unnamed Profile").
        $nameFirst = { param($item) if ([string]::IsNullOrWhiteSpace($item.name)) { $item.displayName } else { $item.name } }
        $displayNameFirst = { param($item) if ([string]::IsNullOrWhiteSpace($item.displayName)) { $item.name } else { $item.displayName } }
        $displayNameOnly = { param($item) $item.displayName }

        $displaySections = @(
            @{ Title = 'Device Configurations'; Bucket = 'DeviceConfigs'; GetName = $nameFirst }
            @{ Title = 'Settings Catalog Policies'; Bucket = 'SettingsCatalog'; GetName = $nameFirst }
            @{ Title = 'Compliance Policies'; Bucket = 'CompliancePolicies'; GetName = $nameFirst }
            @{ Title = 'App Protection Policies'; Bucket = 'AppProtectionPolicies'; GetName = $displayNameOnly }
            @{ Title = 'App Configuration Policies'; Bucket = 'AppConfigurationPolicies'; GetName = $nameFirst }
            @{ Title = 'Platform Scripts'; Bucket = 'PlatformScripts'; GetName = $nameFirst }
            @{ Title = 'Proactive Remediation Scripts'; Bucket = 'HealthScripts'; GetName = $nameFirst }
            @{ Title = 'Autopilot Deployment Profiles'; Bucket = 'DeploymentProfiles'; GetName = $displayNameFirst }
            @{ Title = 'Enrollment Status Page Profiles'; Bucket = 'ESPProfiles'; GetName = $displayNameFirst }
            @{ Title = 'Windows 365 Cloud PC Provisioning Policies'; Bucket = 'CloudPCProvisioningPolicies'; GetName = $displayNameFirst }
            @{ Title = 'Windows 365 Cloud PC User Settings'; Bucket = 'CloudPCUserSettings'; GetName = $displayNameFirst }
            @{ Title = 'Required Apps'; Bucket = 'AppsRequired'; GetName = $displayNameOnly }
            @{ Title = 'Available Apps'; Bucket = 'AppsAvailable'; GetName = $displayNameOnly }
            @{ Title = 'Uninstall Apps'; Bucket = 'AppsUninstall'; GetName = $displayNameOnly }
            @{ Title = 'Endpoint Security - Antivirus Profiles'; Bucket = 'AntivirusProfiles' }
            @{ Title = 'Endpoint Security - Disk Encryption Profiles'; Bucket = 'DiskEncryptionProfiles' }
            @{ Title = 'Endpoint Security - Firewall Profiles'; Bucket = 'FirewallProfiles' }
            @{ Title = 'Endpoint Security - EDR Profiles'; Bucket = 'EndpointDetectionProfiles' }
            @{ Title = 'Endpoint Security - ASR Profiles'; Bucket = 'AttackSurfaceProfiles' }
            @{ Title = 'Endpoint Security - Account Protection Profiles'; Bucket = 'AccountProtectionProfiles' }
        )
        foreach ($section in $displaySections) {
            $sectionParams = @{
                Title        = $section.Title
                Items        = @($relevantPolicies[$section.Bucket])
                EmptyMessage = "No $($section.Title) found for this group."
            }
            if ($section.GetName) { $sectionParams.GetName = $section.GetName }
            Show-CategoryResultTable @sectionParams
        }

        # Add to export data: the Group row first, then categories in the legacy CSV order
        # (Endpoint Security after the Windows 365 categories, the app buckets last).
        Add-ExportData -ExportData $exportData -Category "Group" -Items @([PSCustomObject]@{
                displayName      = $groupName
                id               = $groupId
                AssignmentReason = "N/A"
            })

        $reasonProperty = { param($item) $item.AssignmentReason }
        $exportBatches = @(
            @{ Ids = @('DeviceConfigurations', 'SettingsCatalog', 'CompliancePolicies'); Reason = $reasonProperty }
            # Historical quirk preserved: App Protection rows export AssignmentSummary,
            # a property this cmdlet never sets, so their AssignmentReason column stays empty.
            @{ Ids = @('AppProtectionPolicies'); Reason = { param($item) $item.AssignmentSummary } }
            @{ Ids = @('AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'DeploymentProfiles', 'ESPProfiles',
                    'CloudPCProvisioningPolicies', 'CloudPCUserSettings', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall',
                    'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'Applications'); Reason = $reasonProperty }
        )
        foreach ($batch in $exportBatches) {
            $batchCategories = foreach ($id in $batch.Ids) { $categories | Where-Object { $_.Id -eq $id } }
            Add-CategoryExportData -ExportData $exportData -Categories $batchCategories -Buckets $relevantPolicies -AssignmentReason $batch.Reason
        }
    }

    # Export results if requested
    Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode
}