Checks/Teams/MET-Teams010-ExternalAccessPolicyDrift.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'METCheckInfo',
    Justification = 'Check metadata. Read from the AST by Get-METCheck and never executed.')]
param()

$METCheckInfo = @{
    Name           = 'Per-User External Access Policy Drift'
    Severity       = 'Medium'
    Description    = 'Enumerates non-Global Get-CsExternalAccessPolicy instances and flags EnableFederationAccess, EnableTeamsConsumerAccess or EnableTeamsConsumerInbound turned on where the Global policy turns it off.'
    RequiresModule = @('MicrosoftTeams')
}

$results = [System.Collections.Generic.List[object]]::new()

try {
    $policies = @(Get-CsExternalAccessPolicy -ErrorAction Stop)
}
catch {
    New-METCheckResult -CheckId 'MET-Teams010' -Category Teams -Name 'Per-User External Access Policy Drift' `
        -Result Fail -Severity Medium -AffectedObject 'Teams External Access Policies' `
        -Finding 'Unable to retrieve external access policies.' `
        -Recommendation 'Ensure the account has Teams administrator or higher permissions.' `
        -ReferenceUrl 'https://learn.microsoft.com/en-us/powershell/module/microsoftteams/get-csexternalaccesspolicy' -ErrorMessage $_.ToString()
    return
}

$referenceUrl = 'https://learn.microsoft.com/en-us/powershell/module/microsoftteams/get-csexternalaccesspolicy'
$globalPolicy = $policies | Where-Object { $_.Identity -eq 'Global' } | Select-Object -First 1
$nonGlobal = @($policies | Where-Object { $_.Identity -ne 'Global' })

$baselineProperties = @('EnableFederationAccess', 'EnableTeamsConsumerAccess', 'EnableTeamsConsumerInbound')
$closedAtGlobal = [System.Collections.Generic.List[string]]::new()
$unestablishedAtGlobal = [System.Collections.Generic.List[string]]::new()
if ($nonGlobal.Count -gt 0) {
    foreach ($property in $baselineProperties) {
        $globalProperty = if ($globalPolicy) { $globalPolicy.PSObject.Properties[$property] } else { $null }
        if (-not $globalProperty -or $null -eq $globalProperty.Value) { $unestablishedAtGlobal.Add($property) }
        elseif ($globalProperty.Value -eq $false) { $closedAtGlobal.Add($property) }
    }
}

