Public/Save-InforcerReportOutput.ps1
|
<# .SYNOPSIS Downloads a report output to disk. Required API scope(s): Reports.Read (API key); Reports permission (API client, unconfirmed). .DESCRIPTION GET /reports/runs/{runId}/outputs/{outputId} (legacy API for an API-key login, v1 API for a client ID and secret login) returns the raw bytes plus a Content-Disposition filename. This cmdlet writes those bytes to -OutputPath using the server-suggested filename (sanitized) and emits a result object per saved file. The server's name is reduced to a plain file name (a leading dot becomes '_', so '.csv' is saved as '_.csv'), an existing file is never overwritten, and on Windows saved files are marked as downloaded from the internet. A redirect fails instead of being followed, and a throttled (429) download is not retried. Pipeline-friendly: pipe output records from Invoke-InforcerReport -NoSave or Get-InforcerReportRun -IncludeOutputs and every output is downloaded. .PARAMETER RunId The run identifier (GUID). Pipeline-bindable by property name. .PARAMETER OutputId The output identifier (string) — the id field on each output record. Pipeline-bindable by property name (also accepts -Id as an alias). .PARAMETER ReportType Pipeline-bindable. When piped from Invoke-InforcerReport -NoSave or Get-InforcerReportRun -IncludeOutputs, this is auto-populated from the upstream record and surfaced on the result object so output formatting matches Invoke-InforcerReport. Otherwise $null. .PARAMETER OutputFormat Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped. .PARAMETER TenantId Pipeline-bindable. Same as ReportType — propagated from the upstream record when piped. API key: numeric Client Tenant ID; client ID and secret: Microsoft tenant ID (GUID). Collated outputs carry no tenant ($null). Aliases: -ClientTenantId, -MsTenantId. .PARAMETER OutputPath Directory where downloaded outputs are written. Defaults to the current working directory. Created if it doesn't exist. Existing files are never overwritten: when the name is taken, the output is saved as 'name (1).ext', 'name (2).ext', and so on. .PARAMETER FileName Override the server-suggested filename. Sanitized for cross-platform safety. The file is written under this name directly; the server's name is not used at all. An existing file of this name is kept and the output is saved as 'name (1).ext'. .PARAMETER OutputType PowerShellObject (default) or JsonObject. .EXAMPLE Save-InforcerReportOutput -RunId 00000000-0000-0000-0000-000000000009 -OutputId out-1 Saves a single output to the current directory. .EXAMPLE Invoke-InforcerReport -ReportType ActiveUserCount -OutputFormat csv -TenantId 'Contoso' -NoSave | Save-InforcerReportOutput -OutputPath ./reports Queues + polls a report without saving, then downloads every output to ./reports. .EXAMPLE Get-InforcerReportRun -IncludeOutputs | ForEach-Object { $_.outputs } | Save-InforcerReportOutput -OutputPath ./bulk Bulk-downloads every output from every visible run. .EXAMPLE Save-InforcerReportOutput -RunId 00000000-0000-0000-0000-000000000009 -OutputId out-1 -OutputPath ./out -OutputType JsonObject Returns the saved-file result as a JSON string (depth 100) instead of objects. .OUTPUTS PSObject or String — per saved file, with { RunId, OutputId, TenantId, ReportType, OutputFormat, FilePath, FileName, FileSize, ContentType, CorrelationId }. TenantId, ReportType, and OutputFormat are $null unless populated via pipeline binding. .LINK https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#save-inforcerreportoutput .LINK Invoke-InforcerReport .LINK Get-InforcerReportRun #> function Save-InforcerReportOutput { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] [OutputType([PSObject], [string])] param( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 0)] [guid]$RunId, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, Position = 1)] [Alias('Id')] [string]$OutputId, # Pipeline pass-through properties from upstream cmdlets (Invoke-InforcerReport -NoSave, # Get-InforcerReportRun -IncludeOutputs). Surfaced on the result so it matches the # InforcerCommunity.ReportRunResult format view; left $null when called standalone. [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string]$ReportType, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [string]$OutputFormat, [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)] [Alias('ClientTenantId', 'MsTenantId')] [object]$TenantId, [Parameter(Mandatory = $false)] [string]$OutputPath = $PWD.Path, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$FileName, [Parameter(Mandatory = $false)] [ValidateSet('PowerShellObject', 'JsonObject')] [string]$OutputType = 'PowerShellObject' ) begin { # PowerShell quirk: `return` inside `begin` does NOT prevent `process` from firing for # piped items. Gate `process` on a "begin succeeded" flag instead. # Locals (NOT $script:*) so two parallel invocations sharing one module import don't # clobber each other's OutputPath. $beginOk = $false $resolvedOutputPath = $null if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected yet. Please run Connect-Inforcer first.' ` -ErrorId 'NotConnected' -Category ConnectionError return } try { # Defense-in-depth: refuse system paths. $null = Test-InforcerSafeOutputPath -Path $OutputPath if (-not (Test-Path -LiteralPath $OutputPath -PathType Container)) { $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop } if (Test-Path -LiteralPath $OutputPath -PathType Container) { $resolvedOutputPath = (Resolve-Path -LiteralPath $OutputPath).Path } elseif (-not $WhatIfPreference) { throw "Output directory '$OutputPath' was not created." } } catch { Write-Error -Message "Cannot prepare output directory '$OutputPath': $($_.Exception.Message)" ` -ErrorId 'OutputPathFailed' -Category InvalidArgument return } $beginOk = $true } process { if (-not $beginOk) { return } if ([string]::IsNullOrWhiteSpace($OutputId)) { Write-Error -Message 'OutputId is empty.' -ErrorId 'InvalidOutputId' -Category InvalidArgument return } $runIdStr = $RunId.ToString() $endpoint = "/reports/runs/$runIdStr/outputs/$OutputId" # -FileName wins over the server's Content-Disposition name; the helper sanitizes either one. $downloadParams = @{ Endpoint = $endpoint; DefaultFileName = ('{0}-{1}' -f $runIdStr, $OutputId); DestinationDirectory = $resolvedOutputPath } if (-not [string]::IsNullOrWhiteSpace($FileName)) { $downloadParams['FileName'] = $FileName } $target = "$runIdStr / $OutputId → $resolvedOutputPath" if (-not $PSCmdlet.ShouldProcess($target, 'Download report output')) { return } Write-Verbose "Downloading: $endpoint" try { # Stream straight to disk so multi-GB report outputs don't materialize in memory. $download = Invoke-InforcerRawDownload @downloadParams -ErrorAction Stop } catch { Write-Error -Message "Failed to download output ${OutputId}: $($_.Exception.Message)" ` -ErrorId 'DownloadFailed' -Category ReadError return } if ($null -eq $download) { return } $filePath = $download.FilePath $effectiveName = $download.FileName $fileSize = if ($download.PSObject.Properties['FileSize']) { $download.FileSize } else { (Get-Item -LiteralPath $filePath -ErrorAction SilentlyContinue).Length } $result = [PSCustomObject][ordered]@{ RunId = $runIdStr OutputId = $OutputId TenantId = if ($PSBoundParameters.ContainsKey('TenantId')) { $TenantId } else { $null } ReportType = if ($PSBoundParameters.ContainsKey('ReportType')) { $ReportType } else { $null } OutputFormat = if ($PSBoundParameters.ContainsKey('OutputFormat')) { $OutputFormat } else { $null } FilePath = $filePath FileName = $effectiveName FileSize = $fileSize ContentType = $download.ContentType CorrelationId = $download.CorrelationId } $result.PSObject.TypeNames.Insert(0, 'InforcerCommunity.ReportRunResult') if ($OutputType -eq 'JsonObject') { $result | ConvertTo-Json -Depth 100 } else { $result } } } |