Public/Export-NSPEntraPairingQueue.ps1

function Export-NSPEntraPairingQueue {
    <#
    .SYNOPSIS
        Saves a queue as typed JSON, and optionally as a CSV for audit. Returns the JSON path.
    .DESCRIPTION
        Without -Path, the file goes under %LOCALAPPDATA%\NSP\UserPairing\<tenant ID>. A path inside a
        Git checkout is refused. The JSON is what Import-NSPEntraPairingQueue reads; the CSV is for
        people, and cannot be imported. Cells that a spreadsheet would run as a formula are prefixed
        with an apostrophe, because display names come from the directory. Both files hold personal
        data, so set appropriate ACLs on any folder you choose.
    .EXAMPLE
        Export-NSPEntraPairingQueue -Queue $queue -CsvPath 'D:\Secure\pairing-audit.csv'
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Queue,
        [string]$Path,
        [string]$CsvPath
    )

    if (-not $Path) {
        $name = 'pairing-{0}.queue.json' -f [datetime]::UtcNow.ToString('yyyyMMdd-HHmmss-fff')
        $Path = Join-Path (Join-Path (Get-NSPEntraPairingStatePath) $Queue.tenantId) $name
    }
    Assert-NSPEntraPairingStatePath -Path $Path
    if ($CsvPath) { Assert-NSPEntraPairingStatePath -Path $CsvPath }

    $folder = Split-Path -Parent ([IO.Path]::GetFullPath($Path))
    if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null }
    $utf8 = New-Object Text.UTF8Encoding($false)
    # Replace in one step: the review saves after every decision, and a failed write must not cost
    # the only copy.
    $temporary = "$Path.tmp"
    [IO.File]::WriteAllText($temporary, (ConvertTo-Json -InputObject $Queue -Depth 12), $utf8)
    # -WhatIf:$false: a caller's WhatIf must not leave the temporary file behind. Callers decide
    # whether to export at all.
    Move-Item -LiteralPath $temporary -Destination $Path -Force -WhatIf:$false

    if ($CsvPath) {
        $cell = { param($Value) ConvertTo-NSPEntraPairingCsvCell $Value }
        $rows = foreach ($pair in $Queue.pairs) {
            [pscustomobject]@{
                TenantId = $Queue.tenantId
                Forest = $Queue.forest
                AnchorStrategy = $Queue.sourceAnchor.strategy
                AnchorStatus = $Queue.sourceAnchor.status
                CloudObjectId = $pair.cloudObjectId
                CloudUserPrincipalName = & $cell $pair.cloudUpnAtReview
                CloudDisplayName = & $cell $pair.cloudDisplayNameAtReview
                Status = $pair.status
                Decision = $pair.decision
                AdObjectGuid = $pair.adObjectGuid
                AdSid = $pair.adSid
                AdDistinguishedName = & $cell $pair.adDistinguishedNameAtReview
                MatchEvidence = @($pair.matchEvidence) -join ';'
                DecidedAtUtc = $pair.decidedAtUtc
                DecidedBy = & $cell $pair.decidedBy
                Note = & $cell $pair.note
                ExecutionState = $pair.executionState
            }
        }
        $csvFolder = Split-Path -Parent ([IO.Path]::GetFullPath($CsvPath))
        if (-not (Test-Path -LiteralPath $csvFolder)) { New-Item -ItemType Directory -Path $csvFolder -Force | Out-Null }
        [IO.File]::WriteAllText($CsvPath, ((@($rows) | ConvertTo-Csv -NoTypeInformation) -join "`r`n"), $utf8)
    }
    $Path
}