tests/Test-Assessment.25384.ps1

<#
.SYNOPSIS
    Checks if Application Administrator rights are constrained to specific Private Access apps.
 
.DESCRIPTION
    This test validates that Application Administrator role assignments are scoped to specific
    applications rather than tenant-wide, and that assignments follow least privilege principles.
 
.NOTES
    Test ID: 25384
    Category: Access control
    Required API: roleManagement/directory (beta)
#>


function Test-Assessment-25384 {
    [ZtTest(
        Category = 'Access control',
        ImplementationCost = 'Low',
        MinimumLicense = ('P1'),
        Pillar = 'Network',
        RiskLevel = 'High',
        SfiPillar = 'Protect identities and secrets',
        TenantType = ('Workforce'),
        TestId = 25384,
        Title = 'Application admin rights are constrained to specific Private Access apps, not tenant-wide',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity = 'Checking Application Administrator role assignments'
    Write-ZtProgress -Activity $activity -Status 'Getting role definition'

    # Query 1: Get Application Administrator role definition
    $appAdminRoleId = Get-ZtRoleInfo -RoleName 'ApplicationAdministrator'

    Write-ZtProgress -Activity $activity -Status 'Getting role assignments with principal details'

    # Query 2: Get Application Administrator role assignments with expanded principal (no nested $select)
    $assignments = Invoke-ZtGraphRequest -RelativeUri "roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$appAdminRoleId'&`$expand=principal" -ApiVersion beta

    # Default to empty array if no assignments found
    $assignments = $assignments ?? @()

    # Collect scoped IDs from assignments for Q3 resolution
    $spIds = @()
    $appIds = @()
    foreach ($assignment in $assignments) {
        if ($assignment.directoryScopeId -ne '/') {
            $scopeId = $assignment.directoryScopeId -replace '^/', ''
            if ($scopeId -match '^servicePrincipals/(.+)') {
                $spIds += $Matches[1]
            } elseif ($scopeId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
                $appIds += $scopeId
            }
        }
    }

    Write-ZtProgress -Activity $activity -Status 'Resolving scoped service principals and applications'

    # Query 3: Resolve scoped service principals and applications
    $spLookup = @{}
    $appLookup = @{}

    # Fetch service principals referenced in scoped assignments
    $uniqueSpIds = $spIds | Select-Object -Unique

    if ($uniqueSpIds) {
        $sps = Invoke-ZtGraphBatchRequest -Path "servicePrincipals/{0}?`$select=id,displayName,appId,appOwnerOrganizationId" -ArgumentList $uniqueSpIds -ApiVersion beta
        foreach ($sp in $sps) { $spLookup[$sp.id] = $sp }
    }

    # Fetch applications directly referenced in scoped assignments (app registrations)
    $uniqueAppIds = $appIds | Select-Object -Unique

       if ($uniqueAppIds) {
        $apps = Invoke-ZtGraphBatchRequest -Path "applications/{0}?`$select=id,displayName,appId,tags,appOwnerOrganizationId" -ArgumentList $uniqueAppIds -ApiVersion beta
        foreach ($app in $apps) {
            $appLookup[$app.id] = $app
            if ($app.appId) { $appLookup[$app.appId] = $app }
        }
    }

    Write-ZtProgress -Activity $activity -Status 'Detecting Private Access and Quick Access apps'

    # Query 4: Detect Private Access / Quick Access apps via tags (bulk fetch)
    $paQaAppLookup = @{}
    try {
        $paQuickAccessApps = Invoke-ZtGraphRequest -RelativeUri "applications" -Filter "(tags/any(t: t eq 'PrivateAccessNonWebApplication') or tags/any(t: t eq 'NetworkAccessQuickAccessApplication'))" -Select 'id,displayName,appId,tags' -ApiVersion beta
        foreach ($app in $paQuickAccessApps) {
            if ($app.appId) { $paQaAppLookup[$app.appId] = $app }
            if ($app.id) { $paQaAppLookup[$app.id] = $app }
        }
    } catch {
        Write-PSFMessage "Unable to query Private Access/Quick Access apps by tags" -Level Verbose
    }

    # Fetch application details for service principals from Q3 (if not already in PA/QA lookup)
    $spAppIds = @($spLookup.Values | Where-Object { $_.appId } | ForEach-Object { $_.appId }) | Select-Object -Unique
    $appIdsToFetch = $spAppIds | Where-Object { -not $paQaAppLookup.ContainsKey($_) -and -not $appLookup.ContainsKey($_) }

    if ($appIdsToFetch) {
        $apps = Invoke-ZtGraphBatchRequest -Path "applications?`$filter=appId eq '{0}'&`$select=id,displayName,appId,tags,appOwnerOrganizationId" -ArgumentList $appIdsToFetch -ApiVersion beta
        foreach ($app in $apps) {
            if ($app) {
                $appLookup[$app.id] = $app
                $appLookup[$app.appId] = $app
            }
        }
    }

    #endregion Data Collection

    #region Assessment Logic
    $testResultMarkdown = ''
    $passed = $true
    $tenantWideAssignments = @()
    $scopedAssignments = @()
    $problematicAssignments = @()
    $warnings = @()

    foreach ($assignment in $assignments) {
        $principalType = if ($assignment.principal.'@odata.type') {
            $assignment.principal.'@odata.type' -replace '#microsoft.graph.', ''
        } else { 'unknown' }

        $assignmentInfo = [PSCustomObject]@{
            DirectoryScopeId    = $assignment.directoryScopeId
            PrincipalId         = $assignment.principalId
            PrincipalDisplayName = $assignment.principal.displayName
            PrincipalUPN        = $assignment.principal.userPrincipalName
            PrincipalType       = $principalType
            UserType            = $assignment.principal.userType
            AccountEnabled      = $assignment.principal.accountEnabled
            AppDisplayName      = ''
            AppId               = ''
            IsPAApp             = $false
        }

        if ($assignment.directoryScopeId -eq '/') {
            $tenantWideAssignments += $assignmentInfo
            $passed = $false
        } else {
            $scopeId = $assignment.directoryScopeId -replace '^/', ''
            if ($scopeId -match '^servicePrincipals/(.+)') {
                $spId = $Matches[1]
                if ($spLookup.ContainsKey($spId)) {
                    $sp = $spLookup[$spId]
                    $assignmentInfo.AppDisplayName = $sp.displayName
                    $assignmentInfo.AppId = $sp.appId
                    $app = $paQaAppLookup[$sp.appId] ?? $appLookup[$sp.appId]
                    if ($app) {
                        $assignmentInfo.IsPAApp = ($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication')
                    }
                }
            } else {
                $app = $paQaAppLookup[$scopeId] ?? $appLookup[$scopeId]
                if ($app) {
                    $assignmentInfo.AppDisplayName = $app.displayName
                    $assignmentInfo.AppId = $app.appId
                    $assignmentInfo.IsPAApp = ($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication')
                }
            }
            $scopedAssignments += $assignmentInfo
        }

        if ($principalType -in @('group', 'servicePrincipal') -or $assignment.principal.userType -eq 'Guest') {
            $problematicAssignments += $assignmentInfo
            $passed = $false
        }
    }

    if ($assignments.Count -gt 5) {
        $warnings += "Assignment count ($($assignments.Count)) exceeds recommended threshold of 5"
    }

    $scopedNonPACount = ($scopedAssignments | Where-Object { -not $_.IsPAApp -and $_.AppDisplayName }).Count
    if ($scopedNonPACount -gt 0) {
        $warnings += "$scopedNonPACount scoped assignment(s) to apps that are not confirmed as Private Access or Quick Access apps"
    }
    #endregion Assessment Logic

    #region Report Generation
    $mdInfo = ''

    # Summary Section
    $mdInfo += "`n## Summary`n`n"
    $mdInfo += "| Metric | Count |`n"
    $mdInfo += "| :--- | ---: |`n"
    $mdInfo += "| Total Assignments | $($assignments.Count) |`n"
    $mdInfo += "| Tenant-Wide Assignments | $($tenantWideAssignments.Count) |`n"
    $mdInfo += "| Scoped Assignments | $($scopedAssignments.Count) |`n"
    $mdInfo += "| Problematic Assignments | $($problematicAssignments.Count) |`n`n"

    # Application Administrator Assignments
    $mdInfo += "`n## Application Administrator Assignments:`n`n"
    $mdInfo += "- Count: $($assignments.Count)`n`n"

    if ($assignments.Count -gt 0) {
        $mdInfo += "| DirectoryScopeId | Principal DisplayName | UPN | AccountEnabled | Type | User Type |`n"
        $mdInfo += "| :--- | :--- | :--- | :---: | :--- | :--- |`n"
        foreach ($rawA in $assignments) {
            $scope = $rawA.directoryScopeId
            $displayName = $rawA.principal.displayName
            $upn = $rawA.principal.userPrincipalName
            $acctEnabled = if ($null -ne $rawA.principal.accountEnabled) { $rawA.principal.accountEnabled } else { '' }
            $pType = if ($rawA.principal.'@odata.type') { $rawA.principal.'@odata.type' -replace '#microsoft.graph.', '' } else { 'unknown' }
            $uType = $rawA.principal.userType
            $mdInfo += "| $(Get-SafeMarkdown -Text $scope) | $(Get-SafeMarkdown -Text $displayName) | $upn | $acctEnabled | $pType | $uType |`n"
        }
        $mdInfo += "`n"
    }

    # Build map of all discovered apps for display
    $scopedAppsMap = @{ }
    foreach ($app in $paQaAppLookup.Values) {
        if ($app.appId) {
            $scopedAppsMap[$app.appId] = $app
        } elseif ($app.id) {
            $scopedAppsMap[$app.id] = $app
        }
    }
    foreach ($app in $appLookup.Values) {
        if ($app.appId) {
            $scopedAppsMap[$app.appId] = $app
        } elseif ($app.id) {
            $scopedAppsMap[$app.id] = $app
        }
    }
    foreach ($sp in $spLookup.Values) {
        if ($sp.appId) {
            if (-not $scopedAppsMap.ContainsKey($sp.appId)) {
                if ($appLookup.ContainsKey($sp.appId)) {
                    $scopedAppsMap[$sp.appId] = $appLookup[$sp.appId]
                } else {
                    $scopedAppsMap[$sp.appId] = [PSCustomObject]@{
                        displayName = $sp.displayName
                        appId = $sp.appId
                        id = $null
                        tags = @()
                    }
                }
            }
        }
    }

    # Scoped Apps section
    if ($scopedAppsMap.Count -gt 0) {
        $mdInfo += "`n## Scoped Apps:`n`n"
        $mdInfo += "| App DisplayName | appId / servicePrincipalId | Tags (includes PA/QA?) |`n"
        $mdInfo += "| :--- | :--- | :--- |`n"
        foreach ($app in $scopedAppsMap.Values) {
            $display = if ($app.displayName) {
                $(Get-SafeMarkdown -Text $app.displayName)
            } else {
                'Unknown'
            }
            $id = if ($app.appId) {
                $app.appId
            } elseif ($app.id) {
                $app.id
            } else {
                ''
            }
            $tags = if ($app.tags) {
                ($app.tags -join ', ')
            } else {
                ''
            }
            $paqa = if ($app.tags -and (($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication'))) {
                '✅'
            } else {
                '❌'
            }
            $mdInfo += "| $display | $id | $tags $paqa |`n"
        }
        $mdInfo += "`n"
    }

    # Tenant-Wide Assignments
    if ($tenantWideAssignments.Count -gt 0) {
        $mdInfo += "`n## ❌ Tenant-Wide Assignments`n`n"
        $mdInfo += "The following Application Administrator assignments have tenant-wide scope and should be constrained:`n`n"
        $mdInfo += "| Principal | Type | User Type | Scope |`n"
        $mdInfo += "| :--- | :--- | :--- | :--- |`n"
        foreach ($a in $tenantWideAssignments) {
            $principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
            $mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $($a.UserType) | Tenant-wide (/) |`n"
        }
        $mdInfo += "`n"
    }

    # Problematic Assignments
    if ($problematicAssignments.Count -gt 0) {
        $mdInfo += "`n## ❌ Problematic Principal Assignments`n`n"
        $mdInfo += "The following assignments use groups, service principals, or guest users:`n`n"
        $mdInfo += "| Principal | Type | User Type | Scope |`n"
        $mdInfo += "| :--- | :--- | :--- | :--- |`n"
        foreach ($a in $problematicAssignments) {
            $principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
            $scope = if ($a.DirectoryScopeId -eq '/') { 'Tenant-wide (/)' } else { 'Scoped' }
            $mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $($a.UserType) | $scope |`n"
        }
        $mdInfo += "`n"
    }

    # Scoped Assignments
    if ($scopedAssignments.Count -gt 0) {
        $mdInfo += "`n## ✅ Scoped Assignments`n`n"
        $mdInfo += "The following Application Administrator assignments are scoped to specific applications:`n`n"
        $mdInfo += "| Principal | Type | Application | PA/QA App |`n"
        $mdInfo += "| :--- | :--- | :--- | :---: |`n"
        foreach ($a in $scopedAssignments | Sort-Object PrincipalDisplayName) {
            $principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
            $appName = if ($a.AppDisplayName) { $a.AppDisplayName } else { 'Unknown app' }
            $paIcon = if ($a.IsPAApp) { '✅' } else { '❌' }
            $mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $(Get-SafeMarkdown -Text $appName) | $paIcon |`n"
        }
        $mdInfo += "`n"
    }

    # Warnings
    if ($warnings.Count -gt 0) {
        $mdInfo += "`n## ⚠️ Warnings`n`n"
        foreach ($warning in $warnings) {
            $mdInfo += "- $warning`n"
        }
        $mdInfo += "`n"
    }

    # Portal Link
    $portalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AllRolesBlade"
    $portalLinkText = Get-SafeMarkdown -Text "View in Entra Portal: Roles and administrators"
    $mdInfo += "`n[$portalLinkText]($portalLink)"

    $testResultMarkdown = $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '25384'
        Title  = 'Application admin rights are constrained to specific Private Access apps, not tenant-wide'
        Status = $passed
        Result = $testResultMarkdown
    }

    Add-ZtTestResultDetail @params
}