Private/Collect/Invoke-PpaReadCmdlet.ps1

# Invoke-PpaReadCmdlet.ps1 - the read-only collector wrapper, plus the run manifest it feeds.
# Every tenant read goes through here. It runs a single Get-* cmdlet, captures any
# failure, and NEVER throws - so one missing cmdlet or access-denied degrades a single
# finding instead of failing the run (graceful degradation, PLAN.md section 2 & 9).
# ASCII-only source (Windows PowerShell 5.1).
#
# Read-only note: the cmdlet is invoked indirectly via the call operator on a string
# ($Name), so no mutating "Verb-Noun" literal ever appears here or in callers - callers
# pass the cmdlet name as a string (e.g. Invoke-PpaReadCmdlet -Name 'Get-Label').
#
# Run manifest (F-008): the chokepoint records every dispatched read - cmdlet name,
# resulting status, object count, UTC timestamp - into module-script state that the
# orchestrator writes alongside the report. METADATA ONLY: arguments, filter strings and
# returned data are NEVER recorded, so this on-disk artifact cannot widen the data surface.
# Accumulation is a no-op unless a run started it (Initialize-PpaRunManifest), and the
# recording sits OUTSIDE the verb guardrail - it never touches the read-only check.

Set-StrictMode -Off

# Module-script state for the run manifest. $null when no run is active.
$script:PpaRunManifest = $null       # List[object] of per-cmdlet entries
$script:PpaRunManifestInfo = $null   # run header (startedAt, PowerShell edition/version)

function Initialize-PpaRunManifest {
    # Begin a run manifest: reset the entry list and stamp the run-start header. PS 5.1-safe.
    [CmdletBinding()]
    param()
    $script:PpaRunManifest = New-Object System.Collections.Generic.List[object]
    $script:PpaRunManifestInfo = [ordered]@{
        startedAt = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
        psEdition = [string]$PSVersionTable.PSEdition
        psVersion = [string]$PSVersionTable.PSVersion
    }
}

function Write-PpaRunManifestEntry {
    # Record ONE dispatched read. Metadata only - cmdlet name, status, object count, UTC
    # timestamp; never arguments or returned data. No-op unless a run is active.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$Name,
        [Parameter(Mandatory = $true)][string]$Status,
        [int]$Count = 0
    )
    if ($null -eq $script:PpaRunManifest) { return }
    [void]$script:PpaRunManifest.Add([pscustomobject][ordered]@{
        cmdlet = $Name
        status = $Status
        count  = $Count
        at     = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
    })
}

function Get-PpaRunManifest {
    # Assemble the manifest object (run header + per-cmdlet entries), or $null when no run
    # is active. Pure - never touches disk.
    [CmdletBinding()]
    param([string]$PpaVersion = '')
    if ($null -eq $script:PpaRunManifest) { return $null }
    return [pscustomobject][ordered]@{
        tool          = 'PurviewPostureAnalyzer'
        kind          = 'run-manifest'
        schemaVersion = [pscustomobject][ordered]@{ major = 1; minor = 0 }
        ppaVersion    = [string]$PpaVersion
        startedAt     = [string]$script:PpaRunManifestInfo.startedAt
        endedAt       = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
        powerShell    = [pscustomobject][ordered]@{
            edition = [string]$script:PpaRunManifestInfo.psEdition
            version = [string]$script:PpaRunManifestInfo.psVersion
        }
        cmdlets       = @($script:PpaRunManifest.ToArray())
    }
}

function Export-PpaRunManifest {
    # Write the run manifest as JSON alongside the report (fixed name in the timestamped
    # reports dir, like posture-report.json). Metadata only, so it is safe unredacted and
    # there is nothing to redact. Returns the path, or $null when no run is active.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$Directory,
        [string]$PpaVersion = ''
    )
    $model = Get-PpaRunManifest -PpaVersion $PpaVersion
    if ($null -eq $model) { return $null }
    if (-not (Test-Path -LiteralPath $Directory)) {
        New-Item -ItemType Directory -Path $Directory -Force | Out-Null
    }
    $path = Join-Path $Directory 'posture-run-manifest.json'
    [System.IO.File]::WriteAllText($path, (ConvertTo-Json -InputObject $model -Depth 8), (New-Object System.Text.UTF8Encoding($false)))
    Write-Host ("Manifest : {0} ({1} cmdlet call(s) recorded; metadata only - names, statuses, counts, timestamps)" -f $path, @($model.cmdlets).Count)
    return $path
}

function Invoke-PpaReadCmdlet {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$Name,
        [hashtable]$Arguments = @{}
    )

    # Guardrail: only ever dispatch read verbs. Refuse anything that could mutate.
    $verb = ($Name -split '-', 2)[0]
    if ($verb -notin @('Get', 'Search', 'Test', 'Resolve')) {
        Write-Verbose "Invoke-PpaReadCmdlet: refused non-read cmdlet '$Name'."
        Write-PpaRunManifestEntry -Name $Name -Status 'Blocked' -Count 0
        return [pscustomobject]@{ Name = $Name; Status = 'Blocked'; Data = @(); Error = "Refused non-read cmdlet '$Name'." }
    }

    if (-not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) {
        Write-Verbose "Invoke-PpaReadCmdlet: $Name -> CommandNotFound (session not connected, or module not imported)."
        Write-PpaRunManifestEntry -Name $Name -Status 'CommandNotFound' -Count 0
        return [pscustomobject]@{ Name = $Name; Status = 'CommandNotFound'; Data = @(); Error = "Cmdlet '$Name' is not available in this session." }
    }

    try {
        $result = & $Name @Arguments -ErrorAction Stop
        Write-Verbose ("Invoke-PpaReadCmdlet: {0} -> Ok ({1} object(s))." -f $Name, (@($result).Count))
        Write-PpaRunManifestEntry -Name $Name -Status 'Ok' -Count (@($result).Count)
        return [pscustomobject]@{ Name = $Name; Status = 'Ok'; Data = @($result); Error = $null }
    }
    catch {
        $msg = [string]$_.Exception.Message
        $status = if ($msg -match '(?i)access\s+is\s+denied|not\s+authorized|insufficient|permission|unauthorized|forbidden|isn.t connected|no active connection|not connected|connect-') { 'AccessDenied' } else { 'Error' }
        Write-Verbose ("Invoke-PpaReadCmdlet: {0} -> {1} [{2}]: {3}" -f $Name, $status, $_.Exception.GetType().Name, $msg)
        Write-PpaRunManifestEntry -Name $Name -Status $status -Count 0
        return [pscustomobject]@{ Name = $Name; Status = $status; Data = @(); Error = $msg }
    }
}