Inventory/Get-TeamsInventory.ps1
|
<#
.SYNOPSIS Generates a per-team inventory with owners, member counts, and channel counts. .DESCRIPTION Enumerates all Teams-enabled Microsoft 365 groups via Microsoft Graph and collects per-team detail including visibility, owner list, member count, channel count, and archive status. Designed for M&A due diligence and migration planning. For each team, three additional Graph API calls are made (owners, members/$count, channels). On tenants with many teams this may take several minutes due to API rate limiting. The Graph SDK handles 429 throttling retries automatically. Requires Microsoft.Graph.Authentication module and an active Graph connection with Group.Read.All, Team.ReadBasic.All, TeamMember.Read.All, and Channel.ReadBasic.All permissions. .PARAMETER OutputPath Optional path to export results as CSV. If not specified, results are returned to the pipeline. .EXAMPLE PS> . .\Common\Connect-Service.ps1 PS> Connect-Service -Service Graph -Scopes 'Group.Read.All','Team.ReadBasic.All','TeamMember.Read.All','Channel.ReadBasic.All' PS> .\Inventory\Get-TeamsInventory.ps1 Returns per-team inventory for all teams in the tenant. .EXAMPLE PS> .\Inventory\Get-TeamsInventory.ps1 -OutputPath '.\teams-inventory.csv' Exports the Teams inventory to CSV. .NOTES M365 Assess — M&A Inventory #> [CmdletBinding()] param( [Parameter()] [ValidateNotNullOrEmpty()] [string]$OutputPath ) $ErrorActionPreference = 'Stop' # Verify Graph connection if (-not (Assert-GraphConnection)) { return } # ------------------------------------------------------------------ # Retrieve all Teams-enabled groups (paginated) # ------------------------------------------------------------------ Write-Verbose "Retrieving Teams-enabled groups from Microsoft Graph..." $allGroups = [System.Collections.Generic.List[object]]::new() $uri = "/v1.0/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$select=id,displayName,description,visibility,createdDateTime,mail&`$top=999" do { try { $response = Invoke-MgGraphRequest -Method GET -Uri $uri } catch { Write-Error "Failed to retrieve Teams groups: $_" return } if ($response.value) { foreach ($group in $response.value) { $allGroups.Add($group) } } $uri = $response.'@odata.nextLink' } while ($uri) if ($allGroups.Count -eq 0) { Write-Verbose "No Teams-enabled groups found in this tenant" return } Write-Verbose "Found $($allGroups.Count) teams. Collecting per-team detail..." # ------------------------------------------------------------------ # Collect per-team detail (owners, member count, channels) # ------------------------------------------------------------------ $results = [System.Collections.Generic.List[PSCustomObject]]::new() $counter = 0 foreach ($group in $allGroups) { $counter++ if ($counter % 10 -eq 0 -or $counter -eq 1) { Write-Verbose "[$counter/$($allGroups.Count)] $($group.displayName)" } $teamId = $group.id # Get team settings (isArchived) $isArchived = $false try { $teamDetail = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/teams/$teamId" $isArchived = [bool]$teamDetail.isArchived } catch { Write-Warning "Could not retrieve team settings for $($group.displayName): $_" } # Get owners $ownerCount = 0 $ownerList = '' try { $owners = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/groups/$teamId/owners?`$select=displayName,userPrincipalName" if ($owners.value) { $ownerCount = $owners.value.Count $ownerList = ($owners.value | ForEach-Object { $_.userPrincipalName }) -join '; ' } } catch { Write-Warning "Could not retrieve owners for $($group.displayName): $_" } # Get member count $memberCount = 0 try { $membersResponse = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/groups/$teamId/members?`$select=id&`$top=1" -Headers @{ 'ConsistencyLevel' = 'eventual' } # Use @odata.count if available, otherwise enumerate if ($null -ne $membersResponse.'@odata.count') { $memberCount = $membersResponse.'@odata.count' } else { # Fall back to counting via /members with $count=true try { $countResponse = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/groups/$teamId/members/`$count" -Headers @{ 'ConsistencyLevel' = 'eventual' } $memberCount = [int]$countResponse } catch { # Last resort: enumerate all members $allMembers = [System.Collections.Generic.List[object]]::new() $memberUri = "/v1.0/groups/$teamId/members?`$select=id&`$top=999" do { $memberPage = Invoke-MgGraphRequest -Method GET -Uri $memberUri if ($memberPage.value) { foreach ($m in $memberPage.value) { $allMembers.Add($m) } } $memberUri = $memberPage.'@odata.nextLink' } while ($memberUri) $memberCount = $allMembers.Count } } } catch { Write-Warning "Could not retrieve member count for $($group.displayName): $_" } # Get channels $channelCount = 0 $privateChannels = 0 $sharedChannels = 0 try { $channels = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/teams/$teamId/channels?`$select=id,displayName,membershipType" if ($channels.value) { $channelCount = $channels.value.Count $privateChannels = @($channels.value | Where-Object { $_.membershipType -eq 'private' }).Count $sharedChannels = @($channels.value | Where-Object { $_.membershipType -eq 'shared' }).Count } } catch { Write-Warning "Could not retrieve channels for $($group.displayName): $_" } $results.Add([PSCustomObject]@{ DisplayName = $group.displayName Mail = $group.mail Visibility = $group.visibility Description = $group.description CreatedDateTime = $group.createdDateTime IsArchived = $isArchived OwnerCount = $ownerCount Owners = $ownerList MemberCount = $memberCount ChannelCount = $channelCount PrivateChannels = $privateChannels SharedChannels = $sharedChannels }) } $report = @($results) | Sort-Object -Property DisplayName Write-Verbose "Inventory complete: $($report.Count) teams" if ($OutputPath) { $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Output "Exported Teams inventory ($($report.Count) teams) to $OutputPath" } else { Write-Output $report } |