Skip to content

Policy Assignments

Resolves which policies land on which groups, so 'why did this device get that setting?' has an answer.

Download the script View on GitHub

1 · The script

Read-only by construction — it authenticates with a Managed Identity, calls Microsoft Graph with GET only, and writes a sanitized CSV snapshot. Set the three CONFIG values at the top, then run.

View the full script (26 KB)
#requires -Version 7.0
<#
.SYNOPSIS
    Collect-PolicyAssignments.ps1 — the policy-assignment collector for the Zero-Access
    Pattern. Read-only, Managed-Identity, pre-aggregating.

.DESCRIPTION
    Sweeps every Intune policy type (compliance, configuration profiles, settings catalog,
    endpoint-security intents), flattens their assignments, resolves each targeted group once
    (static groups → members; dynamic groups → membership rule), flags assignments pointing
    at deleted groups, and writes a family of CSVs:

      root/ (full, with IDs — for Power BI):
        PolicyAssignments.csv, UnassignedPolicies.csv, PolicyGroupMembership(.csv|_Part###),
        DeletedGroupAssignments.csv, DynamicGroupRules.csv, PolicyAssignments_Summary.csv
      agent-data/ (slim, no IDs, size-gated — for the search index): the same set

    The per-policy Summary is the important one for the agent: ONE row per policy carrying the
    COMPLETE include/exclude targeting, so the agent answers "who is this assigned to" from a
    single complete document instead of a partial sample of assignment rows.

    Read-only throughout — every call is a GET. Nothing is written to the tenant.

.NOTES
    GENERIC / PARAMETERIZED: no resource group, storage account, or container is hardcoded.
    Run against a personal lab tenant only.

    BETA ENDPOINT: policy lists use https://graph.microsoft.com/beta; group resolution uses
    /v1.0. Beta endpoints can change without notice — re-verify after Graph updates.

    Read-only Graph app roles on the Managed Identity:
      DeviceManagementConfiguration.Read.All   (policies + endpoint-security intents)
      Group.Read.All, Directory.Read.All       (group metadata + transitiveMembers)
    Plus "Storage Blob Data Contributor" on the storage account (to write CSVs).

    MIT licensed. Microsoft, Intune, Entra, Microsoft Graph and Azure are trademarks of the
    Microsoft group of companies. Independent content; not endorsed by Microsoft. Verify every
    endpoint and permission in your own lab tenant before relying on it.
#>

# ===========================================================================
#  CONFIGURE ME  ->  set these to your own values, then run.
#  These three lines are the only thing you MUST change.
# ===========================================================================
$ResourceGroup  = "<your-resource-group-name>"     # resource group that holds your storage account
$StorageAccount = "<your-storage-account-name>"    # storage account name (lowercase, globally unique)
$Container      = "<your-container-name>"           # blob container, e.g. "intune-report"

# -- optional settings (safe to leave as they are) --------------------------
$Keyword = ""   # optional filter: leave empty for everything (e.g. "Compliance","USB","BitLocker")
# ===========================================================================

# Safety net - stop if the placeholders above weren't replaced.
if ("$ResourceGroup $StorageAccount $Container" -match '<your-') {
    throw "Please set ResourceGroup, StorageAccount, and Container at the top of the script before running."
}

# --- Step 1: Initialize Variables
$GraphBase   = "https://graph.microsoft.com/beta"
$GraphBaseV1 = "https://graph.microsoft.com/v1.0"

$ExportDir = $env:TEMP
$Today     = Get-Date -Format 'yyyy-MM-dd'

$ProgressPreference = 'SilentlyContinue'
$VerbosePreference  = 'Continue'

# Hardening options
$MaxGraphRetries               = 8
$BaseRetrySeconds              = 3
$InterRequestDelayMs           = 75
$GroupChunkRowCount            = 250000
$EnableGroupMembershipChunking = $true
$UploadChunkFilesToAgentData   = $true

# Cap for the semicolon-joined target lists in the summary file
$TargetListMaxChars = 1500

Write-Verbose "Script started at $(Get-Date)"


# --- Step 2: Authenticate with Azure Managed Identity
function Get-ManagedIdentityToken {
    $url = $env:IDENTITY_ENDPOINT
    $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
    $headers.Add("X-IDENTITY-HEADER", $env:IDENTITY_HEADER)
    $headers.Add("Metadata", "True")
    $body = @{ resource = 'https://graph.microsoft.com/' }

    Write-Verbose "Obtaining access token"
    $token = (Invoke-RestMethod $url -Method 'POST' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body $body).access_token
    Write-Verbose "Access token obtained successfully"
    return $token
}

$accessToken = Get-ManagedIdentityToken
$script:Headers = @{ 'Authorization' = "Bearer $accessToken" }


# --- Step 3: Get storage account context
try {
    $StorageAccountContext = (Get-AzStorageAccount -ResourceGroupName $ResourceGroup -Name $StorageAccount).Context
    Write-Verbose "Storage account context obtained successfully"
}
catch {
    Write-Error "Failed to get storage account context: $_"
    return
}


# --- Step 4: Graph GET with retry + pagination
function Invoke-MyGraphGetRequest {
    param (
        [Parameter(Mandatory = $true)]
        [string]$URL
    )

    Write-Verbose "Sending request to $URL"
    $AllResults = @()
    $NextUrl = $URL

    while ($NextUrl) {
        $Attempt = 0
        $Succeeded = $false

        while (-not $Succeeded -and $Attempt -lt $MaxGraphRetries) {
            $Attempt++

            try {
                $Response = Invoke-WebRequest -Uri $NextUrl -Method GET -Headers $script:Headers -UseBasicParsing -ErrorAction Stop
                $Succeeded = $true

                if ($InterRequestDelayMs -gt 0) {
                    Start-Sleep -Milliseconds $InterRequestDelayMs
                }

                Write-Verbose "Received response from $NextUrl"
                $ResponseData = $Response.Content | ConvertFrom-Json

                if ($null -ne $ResponseData.value) {
                    $AllResults += $ResponseData.value
                    $NextUrl = $ResponseData.'@odata.nextLink'
                }
                else {
                    return $ResponseData
                }
            }
            catch {
                $StatusCode = $null
                $RetryAfter = $null

                if ($_.Exception.Response) {
                    try { $StatusCode = [int]$_.Exception.Response.StatusCode } catch {}
                    try { $RetryAfter = $_.Exception.Response.Headers['Retry-After'] } catch {}
                }

                if ($StatusCode -in 429, 503, 504) {
                    $WaitSeconds = if ($RetryAfter) {
                        [int]$RetryAfter
                    }
                    else {
                        [math]::Min(60, ($BaseRetrySeconds * [math]::Pow(2, ($Attempt - 1))))
                    }

                    Write-Warning "Transient Graph error $StatusCode for $NextUrl. Retry $Attempt/$MaxGraphRetries after $WaitSeconds seconds."
                    Start-Sleep -Seconds $WaitSeconds
                }
                else {
                    Write-Warning "Failed to fetch data from ${NextUrl}: $_"
                    return $null
                }
            }
        }

        if (-not $Succeeded) {
            Write-Warning "Exceeded retry count for $NextUrl"
            return $null
        }
    }

    return $AllResults
}


# --- Step 5: Define policy endpoints to sweep
$PolicyEndpoints = @(
    @{
        Type = 'Compliance'
        ListUri = "$GraphBase/deviceManagement/deviceCompliancePolicies?`$expand=assignments"
        AssignmentMode = 'ExpandedOnly'
        AssignmentsUri = $null
    }
    @{
        Type = 'ConfigurationProfile'
        ListUri = "$GraphBase/deviceManagement/deviceConfigurations?`$expand=assignments"
        AssignmentMode = 'ExpandedOnly'
        AssignmentsUri = $null
    }
    @{
        Type = 'SettingsCatalog'
        ListUri = "$GraphBase/deviceManagement/configurationPolicies?`$expand=assignments"
        AssignmentMode = 'ExpandedOnly'
        AssignmentsUri = $null
    }
    @{
        Type = 'EndpointSecurity'
        ListUri = "$GraphBase/deviceManagement/intents"
        AssignmentMode = 'PerPolicy'
        AssignmentsUri = "$GraphBase/deviceManagement/intents/{0}/assignments"
    }
)


# --- Step 6: Helper functions
function Get-PolicyName($p) {
    foreach ($f in 'displayName', 'name') {
        if ($p.$f) { return $p.$f }
    }
    return $p.id
}

function Get-PolicyAssignments {
    param(
        [Parameter(Mandatory = $true)]$Policy,
        [Parameter(Mandatory = $true)]$Endpoint
    )

    switch ($Endpoint.AssignmentMode) {
        'PerPolicy' {
            $uri = [string]::Format($Endpoint.AssignmentsUri, $Policy.id)
            $assignments = Invoke-MyGraphGetRequest -URL $uri
            return @($assignments)
        }
        'ExpandedOnly' {
            return @($Policy.assignments)
        }
        default {
            return @()
        }
    }
}


# --- Step 7: Sweep policies, flatten assignments, collect distinct group IDs
$assignmentRows = [System.Collections.Generic.List[object]]::new()
$groupIds       = [System.Collections.Generic.HashSet[string]]::new()

foreach ($ep in $PolicyEndpoints) {
    Write-Verbose "Sweeping $($ep.Type)"
    $policies = Invoke-MyGraphGetRequest -URL $ep.ListUri

    if (-not $policies) {
        Write-Warning "No data returned for $($ep.Type)"
        continue
    }

    foreach ($pol in $policies) {
        $polName = Get-PolicyName $pol

        if ($Keyword -and
            ($polName -notmatch [regex]::Escape($Keyword)) -and
            ($ep.Type -notmatch [regex]::Escape($Keyword))) {
            continue
        }

        $asgs = Get-PolicyAssignments -Policy $pol -Endpoint $ep

        if (-not $asgs -or $asgs.Count -eq 0) {
            $assignmentRows.Add([pscustomobject]@{
                PolicyName = $polName
                PolicyType = $ep.Type
                PolicyId = $pol.id
                Intent = ''
                TargetType = 'UNASSIGNED'
                TargetGroupId = ''
                TargetGroupName = '(none)'
                TargetGroupStatus = ''
                IsDynamicGroup = ''
                GroupMembershipRule = ''
                GroupMembershipRuleProcessingState = ''
                SnapshotDate = $Today
            })
            continue
        }

        foreach ($a in $asgs) {
            $t = $a.target

            if (-not $t) {
                $assignmentRows.Add([pscustomobject]@{
                    PolicyName = $polName
                    PolicyType = $ep.Type
                    PolicyId = $pol.id
                    Intent = 'Include'
                    TargetType = 'Unknown'
                    TargetGroupId = ''
                    TargetGroupName = '(missing target)'
                    TargetGroupStatus = ''
                    IsDynamicGroup = ''
                    GroupMembershipRule = ''
                    GroupMembershipRuleProcessingState = ''
                    SnapshotDate = $Today
                })
                continue
            }

            $odata  = $t.'@odata.type'
            $intent = if ($odata -match 'exclusion') { 'Exclude' } else { 'Include' }

            switch -Regex ($odata) {
                'allDevices' {
                    $tType = 'All Devices'
                    $gid   = ''
                    $gname = 'All Devices'
                    break
                }
                'allLicensedUsers' {
                    $tType = 'All Users'
                    $gid   = ''
                    $gname = 'All Users'
                    break
                }
                'groupAssignment|exclusionGroup' {
                    $tType = 'Group'
                    $gid   = $t.groupId
                    $gname = $gid
                    if ($gid) { [void]$groupIds.Add($gid) }
                    break
                }
                default {
                    $tType = if ($odata) { $odata } else { 'Unknown' }
                    $gid   = ''
                    $gname = if ($odata) { $odata } else { '(unknown target)' }
                }
            }

            $assignmentRows.Add([pscustomobject]@{
                PolicyName = $polName
                PolicyType = $ep.Type
                PolicyId = $pol.id
                Intent = $intent
                TargetType = $tType
                TargetGroupId = $gid
                TargetGroupName = $gname
                TargetGroupStatus = ''
                IsDynamicGroup = ''
                GroupMembershipRule = ''
                GroupMembershipRuleProcessingState = ''
                SnapshotDate = $Today
            })
        }
    }
}


# --- Step 8: Resolve each distinct group once: metadata + members only for static groups
$groupCache = @{}
$deletedGroupAssignments = [System.Collections.Generic.List[object]]::new()

Write-Verbose "Resolving group names and metadata..."

foreach ($gid in $groupIds) {
    $groupUrl = "{0}/groups/{1}?`$select=id,displayName,groupTypes,membershipRule,membershipRuleProcessingState" -f $GraphBaseV1, $gid
    $groupObj = Invoke-MyGraphGetRequest -URL $groupUrl

    if (-not $groupObj -or -not $groupObj.id) {
        Write-Warning "Group not found or no longer resolvable: $gid"
        $groupCache[$gid] = @{
            Name = $gid
            Exists = $false
            IsDynamic = $false
            MembershipRule = $null
            MembershipRuleProcessingState = $null
            Members = @()
        }
        continue
    }

    $isDynamic = @($groupObj.groupTypes) -contains 'DynamicMembership'

    if ($isDynamic) {
        $groupCache[$gid] = @{
            Name = $groupObj.displayName
            Exists = $true
            IsDynamic = $true
            MembershipRule = $groupObj.membershipRule
            MembershipRuleProcessingState = $groupObj.membershipRuleProcessingState
            Members = @()
        }
    }
    else {
        $members = Invoke-MyGraphGetRequest -URL ("{0}/groups/{1}/transitiveMembers?`$select=id,displayName,deviceId,userPrincipalName" -f $GraphBaseV1, $gid)

        $groupCache[$gid] = @{
            Name = $groupObj.displayName
            Exists = $true
            IsDynamic = $false
            MembershipRule = $null
            MembershipRuleProcessingState = $null
            Members = @(
                $members | ForEach-Object {
                    [pscustomobject]@{
                        MemberType = if ($_.userPrincipalName) { 'User' } elseif ($_.deviceId) { 'Device' } else { 'Other' }
                        MemberName = if ($_.userPrincipalName) { $_.userPrincipalName } else { $_.displayName }
                        MemberId   = $_.id
                    }
                }
            )
        }
    }
}


# Backfill group metadata into assignment rows
foreach ($row in $assignmentRows) {
    if ($row.TargetGroupId -and $groupCache.ContainsKey($row.TargetGroupId)) {
        $g = $groupCache[$row.TargetGroupId]

        $row.TargetGroupName = $g.Name
        $row.TargetGroupStatus = if ($g.Exists) { 'Active' } else { 'DeletedOrMissing' }
        $row.IsDynamicGroup = if ($g.Exists) { [string]$g.IsDynamic } else { '' }
        $row.GroupMembershipRule = if ($g.IsDynamic) { $g.MembershipRule } else { '' }
        $row.GroupMembershipRuleProcessingState = if ($g.IsDynamic) { $g.MembershipRuleProcessingState } else { '' }

        if (-not $g.Exists) {
            $deletedGroupAssignments.Add([pscustomobject]@{
                PolicyName = $row.PolicyName
                PolicyType = $row.PolicyType
                PolicyId = $row.PolicyId
                Intent = $row.Intent
                TargetType = $row.TargetType
                DeletedGroupId = $row.TargetGroupId
                DeletedGroupName = $row.TargetGroupName
                SnapshotDate = $Today
            })
        }
    }
}


# --- Step 9: Build outputs
# 9a. PolicyAssignments.csv
$policyAssignments = $assignmentRows | ForEach-Object {
    $mc = ''
    if ($_.TargetGroupId -and $groupCache.ContainsKey($_.TargetGroupId)) {
        $g = $groupCache[$_.TargetGroupId]
        if ($g.Exists -and -not $g.IsDynamic) {
            $mc = $g.Members.Count
        }
    }

    $_ | Add-Member -NotePropertyName MemberCount -NotePropertyValue $mc -PassThru
}

# 9b. UnassignedPolicies.csv
$unassigned = $assignmentRows |
    Where-Object { $_.TargetType -eq 'UNASSIGNED' } |
    Select-Object PolicyName, PolicyType, PolicyId, SnapshotDate

# 9c. PolicyGroupMembership.csv
# Only static groups are expanded to members
$groupMembership = foreach ($gid in $groupCache.Keys) {
    $g = $groupCache[$gid]

    if (-not $g.Exists) { continue }
    if ($g.IsDynamic) { continue }

    foreach ($m in $g.Members) {
        [pscustomobject]@{
            GroupId      = $gid
            GroupName    = $g.Name
            MemberType   = $m.MemberType
            MemberName   = $m.MemberName
            MemberId     = $m.MemberId
            SnapshotDate = $Today
        }
    }
}

# 9d. DeletedGroupAssignments.csv
$deletedGroupAssignmentsCsv = $deletedGroupAssignments |
    Sort-Object PolicyType, PolicyName, DeletedGroupId -Unique

# 9e. DynamicGroupRules.csv
$dynamicGroupRules = foreach ($gid in $groupCache.Keys) {
    $g = $groupCache[$gid]
    if ($g.Exists -and $g.IsDynamic) {
        [pscustomobject]@{
            GroupId                       = $gid
            GroupName                     = $g.Name
            MembershipRule                = $g.MembershipRule
            MembershipRuleProcessingState = $g.MembershipRuleProcessingState
            SnapshotDate                  = $Today
        }
    }
}

# 9f. PolicyAssignments_Summary.csv
#     ONE row per policy = the COMPLETE targeting in a single searchable document.
#     Stops the agent building an answer from a partial sample of assignment rows.
#     Built AFTER the backfill loop so TargetGroupName holds real names, not GUIDs.
Write-Verbose "Building per-policy assignment summary..."

function Limit-TargetList {
    param([string]$Value, [int]$MaxChars)
    if ($Value.Length -gt $MaxChars) { return $Value.Substring(0, $MaxChars) + ' ...(truncated)' }
    return $Value
}

$policySummary = $assignmentRows | Group-Object PolicyName | ForEach-Object {

    $unassignedPolicy = @($_.Group | Where-Object { $_.TargetType -eq 'UNASSIGNED' }).Count -gt 0

    $inc = @($_.Group | Where-Object { $_.Intent -eq 'Include' } |
             Select-Object -ExpandProperty TargetGroupName -Unique)
    $exc = @($_.Group | Where-Object { $_.Intent -eq 'Exclude' } |
             Select-Object -ExpandProperty TargetGroupName -Unique)

    $incStr = Limit-TargetList -Value ($inc -join '; ') -MaxChars $TargetListMaxChars
    $excStr = Limit-TargetList -Value ($exc -join '; ') -MaxChars $TargetListMaxChars

    [pscustomobject]@{
        ReportType     = 'PolicyAssignmentSummary'
        PolicyName     = $_.Name
        PolicyType     = $_.Group[0].PolicyType
        IsUnassigned   = [string]$unassignedPolicy
        IncludeCount   = $inc.Count
        ExcludeCount   = $exc.Count
        IncludeTargets = $incStr
        ExcludeTargets = $excStr
        SnapshotDate   = $Today
    }
}

Write-Verbose "Summary rows: $(@($policySummary).Count)"


# --- Step 10: Slim agent copies (no IDs)
$policyAssignmentsAgent = $policyAssignments | Select-Object `
    PolicyName,
    PolicyType,
    Intent,
    TargetType,
    TargetGroupName,
    TargetGroupStatus,
    IsDynamicGroup,
    GroupMembershipRule,
    GroupMembershipRuleProcessingState,
    SnapshotDate,
    MemberCount

$unassignedAgent = $unassigned | Select-Object `
    PolicyName,
    PolicyType,
    SnapshotDate

$deletedGroupAssignmentsAgent = $deletedGroupAssignmentsCsv | Select-Object `
    PolicyName,
    PolicyType,
    Intent,
    TargetType,
    DeletedGroupName,
    SnapshotDate

$dynamicGroupRulesAgent = $dynamicGroupRules | Select-Object `
    GroupName,
    MembershipRule,
    MembershipRuleProcessingState,
    SnapshotDate

$groupMembershipAgent = $groupMembership | Select-Object `
    GroupName,
    MemberType,
    MemberName,
    SnapshotDate

# Summary has no IDs already - use as-is for both root and agent
$policySummaryAgent = $policySummary


# --- Step 11: Export helpers
function Publish-Csv {
    param(
        [Parameter(Mandatory = $true)]$Data,
        [Parameter(Mandatory = $true)][string]$Name,
        [switch]$SkipAgentCopy
    )

    if (-not $Data) {
        Write-Warning "No rows for $Name"
        return
    }

    $path = Join-Path $ExportDir $Name
    $Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force

    Set-AzStorageBlobContent -File $path -Container $Container -Blob $Name -Context $StorageAccountContext -Force | Out-Null

    $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)
    if (-not $SkipAgentCopy) {
        if ($mb -le 12) {
            Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$Name" -Context $StorageAccountContext -Force | Out-Null
            Write-Output "$Name -> agent-data ($mb MB)"
        }
        else {
            Write-Warning "$Name is $mb MB - over gate; kept in Power BI root only."
        }
    }
    else {
        Write-Verbose "$Name uploaded only to root container path."
    }
}

function Publish-CsvAgentOnly {
    param(
        [Parameter(Mandatory = $true)]$Data,
        [Parameter(Mandatory = $true)][string]$Name
    )

    if (-not $Data) {
        Write-Warning "No rows for slim agent copy $Name"
        return
    }

    $path = Join-Path $ExportDir $Name
    $Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force

    $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)
    if ($mb -le 12) {
        Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$Name" -Context $StorageAccountContext -Force | Out-Null
        Write-Output "$Name -> agent-data ($mb MB)"
    }
    else {
        Write-Warning "$Name is $mb MB - over gate; slim agent copy not uploaded."
    }
}

function Publish-CsvChunks {
    param(
        [Parameter(Mandatory = $true)][array]$Data,
        [Parameter(Mandatory = $true)][string]$BaseName,
        [Parameter(Mandatory = $true)][int]$ChunkSize
    )

    if (-not $Data -or $Data.Count -eq 0) {
        Write-Warning "No rows for $BaseName"
        return
    }

    $totalRows = $Data.Count
    $chunkCount = [math]::Ceiling($totalRows / $ChunkSize)

    Write-Verbose "Chunking $BaseName into $chunkCount file(s) with up to $ChunkSize rows each"

    for ($i = 0; $i -lt $chunkCount; $i++) {
        $start = $i * $ChunkSize
        $end   = [math]::Min($start + $ChunkSize - 1, $totalRows - 1)
        $chunk = $Data[$start..$end]
        $chunkName = "{0}_Part{1:D3}.csv" -f $BaseName, ($i + 1)

        $path = Join-Path $ExportDir $chunkName
        $chunk | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force

        Set-AzStorageBlobContent -File $path -Container $Container -Blob $chunkName -Context $StorageAccountContext -Force | Out-Null

        $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)
        if ($UploadChunkFilesToAgentData -and $mb -le 12) {
            Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$chunkName" -Context $StorageAccountContext -Force | Out-Null
            Write-Output "$chunkName -> agent-data ($mb MB)"
        }
        elseif ($UploadChunkFilesToAgentData) {
            Write-Warning "$chunkName is $mb MB - over gate; uploaded to root only."
        }
    }
}

function Publish-CsvChunksAgentOnly {
    param(
        [Parameter(Mandatory = $true)][array]$Data,
        [Parameter(Mandatory = $true)][string]$BaseName,
        [Parameter(Mandatory = $true)][int]$ChunkSize
    )

    if (-not $Data -or $Data.Count -eq 0) {
        Write-Warning "No rows for slim agent chunk set $BaseName"
        return
    }

    $totalRows = $Data.Count
    $chunkCount = [math]::Ceiling($totalRows / $ChunkSize)

    Write-Verbose "Chunking slim agent copy $BaseName into $chunkCount file(s) with up to $ChunkSize rows each"

    for ($i = 0; $i -lt $chunkCount; $i++) {
        $start = $i * $ChunkSize
        $end   = [math]::Min($start + $ChunkSize - 1, $totalRows - 1)
        $chunk = $Data[$start..$end]
        $chunkName = "{0}_Part{1:D3}.csv" -f $BaseName, ($i + 1)

        $path = Join-Path $ExportDir $chunkName
        $chunk | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force

        $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)
        if ($mb -le 12) {
            Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$chunkName" -Context $StorageAccountContext -Force | Out-Null
            Write-Output "$chunkName -> agent-data ($mb MB)"
        }
        else {
            Write-Warning "$chunkName is $mb MB - over gate; slim agent copy not uploaded."
        }
    }
}


# --- Step 12: Publish full outputs to root + slim outputs to agent-data
Publish-Csv -Data $policyAssignments -Name "PolicyAssignments.csv" -SkipAgentCopy
Publish-Csv -Data $unassigned -Name "UnassignedPolicies.csv" -SkipAgentCopy
Publish-Csv -Data $deletedGroupAssignmentsCsv -Name "DeletedGroupAssignments.csv" -SkipAgentCopy
Publish-Csv -Data $dynamicGroupRules -Name "DynamicGroupRules.csv" -SkipAgentCopy
Publish-Csv -Data $policySummary -Name "PolicyAssignments_Summary.csv" -SkipAgentCopy

Publish-CsvAgentOnly -Data $policyAssignmentsAgent -Name "PolicyAssignments.csv"
Publish-CsvAgentOnly -Data $unassignedAgent -Name "UnassignedPolicies.csv"
Publish-CsvAgentOnly -Data $deletedGroupAssignmentsAgent -Name "DeletedGroupAssignments.csv"
Publish-CsvAgentOnly -Data $dynamicGroupRulesAgent -Name "DynamicGroupRules.csv"
Publish-CsvAgentOnly -Data $policySummaryAgent -Name "PolicyAssignments_Summary.csv"

if ($EnableGroupMembershipChunking -and $groupMembership.Count -gt $GroupChunkRowCount) {
    Publish-Csv -Data (
        [pscustomobject]@{
            FileType      = 'ChunkManifest'
            BaseFileName  = 'PolicyGroupMembership'
            TotalRows     = $groupMembership.Count
            ChunkRowCount = $GroupChunkRowCount
            SnapshotDate  = $Today
        }
    ) -Name "PolicyGroupMembership_Manifest.csv" -SkipAgentCopy

    Publish-CsvChunks -Data @($groupMembership) -BaseName "PolicyGroupMembership" -ChunkSize $GroupChunkRowCount

    Publish-CsvAgentOnly -Data (
        [pscustomobject]@{
            FileType      = 'ChunkManifest'
            BaseFileName  = 'PolicyGroupMembership'
            TotalRows     = $groupMembershipAgent.Count
            ChunkRowCount = $GroupChunkRowCount
            SnapshotDate  = $Today
        }
    ) -Name "PolicyGroupMembership_Manifest.csv"

    Publish-CsvChunksAgentOnly -Data @($groupMembershipAgent) -BaseName "PolicyGroupMembership" -ChunkSize $GroupChunkRowCount
}
else {
    Publish-Csv -Data $groupMembership -Name "PolicyGroupMembership.csv" -SkipAgentCopy
    Publish-CsvAgentOnly -Data $groupMembershipAgent -Name "PolicyGroupMembership.csv"
}

Write-Verbose "Script completed successfully at $(Get-Date)"

2 · The Power BI template

.pbit goes here

Drop policy-assignments.pbit into docs/assets/pbit/ and swap this note for a download button: [:material-download: Policy Assignments template](../assets/pbit/policy-assignments.pbit)

3 · Example report

Policy Assignments — example Power BI report

Replace report-placeholder.svg with a screenshot of your own Policy Assignments report.