Teams & SharePoint PowerShell script Report only

Get-TeamsGuestAccessReport.ps1

Reports which Microsoft Teams teams contain guest users and which external domains they come from.

Overview#

Lists every team (groups with the Team provisioning option) from /groups, reads the members of each team from /groups/{id}/members and keeps the members whose userType is Guest. The guest's home domain is taken from the mail attribute, or decoded from the #EXT# user principal name when mail is empty. By default one row per guest membership is exported; -SummaryOnly exports one row per team instead. -IncludeTeamSettings adds the team's guest channel permissions from /teams/{id}?$select=guestSettings. Prints a summary with the number of teams that have guests, total guests and the top 10 guest domains.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-TeamsGuestAccessReport.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-TeamNameOnly evaluate teams whose display name matches this wildcard pattern (for example 'Project*'). A value without wildcards is matched as *value*.
-SummaryOnlyExport one row per team (TeamName, TeamId, Visibility, MemberCount, GuestCount, GuestDomains) instead of one row per guest.
-IncludeTeamSettingsAlso read each team's guest settings (AllowGuestCreateUpdateChannels, AllowGuestDeleteChannels). Needs the Team.ReadBasic.All and TeamSettings.Read.All scopes and one extra Graph call per team.
-OutputPathPath of the CSV file. Defaults to .\Reports\TeamsGuestAccess_<timestamp>.csv.
-PassThruAlso emit the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-TeamsGuestAccessReport.ps1

Exports every guest membership in every team and shows the top guest domains in the console.

PowerShell
PS> .\Get-TeamsGuestAccessReport.ps1 -TeamName 'Project*' -SummaryOnly -IncludeTeamSettings -OutputPath C:\Temp\ProjectGuests.csv -Verbose

Evaluates only teams starting with "Project", exports one row per team including the guest channel permissions.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : Group.Read.All, GroupMember.Read.All, User.Read.All (delegated); plus Team.ReadBasic.All and TeamSettings.Read.All when -IncludeTeamSettings is used. Global Reader can run it end to end. Notes : Only direct team members are evaluated. External participants of shared channels are not team members and therefore do not appear here. Guest counts are memberships: a guest who belongs to three teams produces three rows. One Graph call per team (two with -IncludeTeamSettings); large tenants should expect a few minutes and may use -TeamName to narrow the scope.

Full source#

PowerShell · Get-TeamsGuestAccessReport.ps1
<#
.SYNOPSIS
    Reports which Microsoft Teams teams contain guest users and which external domains they come from.
.DESCRIPTION
    Lists every team (groups with the Team provisioning option) from /groups, reads the members of each
    team from /groups/{id}/members and keeps the members whose userType is Guest. The guest's home domain
    is taken from the mail attribute, or decoded from the #EXT# user principal name when mail is empty.
    By default one row per guest membership is exported; -SummaryOnly exports one row per team instead.
    -IncludeTeamSettings adds the team's guest channel permissions from /teams/{id}?$select=guestSettings.
    Prints a summary with the number of teams that have guests, total guests and the top 10 guest domains.
.PARAMETER TeamName
    Only evaluate teams whose display name matches this wildcard pattern (for example 'Project*'). A value
    without wildcards is matched as *value*.
.PARAMETER SummaryOnly
    Export one row per team (TeamName, TeamId, Visibility, MemberCount, GuestCount, GuestDomains) instead of one row per guest.
.PARAMETER IncludeTeamSettings
    Also read each team's guest settings (AllowGuestCreateUpdateChannels, AllowGuestDeleteChannels). Needs the
    Team.ReadBasic.All and TeamSettings.Read.All scopes and one extra Graph call per team.
.PARAMETER OutputPath
    Path of the CSV file. Defaults to .\Reports\TeamsGuestAccess_<timestamp>.csv.
.PARAMETER PassThru
    Also emit the report objects to the pipeline.
.EXAMPLE
    PS> .\Get-TeamsGuestAccessReport.ps1
    Exports every guest membership in every team and shows the top guest domains in the console.
