Teams List Shared Channels & External-Tenant Detector
Summary
This script provides a comprehensive inventory of all shared channels across Microsoft Teams in your tenant and identifies external tenant connections. As shared channels grow in popularity for cross-organization collaboration, governance tools often lag behind. This script addresses this gap by:
- Enumerating all teams in the tenant
- Finding all channels with membershipType = "shared"
- Identifying which external organizations have access to these shared channels
- Generating detailed HTML and CSV reports for governance and security review
The reports include information about team owners, shared channel owners, external tenant details, and membership counts to help administrators maintain visibility into cross-tenant collaboration.
Prerequisites
- Microsoft Graph PowerShell SDK modules
- Permissions required:
- Team.ReadBasic.All (to enumerate teams)
- Channel.ReadBasic.All (to list channels)
- ChannelSettings.Read.All (to get shared channel settings)
- ChannelMember.Read.All (to enumerate channel members)
- PowerShell 5.1 or higher
#-------------------------------------------------------------------------------
# Script: Teams List Shared Channels & External-Tenant Detector
#
# Description: This script identifies all shared channels across Microsoft Teams and
# provides details about external tenant connections. It generates both HTML and CSV
# reports for governance and security review.
#
# Required permissions:
# - Team.ReadBasic.All
# - Channel.ReadBasic.All
# - ChannelSettings.Read.All
# - ChannelMember.Read.All
#-------------------------------------------------------------------------------
# Check for and install required modules
if (!(Get-Module -Name Microsoft.Graph -ListAvailable)) {
Write-Host "Microsoft Graph PowerShell SDK not found. Installing..." -ForegroundColor Yellow
Install-Module -Name Microsoft.Graph -Scope CurrentUser -Force
}
# Import the required Microsoft Graph modules
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Teams
Import-Module Microsoft.Graph.Groups
Import-Module Microsoft.Graph.Users
# Connect to Microsoft Graph with the required permissions
Connect-MgGraph -Scopes @(
"Team.ReadBasic.All",
"Channel.ReadBasic.All",
"ChannelSettings.Read.All",
"ChannelMember.Read.All"
) -NoWelcome
# Create output directory if it doesn't exist
$outputDir = ".\TeamsSharedChannelsReport"
if (!(Test-Path -Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# Get current date for report filenames
$reportDate = Get-Date -Format "yyyy-MM-dd"
$csvReportPath = Join-Path $outputDir "SharedChannelsReport_$reportDate.csv"
$htmlReportPath = Join-Path $outputDir "SharedChannelsReport_$reportDate.html"
$summaryReportPath = Join-Path $outputDir "SharedChannelsSummary_$reportDate.csv"
# Note on Shared Channels API:
# The Microsoft Graph API requires a special header "Prefer: include-unknown-enum-members"
# to properly return the "shared" membershipType value. This is because "shared" was added
# after the API was originally designed and wasn't part of the original enum definition.
# Always include this header when working with channels to ensure shared channels are detected.
# Arrays to store data
$sharedChannelData = @()
$externalTenantSummary = @{}
$teamStats = @{}
$internalCount = 0
$externalCount = 0
Write-Host "Starting shared channel inventory..." -ForegroundColor Cyan
# Get all teams in the tenant
try {
Write-Host "Retrieving all teams in the tenant..." -ForegroundColor Green
$allTeams = Get-MgTeam -All
Write-Host "Found $($allTeams.Count) teams." -ForegroundColor Green
}
catch {
Write-Error "Error retrieving teams: $_"
exit
}
$totalTeams = $allTeams.Count
$processedTeams = 0
foreach ($team in $allTeams) {
$processedTeams++
$percentComplete = [math]::Round(($processedTeams / $totalTeams) * 100, 2)
Write-Progress -Activity "Scanning Teams for Shared Channels" -Status "Processing Team: $($team.DisplayName)" -PercentComplete $percentComplete
try {
# Get team owners
$teamOwners = Get-MgGroupOwner -GroupId $team.Id
$teamOwnerNames = ($teamOwners | ForEach-Object { $_.AdditionalProperties.displayName }) -join ", "
# Get all channels for the team with proper headers to include unknown enum values
# Note: We're using direct Graph API calls instead of Get-MgTeamChannel because
# the SDK doesn't support including the "shared" membershipType without the special header
$uri = "https://graph.microsoft.com/v1.0/teams/$($team.Id)/channels"
$headers = @{ "Prefer" = "include-unknown-enum-members" }
$response = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $headers
$channels = $response.value
# FIXED: Filter to only shared channels - using explicit loop instead of Where-Object
$sharedChannels = @()
foreach ($channel in $channels) {
if ($channel.membershipType -eq "shared") {
$sharedChannels += $channel
}
}
# Debug info - list all channels and their membership types
Write-Host " All channels in team '$($team.DisplayName)':" -ForegroundColor DarkGray
foreach ($ch in $channels) {
Write-Host " - $($ch.DisplayName) (Type: $($ch.membershipType))" -ForegroundColor DarkGray
}
if ($sharedChannels -and $sharedChannels.Count -gt 0) {
Write-Host " Found $($sharedChannels.Count) shared channel(s) in team '$($team.DisplayName)'" -ForegroundColor Yellow
foreach ($sharedChannel in $sharedChannels) {
Write-Host " Processing shared channel: $($sharedChannel.DisplayName)" -ForegroundColor Green
try {
# Get channel members
try {
$channelMembers = Get-MgTeamChannelMember -TeamId $team.Id -ChannelId $sharedChannel.Id
$channelMemberCount = $channelMembers.Count
# Get channel owners
$channelOwners = $channelMembers | Where-Object { $_.Roles -contains "owner" }
$channelOwnerNames = ($channelOwners | ForEach-Object { $_.DisplayName }) -join ", "
}
catch {
Write-Warning " Error retrieving channel members for $($sharedChannel.DisplayName): $_"
$channelMemberCount = 0
$channelOwnerNames = "Unknown (access error)"
}
# Get all organizations the channel is shared with
# Must use direct API call with the special header to get external tenant information
$uri = "https://graph.microsoft.com/v1.0/teams/$($team.Id)/channels/$($sharedChannel.Id)/sharedWithTeams"
$headers = @{ "Prefer" = "include-unknown-enum-members" }
$sharedWithOrgs = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $headers
if ($null -eq $sharedWithOrgs.value -or $sharedWithOrgs.value.Count -eq 0) {
Write-Host " Channel '$($sharedChannel.DisplayName)' is a shared channel but has no external tenant connections" -ForegroundColor Magenta
$internalCount++
# Still add the channel to the report as an internal shared channel
$sharedChannelData += [PSCustomObject]@{
TeamId = $team.Id
TeamName = $team.DisplayName
TeamOwners = $teamOwnerNames
ChannelId = $sharedChannel.Id
ChannelName = $sharedChannel.DisplayName
ChannelDescription = $sharedChannel.Description
ChannelOwners = $channelOwnerNames
MemberCount = $channelMemberCount
TenantId = "Internal"
TenantName = "Internal (No external sharing)"
LastModifiedDateTime = $sharedChannel.ModifiedDateTime
}
# Update team stats for internal shared channels
if (-not $teamStats.ContainsKey($team.Id)) {
$teamStats[$team.Id] = @{
"TeamName" = $team.DisplayName
"TotalSharedChannels" = 0
"ExternalTenants" = @{}
}
}
$teamStats[$team.Id]["TotalSharedChannels"]++
}
else {
Write-Host " Channel '$($sharedChannel.DisplayName)' is shared with $($sharedWithOrgs.value.Count) external tenant(s)" -ForegroundColor Cyan
$externalCount++
# Process each external tenant
foreach ($org in $sharedWithOrgs.value) {
# Extract tenant information
$tenantId = $org.tenantId
$tenantName = $org.displayName
# Update external tenant summary
if (-not $externalTenantSummary.ContainsKey($tenantId)) {
$externalTenantSummary[$tenantId] = @{
"TenantName" = $tenantName
"ChannelCount" = 0
"Teams" = @{}
"TotalExternalMembers" = 0
}
}
$externalTenantSummary[$tenantId]["ChannelCount"]++
if (-not $externalTenantSummary[$tenantId]["Teams"].ContainsKey($team.Id)) {
$externalTenantSummary[$tenantId]["Teams"][$team.Id] = @{
"TeamName" = $team.DisplayName
"Channels" = @()
}
}
$externalTenantSummary[$tenantId]["Teams"][$team.Id]["Channels"] += $sharedChannel.DisplayName
# Add to main data collection
$sharedChannelData += [PSCustomObject]@{
TeamId = $team.Id
TeamName = $team.DisplayName
TeamOwners = $teamOwnerNames
ChannelId = $sharedChannel.Id
ChannelName = $sharedChannel.DisplayName
ChannelDescription = $sharedChannel.Description
ChannelOwners = $channelOwnerNames
MemberCount = $channelMemberCount
TenantId = $tenantId
TenantName = $tenantName
LastModifiedDateTime = $sharedChannel.ModifiedDateTime
}
}
# Update team stats
if (-not $teamStats.ContainsKey($team.Id)) {
$teamStats[$team.Id] = @{
"TeamName" = $team.DisplayName
"TotalSharedChannels" = 0
"ExternalTenants" = @{}
}
}
$teamStats[$team.Id]["TotalSharedChannels"]++
foreach ($org in $sharedWithOrgs.value) {
$tenantId = $org.tenantId
if (-not $teamStats[$team.Id]["ExternalTenants"].ContainsKey($tenantId)) {
$teamStats[$team.Id]["ExternalTenants"][$tenantId] = $org.displayName
}
}
}
}
catch {
Write-Warning "Error processing shared channel $($sharedChannel.DisplayName): $_"
}
}
}
}
catch {
Write-Warning "Error processing team $($team.DisplayName): $_"
}
}
Write-Progress -Activity "Scanning Teams for Shared Channels" -Status "Complete" -Completed
Write-Host "Summary:" -ForegroundColor Green
Write-Host " Total shared channels: $($internalCount + $externalCount)" -ForegroundColor Green
Write-Host " Internal only shared channels: $internalCount" -ForegroundColor Green
Write-Host " Externally shared channels: $externalCount" -ForegroundColor Green
# Generate CSV report
if ($sharedChannelData.Count -gt 0) {
$sharedChannelData | Export-Csv -Path $csvReportPath -NoTypeInformation
Write-Host "CSV report exported to: $csvReportPath" -ForegroundColor Green
}
else {
Write-Host "No shared channels found in the tenant." -ForegroundColor Yellow
}
# Generate tenant summary report
$tenantSummaryData = @()
foreach ($tenantId in $externalTenantSummary.Keys) {
$tenantInfo = $externalTenantSummary[$tenantId]
$teamCount = $tenantInfo["Teams"].Count
$tenantSummaryData += [PSCustomObject]@{
TenantId = $tenantId
TenantName = $tenantInfo["TenantName"]
SharedChannelCount = $tenantInfo["ChannelCount"]
TeamCount = $teamCount
Teams = ($tenantInfo["Teams"].Values | ForEach-Object { $_.TeamName }) -join ", "
}
}
$tenantSummaryData | Export-Csv -Path $summaryReportPath -NoTypeInformation
Write-Host "Tenant summary report exported to: $summaryReportPath" -ForegroundColor Green
# Generate HTML report
$htmlHeader = @"
<!DOCTYPE html>
<html>
<head>
<title>Teams Shared Channels Report - $reportDate</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #0078d4; }
h2 { color: #106ebe; margin-top: 30px; }
h3 { color: #106ebe; margin-top: 20px; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; margin-bottom: 30px; }
th { background-color: #0078d4; color: white; text-align: left; padding: 8px; }
td { border: 1px solid #ddd; padding: 8px; }
tr:nth-child(even) { background-color: #f2f2f2; }
tr:hover { background-color: #e6f2ff; }
.summary { background-color: #f0f0f0; padding: 15px; border-radius: 5px; margin: 20px 0; }
.warning { color: #d83b01; }
.info { color: #0078d4; }
.note { background-color: #FFF4CE; padding: 10px; border-left: 4px solid #FFC83D; margin: 15px 0; }
</style>
</head>
<body>
<h1>Teams List Shared Channels & External Tenant Report</h1>
<p>Generated on $reportDate</p>
<div class="summary">
<h2>Summary</h2>
<p><b>Total Teams with Shared Channels:</b> $($teamStats.Count)</p>
<p><b>Total Shared Channels:</b> $($internalCount + $externalCount)</p>
<p><b>External Tenants Connected:</b> $($externalTenantSummary.Count)</p>
<p><b>Internal Only Shared Channels:</b> $internalCount</p>
<p><b>Externally Shared Channels:</b> $externalCount</p>
</div>
<div class="note">
<p><b>Note:</b> Shared channels in Teams can exist in two states: (1) Internally shared within your organization only, or (2) Externally shared with other tenants. This report includes both types to give you a complete picture of shared channel usage.</p>
</div>
"@
$externalTenantHtml = @"
<h2>External Tenant Summary</h2>
<table>
<tr>
<th>Tenant Name</th>
<th>Tenant ID</th>
<th>Shared Channel Count</th>
<th>Teams Connected</th>
</tr>
"@
foreach ($tenant in $tenantSummaryData | Sort-Object -Property SharedChannelCount -Descending) {
$externalTenantHtml += @"
<tr>
<td>$($tenant.TenantName)</td>
<td>$($tenant.TenantId)</td>
<td>$($tenant.SharedChannelCount)</td>
<td>$($tenant.TeamCount)</td>
</tr>
"@
}
$externalTenantHtml += @"
</table>
"@
$externalSharedChannels = $sharedChannelData | Where-Object { $_.TenantId -ne "Internal" }
$internalSharedChannels = $sharedChannelData | Where-Object { $_.TenantId -eq "Internal" }
$sharedChannelsHtml = @"
<h2>Shared Channels Details</h2>
"@
# External Shared Channels
if ($externalSharedChannels.Count -gt 0) {
$sharedChannelsHtml += @"
<h3>Externally Shared Channels ($($externalSharedChannels.Count))</h3>
<table>
<tr>
<th>Team Name</th>
<th>Channel Name</th>
<th>Channel Owners</th>
<th>External Tenant</th>
<th>Member Count</th>
<th>Last Modified</th>
</tr>
"@
foreach ($channel in $externalSharedChannels | Sort-Object -Property TeamName, ChannelName) {
$sharedChannelsHtml += @"
<tr>
<td>$($channel.TeamName)</td>
<td>$($channel.ChannelName)</td>
<td>$($channel.ChannelOwners)</td>
<td>$($channel.TenantName) ($($channel.TenantId))</td>
<td>$($channel.MemberCount)</td>
<td>$($channel.LastModifiedDateTime)</td>
</tr>
"@
}
$sharedChannelsHtml += @"
</table>
"@
}
# Internal Shared Channels
if ($internalSharedChannels.Count -gt 0) {
$sharedChannelsHtml += @"
<h3>Internal Only Shared Channels ($($internalSharedChannels.Count))</h3>
<table>
<tr>
<th>Team Name</th>
<th>Channel Name</th>
<th>Channel Owners</th>
<th>Sharing Status</th>
<th>Member Count</th>
<th>Last Modified</th>
</tr>
"@
foreach ($channel in $internalSharedChannels | Sort-Object -Property TeamName, ChannelName) {
$sharedChannelsHtml += @"
<tr>
<td>$($channel.TeamName)</td>
<td>$($channel.ChannelName)</td>
<td>$($channel.ChannelOwners)</td>
<td>Internal sharing only (not shared with external tenants)</td>
<td>$($channel.MemberCount)</td>
<td>$($channel.LastModifiedDateTime)</td>
</tr>
"@
}
$sharedChannelsHtml += @"
</table>
"@
}
$htmlFooter = @"
<h2>Recommendations</h2>
<ul>
<li>Review shared channels to ensure they comply with your external sharing policies</li>
<li>Verify that channel owners are monitoring external user activity</li>
<li>When working with shared channels via Graph API, always use the "Prefer: include-unknown-enum-members" header</li>
<li>Consider implementing regular audits of external sharing via shared channels</li>
<li>Ensure proper data governance and DLP policies are applied to teams with shared channels</li>
</ul>
<p><i>Report generated using the Teams List Shared Channels & External-Tenant Detector script.</i></p>
</body>
</html>
"@
$fullHtml = $htmlHeader + $externalTenantHtml + $sharedChannelsHtml + $htmlFooter
$fullHtml | Out-File -FilePath $htmlReportPath -Encoding UTF8
Write-Host "HTML report exported to: $htmlReportPath" -ForegroundColor Green
Write-Host "Teams List Shared Channels completed!" -ForegroundColor Cyan
# Disconnect from Microsoft Graph
Disconnect-MgGraph | Out-Null
Write-Host "Disconnected from Microsoft Graph." -ForegroundColor Green
Check out the Microsoft Graph PowerShell SDK to learn more at: https://learn.microsoft.com/graph/powershell/get-started
Contributors
Author(s) |
---|
Valeras Narbutas |
Additional Information
Key Microsoft Graph API Endpoints Used
List all teams in the tenant:
GET /teams
via Get-MgTeam
Get channels in a team:
GET /teams/{team-id}/channels
withPrefer: include-unknown-enum-members
header- This special header is required to properly retrieve the "shared" membershipType
List shared channel details:
GET /teams/{team-id}/channels/{channel-id}/sharedWithTeams
withPrefer: include-unknown-enum-members
header
Get channel members:
GET /teams/{team-id}/channels/{channel-id}/members
via Get-MgTeamChannelMember
Report Types Generated
This script generates three report files:
- Detailed CSV Report - A complete inventory of all shared channels with external connections
- HTML Dashboard - Visual representation with summary statistics and detailed tables
- Tenant Summary CSV - Aggregated view of external tenants and their connections
Example Usage
Basic Usage:
./Teams-List-Shared-Channels.ps1
This will:
- Authenticate to Microsoft Graph
- Scan all teams for shared channels
- Generate reports in the ./TeamsSharedChannelsReport folder
Security Considerations
- The script requires significant permissions to read team and channel information
- Consider running this script as part of a regular security audit process
- Review the generated reports to identify potential external sharing risks
Disclaimer
THESE SAMPLES ARE PROVIDED AS IS WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.