foreach ($policy in $nonGlobal) {
    $reopened = [System.Collections.Generic.List[string]]::new()
    $unknown = [System.Collections.Generic.List[string]]::new()
    foreach ($property in $closedAtGlobal) {
        if ($property -eq 'EnableTeamsConsumerInbound' -and $policy.EnableTeamsConsumerAccess -eq $false) { continue }
        $policyProperty = $policy.PSObject.Properties[$property]
        if (-not $policyProperty -or $null -eq $policyProperty.Value) { $unknown.Add($property) }
        elseif ($policyProperty.Value -eq $true) { $reopened.Add($property) }
    }

    # Global's own value for these properties was never established, so drift cannot be
    # confirmed against it - but a policy that is itself wide open on one of them is still
    # worth naming here instead of disappearing entirely into the one generic Global-level
    # NotApplicable below, which never mentions this policy by identity.
    $exposedUnestablished = [System.Collections.Generic.List[string]]::new()
    foreach ($property in $unestablishedAtGlobal) {
        if ($property -eq 'EnableTeamsConsumerInbound' -and $policy.EnableTeamsConsumerAccess -eq $false) { continue }
        $policyProperty = $policy.PSObject.Properties[$property]
        if ($policyProperty -and $policyProperty.Value -eq $true) { $exposedUnestablished.Add($property) }
    }

    if ($reopened.Count -eq 0 -and $unknown.Count -eq 0 -and $exposedUnestablished.Count -eq 0) { continue }

    $federationFlags = @($reopened | Where-Object { $_ -eq 'EnableFederationAccess' })
    $consumerFlags = @($reopened | Where-Object { $_ -ne 'EnableFederationAccess' })
    $findings = [System.Collections.Generic.List[string]]::new()
    $recommendations = [System.Collections.Generic.List[string]]::new()

    if ($federationFlags.Count -gt 0) {
        $findings.Add("Non-Global external access policy '$($policy.Identity)' re-opens federation with external organizations (EnableFederationAccess enabled) that the Global policy closes, for whoever it is assigned to")
        $recommendations.Add("Unless that user set has a specific need to bypass the Global restriction, run: Set-CsExternalAccessPolicy -Identity '$($policy.Identity)' -EnableFederationAccess `$false.")
    }
    if ($consumerFlags.Count -gt 0) {
        $inboundNote = if ($consumerFlags -contains 'EnableTeamsConsumerInbound') { ' - with EnableTeamsConsumerInbound on, unmanaged accounts can also discover these users and start the conversation, the first-contact path most personal-account phishing and vishing lures rely on' } else { '' }
        $findings.Add("Non-Global external access policy '$($policy.Identity)' re-opens Teams communication with unmanaged (personal) Microsoft accounts ($($consumerFlags -join ', ') enabled) that the Global policy closes$inboundNote. This takes effect wherever AllowTeamsConsumer is enabled at the tenant level (see MET-Teams006)")
        $recommendations.Add("If the user set has no specific need to chat with personal accounts, run: Set-CsExternalAccessPolicy -Identity '$($policy.Identity)' $(($consumerFlags | ForEach-Object { "-$_ `$false" }) -join ' '). Where outbound chat is needed, keep EnableTeamsConsumerInbound `$false so personal accounts cannot initiate contact.")
    }
    if ($unknown.Count -gt 0) {
        $findings.Add("Non-Global external access policy '$($policy.Identity)' did not return $($unknown -join ', '), so whether it re-opens access that the Global policy closes was not established - an unconfirmed state is reported as a gap rather than a pass")
        $recommendations.Add("Confirm directly: Get-CsExternalAccessPolicy -Identity '$($policy.Identity)' | Format-List $($unknown -join ', ').")
    }
    if ($exposedUnestablished.Count -gt 0) {
        $findings.Add("Non-Global external access policy '$($policy.Identity)' has $($exposedUnestablished -join ', ') enabled, but whether this differs from the Global policy was not established because the Global policy did not return $($exposedUnestablished -join ', ') - an unconfirmed baseline is reported as a gap on this policy too, not just at the tenant level")
        $recommendations.Add("Confirm directly: Get-CsExternalAccessPolicy -Identity '$($policy.Identity)', Global | Format-List Identity, $($exposedUnestablished -join ', ').")
    }
    $recommendations.Add("Run: Get-CsOnlineUser -Filter `"ExternalAccessPolicy -eq '$($policy.Identity)'`" to identify affected users before changing scope.")

    $results.Add((New-METCheckResult -CheckId 'MET-Teams010' -Category Teams -Name 'Per-User External Access Policy Drift' `
                -Result Warning -Severity Medium -AffectedObject $policy.Identity `
                -Finding ($findings -join '; ') `
                -Recommendation ($recommendations -join ' ') `
                -ReferenceUrl $referenceUrl))
}

if ($unestablishedAtGlobal.Count -gt 0) {
    $globalLabel = if ($globalPolicy) { "The Global external access policy did not return $($unestablishedAtGlobal -join ', ')" } else { 'No Global external access policy was returned' }
    $results.Add((New-METCheckResult -CheckId 'MET-Teams010' -Category Teams -Name 'Per-User External Access Policy Drift' `
                -Result NotApplicable -Severity Medium -AffectedObject 'Global' `
                -Finding "$globalLabel, so the tenant baseline for $($unestablishedAtGlobal -join ', ') was not established and non-Global policies could not be compared against it. An unconfirmed baseline is reported as unassessed rather than a pass, because nothing here distinguishes a custom policy that re-opens access from one that matches the baseline." `
                -Recommendation "Confirm directly: Get-CsExternalAccessPolicy | Format-List Identity, $($baselineProperties -join ', '). If the properties are absent there too, update the MicrosoftTeams module and re-run this check." `
                -ReferenceUrl $referenceUrl `
                -ErrorMessage "$($unestablishedAtGlobal -join ', ') not established for the Global policy from Get-CsExternalAccessPolicy - the policy or the property was not returned by the installed MicrosoftTeams module version."))
}

# A Pass here asserts "nothing re-opens access that the Global policy closes" - a claim
# that depends on knowing what the Global policy closes. When $unestablishedAtGlobal added a
# NotApplicable above, that claim cannot be made for the properties it names, so Pass must
# not fire alongside it (CLAUDE.md: an absent/unestablished property never yields Pass).
if ($unestablishedAtGlobal.Count -eq 0 -and @($results | Where-Object { $_.Result -eq 'Warning' }).Count -eq 0) {
    New-METCheckResult -CheckId 'MET-Teams010' -Category Teams -Name 'Per-User External Access Policy Drift' `
        -Result Pass -Severity Medium -AffectedObject 'External Access Policies' `
        -Finding 'No non-Global external access policy re-opens federation, or Teams communication with unmanaged accounts, that the Global policy closes' `
        -ReferenceUrl $referenceUrl
}
$results