Public/Get-IntuneUserAssignment.ps1

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

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

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

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

    Write-Host "User selection chosen" -ForegroundColor Green

    # Get User Principal Names from parameter or prompt
    if ($UserPrincipalNames) {
        $upnInput = $UserPrincipalNames
    }
    else {
        # Prompt for one or more User Principal Names
        Write-Host "Please enter User Principal Name(s), separated by commas (,): " -ForegroundColor Cyan
        $upnInput = Read-Host
    }

    # Validate input
    if ([string]::IsNullOrWhiteSpace($upnInput)) {
        Write-Host "No UPN provided. Please try again with a valid UPN." -ForegroundColor Red
        return
    }

    $upns = $upnInput -split ',' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }

    if ($upns.Count -eq 0) {
        Write-Host "No valid UPNs provided. Please try again with at least one valid UPN." -ForegroundColor Red
        return
    }

    # Validate UPN format
    $upnRegex = '^[^@\s]+@[^@\s]+\.[^@\s]+$'
    $invalidUpns = @($upns | Where-Object { $_ -notmatch $upnRegex })
    if ($invalidUpns.Count -gt 0) {
        foreach ($badUpn in $invalidUpns) {
            Write-Host "Invalid UPN format: '$badUpn'. Expected: user@domain.com" -ForegroundColor Red
        }
        $upns = @($upns | Where-Object { $_ -match $upnRegex })
        if ($upns.Count -eq 0) {
            Write-Host "No valid UPNs remaining. Please try again." -ForegroundColor Red
            return
        }
    }

    $exportData = [System.Collections.ArrayList]::new()

    # Renders one legacy three-column section (name/ID/assignment). The Device
    # Configurations and App Protection sections keep their bespoke extra column below.
    function Show-UserSectionTable {
        param(
            [string]$Title,
            [object[]]$Items,
            [string]$NameLabel,
            [string]$IdLabel,
            [scriptblock]$GetName,
            [string]$RedPattern = 'Excluded*'
        )
        if ($null -eq $Items -or $Items.Count -eq 0) { return }
        Write-Host "`n------- $Title -------" -ForegroundColor Cyan
        $headerFormat = "{0,-50} {1,-40} {2,-30}" -f $NameLabel, $IdLabel, "Assignment"
        $separator = Get-Separator
        Write-Host $separator
        Write-Host $headerFormat -ForegroundColor Yellow
        Write-Host $separator
        foreach ($item in $Items) {
            $name = & $GetName $item
            if ($name.Length -gt 47) { $name = $name.Substring(0, 44) + "..." }
            $id = $item.id
            if ($id.Length -gt 37) { $id = $id.Substring(0, 34) + "..." }
            $assignment = $item.AssignmentReason
            if ($assignment.Length -gt 27) { $assignment = $assignment.Substring(0, 24) + "..." }
            $rowFormat = "{0,-50} {1,-40} {2,-30}" -f $name, $id, $assignment
            if ($assignment -like $RedPattern) {
                Write-Host $rowFormat -ForegroundColor Red
            }
            else {
                Write-Host $rowFormat -ForegroundColor White
            }
        }
        Write-Host $separator
    }

    # Legacy per-section name resolution
    $nameFirst = { param($item) if ([string]::IsNullOrWhiteSpace($item.name)) { $item.displayName } else { $item.name } }
    $displayNameOnly = { param($item) $item.displayName }
    $profileNameFirst = { param($item) if (-not [string]::IsNullOrWhiteSpace($item.displayName)) { $item.displayName } elseif (-not [string]::IsNullOrWhiteSpace($item.name)) { $item.name } else { "Unnamed Profile" } }
    $policyNameFirst = { param($item) if (-not [string]::IsNullOrWhiteSpace($item.displayName)) { $item.displayName } elseif (-not [string]::IsNullOrWhiteSpace($item.name)) { $item.name } else { "Unnamed Policy" } }
    $settingNameFirst = { param($item) if (-not [string]::IsNullOrWhiteSpace($item.displayName)) { $item.displayName } elseif (-not [string]::IsNullOrWhiteSpace($item.name)) { $item.name } else { "Unnamed Setting" } }

    $categories = Get-IntuneCategoryDefinition -Audience 'UserContext'
    # Shared across UPNs so each entity set is fetched from Graph once per run
    $entityCache = @{}
    $memberGroupIds = @()
    $appProgress = @{ Current = 0; Total = $null; FilteredTotal = $null }

    $processEntity = {
        param($ctx)

        $entity = $ctx.Entity

        switch ($ctx.Category.Kind) {
            'MobileApps' {
                if ($null -eq $appProgress.Total) {
                    $allCachedApps = @($entityCache[$ctx.Category.EntityType])
                    $appProgress.Total = $allCachedApps.Count
                    $appProgress.FilteredTotal = @($allCachedApps | Where-Object { -not ($_.isFeatured -or $_.isBuiltIn) }).Count
                }
                $appProgress.Current++
                Write-Host "`rFetching Application $($appProgress.Current) of $($appProgress.Total)" -NoNewline

                # Winning-assignment walk: an exclusion for one of the user's groups wins
                # immediately; otherwise the first inclusion (All Users or member group) wins.
                $isExcluded = $false
                $isIncluded = $false
                $winningAssignment = $null
                foreach ($assignment in $ctx.RawAssignments) {
                    if ($assignment.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget' -and
                        $memberGroupIds -contains $assignment.target.groupId) {
                        $isExcluded = $true
                        $winningAssignment = $assignment
                        break
                    }
                    elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.allLicensedUsersAssignmentTarget' -or
                        ($assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and
                        $memberGroupIds -contains $assignment.target.groupId)) {
                        if (-not $isIncluded) { $winningAssignment = $assignment }
                        $isIncluded = $true
                    }
                }

                if ($isIncluded -or $isExcluded) {
                    $filterSuffix = Format-AssignmentFilter `
                        -FilterId $winningAssignment.target.deviceAndAppManagementAssignmentFilterId `
                        -FilterType $winningAssignment.target.deviceAndAppManagementAssignmentFilterType
                    # Excluded apps stay visible in the intent bucket of the excluding assignment
                    $reasonText = if ($isExcluded) { "Excluded$filterSuffix" } else { "Included$filterSuffix" }
                    $appWithReason = $entity.PSObject.Copy()
                    $appWithReason | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue $reasonText -Force
                    switch ($winningAssignment.intent) {
                        "required" { $ctx.Buckets['AppsRequired'].Add($appWithReason) }
                        "available" { $ctx.Buckets['AppsAvailable'].Add($appWithReason) }
                        "uninstall" { $ctx.Buckets['AppsUninstall'].Add($appWithReason) }
                    }
                }

                if ($appProgress.Current -eq $appProgress.FilteredTotal) {
                    # Legacy final overwrite before the next category header
                    Write-Host "`rFetching Application $($appProgress.Total) of $($appProgress.Total)"
                }
            }
            'AppProtection' {
                # Only All Users targets and groups the user is actually a member of count
                $matchingAssignments = @($ctx.Assignments | Where-Object {
                        $_.Reason -eq 'All Users' -or
                        ($_.Reason -in @('Group Assignment', 'Group Exclusion') -and $memberGroupIds -contains $_.GroupId)
                    })
                if ($matchingAssignments.Count -gt 0) {
                    $assignmentSummary = $matchingAssignments | ForEach-Object { Format-AssignmentSummaryLine -Assignment $_ }
                    $entity | Add-Member -NotePropertyName 'AssignmentSummary' -NotePropertyValue ($assignmentSummary -join "; ") -Force
                    $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
                }
            }
            'EndpointSecurity' {
                $assignments = $ctx.Assignments
                if ($null -ne $ctx.RawAssignments) {
                    # Intent-phase detail rows historically carried no filter info, so the
                    # resolved reason gets no filter suffix (unlike the config-policy phase)
                    $assignments = @($assignments | ForEach-Object { [PSCustomObject]@{ Reason = $_.Reason; GroupId = $_.GroupId } })
                }
                $reason = Resolve-AssignmentReason -Assignments $assignments -GroupMembershipIds $memberGroupIds -IncludeReasons @("All Users")
                if ($reason) {
                    $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue $reason -Force
                    $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
                }
            }
            default {
                if ($ctx.Category.Id -in @('CloudPCProvisioningPolicies', 'CloudPCUserSettings')) {
                    # Legacy Cloud PC walk: the first matching assignment wins in raw order
                    foreach ($assignment in $ctx.Assignments) {
                        if ($assignment.Reason -eq "All Users" -or
                            ($assignment.Reason -eq "Group Assignment" -and $memberGroupIds -contains $assignment.GroupId)) {
                            $suffix = Format-AssignmentFilter -FilterId $assignment.FilterId -FilterType $assignment.FilterType
                            $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue "$($assignment.Reason)$suffix" -Force
                            $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
                            break
                        }
                        elseif ($assignment.Reason -eq "Group Exclusion" -and $memberGroupIds -contains $assignment.GroupId) {
                            $suffix = Format-AssignmentFilter -FilterId $assignment.FilterId -FilterType $assignment.FilterType
                            $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue "Excluded$suffix" -Force
                            $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
                            break
                        }
                    }
                    return
                }
                $reason = Resolve-AssignmentReason -Assignments $ctx.Assignments -GroupMembershipIds $memberGroupIds -IncludeReasons @("All Users")
                if ($reason) {
                    $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue $reason -Force
                    $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity)
                }
            }
        }
    }

    foreach ($upn in $upns) {
        Write-Host "Checking following UPN: $upn" -ForegroundColor Yellow

        # Get User Info
        $userInfo = Get-UserInfo -UserPrincipalName $upn
        if (-not $userInfo.Success) {
            Write-Host "User not found: $upn" -ForegroundColor Red
            Write-Host "Please verify the User Principal Name is correct." -ForegroundColor Yellow
            continue
        }

        # Get User Group Memberships
        try {
            $groupMemberships = Get-GroupMemberships -ObjectId $userInfo.Id -ObjectType "User"
            Write-Host "User Group Memberships: $($groupMemberships.displayName -join ', ')" -ForegroundColor Green
        }
        catch {
            Write-Host "Error fetching group memberships for user: $upn" -ForegroundColor Red
            Write-Host "Error details: $($_.Exception.Message)" -ForegroundColor Red
            continue
        }

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

        $memberGroupIds = @($groupMemberships.id)
        $appProgress = @{ Current = 0; Total = $null; FilteredTotal = $null }

        $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -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 results
        Write-Host "`nAssignments for User: $upn" -ForegroundColor Green

        # Calculate category summary
        $categoryNames = @('DeviceConfigs', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'PlatformScripts', 'HealthScripts', 'AppsRequired', 'AppsAvailable', 'AppsUninstall', 'AntivirusProfiles', 'DiskEncryptionProfiles', 'FirewallProfiles', 'EndpointDetectionProfiles', 'AttackSurfaceProfiles', 'AccountProtectionProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings')
        $nonEmptyCount = ($categoryNames | Where-Object { $relevantPolicies[$_].Count -gt 0 }).Count
        $totalDisplayCategories = $categoryNames.Count
        Write-Host "`nFound assignments in $nonEmptyCount of $totalDisplayCategories categories." -ForegroundColor Cyan

        # Display Device Configurations (bespoke four-column table with Platform)
        if ($relevantPolicies.DeviceConfigs.Count -gt 0) {
            Write-Host "`n------- Device Configurations -------" -ForegroundColor Cyan
            $headerFormat = "{0,-45} {1,-20} {2,-35} {3,-20}" -f "Configuration Name", "Platform", "Configuration ID", "Assignment"
            $separator = Get-Separator
            Write-Host $separator
            Write-Host $headerFormat -ForegroundColor Yellow
            Write-Host $separator
            foreach ($config in $relevantPolicies.DeviceConfigs) {
                $configName = if ([string]::IsNullOrWhiteSpace($config.name)) { $config.displayName } else { $config.name }
                if ($configName.Length -gt 42) { $configName = $configName.Substring(0, 39) + "..." }
                $platform = Get-PolicyPlatform -Policy $config
                if ($platform.Length -gt 17) { $platform = $platform.Substring(0, 14) + "..." }
                $configId = $config.id
                if ($configId.Length -gt 32) { $configId = $configId.Substring(0, 29) + "..." }
                $assignment = $config.AssignmentReason
                if ($assignment.Length -gt 17) { $assignment = $assignment.Substring(0, 14) + "..." }
                $rowFormat = "{0,-45} {1,-20} {2,-35} {3,-20}" -f $configName, $platform, $configId, $assignment
                if ($assignment -like "Excluded*") {
                    Write-Host $rowFormat -ForegroundColor Red
                }
                else {
                    Write-Host $rowFormat -ForegroundColor White
                }
            }
            Write-Host $separator
        }

        Show-UserSectionTable -Title 'Settings Catalog Policies' -Items @($relevantPolicies.SettingsCatalog) -NameLabel 'Policy Name' -IdLabel 'Policy ID' -GetName $nameFirst
        Show-UserSectionTable -Title 'Compliance Policies' -Items @($relevantPolicies.CompliancePolicies) -NameLabel 'Policy Name' -IdLabel 'Policy ID' -GetName $nameFirst

        # Display App Protection Policies (bespoke four-column table with Type; the legacy
        # table reads AssignmentReason, which this category never sets, so the column stays blank)
        if ($relevantPolicies.AppProtectionPolicies.Count -gt 0) {
            Write-Host "`n------- App Protection Policies -------" -ForegroundColor Cyan
            $headerFormat = "{0,-40} {1,-30} {2,-20} {3,-30}" -f "Policy Name", "Policy ID", "Type", "Assignment"
            $separator = Get-Separator
            Write-Host $separator
            Write-Host $headerFormat -ForegroundColor Yellow
            Write-Host $separator
            foreach ($policy in $relevantPolicies.AppProtectionPolicies) {
                $policyName = $policy.displayName
                if ($policyName.Length -gt 37) { $policyName = $policyName.Substring(0, 34) + "..." }
                $policyId = $policy.id
                if ($policyId.Length -gt 27) { $policyId = $policyId.Substring(0, 24) + "..." }
                $policyType = switch ($policy.'@odata.type') {
                    "#microsoft.graph.androidManagedAppProtection" { "Android" }
                    "#microsoft.graph.iosManagedAppProtection" { "iOS" }
                    "#microsoft.graph.windowsManagedAppProtection" { "Windows" }
                    default { "Unknown" }
                }
                $assignment = $policy.AssignmentReason
                if ($assignment.Length -gt 27) { $assignment = $assignment.Substring(0, 24) + "..." }
                $rowFormat = "{0,-40} {1,-30} {2,-20} {3,-30}" -f $policyName, $policyId, $policyType, $assignment
                if ($assignment -like "Excluded*") {
                    Write-Host $rowFormat -ForegroundColor Red
                }
                else {
                    Write-Host $rowFormat -ForegroundColor White
                }
            }
            Write-Host $separator
        }

        # Remaining sections in the legacy display order. App rows keep the legacy
        # "*Exclusion*" red-row pattern, which never matches "Excluded..." reasons.
        $displaySections = @(
            @{ Title = 'App Configuration Policies'; Bucket = 'AppConfigurationPolicies'; NameLabel = 'Policy Name'; IdLabel = 'Policy ID'; GetName = $nameFirst }
            @{ Title = 'Platform Scripts'; Bucket = 'PlatformScripts'; NameLabel = 'Script Name'; IdLabel = 'Script ID'; GetName = $nameFirst }
            @{ Title = 'Proactive Remediation Scripts'; Bucket = 'HealthScripts'; NameLabel = 'Script Name'; IdLabel = 'Script ID'; GetName = $nameFirst }
            @{ Title = 'Required Apps'; Bucket = 'AppsRequired'; NameLabel = 'App Name'; IdLabel = 'App ID'; GetName = $displayNameOnly; RedPattern = '*Exclusion*' }
            @{ Title = 'Available Apps'; Bucket = 'AppsAvailable'; NameLabel = 'App Name'; IdLabel = 'App ID'; GetName = $displayNameOnly; RedPattern = '*Exclusion*' }
            @{ Title = 'Uninstall Apps'; Bucket = 'AppsUninstall'; NameLabel = 'App Name'; IdLabel = 'App ID'; GetName = $displayNameOnly; RedPattern = '*Exclusion*' }
            @{ Title = 'Endpoint Security - Antivirus Profiles'; Bucket = 'AntivirusProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Endpoint Security - Disk Encryption Profiles'; Bucket = 'DiskEncryptionProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Endpoint Security - Firewall Profiles'; Bucket = 'FirewallProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Endpoint Security - Endpoint Detection and Response Profiles'; Bucket = 'EndpointDetectionProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Endpoint Security - Attack Surface Reduction Profiles'; Bucket = 'AttackSurfaceProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Endpoint Security - Account Protection Profiles'; Bucket = 'AccountProtectionProfiles'; NameLabel = 'Profile Name'; IdLabel = 'Profile ID'; GetName = $profileNameFirst }
            @{ Title = 'Windows 365 Cloud PC Provisioning Policies'; Bucket = 'CloudPCProvisioningPolicies'; NameLabel = 'Policy Name'; IdLabel = 'Policy ID'; GetName = $policyNameFirst }
            @{ Title = 'Windows 365 Cloud PC User Settings'; Bucket = 'CloudPCUserSettings'; NameLabel = 'Setting Name'; IdLabel = 'Setting ID'; GetName = $settingNameFirst }
        )
        foreach ($section in $displaySections) {
            $sectionParams = @{
                Title     = $section.Title
                Items     = @($relevantPolicies[$section.Bucket])
                NameLabel = $section.NameLabel
                IdLabel   = $section.IdLabel
                GetName   = $section.GetName
            }
            if ($section.RedPattern) { $sectionParams.RedPattern = $section.RedPattern }
            Show-UserSectionTable @sectionParams
        }

        # Add all data to export: the User 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 "User" -Items @([PSCustomObject]@{
                displayName      = $upn
                id               = $userInfo.Id
                AssignmentReason = "N/A"
            })

        $reasonProperty = { param($item) $item.AssignmentReason }
        $exportBatches = @(
            @{ Ids = @('DeviceConfigurations', 'SettingsCatalog', 'CompliancePolicies'); Reason = $reasonProperty }
            # App Protection rows export the AssignmentSummary built during the scan
            @{ 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 "IntuneUserAssignments.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode
}