tests/Test-Assessment.21835.ps1

<#
.SYNOPSIS
    Checks if emergency access accounts are configured appropriately
 
.DESCRIPTION
    This test identifies emergency access accounts based on:
    - Permanent Global Administrator role assignment (cloud-only)
    - Phishing-resistant authentication methods (FIDO2 and/or Certificate)
    - Exclusion from all enabled Conditional Access policies
 
    Per spec, the result is:
    - Fail when fewer than two emergency access accounts are identified
    - Pass when exactly two emergency access accounts are identified
    - Investigate when more than two emergency access accounts are identified
#>


function Test-Assessment-21835 {
    [ZtTest(
        Category = 'Privileged access',
        ImplementationCost = 'Medium',
        MinimumLicense = ('P1'),
        Pillar = 'Identity',
        RiskLevel = 'High',
        SfiPillar = 'Protect engineering systems',
        TenantType = ('Workforce'),
        TestId = 21835,
        Title = 'Emergency access accounts are configured appropriately',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param($Database)

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    if ( -not (Get-ZtLicense EntraIDP1) ) {
        Add-ZtTestResultDetail -SkippedBecause NotLicensedEntraIDP1
        return
    }

    $activity = 'Checking emergency access accounts configuration'
    Write-ZtProgress -Activity $activity -Status 'Starting assessment'

    #region Step 1: Find permanent Global Administrator users
    Write-ZtProgress -Activity $activity -Status 'Finding Global Administrator role members'

    # Global Administrator role template ID: 62e90394-69f5-4237-9190-012177145e10
    $sql = @"
SELECT
    vr.principalId as id,
    ANY_VALUE(vr.principalDisplayName) as displayName,
    ANY_VALUE(vr.userPrincipalName) as userPrincipalName,
    ANY_VALUE(vr.privilegeType) as privilegeType,
    ANY_VALUE(u.onPremisesSyncEnabled) as onPremisesSyncEnabled,
    ANY_VALUE(vr."@odata.type") as "@odata.type"
FROM vwRole vr
LEFT JOIN "User" u ON vr.principalId = u.id
WHERE vr.roleDefinitionId = '62e90394-69f5-4237-9190-012177145e10'
    AND vr.privilegeType = 'Permanent'
    AND vr."@odata.type" = '#microsoft.graph.user'
GROUP BY vr.principalId
"@


    $permanentGAUsers = @(Invoke-DatabaseQuery -Database $Database -Sql $sql)

    Write-PSFMessage "Total permanent GA users: $($permanentGAUsers.Count)" -Level Verbose

    #endregion

    #region Step 2: Find cloud-only GAs with phishing-resistant auth methods
    Write-ZtProgress -Activity $activity -Status 'Analyzing authentication methods'

    $emergencyAccountCandidates = @()

    foreach ($user in $permanentGAUsers) {
        # Only process cloud-only accounts (onPremisesSyncEnabled is null or false)
        if ($user.onPremisesSyncEnabled -ne $true) {
            Write-PSFMessage "Checking auth methods for cloud-only user: $($user.userPrincipalName)" -Level Verbose

            # Use Get-ZtUserAuthenticationMethod helper to get authentication methods
            # Wrap in try/catch: user may have been deleted after the export was taken (returns 403 accessDenied or 404 ResourceNotFound)
            $userAuthInfo = $null
            try {
                $userAuthInfo = Get-ZtUserAuthenticationMethod -UserId $user.id
            }
            catch {
                if ($_.Exception.Message -match '403|Forbidden|accessDenied|404|Request_ResourceNotFound') {
                    Write-PSFMessage "Skipping user $($user.userPrincipalName): user may have been deleted after the export was taken. $_" -Level Warning
                    continue
                }
                throw
            }
            $authMethods = $userAuthInfo.AuthenticationMethods

            if ($authMethods) {
                # Spec: accounts must have ONLY phishing-resistant auth methods (FIDO2 / CBA).
                # passwordAuthenticationMethod is always present and cannot be removed (see #579),
                # so it is treated as ignorable. All remaining methods must be FIDO2 or CBA.
                $phishingResistantTypes = @(
                    '#microsoft.graph.fido2AuthenticationMethod'
                    '#microsoft.graph.x509CertificateAuthenticationMethod'
                )
                $ignorableTypes = @(
                    '#microsoft.graph.passwordAuthenticationMethod'
                )

                $authMethodTypes = @($authMethods | ForEach-Object { $_.'@odata.type' })
                $relevantTypes   = @($authMethodTypes | Where-Object { $_ -notin $ignorableTypes })

                $hasPhishingResistant = $relevantTypes.Count -gt 0 -and
                    -not ($relevantTypes | Where-Object { $_ -notin $phishingResistantTypes })

                if ($hasPhishingResistant) {
                    # This is a candidate emergency account
                    $emergencyAccountCandidates += [PSCustomObject]@{
                        Id = $user.id
                        UserPrincipalName = $user.userPrincipalName
                        DisplayName = $user.displayName
                        OnPremisesSyncEnabled = $user.onPremisesSyncEnabled
                        AuthenticationMethods = $authMethodTypes
                        CAPoliciesTargeting = 0
                        ExcludedFromAllCA = $false
                        # Populated in the CA-evaluation pass below; $null indicates "Unknown" (e.g. user skipped due to 403/404).
                        CAPoliciesMissingExclusion = $null
                    }

                    Write-PSFMessage "Candidate emergency account found: $($user.userPrincipalName)" -Level Verbose
                }
            }
        }
    }

    Write-PSFMessage "Emergency account candidates (cloud-only with phishing-resistant auth): $($emergencyAccountCandidates.Count)" -Level Verbose

    #endregion

    #region Step 3 & 4: Get CA policies and check if all permanent GAs are excluded
    Write-ZtProgress -Activity $activity -Status 'Analyzing Conditional Access policies'

    # Use Get-ZtConditionalAccessPolicy helper function
    $allCAPolicies = Get-ZtConditionalAccessPolicy
    $enabledCAPolicies = $allCAPolicies | Where-Object { $_.state -eq 'enabled' }

    Write-PSFMessage "Found $($enabledCAPolicies.Count) enabled CA policies" -Level Verbose

    # Store CA policy info for ALL permanent GAs (not just candidates)
    $gaCAInfo = @{}

    if ($enabledCAPolicies.Count -eq 0) {
        # No enabled CA policies in the tenant: there is nothing to evaluate per user, and
        # in particular nothing to exclude from. Vacuously, every user is "excluded from all"
        # enabled policies. Skip the per-user Graph calls entirely.
        Write-PSFMessage 'No enabled CA policies found; skipping per-user CA evaluation.' -Level Verbose
        foreach ($user in $permanentGAUsers) {
            $gaCAInfo[$user.id] = @{
                PoliciesTargeting        = 0
                ExcludedFromAll          = $true
                PoliciesMissingExclusion = [System.Collections.Generic.List[object]]::new()
            }
        }
    }
    else {
    foreach ($user in $permanentGAUsers) {
        Write-PSFMessage "Checking CA policy targeting for: $($user.userPrincipalName)" -Level Verbose

        # Wrap in try/catch: user may have been deleted after the export was taken (returns 403 accessDenied or 404 ResourceNotFound)
        $userGroups = $null
        $userRoles = $null
        try {
            $userGroups = Invoke-ZtGraphRequest -RelativeUri "users/$($user.id)/transitiveMemberOf/microsoft.graph.group" `
                -Select 'id' -ApiVersion v1.0

            $userRoles = Invoke-ZtGraphRequest -RelativeUri "users/$($user.id)/memberOf/microsoft.graph.directoryRole" `
                -Select 'id,roleTemplateId' -ApiVersion v1.0
        }
        catch {
            if ($_.Exception.Message -match '403|Forbidden|accessDenied|404|Request_ResourceNotFound') {
                Write-PSFMessage "Skipping user $($user.userPrincipalName): user may have been deleted after the export was taken. $_" -Level Warning
                continue
            }
            throw
        }
        $userGroupIds = @($userGroups | Select-Object -ExpandProperty id)
        # Precompute role template ids once per user so policy evaluation can do plain
        # -contains checks instead of an O(n*m) Where-Object lookup per policy/role.
        $userRoleTemplateIds = @($userRoles | Select-Object -ExpandProperty roleTemplateId)

        $policiesTargetingUser = 0
        $excludedFromAll = $true
        $policiesMissingExclusion = [System.Collections.Generic.List[object]]::new()

        foreach ($policy in $enabledCAPolicies) {
            # Entra CA semantics: exclusions take precedence over inclusions across all dimensions
            # (user, group, role). A user is targeted only if they are included AND not excluded
            # by any of the user/group/role conditions.

            $includeUsers = @($policy.conditions.users.includeUsers)
            $excludeUsers = @($policy.conditions.users.excludeUsers)
            $includeGroups = @($policy.conditions.users.includeGroups)
            $excludeGroups = @($policy.conditions.users.excludeGroups)
            $includeRoles = @($policy.conditions.users.includeRoles)
            $excludeRoles = @($policy.conditions.users.excludeRoles)

            # Determine inclusion across all dimensions (any include match)
            $isIncluded = $false
            if ($includeUsers -contains 'All' -or $includeUsers -contains $user.id) {
                $isIncluded = $true
            }
            if (-not $isIncluded -and $userGroupIds.Count -gt 0) {
                foreach ($groupId in $userGroupIds) {
                    if ($includeGroups -contains $groupId) {
                        $isIncluded = $true
                        break
                    }
                }
            }
            if (-not $isIncluded -and $userRoleTemplateIds.Count -gt 0) {
                foreach ($templateId in $userRoleTemplateIds) {
                    if ($includeRoles -contains $templateId) {
                        $isIncluded = $true
                        break
                    }
                }
            }

            # Determine exclusion across all dimensions (any exclude match wins)
            $isExcluded = $false
            if ($excludeUsers -contains $user.id) {
                $isExcluded = $true
            }
            if (-not $isExcluded -and $userGroupIds.Count -gt 0) {
                foreach ($groupId in $userGroupIds) {
                    if ($excludeGroups -contains $groupId) {
                        $isExcluded = $true
                        break
                    }
                }
            }
            if (-not $isExcluded -and $userRoleTemplateIds.Count -gt 0) {
                foreach ($templateId in $userRoleTemplateIds) {
                    if ($excludeRoles -contains $templateId) {
                        $isExcluded = $true
                        break
                    }
                }
            }

            $isTargeted = $isIncluded -and -not $isExcluded

            if ($isTargeted) {
                $policiesTargetingUser++
                $excludedFromAll = $false
                # Store only the minimal fields needed for the report to avoid retaining full policy payloads.
                $policiesMissingExclusion.Add([pscustomobject]@{
                    id          = $policy.id
                    displayName = $policy.displayName
                })
            }
        }

        $gaCAInfo[$user.id] = @{
            PoliciesTargeting        = $policiesTargetingUser
            ExcludedFromAll          = $excludedFromAll
            PoliciesMissingExclusion = $policiesMissingExclusion
        }
    }
    }

    # Determine emergency access accounts: candidates that are excluded from all enabled CA policies
    $emergencyAccessAccounts = @()
    foreach ($candidate in $emergencyAccountCandidates) {
        if ($gaCAInfo.ContainsKey($candidate.Id)) {
            $caInfo = $gaCAInfo[$candidate.Id]
            $candidate.CAPoliciesTargeting = $caInfo.PoliciesTargeting
            $candidate.ExcludedFromAllCA = $caInfo.ExcludedFromAll
            $candidate.CAPoliciesMissingExclusion = $caInfo.PoliciesMissingExclusion

            if ($caInfo.ExcludedFromAll) {
                $emergencyAccessAccounts += $candidate
                Write-PSFMessage "Emergency access account confirmed: $($candidate.UserPrincipalName)" -Level Verbose
            }
        }
    }

    #endregion

    #region Step 5: Evaluate results and generate report
    Write-ZtProgress -Activity $activity -Status 'Generating results'

    $accountCount = $emergencyAccessAccounts.Count
    Write-PSFMessage "Total emergency access accounts identified: $accountCount" -Level Verbose

    # Determine pass/fail/investigate status per spec:
    # < 2 -> Fail
    # == 2 -> Pass
    # > 2 -> Investigate (set on the splat below based on $accountCount)
    $passed = $false
    $testResultMarkdown = ''

    if ($accountCount -lt 2) {
        $passed = $false
        $testResultMarkdown = "Fewer than two emergency access accounts were identified based on cloud-only state, registered phishing-resistant credentials and CA policy exclusions.`n`n"
    }
    elseif ($accountCount -eq 2) {
        $passed = $true
        $testResultMarkdown = "Two emergency access accounts appear to be configured as per Microsoft guidance based on cloud-only state, registered phishing-resistant credentials and CA policy exclusions.`n`n"
    }
    else {
        # Investigate: more than two candidate emergency access accounts identified.
        $passed = $false
        $testResultMarkdown = "Three or more emergency access accounts appear to be configured based on cloud-only state, registered phishing-resistant credentials and CA policy exclusions. Review these accounts to determine whether this volume is excessive for your organization.`n`n"
    }

    # Add summary information
    $testResultMarkdown += "**Summary:**`n"
    $testResultMarkdown += "- Total permanent Global Administrators: $($permanentGAUsers.Count)`n"
    $testResultMarkdown += "- Cloud-only GAs with phishing-resistant auth: $($emergencyAccountCandidates.Count)`n"
    $testResultMarkdown += "- Emergency access accounts (excluded from all CA): $accountCount`n"
    $testResultMarkdown += "- Enabled Conditional Access policies: $($enabledCAPolicies.Count)`n`n"

    # Add details table
    if ($emergencyAccessAccounts.Count -gt 0) {
        $testResultMarkdown += "## Emergency access accounts`n`n"
        $testResultMarkdown += "| Display name | UPN | Synced from on-premises | Authentication methods |`n"
        $testResultMarkdown += "| :----------- | :-- | :---------------------- | :--------------------- |`n"

        foreach ($account in $emergencyAccessAccounts) {
            $syncStatus = if ($account.onPremisesSyncEnabled -ne $true) { 'No' } else { 'Yes' }
            $authMethodDisplay = ($account.AuthenticationMethods | ForEach-Object {
                $_ -replace '#microsoft.graph.', '' -replace 'AuthenticationMethod', ''
            } | Select-Object -Unique) -join ', '

            $portalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($account.Id)"

            $testResultMarkdown += "| $(Get-SafeMarkdown -Text $account.DisplayName) | [$(Get-SafeMarkdown -Text $account.UserPrincipalName)]($portalLink) | $syncStatus | $authMethodDisplay |`n"
        }
        $testResultMarkdown += "`n"
    }

    # Add comprehensive table of all permanent GA accounts
    if ($permanentGAUsers.Count -gt 0) {
        $testResultMarkdown += "## All permanent Global Administrators`n`n"
        $testResultMarkdown += "| Display name | UPN | Cloud only | Phishing resistant auth | All CA excluded | CA policies missing exclusion |`n"
        $testResultMarkdown += "| :----------- | :-- | :--------: | :---------------------: | :---------: | :---------------------------- |`n"

        $userSummary = @()
        foreach ($user in $permanentGAUsers) {
            $portalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($user.id)"

            # Check if cloud-only
            $isCloudOnly = ($user.onPremisesSyncEnabled -ne $true)
            $cloudOnlyEmoji = if ($isCloudOnly) { '✅' } else { '❌' }

            # Check if excluded from all enabled CA policies (using per-user CA info, not emergency account membership)
            $isCAExcluded = ($gaCAInfo.ContainsKey($user.id) -and $gaCAInfo[$user.id].ExcludedFromAll)
            $caExcludedEmoji = if ($isCAExcluded) { '✅' } else { '❌' }

            # Check if has phishing-resistant auth only
            $candidate = $emergencyAccountCandidates | Where-Object { $_.Id -eq $user.id }
            $isPhishingResistant = [bool]$candidate
            $phishingResistantEmoji = if ($isPhishingResistant) { '✅' } else { '❌' }

            # Build CA policies missing exclusion cell
            if (-not $gaCAInfo.ContainsKey($user.id)) {
                $caPoliciesCell = 'Unknown'
            }
            elseif ($gaCAInfo[$user.id].PoliciesMissingExclusion.Count -gt 0) {
                $caPoliciesCell = ($gaCAInfo[$user.id].PoliciesMissingExclusion | ForEach-Object {
                    $link = "https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/$($_.id)"
                    "[$(Get-SafeMarkdown -Text $_.displayName)]($link)"
                }) -join ', '
            }
            else {
                $caPoliciesCell = 'None'
            }

            $userSummary += [PSCustomObject]@{
                DisplayName = $user.displayName
                UserPrincipalName = $user.userPrincipalName
                PortalLink = $portalLink
                CloudOnly = $cloudOnlyEmoji
                CAExcluded = $caExcludedEmoji
                PhishingResistant = $phishingResistantEmoji
                CAPoliciesMissingExclusion = $caPoliciesCell
                # Boolean values used for sorting so order is independent of glyph rendering / culture.
                IsCloudOnly = $isCloudOnly
                IsCAExcluded = $isCAExcluded
                IsPhishingResistant = $isPhishingResistant
            }
        }

        # Show users that have passed every criteria first. Sort by the underlying booleans
        # ($true sorts after $false, so use -Descending) instead of the rendered emoji glyphs.
        $userSummary = $userSummary | Sort-Object -Property IsCAExcluded, IsPhishingResistant, IsCloudOnly -Descending

        foreach ($user in $userSummary) {
            $testResultMarkdown += "| $(Get-SafeMarkdown -Text $user.DisplayName) | [$(Get-SafeMarkdown -Text $user.UserPrincipalName)]($($user.PortalLink)) | $($user.CloudOnly) | $($user.PhishingResistant) | $($user.CAExcluded) | $($user.CAPoliciesMissingExclusion) |`n"
        }

        $testResultMarkdown += "`n"
    }

    #endregion

    $params = @{
        TestId = '21835'
        Status = $passed
        Result = $testResultMarkdown
    }

    # Only add CustomStatus when it's "Investigate" (more than 2 emergency access accounts)
    if ($accountCount -gt 2) {
        $params.CustomStatus = 'Investigate'
    }

    Add-ZtTestResultDetail @params
}