.EXAMPLE
    PS> .\Get-TeamsGuestAccessReport.ps1 -TeamName 'Project*' -SummaryOnly -IncludeTeamSettings -OutputPath C:\Temp\ProjectGuests.csv -Verbose
    Evaluates only teams starting with "Project", exports one row per team including the guest channel permissions.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
    Permissions : Group.Read.All, GroupMember.Read.All, User.Read.All (delegated); plus Team.ReadBasic.All and
                  TeamSettings.Read.All when -IncludeTeamSettings is used. Global Reader can run it end to end.
    Notes       : Only direct team members are evaluated. External participants of shared channels are not team
                  members and therefore do not appear here. Guest counts are memberships: a guest who belongs to
                  three teams produces three rows. One Graph call per team (two with -IncludeTeamSettings); large
                  tenants should expect a few minutes and may use -TeamName to narrow the scope.
.LINK
    https://learn.microsoft.com/graph/api/group-list-members
.LINK
    https://learn.microsoft.com/graph/api/team-get
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication

[CmdletBinding()]
param(
    [Parameter()]
    [string]$TeamName,

    [Parameter()]
    [switch]$SummaryOnly,

    [Parameter()]
    [switch]$IncludeTeamSettings,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-GraphIfNeeded {
    <# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Scopes
    )
    $context = Get-MgContext
    $missingScopes = @()
    if ($null -ne $context) {
        $missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
    }
    if ($null -eq $context -or $missingScopes.Count -gt 0) {
        Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
    }
    else {
        Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
    }
}

function Invoke-GraphPaged {
    <# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [hashtable]$Headers
    )
    $results = New-Object -TypeName System.Collections.Generic.List[object]
    $nextLink = $Uri
    while (-not [string]::IsNullOrEmpty($nextLink)) {
        $requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
        if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
        $response = Invoke-MgGraphRequest @requestParams
        if ($null -ne $response.PSObject.Properties['value']) {
            foreach ($item in $response.value) { $results.Add($item) }
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }
        $nextLink = $response.'@odata.nextLink'
    }
    return $results
}

function Get-GuestDomain {
    <# Returns the guest's home domain from the mail address, or decoded from a UPN like user_contoso.com#EXT#@tenant.onmicrosoft.com. #>
    param(
        [Parameter()]
        [string]$Mail,

        [Parameter()]
        [string]$UserPrincipalName
    )
    if (-not [string]::IsNullOrWhiteSpace($Mail) -and $Mail.Contains('@')) {
        return $Mail.Split('@')[-1].ToLowerInvariant()
    }
    if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName) -and $UserPrincipalName -match '^(?<local>.+)#EXT#@') {
        $local = $Matches['local']
        $separator = $local.LastIndexOf('_')
        if ($separator -ge 0 -and $separator -lt ($local.Length - 1)) {
            return $local.Substring($separator + 1).ToLowerInvariant()
        }
    }
    return $null
}
#endregion Helpers

#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('TeamsGuestAccess_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

$scopes = @('Group.Read.All', 'GroupMember.Read.All', 'User.Read.All')
if ($IncludeTeamSettings) { $scopes += @('Team.ReadBasic.All', 'TeamSettings.Read.All') }
try {
    Connect-GraphIfNeeded -Scopes $scopes
}
catch {
    throw "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
}

Write-Verbose 'Listing all teams from /groups.'
try {
    $groupsUri = 'https://graph.microsoft.com/v1.0/groups?$filter=resourceProvisioningOptions/Any(x:x eq ''Team'')&$select=id,displayName,visibility&$top=999'
    $teams = @(Invoke-GraphPaged -Uri $groupsUri)
}
catch {
    throw "Failed to list teams: $($_.Exception.Message)"
}
if (-not [string]::IsNullOrWhiteSpace($TeamName)) {
    $namePattern = $TeamName
    if ($namePattern -notmatch '[\*\?]') { $namePattern = "*$namePattern*" }
    $teams = @($teams | Where-Object { $_.displayName -like $namePattern })
}
Write-Verbose "Evaluating $($teams.Count) teams."

