Orchestrator/Test-GraphPermissions.ps1

<#
.SYNOPSIS
    Validates Graph API permissions after connection against required permissions.
.DESCRIPTION
    For delegated auth, compares granted scopes from Get-MgContext against the
    scopes required by selected assessment sections (sectionScopeMap).

    For app-only auth (B2 #773), queries the running app's service principal
    appRoleAssignments and compares against the per-section app permissions
    declared in Setup/PermissionDefinitions.ps1. Both paths emit per-section
    deficit warnings before collectors run, so users know which sections may
    produce incomplete results.
#>


# B2 #773: dot-source PermissionDefinitions for the per-section app-role map.
# Same source of truth as Grant-M365AssessConsent and the generated
# docs/PERMISSIONS.md (B7 #778). Idempotent re-source on each load.
. (Join-Path -Path $PSScriptRoot -ChildPath '..\Setup\PermissionDefinitions.ps1')

function Write-PermissionDeficitsFile {
    <#
    .SYNOPSIS
        Writes _PermissionDeficits.json with structured per-section deficit data.
    .DESCRIPTION
        Companion to Test-GraphAppRolePermissions / Test-GraphPermissions
        (#812 B2 followup). The HTML report's Permissions panel and the evidence
        package both consume this file. The shape is forward-compatible -- new
        keys can be added without breaking older readers.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$OutputFolder,
        [Parameter(Mandatory)] [ValidateSet('Delegated', 'AppOnly')] [string]$AuthMode,
        [Parameter(Mandatory)] [string[]]$ActiveSections,
        [Parameter()] [object]$RequiredRoles,   # HashSet[string] or [string[]]
        [Parameter()] [object]$GrantedRoles,    # HashSet[string] or [string[]]
        [Parameter()] [object]$MissingByRole = @(),
        [Parameter()] [hashtable]$PerSection = @{}
    )

    $reqArr = @($RequiredRoles | Where-Object { $_ })
    $grtArr = @($GrantedRoles  | Where-Object { $_ })
    $missArr = @($MissingByRole | Where-Object { $_ })

    # Per-section view: which roles each section needs, and which are missing.
    $sectionDeficits = [ordered]@{}
    foreach ($s in ($ActiveSections | Sort-Object -Unique)) {
        $required = if ($PerSection.ContainsKey($s)) { @($PerSection[$s]) } else { @() }
        $missing  = @($required | Where-Object { $missArr -icontains $_ })
        $sectionDeficits[$s] = [ordered]@{
            required = $required
            missing  = $missing
            ok       = ($missing.Count -eq 0)
        }
    }

    $payload = [ordered]@{
        schemaVersion  = '1.0'
        authMode       = $AuthMode
        generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
        activeSections = $ActiveSections
        required       = $reqArr
        granted        = $grtArr
        missing        = $missArr
        sections       = $sectionDeficits
    }
    $path = Join-Path -Path $OutputFolder -ChildPath '_PermissionDeficits.json'
    $payload | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding UTF8
    Write-AssessmentLog -Level INFO -Message "Wrote permission deficit map: $path" -Section 'Setup'
}

function Test-GraphAppRolePermissions {
    <#
    .SYNOPSIS
        Validates app-only Graph permissions by querying the running SP's
        app-role assignments and comparing against per-section requirements.
    .DESCRIPTION
        Used by Test-GraphPermissions when app-only auth is detected. Reads
        $script:RequiredGraphPermissions from PermissionDefinitions.ps1
        (which has Sections annotations on every permission) and inverts to
        a per-section view; queries the SP's appRoleAssignments via Graph;
        resolves role IDs to permission names through the Microsoft Graph
        SP's appRoles collection; reports missing roles per active section.

        Failures to query (e.g., the app lacks Application.Read.All to
        introspect itself) produce an explicit "could not verify" warning,
        not silence. AC for B2 #773.
    .PARAMETER Context
        The Get-MgContext output for the current Graph connection.
    .PARAMETER ActiveSections
        Section names the user selected for this run.
    .PARAMETER OutputFolder
        Optional. When supplied, writes _PermissionDeficits.json into this folder
        with the structured deficit map so the HTML report's Permissions panel
        and the evidence package can surface it (#812 B2 followup).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Context,

        [Parameter(Mandatory)]
        [string[]]$ActiveSections,

        [Parameter()]
        [string]$OutputFolder
    )

    $clientId = $Context.ClientId
    if (-not $clientId) {
        Write-Host ' i App-only auth detected but no ClientId in context -- cannot validate app roles.' -ForegroundColor Yellow
        Write-AssessmentLog -Level WARN -Message 'App-role validation skipped: ClientId missing from Graph context' -Section 'Setup'
        return
    }

    # Build per-section app-role requirements from the shared definitions.
    $perSection = @{}
    foreach ($entry in $script:RequiredGraphPermissions) {
        $sections = $entry.Sections -split ',' | ForEach-Object { $_.Trim() }
        foreach ($s in $sections) {
            if (-not $perSection.ContainsKey($s)) {
                $perSection[$s] = [System.Collections.Generic.List[string]]::new()
            }
            $perSection[$s].Add($entry.Name)
        }
    }

    # Required permissions for the selected sections (deduplicated, case-insensitive).
    $requiredRoles = New-Object -TypeName System.Collections.Generic.HashSet[string] -ArgumentList @([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($s in $ActiveSections) {
        if ($perSection.ContainsKey($s)) {
            foreach ($p in $perSection[$s]) { [void]$requiredRoles.Add($p) }
        }
    }

    if ($requiredRoles.Count -eq 0) {
        Write-Host ' i App-only auth: no Graph app roles required for the selected sections.' -ForegroundColor DarkGray
        return
    }

    # Query the running SP's app-role assignments and resolve role IDs through
    # the Microsoft Graph SP's appRoles collection. Wrapped in try/catch so
    # the structured "could not verify" warning fires on any failure.
    try {
        $sp = Get-MgServicePrincipal -Filter "appId eq '$clientId'" -Top 1 -ErrorAction Stop
        if (-not $sp) {
            Write-Host " ! App-only auth: could not locate service principal for ClientId '$clientId'." -ForegroundColor Yellow
            Write-AssessmentLog -Level WARN -Message 'App-role validation skipped: SP lookup returned no results' -Section 'Setup'
            return
        }

        $assignments = @(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All -ErrorAction Stop)

        $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Top 1 -ErrorAction Stop
        if (-not $graphSp) {
            Write-Host ' ! App-only auth: Microsoft Graph SP not resolvable -- cannot map role IDs to names.' -ForegroundColor Yellow
            Write-AssessmentLog -Level WARN -Message 'App-role validation skipped: Microsoft Graph SP not found' -Section 'Setup'
            return
        }

        $roleById = @{}
        foreach ($r in $graphSp.AppRoles) {
            $roleById[[string]$r.Id] = $r.Value
        }

        $grantedRoles = New-Object -TypeName System.Collections.Generic.HashSet[string] -ArgumentList @([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($a in $assignments) {
            if ($a.ResourceId -eq $graphSp.Id) {
                $name = $roleById[[string]$a.AppRoleId]
                if ($name) { [void]$grantedRoles.Add($name) }
            }
        }

        $missing = @($requiredRoles | Where-Object { -not $grantedRoles.Contains($_) })

        if ($missing.Count -eq 0) {
            Write-Host " $([char]0x2714) All $($requiredRoles.Count) required Graph app role(s) granted" -ForegroundColor Green
            Write-AssessmentLog -Level INFO -Message "Graph app-role validation passed ($($requiredRoles.Count) roles)" -Section 'Setup'
            if ($OutputFolder -and (Test-Path -Path $OutputFolder -PathType Container)) {
                Write-PermissionDeficitsFile -OutputFolder $OutputFolder -AuthMode 'AppOnly' `
                    -ActiveSections $ActiveSections -RequiredRoles $requiredRoles -GrantedRoles $grantedRoles `
                    -MissingByRole @() -PerSection $perSection
            }
            return
        }

        # Map missing roles back to affected sections.
        $affectedSections = @{}
        foreach ($role in $missing) {
            foreach ($s in $ActiveSections) {
                if (-not $perSection.ContainsKey($s)) { continue }
                if ($perSection[$s] | Where-Object { $_ -ieq $role }) {
                    if (-not $affectedSections.ContainsKey($s)) {
                        $affectedSections[$s] = [System.Collections.Generic.List[string]]::new()
                    }
                    $affectedSections[$s].Add($role)
                }
            }
        }

        Write-Host ''
        Write-Host " $([char]0x26A0) $($missing.Count) Graph app role(s) not granted -- some checks may fail:" -ForegroundColor Yellow
        foreach ($s in $affectedSections.Keys | Sort-Object) {
            $list = ($affectedSections[$s] | Sort-Object) -join ', '
            Write-Host " ${s}: $list" -ForegroundColor Yellow
        }
        Write-Host ' To fix: Entra ID > App registrations > [your app] > API permissions >' -ForegroundColor DarkGray
        Write-Host ' Add a permission > Microsoft Graph > Application permissions' -ForegroundColor DarkGray
        Write-Host " Then click 'Grant admin consent for [tenant]' and re-run." -ForegroundColor DarkGray
        Write-Host ''

        Write-AssessmentLog -Level WARN -Message "Missing Graph app roles: $($missing -join ', ')" -Section 'Setup'

        if ($OutputFolder -and (Test-Path -Path $OutputFolder -PathType Container)) {
            Write-PermissionDeficitsFile -OutputFolder $OutputFolder -AuthMode 'AppOnly' `
                -ActiveSections $ActiveSections -RequiredRoles $requiredRoles -GrantedRoles $grantedRoles `
                -MissingByRole $missing -PerSection $perSection
        }
    }
    catch {
        # Structured "could not verify" warning per the AC -- never silent.
        Write-Host " ! App-only auth: could not validate app roles -- $($_.Exception.Message)" -ForegroundColor Yellow
        Write-Host ' This usually means the app lacks Application.Read.All or Directory.Read.All' -ForegroundColor DarkGray
        Write-Host ' (needed to read its own service principal). Add either permission and re-run.' -ForegroundColor DarkGray
        Write-AssessmentLog -Level WARN -Message "App-role validation could not be performed: $($_.Exception.Message)" -Section 'Setup'
    }
}

function Test-GraphPermissions {
    <#
    .SYNOPSIS
        Validates Graph API scopes after connection.
    .DESCRIPTION
        Compares the scopes granted by Get-MgContext against the scopes
        required by the selected assessment sections (from sectionScopeMap).
        Warns about missing scopes before collectors run, so users know
        which sections may produce incomplete results.

        With app-only auth (certificate/managed identity), scopes are
        determined by app registration and Get-MgContext.Scopes may show
        '.default' only. In this case the check is skipped with an
        informational message.
    .PARAMETER RequiredScopes
        Array of Graph scope strings required for the selected sections.
    .PARAMETER SectionScopeMap
        Hashtable mapping section names to their required scope arrays.
    .PARAMETER ActiveSections
        Array of section names the user selected.
    .PARAMETER OutputFolder
        Optional. When supplied, writes _PermissionDeficits.json into this folder
        so the HTML report's Permissions panel and the evidence package can
        surface the deficit map (#812 B2 followup).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$RequiredScopes,

        [Parameter(Mandatory)]
        [hashtable]$SectionScopeMap,

        [Parameter(Mandatory)]
        [string[]]$ActiveSections,

        [Parameter()]
        [string]$OutputFolder
    )

    $context = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $context) {
        Write-AssessmentLog -Level WARN -Message 'Graph context not available -- skipping scope validation' -Section 'Setup'
        return
    }

    $grantedScopes = @($context.Scopes)

    # App-only auth: delegated scopes are empty / '.default' (B2 #773).
    # Hand off to the app-role validator instead of skipping silently.
    if ($grantedScopes.Count -eq 0 -or ($grantedScopes.Count -eq 1 -and $grantedScopes[0] -eq '.default')) {
        Write-Host ' i App-only auth detected -- validating Graph app role assignments instead of delegated scopes...' -ForegroundColor DarkGray
        Test-GraphAppRolePermissions -Context $context -ActiveSections $ActiveSections -OutputFolder $OutputFolder
        return
    }

    # Compare required vs granted (case-insensitive)
    $grantedLower = $grantedScopes | ForEach-Object { $_.ToLower() }
    $missingScopes = @($RequiredScopes | Where-Object { $_.ToLower() -notin $grantedLower })

    if ($missingScopes.Count -eq 0) {
        Write-Host " $([char]0x2714) All $($RequiredScopes.Count) required Graph scopes granted" -ForegroundColor Green
        Write-AssessmentLog -Level INFO -Message "Graph scope validation passed ($($RequiredScopes.Count) scopes)" -Section 'Setup'
        if ($OutputFolder -and (Test-Path -Path $OutputFolder -PathType Container)) {
            Write-PermissionDeficitsFile -OutputFolder $OutputFolder -AuthMode 'Delegated' `
                -ActiveSections $ActiveSections -RequiredRoles $RequiredScopes -GrantedRoles $grantedScopes `
                -MissingByRole @() -PerSection $SectionScopeMap
        }
        return
    }

    # Map missing scopes back to affected sections
    $affectedSections = @{}
    foreach ($scope in $missingScopes) {
        foreach ($section in $ActiveSections) {
            if (-not $SectionScopeMap.ContainsKey($section)) { continue }
            $sectionScopes = $SectionScopeMap[$section] | ForEach-Object { $_.ToLower() }
            if ($scope.ToLower() -in $sectionScopes) {
                if (-not $affectedSections.ContainsKey($section)) {
                    $affectedSections[$section] = [System.Collections.Generic.List[string]]::new()
                }
                $affectedSections[$section].Add($scope)
            }
        }
    }

    # Display warnings
    Write-Host ''
    Write-Host " $([char]0x26A0) $($missingScopes.Count) Graph scope(s) not consented -- some checks may fail:" -ForegroundColor Yellow
    foreach ($section in $affectedSections.Keys | Sort-Object) {
        $scopeList = ($affectedSections[$section] | Sort-Object) -join ', '
        Write-Host " ${section}: $scopeList" -ForegroundColor Yellow
    }
    if ($context.AuthType -eq 'AppOnly') {
        Write-Host " To fix: add the missing permission(s) to your app registration, then grant admin consent." -ForegroundColor DarkGray
        Write-Host " Entra ID > App registrations > [your app] > API permissions >" -ForegroundColor DarkGray
        Write-Host " Add a permission > Microsoft Graph > Application permissions" -ForegroundColor DarkGray
        Write-Host " Then click 'Grant admin consent for [tenant]' and re-run." -ForegroundColor DarkGray
    }
    else {
        $scopeArg = ($missingScopes | Sort-Object) -join ','
        Write-Host " To fix: close this session and re-run the assessment. When the browser opens," -ForegroundColor DarkGray
        Write-Host " sign in as a Global Admin and click 'Accept' to grant the missing permission(s)." -ForegroundColor DarkGray
        Write-Host " If consent was already granted by an admin, run in a new PowerShell session:" -ForegroundColor DarkGray
        Write-Host " Disconnect-MgGraph; Connect-MgGraph -Scopes '$scopeArg'" -ForegroundColor Cyan
    }
    Write-Host ''

    Write-AssessmentLog -Level WARN -Message "Missing Graph scopes: $($missingScopes -join ', ')" -Section 'Setup'

    if ($OutputFolder -and (Test-Path -Path $OutputFolder -PathType Container)) {
        Write-PermissionDeficitsFile -OutputFolder $OutputFolder -AuthMode 'Delegated' `
            -ActiveSections $ActiveSections -RequiredRoles $RequiredScopes -GrantedRoles $grantedScopes `
            -MissingByRole $missingScopes -PerSection $SectionScopeMap
    }
}