public/Get-MsecTeamsPolicy.ps1

function Get-MsecTeamsPolicy {
    <#
    .SYNOPSIS
        The Teams settings that decide who can reach your people - external access, guest
        access, meeting lobby and recording, app installation - as one row per setting.

    .DESCRIPTION
        Teams policy is where a tenant quietly becomes reachable from the outside. Anonymous
        meeting join, open federation, guest access and unrestricted app installation are all
        defaults-on or defaults-permissive, none of them appear in a Secure Score or a
        Conditional Access review, and each is a real path in.

        ONE ROW PER SETTING, NOT PER POLICY. A meeting policy object carries roughly eighty
        properties, most of them about layout and captions. Returning whole objects makes the
        handful that matter impossible to see, and impossible to compare between two policies
        or two tenants. Flattened to (PolicyType, PolicyName, Setting, Value), the output
        sorts, filters and diffs.

        ONLY THE SECURITY-RELEVANT SETTINGS ARE PROJECTED, and which ones is a judgement this
        command makes on your behalf - so it is written down in the source rather than hidden.
        -All returns every property of every policy instead, for when you need to see what was
        left out.

        A POLICY TYPE THAT CANNOT BE READ IS REPORTED, NOT SKIPPED. Teams cmdlets fail
        individually when a role is missing or a feature is not licensed, and a report that
        silently omitted federation configuration would read as a tenant with none.

    .PARAMETER PolicyType
        Which policy areas to read. Default is all of them:
          Federation external access - who outside the tenant can chat with your people
          Meeting anonymous join, lobby, recording, external control
          Messaging message deletion, read receipts
          AppPermission which apps users may install
          Client guest access, channel email, third-party storage providers
          Files file sharing in chats with external users, and where uploads land

    .PARAMETER All
        Return every property of every policy, not just the security-relevant projection.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid>
        Get-MsecTeamsPolicy

    .EXAMPLE
        # The settings that let people in from outside.
        Get-MsecTeamsPolicy -PolicyType Federation, Client |
            Where-Object Value -in 'True', 'Everyone', 'EveryoneInCompanyExcludingGuests'

    .EXAMPLE
        # Compare the Global policy against the custom ones - drift is where exceptions hide.
        Get-MsecTeamsPolicy -PolicyType Meeting |
            Group-Object Setting | Where-Object { @($_.Group.Value | Select-Object -Unique).Count -gt 1 }

    .OUTPUTS
        PSCustomObject per setting, PSTypeName 'MsecTeamsPolicy'.

    .NOTES
        Needs Connect-Msec; the Teams sign-in is done for you by calling Connect-MsecTeams,
        which replaces any Teams session already open in this shell. See that command for why
        Teams requires a DIRECTORY ROLE on top of app permissions.

        Policies apply per user, and the Global policy is what a user gets unless they are
        assigned another. A permissive custom policy assigned to nobody is not a finding; one
        assigned to everyone is. This command reads the policies, not their assignments.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateSet('Federation', 'Meeting', 'Messaging', 'AppPermission', 'Client', 'Files')]
        [string[]] $PolicyType = @('Federation', 'Meeting', 'Messaging', 'AppPermission', 'Client', 'Files'),

        [switch] $All
    )

    # CONNECTS ITSELF, like Get-MsecSharePointSiteUser -Url does: every other command in this
    # module is one call after Connect-Msec, and needing a second connect step for this one is
    # a trap rather than a feature.
    #
    # NOT a Get-Command check for Get-Cs*: that succeeds as soon as MicrosoftTeams is
    # INSTALLED, connected or not, so it let an unconnected run through to fail five times with
    # a warning blaming a directory role. The module exposes no "am I connected" cmdlet, so the
    # honest move is to establish the session rather than to guess at it.
    # ...unless the caller deliberately signed in as themselves. Reconnecting as the app there
    # would swap a session that can write for one that cannot, and the next Set-Cs* would fail
    # on rights the caller does have.
    if ($script:MsecSession -and -not $script:MsecTeamsAsCurrentUser) {
        Connect-MsecTeams
    }
    elseif (-not (Get-Command Get-CsTenantFederationConfiguration -ErrorAction SilentlyContinue)) {
        throw 'Not connected to Teams and no msec session to connect with. Run Connect-Msec, or Connect-MicrosoftTeams yourself.'
    }

    # The settings worth reporting, per policy area. Written out rather than derived, because
    # "which of these eighty properties is a security control" is exactly the judgement a
    # reader needs to be able to check and argue with.
    $projection = @{
        Federation = @{
            Cmdlet   = 'Get-CsTenantFederationConfiguration'
            Settings = @(
                'AllowFederatedUsers'          # any other Teams tenant can reach your people
                'AllowPublicUsers'             # consumer Skype
                'AllowTeamsConsumer'           # personal Teams accounts
                'AllowTeamsConsumerInbound'
                'RestrictTeamsConsumerToExternalUserProfiles'
                'AllowedDomains'               # an allow-list is far stronger than a block-list
                'BlockedDomains'
                'SharedSipAddressSpace'
            )
        }
        Meeting = @{
            Cmdlet   = 'Get-CsTeamsMeetingPolicy'
            Settings = @(
                'AllowAnonymousUsersToJoinMeeting'   # no account needed at all
                'AllowAnonymousUsersToStartMeeting'  # and no host present to admit them
                'AutoAdmittedUsers'                  # who bypasses the lobby
                'AllowPSTNUsersToBypassLobby'
                'AllowExternalParticipantGiveRequestControl'  # screen control to an outsider
                'AllowCloudRecording'
                'AllowTranscription'
                'MeetingChatEnabledType'
                'DesignatedPresenterRoleMode'
                'AllowedUsersForMeetingContext'
            )
        }
        Messaging = @{
            Cmdlet   = 'Get-CsTeamsMessagingPolicy'
            Settings = @(
                'AllowUserDeleteMessage'       # deletion by the author defeats retention
                'AllowOwnerDeleteMessage'
                'AllowUserEditMessage'
                'ReadReceiptsEnabledType'
                'AllowSecurityEndUserReporting'
            )
        }
        AppPermission = @{
            Cmdlet   = 'Get-CsTeamsAppPermissionPolicy'
            Settings = @(
                'DefaultCatalogAppsType'       # 'AllowedAppList' is control; 'BlockedAppList' is not
                'GlobalCatalogAppsType'
                'PrivateCatalogAppsType'
                'DefaultCatalogApps'
                'GlobalCatalogApps'
                'PrivateCatalogApps'
            )
        }
        # Files policy is NOT the same surface as the third-party storage switches under
        # Client. Those say which storage providers appear in Teams; this says whether a file
        # can leave the tenant through a chat with someone outside it, and where uploads land
        # by default. A tenant can have every third-party provider off and still allow that.
        Files = @{
            Cmdlet   = 'Get-CsTeamsFilesPolicy'
            Settings = @(
                'FileSharingInChatswithExternalUsers'  # files out of the tenant, via chat
                'DefaultFileUploadAppId'               # where uploads land - a non-default app
                                                       # means files leave SharePoint/OneDrive
                'NativeFileEntryPoints'                # uploading from the local device at all
            )
        }

        Client = @{
            Cmdlet   = 'Get-CsTeamsClientConfiguration'
            Settings = @(
                'AllowGuestUser'               # guests in teams at all
                'AllowEmailIntoChannel'        # anyone who learns the address can post
                'AllowDropBox'; 'AllowBox'; 'AllowGoogleDrive'; 'AllowShareFile'; 'AllowEgnyte'
                'AllowOrganizationTab'
                'AllowScopedPeopleSearchandAccess'
            )
        }
    }

    foreach ($type in $PolicyType) {
        $spec = $projection[$type]

        $policies = $null
        try {
            $policies = @(& $spec.Cmdlet -ErrorAction Stop)
        }
        catch {
            # Named, not skipped: a missing federation configuration reads as a tenant with no
            # external access, which is the opposite of the truth.
            # The hint is a GUESS, so it is only offered where it fits. An authorisation
            # failure against a connected session really is a directory role most of the time;
            # anything else - no session, a transient error - is its own problem, and pointing
            # at role assignment there sends the reader to look for something already correct.
            $hint = if ($_.Exception.Message -match 'unauthor|forbidden|denied|privilege|access') {
                " The likeliest cause is the missing DIRECTORY ROLE: app permissions alone are not enough for Teams, and the app also needs Teams Administrator, Teams Communications Administrator or Global Reader."
            }
            else { '' }
            Write-Warning "Could not read $type policy via $($spec.Cmdlet), so it is NOT covered by this output.$hint Teams said: $($_.Exception.Message)"
            [PSCustomObject]@{
                PSTypeName = 'MsecTeamsPolicy'
                PolicyType = $type
                PolicyName = 'Unreadable'
                Setting    = $null
                Value      = $null
                IsGlobal   = $null
            }
            continue
        }

        foreach ($policy in $policies) {
            # Identity is 'Global' for the tenant-wide policy and 'Tag:<name>' for a custom
            # one. Trimmed, because the prefix is noise in a table.
            $name = [string] $policy.Identity
            if (-not $name) { $name = 'Global' }
            $display = $name -replace '^Tag:', ''

            $settings = if ($All) {
                @($policy.PSObject.Properties.Name | Where-Object { $_ -notin 'Identity' })
            }
            else {
                # Only what this policy object actually carries - the set differs between
                # module versions, and asking for an absent property would emit a row of nulls
                # that reads as "configured off".
                @($spec.Settings | Where-Object { $_ -in $policy.PSObject.Properties.Name })
            }

            foreach ($setting in $settings) {
                $value = $policy.$setting
                if ($null -ne $value -and $value -isnot [string] -and $value -is [System.Collections.IEnumerable]) {
                    $value = (@($value) | ForEach-Object { "$_" }) -join '; '
                }

                [PSCustomObject]@{
                    PSTypeName = 'MsecTeamsPolicy'
                    PolicyType = $type
                    PolicyName = $display
                    Setting    = $setting
                    Value      = [string] $value
                    # The one a user gets unless assigned another - so a permissive Global is a
                    # tenant-wide finding, where a permissive custom policy may apply to nobody.
                    IsGlobal   = ($display -eq 'Global')
                }
            }
        }
    }
}