backup/Build-AssignmentReport.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
Generates a group-centric assignment report across all Intune policy categories.

.DESCRIPTION
Fetches every policy / app / script in the tenant, collects their assignments, inverts
the structure so the output is keyed by Azure AD group, and writes the result to
"Assignment Report\report.json" inside BackupPath.

The report shows, for each group:
  - groupName : Azure AD display name
  - groupType : DynamicMembership | StaticMembership
  - membershipRule : dynamic rule string (null for static groups)
  - assignedTo : object whose keys are Intune category names, each containing an
                      array of { name, type, intent } objects

.PARAMETER BackupPath
The output root directory (same as the rest of the backup).

.PARAMETER Token
Bearer token for Microsoft Graph as a SecureString.

.PARAMETER ScopeTagMap
Unused by this function; present to match the standard backup module signature.

.EXAMPLE
Build-AssignmentReport -BackupPath C:\Backup -Token $token
#>

function Build-AssignmentReport {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$BackupPath,
        [Parameter(Mandatory)] [SecureString]$Token,
        [hashtable]$ScopeTagMap = @{}
    )

    try {
        $folder = Join-Path $BackupPath 'Assignment Report'

        # ---------------------------------------------------------------------------
        # Category definitions
        # Label : key used in the assignedTo object
        # ListUri : Graph endpoint to enumerate items
        # AssignBase : /beta/<AssignBase>/{id}/assignments — $null for app-protection
        # IntentMode : 'field' = read from assignment.intent
        # 'apply' = hardcode "apply"
        # 'empty' = ""
        # TypeFromOdata : $true = strip '#microsoft.graph.' from @odata.type
        # $false = always ""
        # Multiple rows may share the same Label; items accumulate in the same bucket.
        # ---------------------------------------------------------------------------
        $categories = @(
            @{ Label = 'Proactive Remediations';      ListUri = '/beta/deviceManagement/deviceHealthScripts';          AssignBase = 'deviceManagement/deviceHealthScripts';          IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Feature Updates';             ListUri = '/beta/deviceManagement/windowsFeatureUpdateProfiles'; AssignBase = 'deviceManagement/windowsFeatureUpdateProfiles';  IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Quality Updates';             ListUri = '/beta/deviceManagement/windowsQualityUpdateProfiles'; AssignBase = 'deviceManagement/windowsQualityUpdateProfiles';  IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Driver Updates';              ListUri = '/beta/deviceManagement/windowsDriverUpdateProfiles';  AssignBase = 'deviceManagement/windowsDriverUpdateProfiles';   IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Settings Catalog';            ListUri = '/beta/deviceManagement/configurationPolicies';        AssignBase = 'deviceManagement/configurationPolicies';         IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Device Configurations';       ListUri = '/beta/deviceManagement/deviceConfigurations';         AssignBase = 'deviceManagement/deviceConfigurations';          IntentMode = 'apply'; TypeFromOdata = $true  },
            @{ Label = 'Compliance Policies';         ListUri = '/beta/deviceManagement/compliancePolicies';           AssignBase = 'deviceManagement/compliancePolicies';            IntentMode = 'empty'; TypeFromOdata = $true  },
            @{ Label = 'Compliance Policies';         ListUri = '/beta/deviceManagement/deviceCompliancePolicies';     AssignBase = 'deviceManagement/deviceCompliancePolicies';      IntentMode = 'empty'; TypeFromOdata = $true  },
            @{ Label = 'Group Policy Configurations'; ListUri = '/beta/deviceManagement/groupPolicyConfigurations';    AssignBase = 'deviceManagement/groupPolicyConfigurations';     IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Management Intents';          ListUri = '/beta/deviceManagement/intents';                      AssignBase = 'deviceManagement/intents';                       IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Scripts';                     ListUri = '/beta/deviceManagement/deviceManagementScripts';      AssignBase = 'deviceManagement/deviceManagementScripts';       IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Scripts';                     ListUri = '/beta/deviceManagement/deviceShellScripts';           AssignBase = 'deviceManagement/deviceShellScripts';            IntentMode = 'empty'; TypeFromOdata = $false },
            @{ Label = 'Applications';                ListUri = '/beta/deviceAppManagement/mobileApps';                AssignBase = 'deviceAppManagement/mobileApps';                 IntentMode = 'field'; TypeFromOdata = $true  },
            @{ Label = 'App Protection';              ListUri = '/beta/deviceAppManagement/managedAppPolicies';        AssignBase = $null;                                            IntentMode = 'empty'; TypeFromOdata = $true  }
        )

        # Map app-protection @odata.type names to their specific assignments collection
        $appProtectionMap = @{
            'iosManagedAppProtection'               = 'deviceAppManagement/iosManagedAppProtections'
            'androidManagedAppProtection'           = 'deviceAppManagement/androidManagedAppProtections'
            'mdmWindowsInformationProtectionPolicy' = 'deviceAppManagement/mdmWindowsInformationProtectionPolicies'
            'windowsManagedAppProtection'           = 'deviceAppManagement/windowsManagedAppProtections'
            'targetedManagedAppConfiguration'       = 'deviceAppManagement/targetedManagedAppConfigurations'
        }

        # groupId -> [ordered]@{ groupName; groupType; membershipRule; assignedTo <ordered> }
        $groupAssignments = [ordered]@{}
        # groupId -> Graph group object (cached to avoid re-fetching)
        $groupCache = @{}

        # -----------------------------------------------------------------------
        # Main loop: enumerate every category, then every item, then its assignments
        # -----------------------------------------------------------------------
        foreach ($cat in $categories) {
            $items = @()
            try {
                $fetched = Invoke-GraphRequest2 -Uri $cat.ListUri -Token $Token
                if ($fetched) { $items = @($fetched) }
            }
            catch {
                Write-Verbose "Assignment report: failed to list '$($cat.Label)' from $($cat.ListUri): $_"
                continue
            }

            foreach ($item in $items) {
                $itemId   = $item.id
                $itemName = if ($item.displayName) { $item.displayName } elseif ($item.name) { $item.name } else { $itemId }
                $itemType = ''
                if ($cat.TypeFromOdata -and $item.'@odata.type') {
                    $itemType = $item.'@odata.type' -replace '#microsoft\.graph\.', ''
                }

                # Build the assignments URI
                if ($null -ne $cat.AssignBase) {
                    $assignUri = "/beta/$($cat.AssignBase)/$itemId/assignments"
                }
                else {
                    # App protection: derive collection from @odata.type
                    $typeName = $item.'@odata.type' -replace '#microsoft\.graph\.', ''
                    if (-not $appProtectionMap.ContainsKey($typeName)) {
                        Write-Verbose "Assignment report: unknown app-protection type '$typeName', skipping '$itemName'"
                        continue
                    }
                    $assignUri = "/beta/$($appProtectionMap[$typeName])/$itemId/assignments"
                }

                $assignments = @()
                try {
                    $fetched = Invoke-GraphRequest2 -Uri $assignUri -Token $Token
                    if ($fetched) { $assignments = @($fetched) }
                }
                catch {
                    Write-Verbose "Assignment report: failed to fetch assignments for '$itemName': $_"
                    continue
                }

                foreach ($assignment in $assignments) {
                    $target     = $assignment.target
                    $targetType = $target.'@odata.type'

                    # Only group-based targets (skip allDevices / allLicensedUsers etc.)
                    if ($targetType -notmatch 'groupAssignmentTarget|exclusionGroupAssignmentTarget') {
                        continue
                    }

                    $groupId = $target.groupId
                    if (-not $groupId) { continue }

                    # Resolve + cache group details
                    if (-not $groupCache.ContainsKey($groupId)) {
                        try {
                            $groupCache[$groupId] = Invoke-GraphRequest2 `
                                -Uri "/beta/groups/$($groupId)?`$select=id,displayName,groupTypes,membershipRule" `
                                -Token $Token
                        }
                        catch {
                            Write-Verbose "Assignment report: could not resolve group ${groupId}: $_"
                            $groupCache[$groupId] = [PSCustomObject]@{
                                displayName    = $groupId
                                groupTypes     = @()
                                membershipRule = $null
                            }
                        }
                    }

                    $grpInfo = $groupCache[$groupId]
                    $grpType = if (@($grpInfo.groupTypes) -contains 'DynamicMembership') { 'DynamicMembership' } else { 'StaticMembership' }

                    if (-not $groupAssignments.ContainsKey($groupId)) {
                        $groupAssignments[$groupId] = [ordered]@{
                            groupName      = $grpInfo.displayName
                            groupType      = $grpType
                            membershipRule = $grpInfo.membershipRule
                            assignedTo     = [ordered]@{}
                        }
                    }

                    $assignedTo = $groupAssignments[$groupId]['assignedTo']
                    if (-not $assignedTo.Contains($cat.Label)) {
                        $assignedTo[$cat.Label] = [System.Collections.Generic.List[object]]::new()
                    }

                    $intent = switch ($cat.IntentMode) {
                        'field'  { if ($assignment.intent) { $assignment.intent } else { '' } }
                        'apply'  { 'apply' }
                        default  { '' }
                    }

                    # Deduplicate: same item should appear only once per category per group
                    if (-not ($assignedTo[$cat.Label] | Where-Object { $_.name -eq $itemName })) {
                        $assignedTo[$cat.Label].Add([PSCustomObject]@{
                            name   = $itemName
                            type   = $itemType
                            intent = $intent
                        })
                    }
                }
            }
        }

        if ($groupAssignments.Count -eq 0) {
            Write-Verbose 'Assignment report: no group assignments found'
            return
        }

        # Build sorted output — convert List values to plain arrays for clean JSON
        $report = @(
            $groupAssignments.GetEnumerator() |
            Sort-Object { $_.Value['groupName'] } |
            ForEach-Object {
                $entry      = $_.Value
                $assignedTo = [ordered]@{}
                foreach ($key in $entry['assignedTo'].Keys) {
                    $assignedTo[$key] = @($entry['assignedTo'][$key])
                }
                [ordered]@{
                    groupName      = $entry['groupName']
                    groupType      = $entry['groupType']
                    membershipRule = $entry['membershipRule']
                    assignedTo     = $assignedTo
                }
            }
        )

        if (!(Test-Path -Path $folder -PathType Container)) {
            New-Item -ItemType Directory -Path $folder -Force > $null
        }

        $filePath = Join-Path $folder 'report.json'
        $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $filePath -Encoding UTF8 -Force

        Write-Verbose "Assignment report saved to $filePath ($($report.Count) groups)"
    }
    catch {
        Write-Error "Failed to build assignment report: $_"
        return
    }
}

Export-ModuleMember -Function Build-AssignmentReport