$guestRows = New-Object -TypeName System.Collections.Generic.List[object]
$teamRows = New-Object -TypeName System.Collections.Generic.List[object]
$counter = 0
foreach ($team in $teams) {
    $counter++
    Write-Progress -Activity 'Reading team membership' -Status "$counter of $($teams.Count): $($team.displayName)" -PercentComplete ([int](($counter / $teams.Count) * 100))

    try {
        $members = @(Invoke-GraphPaged -Uri ('https://graph.microsoft.com/v1.0/groups/{0}/members?$select=id,displayName,userPrincipalName,mail,userType&$top=999' -f $team.id))
    }
    catch {
        Write-Warning "Could not read the members of team '$($team.displayName)': $($_.Exception.Message)"
        continue
    }
    $guests = @($members | Where-Object { $_.userType -eq 'Guest' })

    $allowGuestCreateUpdateChannels = $null
    $allowGuestDeleteChannels = $null
    if ($IncludeTeamSettings) {
        try {
            $teamSettings = Invoke-MgGraphRequest -Method GET -Uri ('https://graph.microsoft.com/v1.0/teams/{0}?$select=guestSettings' -f $team.id) -OutputType PSObject -ErrorAction Stop
            $allowGuestCreateUpdateChannels = $teamSettings.guestSettings.allowCreateUpdateChannels
            $allowGuestDeleteChannels = $teamSettings.guestSettings.allowDeleteChannels
        }
        catch {
            Write-Warning "Could not read the guest settings of team '$($team.displayName)': $($_.Exception.Message)"
        }
    }

    $domains = @($guests | ForEach-Object { Get-GuestDomain -Mail $_.mail -UserPrincipalName $_.userPrincipalName } | Where-Object { $null -ne $_ } | Sort-Object -Unique)
    $teamRows.Add([PSCustomObject]@{
            TeamName                       = $team.displayName
            TeamId                         = $team.id
            Visibility                     = $team.visibility
            MemberCount                    = $members.Count
            GuestCount                     = $guests.Count
            GuestDomains                   = ($domains -join ';')
            AllowGuestCreateUpdateChannels = $allowGuestCreateUpdateChannels
            AllowGuestDeleteChannels       = $allowGuestDeleteChannels
        })
    foreach ($guest in $guests) {
        $guestRows.Add([PSCustomObject]@{
                TeamName                       = $team.displayName
                TeamId                         = $team.id
                Visibility                     = $team.visibility
                GuestDisplayName               = $guest.displayName
                GuestUserPrincipalName         = $guest.userPrincipalName
                GuestEmail                     = $guest.mail
                GuestDomain                    = Get-GuestDomain -Mail $guest.mail -UserPrincipalName $guest.userPrincipalName
                MemberCount                    = $members.Count
                GuestCount                     = $guests.Count
                AllowGuestCreateUpdateChannels = $allowGuestCreateUpdateChannels
                AllowGuestDeleteChannels       = $allowGuestDeleteChannels
            })
    }
    Start-Sleep -Milliseconds 200
}
Write-Progress -Activity 'Reading team membership' -Completed

if ($SummaryOnly) {
    $output = @($teamRows | Sort-Object -Property @{ Expression = 'GuestCount'; Descending = $true }, TeamName)
}
else {
    $output = @($guestRows | Sort-Object -Property TeamName, GuestDisplayName)
}

if ($output.Count -gt 0) {
    $output | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Warning 'No rows to export (no guests found in the evaluated teams); no CSV was written.'
}

$teamsWithGuests = @($teamRows | Where-Object { $_.GuestCount -gt 0 }).Count
$uniqueGuests = @($guestRows | Select-Object -ExpandProperty GuestUserPrincipalName -Unique).Count
$domainGroups = @($guestRows | Where-Object { -not [string]::IsNullOrWhiteSpace($_.GuestDomain) } | Group-Object -Property GuestDomain)
$topDomains = @($domainGroups | Sort-Object -Property Count -Descending | Select-Object -First 10)
Write-Host ''
Write-Host 'Teams guest access summary' -ForegroundColor Cyan
Write-Host ('  Teams evaluated              : {0}' -f $teamRows.Count)
Write-Host ('  Teams with guests            : {0}' -f $teamsWithGuests) -ForegroundColor Yellow
Write-Host ('  Guest memberships / accounts : {0} / {1}' -f $guestRows.Count, $uniqueGuests)
if ($topDomains.Count -gt 0) {
    Write-Host '  Top guest domains:'
    foreach ($domain in $topDomains) {
        Write-Host ('    {0,-45} {1,5}' -f $domain.Name, $domain.Count)
    }
}
Write-Host ('  Rows exported                : {0} -> {1}' -f $output.Count, $OutputPath)

if ($PassThru) { $output }
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.