private/tenantinfo/Add-ZtOverviewPrivateAccess.ps1

<#
.SYNOPSIS
    Builds the Private Access Zero Trust posture funnel (spec 27021).
 
.DESCRIPTION
    Aggregates the per-item rows published by the segmentation (25395), authentication (25396)
    and administration (25384) checks into a Sankey funnel.
 
    Each gate keeps its child roll-up verdict; the flow widths only quantify the gap and never
    replace that verdict. A child that was skipped, errored, timed out or was excluded from the
    run has no verdict, so its band is reported as unavailable instead of being folded into pass,
    fail or manual review.
 
    Every Private Access application enters at the source node and flows through two sequential
    gates. Segmentation and authentication are joined on App ID over the union of both app
    populations, so authentication-only apps (for example Quick Access) stay visible. Only apps
    that clear segmentation are partitioned by the authentication gate. Administration is
    denominated in role assignments, not applications, so it is rendered as a separate band.
#>


function Add-ZtOverviewPrivateAccess {
    [CmdletBinding()]
    param()

    $tenantInfoName = 'OverviewPrivateAccess'

    $activity = 'Building Private Access Zero Trust posture'
    Write-ZtProgress -Activity $activity -Status 'Processing'

    # Skipped, errored and never-run children have no verdict to roll up, so their gate is unavailable
    $gateStatuses = @{}
    foreach ($testId in '25395', '25396', '25384') {
        $gateStatuses[$testId] = switch (Get-ZtTestResultStatus -TestId $testId) {
            'Passed' { 'Passed' }
            'Failed' { 'Failed' }
            'Investigate' { 'Investigate' }
            default { 'Unavailable' }
        }
    }

    $segmentationGate = $gateStatuses['25395']
    $authenticationGate = $gateStatuses['25396']
    $administrationGate = $gateStatuses['25384']

    $segmentationAvailable = $segmentationGate -ne 'Unavailable'
    $authenticationAvailable = $authenticationGate -ne 'Unavailable'
    $administrationAvailable = $administrationGate -ne 'Unavailable'

    if (-not ($segmentationAvailable -or $authenticationAvailable -or $administrationAvailable)) {
        Write-PSFMessage '🟦 Skipping: No Private Access check results available' -Tag Test -Level VeryVerbose
        Add-ZtTenantInfo -Name $tenantInfoName -Value $null
        return
    }

    $segmentationRows = @(Get-ZtTestData -Name 'PrivateAccessSegmentation')
    $authenticationRows = @(Get-ZtTestData -Name 'PrivateAccessAuthentication')
    $administration = Get-ZtTestData -Name 'PrivateAccessAdministration'

    # Distinct, case-insensitive App IDs: equal row counts do not prove the children saw the same apps
    $segmentationByAppId = @{}
    foreach ($row in $segmentationRows) {
        if ($row.AppId) {
            $segmentationByAppId[([string]$row.AppId).Trim().ToLowerInvariant()] = [string]$row.Status
        }
    }

    $authenticationByAppId = @{}
    foreach ($row in $authenticationRows) {
        if ($row.AppId) {
            $authenticationByAppId[([string]$row.AppId).Trim().ToLowerInvariant()] = [string]$row.Status
        }
    }

    # Denominate the funnel in the union so authentication-only apps are not dropped from the source
    $population = @(@($segmentationByAppId.Keys) + @($authenticationByAppId.Keys) | Select-Object -Unique)

    # Gate 1 - partition every Private Access app by its segmentation status
    $broadSegments = 0
    $segmentationReview = 0
    $segmentationUnavailable = 0
    $leastPrivilegeApps = [System.Collections.Generic.List[string]]::new()

    foreach ($appId in $population) {
        switch ($segmentationByAppId[$appId]) {
            'Fail' { $broadSegments++ }
            'ManualReview' { $segmentationReview++ }
            'Pass' { $leastPrivilegeApps.Add($appId) }
            # No segmentation row: 25395 never evaluated this app, so its segmentation state is unknown
            default { $segmentationUnavailable++ }
        }
    }

    # Gate 2 - partition only the segmentation-clean apps, joined to the auth gate on App ID
    $bothPopulationsComplete = $segmentationAvailable -and $authenticationAvailable
    $passwordOnly = 0
    $authenticationReview = 0
    $authenticationUnavailable = 0
    $strongAuth = 0

    foreach ($appId in $leastPrivilegeApps) {
        switch ($authenticationByAppId[$appId]) {
            'Pass' { $strongAuth++ }
            'Fail' { $passwordOnly++ }
            'ManualReview' { $authenticationReview++ }
            default {
                # An unmatched app is only a genuine review item when both children completed
                if ($bothPopulationsComplete) { $authenticationReview++ } else { $authenticationUnavailable++ }
            }
        }
    }

    # Gate 3 - separate band denominated in Application Administrator assignments
    $tenantWideAdmin = 0
    $scopedAdminAtRisk = 0
    $scopedAdminZeroTrust = 0
    if ($administrationAvailable -and $administration) {
        $tenantWideAdmin = [Math]::Max(0, ($administration.TenantWide -as [int]) ?? 0)
        $scopedTotal = [Math]::Max(0, ($administration.Scoped -as [int]) ?? 0)
        # Scoped assignments held by groups, service principals or guests still fail 25384
        $scopedAdminAtRisk = [Math]::Min($scopedTotal, [Math]::Max(0, ($administration.ScopedAtRisk -as [int]) ?? 0))
        $scopedAdminZeroTrust = $scopedTotal - $scopedAdminAtRisk
    }

    $gates = @(
        [PSCustomObject]@{ testId = '25395'; name = 'Least-privilege segmentation'; status = $segmentationGate }
        [PSCustomObject]@{ testId = '25396'; name = 'Strong authentication'; status = $authenticationGate }
        [PSCustomObject]@{ testId = '25384'; name = 'Administrative containment'; status = $administrationGate }
    )

    $allGateStatuses = @($segmentationGate, $authenticationGate, $administrationGate)
    $degraded = $allGateStatuses -contains 'Unavailable'

    $populationMismatch = $false
    if ($bothPopulationsComplete) {
        $populationMismatch =
            @($segmentationByAppId.Keys | Where-Object { -not $authenticationByAppId.ContainsKey($_) }).Count -gt 0 -or
            @($authenticationByAppId.Keys | Where-Object { -not $segmentationByAppId.ContainsKey($_) }).Count -gt 0
    }

    $overallStatus = if ($allGateStatuses -contains 'Failed') {
        'Failed'
    }
    # An unavailable gate cannot be asserted to pass, so the overall result stays short of Passed
    elseif (($allGateStatuses -contains 'Investigate') -or $degraded -or $populationMismatch) {
        'Investigate'
    }
    else {
        'Passed'
    }

    $nodes = @(
        @{ source = 'Private Access apps'; target = 'Broad segments - at-risk'; value = $broadSegments }
        @{ source = 'Private Access apps'; target = 'Segmentation manual review'; value = $segmentationReview }
        @{ source = 'Private Access apps'; target = 'Segmentation unavailable'; value = $segmentationUnavailable }
        @{ source = 'Private Access apps'; target = 'Least-privilege segments'; value = $leastPrivilegeApps.Count }
        @{ source = 'Least-privilege segments'; target = 'Password-only - at-risk'; value = $passwordOnly }
        @{ source = 'Least-privilege segments'; target = 'Authentication manual review'; value = $authenticationReview }
        @{ source = 'Least-privilege segments'; target = 'Authentication unavailable'; value = $authenticationUnavailable }
        @{ source = 'Least-privilege segments'; target = 'Strong auth - Zero Trust'; value = $strongAuth }
        @{ source = 'Application Administrator assignments'; target = 'Tenant-wide admin - at-risk'; value = $tenantWideAdmin }
        @{ source = 'Application Administrator assignments'; target = 'App-scoped admin - at-risk'; value = $scopedAdminAtRisk }
        @{ source = 'Application Administrator assignments'; target = 'App-scoped admin - Zero Trust'; value = $scopedAdminZeroTrust }
        @{ source = 'Application Administrator assignments'; target = 'Administration unavailable'; value = [int](-not $administrationAvailable) }
    )

    $description = "$($population.Count) Private Access application(s) evaluated. $strongAuth reached the Zero Trust set by clearing both least-privilege segmentation and strong authentication."
    if ($degraded) {
        $unavailableGates = @($gates | Where-Object { $_.status -eq 'Unavailable' } | ForEach-Object { $_.name })
        $description += " Flow widths are incomplete because the following gate(s) produced no result: $($unavailableGates -join ', ')."
    }

    $summary = @{
        description        = $description
        nodes              = $nodes
        # Per-gate roll-up verdicts; the widths quantify the gap but never determine the verdict
        gates              = $gates
        overallStatus      = $overallStatus
        # At least one gate has no verdict, so the funnel is only a partial view
        degraded           = $degraded
        applicationCount   = $population.Count
        # An over-privileged Application Administrator can rewrite segments and authentication for every app
        adminAtRisk        = ($tenantWideAdmin + $scopedAdminAtRisk) -gt 0
        tenantWideAdmin    = $tenantWideAdmin
        scopedAdminAtRisk  = $scopedAdminAtRisk
        # The two gates evaluated different app sets, so unmatched apps need review
        populationMismatch = $populationMismatch
    }

    Add-ZtTenantInfo -Name $tenantInfoName -Value $summary
}